slop game oder so
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)).
|
||||
|
||||

|
||||

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