slop game oder so

This commit is contained in:
2026-09-24 17:57:54 +02:00
commit 4c997eee21
166 changed files with 14976 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "glory-defense",
"runtimeExecutable": ".venv/Scripts/python.exe",
"runtimeArgs": ["-m", "flask", "run", "--port", "5057", "--debug"],
"port": 5057
}
]
}
+24
View File
@@ -0,0 +1,24 @@
# Beispielkonfiguration für Glory Defense.
# Kopieren nach ".env" (lokal) oder die Werte in cPanel unter
# "Setup Python App" -> "Environment variables" eintragen.
# Geheimer Schlüssel zum Signieren der Run-Tokens und zum Hashen von IP-Adressen.
# Erzeugen z. B. mit: python -c "import secrets; print(secrets.token_hex(32))"
# Bleibt er leer, erzeugt die App beim ersten Start instance/secret_key.
SECRET_KEY=
# Pfad zur SQLite-Datenbank. Relative Pfade gelten relativ zum Projektordner.
GLORY_DB_PATH=instance/glory_defense.sqlite3
# Rate-Limit für neue Highscore-Einträge: maximal GLORY_RATE_LIMIT_MAX Versuche
# pro Client innerhalb von GLORY_RATE_LIMIT_WINDOW Sekunden.
GLORY_RATE_LIMIT_MAX=5
GLORY_RATE_LIMIT_WINDOW=600
# Nur auf "1" setzen, wenn ein Reverse-Proxy X-Forwarded-For-Header setzt, denen
# vertraut werden kann. Bei cPanel/Passenger normalerweise nicht nötig.
GLORY_TRUST_PROXY=0
# Optionale Sperrliste für Namen auf der Ehrentafel (ein Begriff pro Zeile, # = Kommentar).
# Fehlt die Datei, ist nichts gesperrt. Relative Pfade gelten ab Projektordner.
GLORY_NAME_BLOCKLIST=instance/name_blocklist.txt
+31
View File
@@ -0,0 +1,31 @@
# Python
__pycache__/
*.py[cod]
.venv/
venv/
.pytest_cache/
.ruff_cache/
# Laufzeitdaten (SQLite-Datenbank, automatisch erzeugter Schlüssel)
instance/
*.sqlite3
.env
# Upload-Paket (scripts/build_deploy_zip.py)
dist/
# Node / Tests
node_modules/
test-results/
playwright-report/
coverage/
# cPanel / Passenger
tmp/
stderr.log
# Editor / OS
.vscode/
.idea/
Thumbs.db
.DS_Store
+218
View File
@@ -0,0 +1,218 @@
# Glory Defense
**Für Freiheit und Frieden!** – Ein satirischer 2D-Browser-Shooter in Draufsicht.
Als *Präsident Maximilian Valor* verteidigt man die „Vereinigte Erde“ gegen
außerirdische Invasoren, begleitet von pathetischen Durchsagen des „Ministeriums
für Zuversicht“. Der Humor richtet sich gegen Militarismus und Propaganda im
Allgemeinen. Alle Figuren und Organisationen sind frei erfunden (siehe
[Disclaimer](#disclaimer)).
![Hauptmenü](docs/images/menu.png)
![Spielszene](docs/images/gameplay.png)
- **Frontend:** Phaser 3.90 (lokal eingebunden, kein CDN), native ES-Module, kein Build-Schritt
- **Backend:** Flask (monolithisch), SQLite, kleine JSON-API für Highscores
- **Hosting:** läuft auf cPanel über „Setup Python App“ (Phusion Passenger)
## Inhalt
- [Spielprinzip](#spielprinzip)
- [Steuerung](#steuerung)
- [Lokales Setup](#lokales-setup)
- [Starten](#starten)
- [Tests](#tests)
- [Projektstruktur](#projektstruktur)
- [Konfiguration](#konfiguration)
- [Weitere Dokumentation](#weitere-dokumentation)
- [Disclaimer](#disclaimer)
## Spielprinzip
- **Wellen-Modus:** endlose Angriffswellen mit steigender Schwierigkeit, dazwischen
kurze Pausen mit Propaganda-Durchsage, Wellenbonus und etwas Heilung.
- **Zwei Fraktionen mit je drei Gegnertypen:**
- *Die Schwärmer* (insektoid, schnell, Nahkampf): Krabbler, Sprungkralle, Brutmutter
- *Die Blechlegion* (Roboter, langsam, Fernkampf): Nieter-Drohne, Blechsoldat, Walzmeister
- **Gegner-KI:** verfolgen, umzingeln, Abstand halten, flankieren, Sprungangriff mit
Vorwarnung, Mörser mit Bodenmarkierung.
- **Drei Waffen:** Karabiner »Zuversicht«, Schrotflinte »Überzeugungskraft« und die
Dienstpistole »Anstand« mit unbegrenzter Munition.
- **Orbital-Befehle** per Tastenkombination (Q halten): Orbitalschlag »Morgenröte«,
Nachschubkapsel »Fürsorgepaket« und Geschütz »Wachsamer Nachbar«. Alle mit
Abklingzeit und Bodenmarkierung bis zum Einschlag.
- **Punkte:** Abschüsse in schneller Folge bauen eine Serie auf (Multiplikator bis ×4).
Treffer am Präsidenten beenden sie.
- **Ehrentafel:** Top 10 werden auf dem Server gespeichert (mit Plausibilitätsprüfung
und Rate-Limit).
Details und alle Balancing-Werte: [docs/GAME_DESIGN.md](docs/GAME_DESIGN.md).
## Steuerung
| Taste | Aktion |
|---|---|
| **W A S D** | Bewegen (8 Richtungen) |
| **Maus** | Zielen |
| **Linksklick** | Schießen (Karabiner: gedrückt halten) |
| **R** | Nachladen |
| **1 / 2 / 3**, **Mausrad** | Waffe wechseln |
| **Leertaste** | Ausweichrolle (kurz unverwundbar, 1,5 s Abklingzeit) |
| **Q halten + Pfeiltasten oder WASD** | Orbital-Befehl eingeben, Ziel ist der Mauszeiger |
| **Esc** | Pause mit Lautstärkereglern |
| Orbital-Befehl | Code | Abklingzeit |
|---|---|---|
| Orbitalschlag »Morgenröte« | ↑ → ↓ ↓ | 30 s |
| Nachschubkapsel »Fürsorgepaket« | ← ← ↓ → | 45 s |
| Geschütz »Wachsamer Nachbar« | → ← → ↑ ↑ | 60 s |
Warum **Q** als Halte-Taste: Mit gehaltener **Strg** würde **W** den Browser-Tab
schließen (lässt sich nicht abfangen), und fünfmal **Shift** öffnet unter Windows die
Einrastfunktion.
## Lokales Setup
Voraussetzungen:
- **Python 3.10 oder neuer** (für Server und Python-Tests)
- optional **Node.js 22 (ab 22.13) oder 24** (nur für JavaScript-Unit-Tests und ESLint)
- optional **Microsoft Edge oder Google Chrome** (für die End-to-End-Tests)
Fürs Spielen und für den Server-Betrieb ist Node nicht nötig: Phaser liegt bereits
unter `app/static/js/lib/`.
**Windows (PowerShell):**
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r requirements-dev.txt
Copy-Item .env.example .env
```
Blockiert Windows `Activate.ps1` („Die Ausführung von Skripts ist auf diesem System
deaktiviert“), gibt es zwei Wege:
- die Ausführungsrichtlinie nur für den eigenen Benutzer lockern:
`Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`, oder
- ohne Aktivierung arbeiten und jeden Befehl über den Python der venv aufrufen:
`.\.venv\Scripts\python.exe -m pip install -r requirements-dev.txt`,
`.\.venv\Scripts\python.exe -m flask run`, `.\.venv\Scripts\python.exe -m pytest`.
**Linux/macOS:**
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-dev.txt
cp .env.example .env
```
In `.env` einen eigenen `SECRET_KEY` eintragen, zum Beispiel erzeugt mit:
```bash
python -c "import secrets; print(secrets.token_hex(32))"
```
Bleibt `SECRET_KEY` leer, erzeugt die App beim ersten Start automatisch
`instance/secret_key`. Für die JavaScript-Werkzeuge (optional):
```bash
npm install
```
## Starten
Bei aktivierter venv:
```bash
flask run
```
Dann <http://127.0.0.1:5000> öffnen. Mit `flask run --debug` lädt der Server bei
Codeänderungen neu. Die Datenbank (`instance/glory_defense.sqlite3`) wird beim
ersten Start automatisch angelegt. Nachträglich geht das auch mit `flask init-db`.
Weitere Adressen:
- `/disclaimer`: Disclaimer als eigene Seite (auch ohne JavaScript lesbar)
- `/api/highscores`: Top 10 als JSON
- `/healthz`: Funktionstest für das Deployment
## Tests
Kurzfassung. Die Details stehen in [docs/TESTING.md](docs/TESTING.md).
```bash
pytest # Backend, Assets, Passenger-Import und End-to-End im Browser
pytest -m "not e2e" # ohne Browser-Tests
npm test # JavaScript-Unit-Tests (Vitest)
ruff check .
ruff format --check .
npm run lint # ESLint
```
## Projektstruktur
```
passenger_wsgi.py Einstiegspunkt für Phusion Passenger (cPanel)
app/
__init__.py App-Factory, Security-Header, Fehlerbehandlung
config.py Konfiguration aus Umgebungsvariablen, Plausibilitätsgrenzen
routes.py Seiten: /, /disclaimer, /healthz
api.py JSON-API: /api/highscores, /api/runs
models.py SQLite: Schema, Highscores, Rate-Limit, Run-Tokens, CLI (flask scores …)
validation.py Namensbereinigung und Plausibilitätsprüfung
templates/ HTML (Disclaimer-Text liegt nur einmal vor)
static/
css/site.css
js/main.js Start: Dienste bauen, Phaser konfigurieren
js/config.js ALLE Balancing-Werte
js/content/texts.js Alle Texte (Durchsagen, Briefings, Credits …)
js/logic/ Reine Spiellogik ohne Phaser (per Vitest getestet)
js/scenes/ Boot, Menü, Disclaimer, Briefing, Spiel, HUD, Pause, Game Over …
js/entities/ Präsident, Gegner, Projektile, Geschütz, Kapsel
js/systems/ Orbital-Befehle, Effekte, Audio, Assets, Overlays, API-Client …
js/lib/phaser.min.js Phaser 3.90.0 (MIT)
assets/ Grafiken und Klänge (siehe docs/ASSETS.md)
tests/ pytest (Backend, Assets, Passenger), tests/js (Vitest), tests/e2e (Playwright)
docs/ Architektur, Game Design, Deployment, Assets, Testing
scripts/vendor-phaser.mjs kopiert Phaser aus node_modules nach app/static/js/lib
scripts/build_deploy_zip.py baut das Upload-Paket für cPanel (dist/glory_defense.zip)
```
## Konfiguration
| Umgebungsvariable | Standard | Bedeutung |
|---|---|---|
| `SECRET_KEY` | automatisch (`instance/secret_key`) | signiert Run-Tokens, hasht IP-Adressen für das Rate-Limit |
| `GLORY_DB_PATH` | `instance/glory_defense.sqlite3` | SQLite-Datei; relative Pfade gelten ab Projektordner |
| `GLORY_RATE_LIMIT_MAX` | `5` | Highscore-Einreichungen pro Client … |
| `GLORY_RATE_LIMIT_WINDOW` | `600` | … innerhalb dieser Sekunden |
| `GLORY_TRUST_PROXY` | `0` | `1` nur hinter einem vertrauenswürdigen Reverse-Proxy |
| `GLORY_NAME_BLOCKLIST` | `instance/name_blocklist.txt` | optionale Sperrliste für Namen auf der Ehrentafel |
Spielwerte (Leben, Schaden, Wellen, Abklingzeiten …) stehen zentral in
`app/static/js/config.js`.
## Weitere Dokumentation
- [docs/DEPLOYMENT_CPANEL.md](docs/DEPLOYMENT_CPANEL.md): Schritt-für-Schritt-Anleitung für cPanel
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): Aufbau von Backend und Frontend, Szenenfluss
- [docs/GAME_DESIGN.md](docs/GAME_DESIGN.md): Gegner, Waffen, Orbital-Befehle, Balancing
- [docs/ASSETS.md](docs/ASSETS.md): Quellen und Lizenzen aller Grafiken und Klänge
- [docs/TESTING.md](docs/TESTING.md): Tests ausführen und erweitern
## Disclaimer
Glory Defense ist **Satire und reine Fiktion**. Alle Figuren, Staaten, Organisationen
und Fraktionen sind frei erfunden. Ähnlichkeiten mit realen Personen, lebend oder
verstorben, sind nicht beabsichtigt und rein zufällig. Das Spiel vertritt keine
politische Position und richtet sich nicht gegen reale Parteien, Politikerinnen und
Politiker oder Länder. Die übertriebenen Durchsagen sind Gegenstand der Satire, nicht
ihre Botschaft.
Grafiken und Klänge stammen aus eigenen Entwürfen (CC0) und von
[Kenney](https://kenney.nl) (CC0). Schriften sind Systemschriften und werden nicht
mitgeliefert. Phaser steht unter der MIT-Lizenz. Details in
[docs/ASSETS.md](docs/ASSETS.md).
+89
View File
@@ -0,0 +1,89 @@
"""Glory Defense – Flask-App-Factory.
Die App ist bewusst monolithisch und zustandslos (Zustand nur in SQLite), damit sie
unter Phusion Passenger mit mehreren Prozessen und ohne Hintergrunddienste läuft.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from flask import Flask, Response, jsonify, request
from werkzeug.exceptions import HTTPException
from werkzeug.middleware.proxy_fix import ProxyFix
from . import api, models, routes
from .config import INSTANCE_DIR, load_config
#: Content-Security-Policy: Skripte nur vom eigenen Server (Phaser liegt lokal).
#: ``blob:``/``data:`` braucht Phaser, weil es geladene Bilder, SVGs und Audio als
#: Blob-URLs dekodiert und eingebaute Standardtexturen als Data-URLs mitbringt.
CONTENT_SECURITY_POLICY = "; ".join(
[
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"media-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self' data: blob:",
"worker-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
]
)
def _apply_security_headers(response: Response) -> Response:
response.headers.setdefault("Content-Security-Policy", CONTENT_SECURITY_POLICY)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "same-origin")
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
return response
def _handle_http_error(exc: HTTPException) -> Response | HTTPException:
"""API-Pfade bekommen Fehler als JSON, damit das Frontend sie einheitlich auswerten kann."""
if request.path.startswith("/api/"):
body: dict[str, Any] = {
"error": (exc.name or "error").lower().replace(" ", "_"),
"message": exc.description or "",
}
response = jsonify(body)
response.status_code = exc.code or 500
return response
return exc
def create_app(test_config: Mapping[str, Any] | None = None) -> Flask:
"""Erzeugt und konfiguriert die Flask-App.
Args:
test_config: Optionale Konfigurationswerte mit Vorrang vor den
Umgebungsvariablen (vor allem für Tests).
Returns:
Die fertig konfigurierte Flask-Anwendung.
"""
app = Flask(__name__, instance_path=str(INSTANCE_DIR))
app.config.from_mapping(load_config(test_config))
# Antworten in der Reihenfolge, in der der Code die Felder aufbaut (lesbarer).
app.json.sort_keys = False # type: ignore[attr-defined]
if app.config["TRUST_PROXY"]:
# Nur hinter einem vertrauenswürdigen Proxy aktivieren, sonst könnten Clients
# ihre IP (und damit das Rate-Limit) per Header fälschen. Übernommen werden nur
# Client-IP und Protokoll: Host und Pfadpräfix aus Headern landeten sonst in den
# erzeugten URLs (z. B. X-Forwarded-Prefix: //fremde.domain).
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1) # type: ignore[method-assign]
models.init_app(app)
app.register_blueprint(routes.bp)
app.register_blueprint(api.bp)
app.after_request(_apply_security_headers)
app.register_error_handler(HTTPException, _handle_http_error)
return app
+168
View File
@@ -0,0 +1,168 @@
"""JSON-API: Highscores lesen/einreichen und Run-Tokens ausgeben.
Ablauf einer Einreichung:
1. Beim Spielstart holt das Frontend per ``POST /api/runs`` ein signiertes Run-Token.
2. Nach dem Game Over schickt es Name, Punkte, Welle, Spieldauer und das Token.
3. Der Server prüft Rate-Limit, Format, Token (Signatur, Alter, Einmaligkeit) und
Plausibilität, bevor er speichert.
"""
from __future__ import annotations
import hashlib
import hmac
import time
import uuid
from datetime import datetime, timezone
from flask import Blueprint, Response, current_app, jsonify, request
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from .config import ensure_secret_key
from .models import (
consume_run_token,
get_db,
immediate_transaction,
insert_score,
rank_of,
register_submission_attempt,
top_scores,
)
from .validation import (
PlausibilityRules,
SubmissionError,
check_plausibility,
is_name_blocked,
load_blocklist,
parse_submission,
)
bp = Blueprint("api", __name__, url_prefix="/api")
#: Salt trennt Run-Tokens von anderen Signaturen, die denselben SECRET_KEY nutzen könnten.
RUN_TOKEN_SALT = "glory-defense.run-token.v1" # noqa: S105 (Salt, kein Passwort)
def _now() -> float:
"""Aktuelle Unix-Zeit; eigene Funktion, damit Tests die Uhr gezielt verstellen können."""
return time.time()
def _serializer() -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(ensure_secret_key(current_app.config), salt=RUN_TOKEN_SALT)
def _client_hash() -> str:
"""HMAC der Client-IP: genug, um Clients zu unterscheiden, ohne IPs zu speichern."""
address = request.remote_addr or "unknown"
key = ensure_secret_key(current_app.config).encode("utf-8")
return hmac.new(key, address.encode("utf-8"), hashlib.sha256).hexdigest()
def error_response(status: int, code: str, message: str, **extra: object) -> tuple[Response, int]:
"""Einheitliches Fehlerformat: ``{"error": code, "message": text, ...}``."""
return jsonify({"error": code, "message": message, **extra}), status
def _no_store(response: Response) -> Response:
# Highscores ändern sich laufend; Browser und Proxies sollen sie nicht zwischenspeichern.
response.headers["Cache-Control"] = "no-store"
return response
@bp.post("/runs")
def start_run() -> tuple[Response, int]:
"""Gibt ein signiertes Run-Token für eine neue Spielrunde aus."""
token = _serializer().dumps({"rid": uuid.uuid4().hex})
return _no_store(jsonify({"run_token": token})), 201
@bp.get("/highscores")
def list_highscores() -> Response:
"""Liefert die Top-N-Highscores."""
scores = top_scores(get_db(), current_app.config["HIGHSCORE_TOP_N"])
return _no_store(jsonify({"highscores": scores}))
@bp.post("/highscores")
def submit_highscore() -> tuple[Response, int]:
"""Nimmt einen neuen Highscore entgegen (Details siehe Moduldokumentation)."""
config = current_app.config
db = get_db()
now = _now()
# Das Rate-Limit greift vor allem anderen, damit auch fehlerhafte Anfragen zählen.
allowed, retry_after = register_submission_attempt(
db,
_client_hash(),
now,
window_s=config["RATE_LIMIT_WINDOW_SECONDS"],
max_attempts=config["RATE_LIMIT_MAX_SUBMISSIONS"],
)
if not allowed:
response, status = error_response(
429,
"rate_limited",
"Zu viele Einreichungen. Bitte später erneut versuchen.",
retry_after=retry_after,
)
response.headers["Retry-After"] = str(retry_after)
return response, status
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return error_response(400, "invalid_json", "Erwartet wird ein JSON-Objekt.")
rules = PlausibilityRules.from_config(config)
try:
submission = parse_submission(payload, rules)
except SubmissionError as exc:
return error_response(400, "validation_failed", "Ungültige Eingaben.", fields=exc.fields)
# Optionale Sperrliste des Betreibers (z. B. Namen realer Personen, Beleidigungen).
if is_name_blocked(submission.name, load_blocklist(config["NAME_BLOCKLIST_PATH"])):
return error_response(
400,
"validation_failed",
"Ungültige Eingaben.",
fields={"name": "Dieser Name ist nicht zulässig."},
)
try:
token_data, issued_at = _serializer().loads(
submission.run_token,
max_age=config["RUN_TOKEN_MAX_AGE_SECONDS"],
return_timestamp=True,
)
except SignatureExpired:
return error_response(422, "run_token_expired", "Das Run-Token ist abgelaufen.")
except BadSignature:
return error_response(422, "run_token_invalid", "Das Run-Token ist ungültig.")
run_id = token_data.get("rid") if isinstance(token_data, dict) else None
if not isinstance(run_id, str):
return error_response(422, "run_token_invalid", "Das Run-Token ist ungültig.")
elapsed_s = (datetime.now(timezone.utc) - issued_at).total_seconds()
reason = check_plausibility(submission, elapsed_s, rules)
if reason is not None:
return error_response(422, "implausible_score", reason)
# Token einlösen und speichern gehören zusammen: Scheitert das Speichern,
# darf das Token nicht verbraucht sein.
with immediate_transaction(db):
if not consume_run_token(db, run_id, now, config["RUN_TOKEN_MAX_AGE_SECONDS"]):
return error_response(
409, "run_token_used", "Für diese Runde wurde bereits ein Highscore gespeichert."
)
score_id = insert_score(
db, submission.name, submission.score, submission.wave, submission.duration_s
)
body = {
"id": score_id,
"rank": rank_of(db, score_id),
"name": submission.name,
"highscores": top_scores(db, config["HIGHSCORE_TOP_N"]),
}
return _no_store(jsonify(body)), 201
+177
View File
@@ -0,0 +1,177 @@
"""Konfiguration der Anwendung.
Alle Werte kommen aus Umgebungsvariablen, damit dieselbe Codebasis lokal (``.env``)
und auf cPanel (Umgebungsvariablen in „Setup Python App“) unverändert läuft.
"""
from __future__ import annotations
import logging
import os
import secrets
import time
from collections.abc import Mapping, MutableMapping
from pathlib import Path
from typing import Any
#: Projektordner (enthält ``passenger_wsgi.py``). Alle relativen Pfade werden hieran
#: verankert, weil Passenger die App nicht zwingend aus diesem Ordner heraus startet.
PROJECT_ROOT: Path = Path(__file__).resolve().parent.parent
INSTANCE_DIR: Path = PROJECT_ROOT / "instance"
DEFAULT_DB_PATH: Path = INSTANCE_DIR / "glory_defense.sqlite3"
SECRET_KEY_FILE: Path = INSTANCE_DIR / "secret_key"
#: Optionale Sperrliste für Spielernamen (ein Begriff pro Zeile). Fehlt sie, ist nichts gesperrt.
DEFAULT_BLOCKLIST_PATH: Path = INSTANCE_DIR / "name_blocklist.txt"
#: Maximale Größe eines Requests. Highscore-Einträge sind winzig; alles darüber ist Missbrauch.
MAX_CONTENT_LENGTH_BYTES = 16 * 1024
#: Grenzen für die Plausibilitätsprüfung eingereichter Punktestände.
#: Absichtlich großzügig: Die Spiellogik läuft im Browser, der Server kann deshalb nur
#: offensichtlich unmögliche Werte aussortieren, echtes Cheaten aber nicht verhindern.
#: Die Werte sind auf die Balancing-Werte in ``app/static/js/config.js`` abgestimmt
#: (Herleitung in ``docs/GAME_DESIGN.md``).
GAME_RULE_DEFAULTS: dict[str, int] = {
"HIGHSCORE_TOP_N": 10,
"SCORE_MAX": 50_000_000,
# Sockel, damit sehr kurze Runden mit einem Glückstreffer nicht abgelehnt werden.
"SCORE_FLAT_ALLOWANCE": 2_000,
# Obergrenze für den Punkte-Durchschnitt über die gesamte Runde (realistisch 15–120/s).
"SCORE_MAX_POINTS_PER_SECOND": 200,
"WAVE_MAX": 999,
# Jede abgeschlossene Welle kostet mindestens diese Zeit (Pause zwischen den Wellen).
"WAVE_MIN_SECONDS": 5,
"DURATION_MAX_SECONDS": 6 * 60 * 60,
# Spielraum für Uhren- und Netzwerkabweichungen beim Vergleich mit der Token-Zeit.
"DURATION_TOLERANCE_SECONDS": 30,
# Ein Run-Token muss innerhalb dieser Zeit eingelöst werden.
"RUN_TOKEN_MAX_AGE_SECONDS": 6 * 60 * 60 + 30 * 60,
}
logger = logging.getLogger(__name__)
def resolve_project_path(value: str | os.PathLike[str]) -> Path:
"""Löst ``value`` relativ zum Projektordner auf, absolute Pfade bleiben unverändert.
Das aktuelle Arbeitsverzeichnis wird bewusst ignoriert: Unter Passenger ist es nicht
garantiert der Projektordner, und eine Datenbank „irgendwo“ wäre ein stiller Fehler.
"""
path = Path(value).expanduser()
return path if path.is_absolute() else PROJECT_ROOT / path
def env_int(name: str, default: int, minimum: int = 0) -> int:
"""Liest eine ganzzahlige Umgebungsvariable mit Standardwert und Untergrenze.
Raises:
ValueError: Wenn der Wert keine ganze Zahl ist oder unter ``minimum`` liegt.
Lieber beim Start laut scheitern als mit einer kaputten Konfiguration laufen.
"""
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError as exc:
raise ValueError(f"Umgebungsvariable {name} muss eine ganze Zahl sein: {raw!r}") from exc
if value < minimum:
raise ValueError(f"Umgebungsvariable {name} muss mindestens {minimum} sein: {value}")
return value
def env_bool(name: str, default: bool) -> bool:
"""Liest eine boolesche Umgebungsvariable (``1``, ``true``, ``yes``, ``on``, ``ja``)."""
raw = os.environ.get(name, "").strip().lower()
if not raw:
return default
return raw in {"1", "true", "yes", "on", "ja"}
def load_or_create_secret_key(path: Path = SECRET_KEY_FILE) -> str:
"""Liefert einen dauerhaft gespeicherten Zufallsschlüssel und legt ihn bei Bedarf an.
Warum eine Datei statt eines Zufallswerts pro Start: Passenger betreibt mehrere
Prozesse parallel. Ein Run-Token, das Prozess A signiert, muss Prozess B prüfen
können – dafür brauchen alle Prozesse denselben Schlüssel.
"""
existing = _read_secret(path)
if existing:
return existing
path.parent.mkdir(parents=True, exist_ok=True)
key = secrets.token_hex(32)
try:
# O_EXCL macht das Anlegen atomar: Starten zwei Prozesse gleichzeitig, gewinnt einer.
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
return _wait_for_secret(path)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(key)
return key
def _read_secret(path: Path) -> str:
try:
return path.read_text(encoding="utf-8").strip()
except FileNotFoundError:
return ""
def _wait_for_secret(path: Path, attempts: int = 20, delay_s: float = 0.05) -> str:
"""Wartet kurz, bis ein parallel startender Prozess den Schlüssel fertig geschrieben hat."""
for _ in range(attempts):
key = _read_secret(path)
if key:
return key
time.sleep(delay_s)
raise RuntimeError(f"Schlüsseldatei {path} existiert, ist aber leer.")
def load_config(overrides: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""Baut die Flask-Konfiguration aus Umgebungsvariablen und optionalen Overrides.
Args:
overrides: Werte mit Vorrang vor den Umgebungsvariablen (v. a. für Tests).
Returns:
Ein Dictionary für ``app.config.from_mapping``.
"""
config: dict[str, Any] = {
"DATABASE_PATH": str(
resolve_project_path(os.environ.get("GLORY_DB_PATH") or DEFAULT_DB_PATH)
),
"TRUST_PROXY": env_bool("GLORY_TRUST_PROXY", default=False),
"RATE_LIMIT_MAX_SUBMISSIONS": env_int("GLORY_RATE_LIMIT_MAX", 5, minimum=1),
"RATE_LIMIT_WINDOW_SECONDS": env_int("GLORY_RATE_LIMIT_WINDOW", 600, minimum=1),
"NAME_BLOCKLIST_PATH": str(
resolve_project_path(os.environ.get("GLORY_NAME_BLOCKLIST") or DEFAULT_BLOCKLIST_PATH)
),
"MAX_CONTENT_LENGTH": MAX_CONTENT_LENGTH_BYTES,
# Ohne SECRET_KEY bleibt der Wert leer; ensure_secret_key() ergänzt ihn bei Bedarf.
"SECRET_KEY": os.environ.get("SECRET_KEY", "").strip() or None,
**GAME_RULE_DEFAULTS,
}
if overrides:
config.update(overrides)
return config
def ensure_secret_key(config: MutableMapping[str, Any]) -> str:
"""Liefert den SECRET_KEY und legt ``instance/secret_key`` erst bei Bedarf an.
Warum erst bei Bedarf: Befehle im cPanel-Terminal (z. B. ``flask … scores list``)
sehen die in cPanel gesetzten Umgebungsvariablen nicht. Sie brauchen den Schlüssel
aber auch nicht und sollen deshalb keine überflüssige Schlüsseldatei anlegen.
"""
key = config.get("SECRET_KEY")
if key:
return str(key)
key = load_or_create_secret_key(SECRET_KEY_FILE)
config["SECRET_KEY"] = key
logger.warning(
"SECRET_KEY ist nicht gesetzt – verwende den automatisch erzeugten Schlüssel aus %s. "
"Für den Produktivbetrieb bitte SECRET_KEY als Umgebungsvariable setzen.",
SECRET_KEY_FILE,
)
return key
+296
View File
@@ -0,0 +1,296 @@
"""SQLite-Zugriff: Schema, Highscores, Rate-Limit-Protokoll und verbrauchte Run-Tokens.
SQLite reicht hier völlig aus und braucht auf Shared Hosting keinen separaten
Datenbankserver. Weil Passenger mehrere Prozesse parallel betreibt, liegt auch der
Zustand für Rate-Limiting und Einmal-Tokens in der Datenbank statt im Arbeitsspeicher.
"""
from __future__ import annotations
import math
import sqlite3
import unicodedata
from collections.abc import Iterable, Iterator
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import click
from flask import Flask, current_app, g
from flask.cli import AppGroup, with_appcontext
SCHEMA_VERSION = 1
#: Wie lange ein Prozess auf eine gesperrte Datenbank wartet, bevor er aufgibt.
BUSY_TIMEOUT_SECONDS = 10.0
SCHEMA = """
CREATE TABLE IF NOT EXISTS highscores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
score INTEGER NOT NULL CHECK (score >= 0),
wave INTEGER NOT NULL CHECK (wave >= 1),
duration_s INTEGER NOT NULL CHECK (duration_s >= 0),
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_highscores_rank ON highscores (score DESC, id ASC);
-- Ein Eintrag pro angenommenem Einreichungsversuch; client_hash ist ein HMAC der
-- IP-Adresse, damit keine IP-Adressen im Klartext gespeichert werden.
CREATE TABLE IF NOT EXISTS submission_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_hash TEXT NOT NULL,
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_submission_log_client ON submission_log (client_hash, created_at);
-- Bereits eingelöste Run-Tokens: jede Spielrunde darf nur einen Highscore erzeugen.
CREATE TABLE IF NOT EXISTS used_run_tokens (
run_id TEXT PRIMARY KEY,
used_at REAL NOT NULL
);
"""
def connect(db_path: str | Path) -> sqlite3.Connection:
"""Öffnet eine Verbindung im Autocommit-Modus; Transaktionen werden explizit gesteuert."""
conn = sqlite3.connect(str(db_path), timeout=BUSY_TIMEOUT_SECONDS, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
@contextmanager
def immediate_transaction(db: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
"""Transaktion, die die Schreibsperre sofort holt (``BEGIN IMMEDIATE``).
So kann zwischen Lesen und Schreiben kein anderer Prozess dazwischenfunken;
bei einer Ausnahme wird alles zurückgerollt.
"""
db.execute("BEGIN IMMEDIATE")
try:
yield db
except BaseException:
db.execute("ROLLBACK")
raise
db.execute("COMMIT")
def init_db(db_path: str | Path) -> None:
"""Legt Datenbankdatei, Elternordner und Tabellen an. Mehrfaches Ausführen ist harmlos."""
path = Path(db_path)
path.parent.mkdir(parents=True, exist_ok=True)
conn = connect(path)
try:
conn.executescript(SCHEMA)
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION:d}")
finally:
conn.close()
def get_db() -> sqlite3.Connection:
"""Liefert die Verbindung des aktuellen Requests (wird am Request-Ende geschlossen)."""
if "db" not in g:
g.db = connect(current_app.config["DATABASE_PATH"])
return g.db
def close_db(_exc: BaseException | None = None) -> None:
"""Schließt die Request-Verbindung, falls eine geöffnet wurde."""
db = g.pop("db", None)
if db is not None:
db.close()
@click.command("init-db")
@with_appcontext
def init_db_command() -> None:
"""Flask-CLI: ``flask init-db`` legt die Datenbank an (auch nachträglich)."""
init_db(current_app.config["DATABASE_PATH"])
click.echo(f"Datenbank bereit: {current_app.config['DATABASE_PATH']}")
def init_app(app: Flask) -> None:
"""Verdrahtet die Datenbank mit der App und stellt sicher, dass das Schema existiert."""
init_db(app.config["DATABASE_PATH"])
app.teardown_appcontext(close_db)
app.cli.add_command(init_db_command)
app.cli.add_command(scores_cli)
# --------------------------------------------------------------------------- Highscores
def top_scores(db: sqlite3.Connection, limit: int) -> list[dict[str, Any]]:
"""Liefert die besten ``limit`` Einträge; bei Gleichstand gewinnt der ältere Eintrag."""
rows = db.execute(
"SELECT name, score, wave, created_at FROM highscores ORDER BY score DESC, id ASC LIMIT ?",
(limit,),
).fetchall()
return [
{
"rank": rank,
"name": row["name"],
"score": row["score"],
"wave": row["wave"],
"created_at": row["created_at"],
}
for rank, row in enumerate(rows, start=1)
]
def insert_score(
db: sqlite3.Connection,
name: str,
score: int,
wave: int,
duration_s: int,
now: datetime | None = None,
) -> int:
"""Speichert einen Highscore und gibt dessen ID zurück."""
created_at = (now or datetime.now(timezone.utc)).isoformat(timespec="seconds")
cursor = db.execute(
"INSERT INTO highscores (name, score, wave, duration_s, created_at) VALUES (?, ?, ?, ?, ?)",
(name, score, wave, duration_s, created_at),
)
return int(cursor.lastrowid or 0)
def rank_of(db: sqlite3.Connection, score_id: int) -> int:
"""Platzierung eines Eintrags nach derselben Sortierung wie :func:`top_scores`."""
row = db.execute(
"SELECT 1 + COUNT(*) AS rank FROM highscores AS other, "
"(SELECT score, id FROM highscores WHERE id = ?) AS mine "
"WHERE other.score > mine.score OR (other.score = mine.score AND other.id < mine.id)",
(score_id,),
).fetchone()
return int(row["rank"])
# ------------------------------------------------------------------------- Moderation
def list_scores(db: sqlite3.Connection, limit: int) -> list[dict[str, Any]]:
"""Einträge mit ID für die Moderation (gleiche Sortierung wie die Ehrentafel)."""
rows = db.execute(
"SELECT id, name, score, wave, duration_s, created_at FROM highscores "
"ORDER BY score DESC, id ASC LIMIT ?",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def delete_scores(db: sqlite3.Connection, score_ids: Iterable[int]) -> int:
"""Löscht Einträge per ID und gibt die Anzahl gelöschter Einträge zurück."""
ids = [int(score_id) for score_id in score_ids]
if not ids:
return 0
placeholders = ", ".join("?" for _ in ids)
cursor = db.execute(f"DELETE FROM highscores WHERE id IN ({placeholders})", ids) # noqa: S608 – nur Platzhalter
return cursor.rowcount
def delete_scores_by_name(db: sqlite3.Connection, name: str) -> int:
"""Löscht alle Einträge mit genau diesem Namen (ohne Rücksicht auf Groß-/Kleinschreibung).
Verglichen wird in Python: SQLites ``COLLATE NOCASE`` beachtet nur ASCII, „Ü“ und „ü“
wären dort verschieden.
"""
def fold(text: str) -> str:
return unicodedata.normalize("NFKC", text).strip().casefold()
target = fold(name)
rows = db.execute("SELECT id, name FROM highscores").fetchall()
return delete_scores(db, [row["id"] for row in rows if fold(row["name"]) == target])
#: CLI-Gruppe ``flask scores …``. Kommandos einer AppGroup laufen automatisch im App-Kontext.
scores_cli = AppGroup("scores", help="Ehrentafel moderieren (auflisten, Einträge löschen).")
@scores_cli.command("list")
@click.option("--limit", default=50, show_default=True, type=click.IntRange(1, 100_000))
def scores_list_command(limit: int) -> None:
"""Zeigt Einträge mit ID, Punkten, Welle, Datum und Name."""
rows = list_scores(get_db(), limit)
if not rows:
click.echo("Keine Einträge.")
return
click.echo(f"{'ID':>6} {'Punkte':>9} {'Welle':>5} {'Datum (UTC)':<25} Name")
for row in rows:
numbers = f"{row['id']:>6} {row['score']:>9} {row['wave']:>5}"
click.echo(f"{numbers} {row['created_at']:<25} {row['name']}")
@scores_cli.command("delete")
@click.argument("score_ids", nargs=-1, required=True, type=int)
def scores_delete_command(score_ids: tuple[int, ...]) -> None:
"""Löscht Einträge anhand ihrer ID (siehe ``flask scores list``)."""
deleted = delete_scores(get_db(), score_ids)
click.echo(f"{deleted} Eintrag/Einträge gelöscht.")
@scores_cli.command("delete-name")
@click.argument("name")
def scores_delete_name_command(name: str) -> None:
"""Löscht alle Einträge mit diesem Namen."""
deleted = delete_scores_by_name(get_db(), name)
click.echo(f"{deleted} Eintrag/Einträge mit dem Namen {name!r} gelöscht.")
# ------------------------------------------------------------------------- Rate-Limit
def register_submission_attempt(
db: sqlite3.Connection,
client_hash: str,
now: float,
window_s: int,
max_attempts: int,
) -> tuple[bool, int]:
"""Prüft das Rate-Limit (gleitendes Zeitfenster) und protokolliert den Versuch.
Zählen und Eintragen passieren in einer ``BEGIN IMMEDIATE``-Transaktion, damit zwei
parallele Passenger-Prozesse das Limit nicht gemeinsam überschreiten können.
Abgelehnte Versuche werden nicht protokolliert, sonst würde ein blockierter Client
sein eigenes Zeitfenster endlos verlängern.
Returns:
``(erlaubt, retry_after_s)``; ``retry_after_s`` ist 0, wenn erlaubt.
"""
with immediate_transaction(db):
# Aufräumen im selben Zug hält die Tabelle klein, ganz ohne Cronjob.
db.execute("DELETE FROM submission_log WHERE created_at < ?", (now - window_s,))
rows = db.execute(
"SELECT created_at FROM submission_log WHERE client_hash = ? ORDER BY created_at ASC",
(client_hash,),
).fetchall()
if len(rows) >= max_attempts:
# Ein Platz wird frei, sobald der älteste Versuch aus dem Fenster fällt.
oldest = float(rows[0]["created_at"])
return False, max(1, math.ceil(oldest + window_s - now))
db.execute(
"INSERT INTO submission_log (client_hash, created_at) VALUES (?, ?)",
(client_hash, now),
)
return True, 0
# ------------------------------------------------------------------------- Run-Tokens
def consume_run_token(db: sqlite3.Connection, run_id: str, now: float, max_age_s: int) -> bool:
"""Markiert ein Run-Token als eingelöst.
Returns:
``False``, wenn das Token schon einmal eingelöst wurde.
"""
# Abgelaufene Tokens scheitern ohnehin an der Signaturprüfung und können weg.
db.execute("DELETE FROM used_run_tokens WHERE used_at < ?", (now - max_age_s,))
cursor = db.execute(
"INSERT OR IGNORE INTO used_run_tokens (run_id, used_at) VALUES (?, ?)",
(run_id, now),
)
return cursor.rowcount == 1
+28
View File
@@ -0,0 +1,28 @@
"""HTML-Seiten und Health-Check."""
from __future__ import annotations
from flask import Blueprint, Response, jsonify, render_template
from .models import get_db
bp = Blueprint("pages", __name__)
@bp.get("/")
def index() -> str:
"""Spielseite: lädt Phaser und das Spiel als ES-Module."""
return render_template("index.html")
@bp.get("/disclaimer")
def disclaimer() -> str:
"""Disclaimer als eigenständige, auch ohne JavaScript lesbare Seite."""
return render_template("disclaimer.html")
@bp.get("/healthz")
def healthz() -> Response:
"""Kurzer Funktionstest für das Deployment (App startet, Datenbank erreichbar)."""
get_db().execute("SELECT 1").fetchone()
return jsonify({"status": "ok"})
@@ -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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

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
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

+12
View File
@@ -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

+22
View File
@@ -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

+15
View File
@@ -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

+14
View File
@@ -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

+14
View File
@@ -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

+21
View File
@@ -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

+10
View File
@@ -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

+8
View File
@@ -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

+23
View File
@@ -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

+231
View File
@@ -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; }
}
+7
View File
@@ -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

+449
View File
@@ -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,
});
+227
View File
@@ -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.',
});
+106
View File
@@ -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;
}
}
+250
View File
@@ -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;
}
}
+180
View File
@@ -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);
}
}
+78
View File
@@ -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);
}
}
+20
View File
@@ -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.
File diff suppressed because one or more lines are too long
+132
View File
@@ -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;
}
}
+72
View File
@@ -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);
}
}
}
+225
View File
@@ -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));
}
+94
View File
@@ -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 };
}
+87
View File
@@ -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);
}
+137
View File
@@ -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 });
}
+69
View File
@@ -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;
}
+127
View File
@@ -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,
};
}
}
+145
View File
@@ -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;
}
+111
View File
@@ -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;
}
}
+230
View File
@@ -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}`);
}
}
+146
View File
@@ -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);
}
+110
View File
@@ -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);
}
+47
View File
@@ -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');
}
}
+68
View File
@@ -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)));
}
}
+56
View File
@@ -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());
}
}
+185
View File
@@ -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 });
}
}
+664
View File
@@ -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,
};
}
}
+292
View File
@@ -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 });
}
}
+136
View File
@@ -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;
}
}
}

Some files were not shown because too many files have changed in this diff Show More