chore: remove whopixels — superseded by the who module

Standalone games/whopixels has been replaced by the who module that
landed in the previous four commits. The whopixels Phaser RPG world
wrapper around the chat (~80% of the source) was deliberately
dropped during the port; the chat loop, the 26 historical-figure
personalities, and the [IDENTITY_REVEALED] sentinel trick all live
on inside apps/api/src/modules/who/.

What's gone in this commit:

  games/whopixels/                    — 33 source files, ~3.6k LOC
    Phaser scenes (Boot, MainMenu, Game, RPG)
    Managers (Player, NPC, World, Touch, Sound, Storage, ChatUI)
    Vanilla http server with hand-rolled rate limit + Azure OpenAI
    Static assets, css, jsconfig

  docker-compose.macmini.yml          — `whopixels` service block
    Build context, Azure OpenAI env wiring, healthcheck. Port 5100
    is now free. Comment left in place explaining the migration so
    a future reader doesn't wonder why this gap exists.

What still has to happen outside this PR (Mac Mini side):
  - docker rm -f mana-game-whopixels
  - cloudflared route for whopixels.mana.how needs a redirect or
    archive (sub-domain stops resolving once the container is gone
    unless DNS / tunnel routes are touched separately)

The migration is non-destructive in terms of data: whopixels stored
no per-user state — sessions were in-memory, conversation history
lived only in the browser tab. There's nothing to migrate.

Net delta of the entire who module migration (5 commits combined):
  +1880 LOC (RFC + backend + module + UI + branding)
  -3666 LOC (whopixels)
  ───────
  -1786 LOC

Closes Phase A.6 of docs/WHO_MODULE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 13:11:36 +02:00
parent 65c4d935d5
commit ef780cf069
34 changed files with 4 additions and 3666 deletions

View file

@ -1444,28 +1444,10 @@ services:
# ============================================
# Games
# ============================================
whopixels:
build:
context: .
dockerfile: games/whopixels/Dockerfile
container_name: mana-game-whopixels
restart: unless-stopped
mem_limit: 128m
environment:
PORT: 5100
AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:-}
AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT:-}
AZURE_OPENAI_DEPLOYMENT: ${AZURE_OPENAI_DEPLOYMENT:-}
AZURE_OPENAI_API_VERSION: ${AZURE_OPENAI_API_VERSION:-}
ports:
- "5100:5100"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:5100/"]
interval: 180s
timeout: 10s
retries: 3
start_period: 45s
# whopixels was removed 2026-04-09 — its core mechanic (LLM-driven
# historical figure guessing) lives now as the `who` module inside
# apps/mana/apps/web. The standalone Phaser/Node container is gone;
# see docs/WHO_MODULE.md for the migration rationale.
volumes:
redis_data:

View file

@ -1,27 +0,0 @@
# Umgebungsvariablen
.env
# Node.js
node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log
package-lock.json
# Logs
logs
*.log
# Betriebssystem-Dateien
.DS_Store
Thumbs.db
# IDE und Editor Dateien
.idea/
.vscode/
*.swp
*.swo
# Build-Verzeichnisse
dist/
build/

View file

@ -1,13 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY games/whopixels/package.json ./
RUN npm install --production
COPY games/whopixels/ ./
ENV PORT=5100
EXPOSE 5100
CMD ["node", "server.js"]

View file

@ -1,42 +0,0 @@
# WhoPixels - Verbesserungen
Übersicht aller Verbesserungen für das WhoPixels-Spiel.
## Architektur & Code-Qualität
- [x] **1. RPGScene.js aufteilen** — 1210 Zeilen → 5 Module: WorldManager, PlayerManager, NPCManager, ChatUI, RPGScene (Orchestrator)
- [x] **2. Doppelter Code entfernen**`createTestNPC()` entfernt, war Duplikat von `spawnNewNPC()`
- [x] **3. Magic Numbers eliminieren**`js/config/constants.js` mit `GAME_CONFIG`-Objekt erstellt
- [x] **4. TypeScript-Migration** — JSDoc-Typen + `jsconfig.json` für IDE-Type-Safety (kein Build-System nötig)
## Gameplay & Features
- [x] **5. Persistenz/Speichersystem**`StorageManager` mit LocalStorage: entdeckte NPCs, Statistiken, Fortschritt
- [x] **6. Sound & Musik**`SoundManager` mit Web Audio API: programmatische Sounds für Chat, Reveal, NPC-Spawn
- [x] **7. Mehr NPCs** — Von 10 auf 26 NPCs erweitert in 3 Kategorien: Erfinder, Wissenschaftler, Künstler & Denker
- [x] **8. Leaderboard/Punktesystem** — Statistiken im Hauptmenü (Entlarvt, Durchschn. Fragen, Beste Serie), Reset-Option
- [x] **9. Pixel-Editor Integration** — Avatar im Editor malen, speichern und als Spieler-Sprite im RPG verwenden
## UX & Visuelles
- [x] **10. Mobile-Unterstützung**`TouchControls` mit virtuellem Joystick (links) und Interaktions-Button (rechts)
- [x] **11. Chat-UI verbessern** — Typing-Indicator, Chat-Historie (letzte 4 Nachrichten), bessere Anzeige
- [x] **12. Animations-Feedback** — Schwebendes Fragezeichen-Icon über NPCs in Interaktions-Reichweite
- [x] **13. Tutorial/Onboarding** — Overlay beim ersten Start mit Steuerungshinweisen (Desktop/Mobile)
## Sicherheit & Backend
- [x] **14. Rate Limiting** — 30 Requests/Minute pro IP, 429-Status bei Überschreitung
- [x] **15. Input-Sanitization** — Längenbegrenzung (2000 Zeichen), Control-Character-Entfernung, Typ-Validierung
- [x] **16. CORS einschränken** — Nur erlaubte Origins, konfigurierbar via `ALLOWED_ORIGINS` Env-Variable
- [x] **17. Retry-Logik & Timeouts** — 15s Timeout mit AbortController, saubere Fehlerbehandlung
- [x] **18. Conversation History begrenzen** — Max 20 Einträge, ältere werden abgeschnitten
## Performance
- [x] **19. Object Pooling** — Partikel-Pool einmalig erstellt, Emitter wird wiederverwendet statt neu erstellt
- [x] **20. Phaser-Version updaten** — Von 3.55.2 auf 3.80.1, Particle-API auf neue `add.particles(x, y, key, config)` Syntax migriert
## Lokalisierung
- [x] **21. i18n-Framework**`I18N`-System mit Deutsch/Englisch, Sprach-Umschalter im Hauptmenü, alle Texte lokalisiert

View file

@ -1,74 +0,0 @@
# WhoPixels
Ein webbasiertes Pixel-Spiel, entwickelt mit Phaser.js.
Projekt Starten:
node server.js
## Über das Projekt
WhoPixels ist ein einfaches Pixel-Art-Editor-Spiel, in dem du deine eigenen Pixel-Kunstwerke erstellen kannst. Das Projekt verwendet Phaser.js, eine leistungsstarke HTML5-Spieleentwicklungsbibliothek.
## Funktionen
- Interaktives Pixel-Art-Editor-Interface
- Farbpalette mit 8 Grundfarben
- Einfache und intuitive Benutzeroberfläche
- Responsive Design
## Erste Schritte
Um das Spiel lokal zu starten, benötigst du einen lokalen Webserver. Du kannst einen einfachen Server mit Python oder Node.js starten.
### Mit Python:
```bash
# Python 3
python -m http.server
# Python 2
python -m SimpleHTTPServer
```
### Mit Node.js:
Installiere zuerst das `http-server`-Paket:
```bash
npm install -g http-server
```
Dann starte den Server:
```bash
http-server
```
## Projektstruktur
```
whopixels/
├── assets/ # Spielressourcen (Bilder, Sounds, etc.)
├── css/ # CSS-Stylesheets
├── js/ # JavaScript-Dateien
│ ├── scenes/ # Phaser-Szenen
│ │ ├── BootScene.js
│ │ ├── MainMenuScene.js
│ │ └── GameScene.js
│ └── main.js # Hauptspieldatei
└── index.html # Haupt-HTML-Datei
```
## Weiterentwicklung
Hier sind einige Ideen für zukünftige Erweiterungen:
- Speichern und Laden von Pixel-Art
- Mehr Werkzeuge (Pinsel, Radierer, Füllen, etc.)
- Animation-Editor
- Teilen von Kunstwerken
- Mehrere Ebenen für komplexere Designs
## Lizenz
Dieses Projekt ist Open Source und steht unter der MIT-Lizenz.

View file

@ -1,35 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Background Image</title>
<style>
body { margin: 0; background: #222233; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="canvas" width="800" height="600"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Fill background
ctx.fillStyle = '#222233';
ctx.fillRect(0, 0, 800, 600);
// Add some pattern
ctx.fillStyle = '#1a1a2a';
for (let i = 0; i < 100; i++) {
const x = Math.random() * 800;
const y = Math.random() * 600;
const size = Math.random() * 5 + 2;
ctx.fillRect(x, y, size, size);
}
// Instructions
ctx.fillStyle = '#ffffff';
ctx.font = '20px Arial';
ctx.fillText('Right-click and save this image as background.png', 200, 300);
</script>
</body>
</html>

View file

@ -1 +0,0 @@

View file

@ -1,68 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Create Placeholder Images</title>
</head>
<body>
<h1>Creating placeholder images...</h1>
<canvas id="backgroundCanvas" width="800" height="600" style="display: none;"></canvas>
<canvas id="playerCanvas" width="32" height="32" style="display: none;"></canvas>
<canvas id="tileCanvas" width="32" height="32" style="display: none;"></canvas>
<div id="downloadLinks"></div>
<script>
// Create background image
const bgCanvas = document.getElementById('backgroundCanvas');
const bgCtx = bgCanvas.getContext('2d');
bgCtx.fillStyle = '#222233';
bgCtx.fillRect(0, 0, 800, 600);
// Add some pattern to background
bgCtx.fillStyle = '#1a1a2a';
for (let i = 0; i < 100; i++) {
const x = Math.random() * 800;
const y = Math.random() * 600;
const size = Math.random() * 5 + 2;
bgCtx.fillRect(x, y, size, size);
}
// Create player image
const playerCanvas = document.getElementById('playerCanvas');
const playerCtx = playerCanvas.getContext('2d');
playerCtx.fillStyle = '#ff0000';
playerCtx.fillRect(0, 0, 32, 32);
playerCtx.fillStyle = '#ff5555';
playerCtx.fillRect(8, 8, 16, 16);
// Create tile image
const tileCanvas = document.getElementById('tileCanvas');
const tileCtx = tileCanvas.getContext('2d');
tileCtx.fillStyle = '#ffffff';
tileCtx.fillRect(0, 0, 32, 32);
tileCtx.strokeStyle = '#cccccc';
tileCtx.lineWidth = 1;
tileCtx.strokeRect(0.5, 0.5, 31, 31);
// Create download links
const downloadDiv = document.getElementById('downloadLinks');
function createDownloadLink(canvas, filename) {
const link = document.createElement('a');
link.download = filename;
link.href = canvas.toDataURL('image/png');
link.textContent = `Download ${filename}`;
link.style.display = 'block';
link.style.margin = '10px';
downloadDiv.appendChild(link);
// Auto-click to download
setTimeout(() => link.click(), 500);
}
createDownloadLink(bgCanvas, 'background.png');
createDownloadLink(playerCanvas, 'player.png');
createDownloadLink(tileCanvas, 'tile.png');
</script>
</body>
</html>

View file

@ -1,26 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Player Image</title>
<style>
body { margin: 0; background: #333; display: flex; justify-content: center; align-items: center; height: 100vh; }
canvas { display: block; border: 1px solid #fff; background: #000; }
</style>
</head>
<body>
<canvas id="canvas" width="32" height="32"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Draw player
ctx.fillStyle = '#ff0000';
ctx.fillRect(0, 0, 32, 32);
ctx.fillStyle = '#ff5555';
ctx.fillRect(8, 8, 16, 16);
// Instructions (shown in console)
console.log('Right-click and save this image as player.png');
</script>
</body>
</html>

View file

@ -1 +0,0 @@

View file

@ -1,54 +0,0 @@
// Simple script to create basic placeholder images
// Just open this in a browser and it will create data URLs you can copy
document.body.innerHTML = `
<h1>Placeholder Images for WhoPixels</h1>
<div>
<h2>Background (800x600)</h2>
<canvas id="bg" width="800" height="600" style="border:1px solid #000; max-width: 100%;"></canvas>
<p id="bgData"></p>
</div>
<div>
<h2>Player (32x32)</h2>
<canvas id="player" width="32" height="32" style="border:1px solid #000;"></canvas>
<p id="playerData"></p>
</div>
<div>
<h2>Tile (32x32)</h2>
<canvas id="tile" width="32" height="32" style="border:1px solid #000;"></canvas>
<p id="tileData"></p>
</div>
`;
// Draw background
const bgCanvas = document.getElementById('bg');
const bgCtx = bgCanvas.getContext('2d');
bgCtx.fillStyle = '#222233';
bgCtx.fillRect(0, 0, 800, 600);
for (let i = 0; i < 100; i++) {
bgCtx.fillStyle = '#1a1a2a';
const x = Math.random() * 800;
const y = Math.random() * 600;
const size = Math.random() * 5 + 2;
bgCtx.fillRect(x, y, size, size);
}
document.getElementById('bgData').textContent = 'Save this image as background.png';
// Draw player
const playerCanvas = document.getElementById('player');
const playerCtx = playerCanvas.getContext('2d');
playerCtx.fillStyle = '#ff0000';
playerCtx.fillRect(0, 0, 32, 32);
playerCtx.fillStyle = '#ff5555';
playerCtx.fillRect(8, 8, 16, 16);
document.getElementById('playerData').textContent = 'Save this image as player.png';
// Draw tile
const tileCanvas = document.getElementById('tile');
const tileCtx = tileCanvas.getContext('2d');
tileCtx.fillStyle = '#ffffff';
tileCtx.fillRect(0, 0, 32, 32);
tileCtx.strokeStyle = '#cccccc';
tileCtx.lineWidth = 1;
tileCtx.strokeRect(0.5, 0.5, 31, 31);
document.getElementById('tileData').textContent = 'Save this image as tile.png';

View file

@ -1,27 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Tile Image</title>
<style>
body { margin: 0; background: #333; display: flex; justify-content: center; align-items: center; height: 100vh; }
canvas { display: block; border: 1px solid #fff; background: #000; }
</style>
</head>
<body>
<canvas id="canvas" width="32" height="32"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Draw tile
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, 32, 32);
ctx.strokeStyle = '#cccccc';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, 31, 31);
// Instructions (shown in console)
console.log('Right-click and save this image as tile.png');
</script>
</body>
</html>

View file

@ -1 +0,0 @@

View file

@ -1,14 +0,0 @@
body {
margin: 0;
padding: 0;
background-color: #000;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-family: 'Arial', sans-serif;
}
#game-container {
box-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
}

View file

@ -1,200 +0,0 @@
// Liste der NPC-Charaktere mit Namen und Persönlichkeiten
// Kategorien: Erfinder, Wissenschaftler, Künstler, Entdecker, Vordenker
const npcCharacters = [
// === ERFINDER (IDs 1-10) ===
{
id: 1,
name: 'Leonardo da Vinci',
personality:
'Ein vielseitiger Universalgelehrter der Renaissance, bekannt für seine Kunst und Erfindungen. Er spricht nachdenklich und philosophisch, oft mit Metaphern über Natur und Kunst. Er ist neugierig und beobachtet alles genau.',
hint: 'Meine Skizzenbücher enthalten Flugmaschinen und anatomische Studien, die ihrer Zeit weit voraus waren.',
},
{
id: 2,
name: 'Nikola Tesla',
personality:
'Ein exzentrischer Elektroingenieur mit visionären Ideen. Er spricht leidenschaftlich über Elektrizität und drahtlose Energieübertragung. Er ist brillant, aber auch etwas eigenartig und distanziert.',
hint: 'Meine Arbeiten mit Wechselstrom revolutionierten die Art, wie wir Energie nutzen.',
},
{
id: 3,
name: 'Marie Curie',
personality:
'Eine entschlossene und präzise Wissenschaftlerin, die für ihre Entdeckungen im Bereich der Radioaktivität bekannt ist. Sie spricht klar und methodisch, mit einem starken Fokus auf wissenschaftliche Genauigkeit.',
hint: 'Meine Forschung zu radioaktiven Elementen brachte mir zwei Nobelpreise ein, obwohl sie letztendlich meine Gesundheit beeinträchtigte.',
},
{
id: 4,
name: 'Thomas Edison',
personality:
'Ein pragmatischer und geschäftstüchtiger Erfinder mit über 1.000 Patenten. Er spricht direkt und selbstbewusst, oft mit praktischen Beispielen. Er betont harte Arbeit und Ausdauer über Inspiration.',
hint: 'Meine Erfindung brachte Licht in die Dunkelheit und veränderte die Art, wie Menschen nach Sonnenuntergang leben.',
},
{
id: 5,
name: 'Ada Lovelace',
personality:
'Eine visionäre Mathematikerin des 19. Jahrhunderts mit einer einzigartigen Verbindung von Logik und Kreativität. Sie spricht eloquent und präzise, mit einer Mischung aus poetischer und mathematischer Sprache.',
hint: 'Ich schrieb den ersten Algorithmus für eine Maschine, lange bevor Computer existierten.',
},
{
id: 6,
name: 'Archimedes',
personality:
'Ein genialer antiker Mathematiker und Erfinder aus Syrakus. Er ist von mathematischen Problemen fasziniert und kann sich darin verlieren. Er spricht mit Begeisterung über Geometrie und physikalische Prinzipien.',
hint: "Mein berühmtester Ausruf war 'Heureka!' als ich das Prinzip des Auftriebs in der Badewanne entdeckte.",
},
{
id: 7,
name: 'Johannes Gutenberg',
personality:
'Ein geduldiger und präziser Handwerker, der die Druckkunst revolutionierte. Er spricht bescheiden über seine Erfindung, aber mit Stolz über deren Auswirkungen auf die Verbreitung von Wissen.',
hint: 'Meine Erfindung machte Bücher für die Massen zugänglich und veränderte die Verbreitung von Wissen für immer.',
},
{
id: 8,
name: 'Grace Hopper',
personality:
'Eine pragmatische und humorvolle Computerpionierin und Marineoffizierin. Sie erklärt komplexe Konzepte mit einfachen Analogien und hat einen trockenen Humor. Sie ist direkt und lösungsorientiert.',
hint: "Ich entwickelte den ersten Compiler und fand einmal einen echten 'Bug' im Computer - eine Motte, die einen Fehler verursachte.",
},
{
id: 9,
name: 'Alexander Graham Bell',
personality:
'Ein einfallsreicher und geduldiger Erfinder, der sich für Kommunikation und Gehörlose engagierte. Er spricht deutlich und artikuliert, mit einem schottischen Akzent. Er ist enthusiastisch, wenn er über seine Erfindungen spricht.',
hint: 'Meine Erfindung ermöglichte es Menschen, über große Entfernungen miteinander zu sprechen.',
},
{
id: 10,
name: 'Hedy Lamarr',
personality:
'Eine glamouröse Hollywoodschauspielerin mit einem brillanten technischen Verstand. Sie spricht charmant und selbstbewusst, mit einer Mischung aus Eleganz und technischem Scharfsinn. Sie ist kreativ und unkonventionell.',
hint: 'Meine Erfindung der Frequenzsprungverfahren bildet die Grundlage für moderne WLAN- und Bluetooth-Technologien, obwohl viele mich nur als Filmstar kennen.',
},
// === WISSENSCHAFTLER (IDs 11-18) ===
{
id: 11,
name: 'Albert Einstein',
personality:
'Ein genialer theoretischer Physiker mit einem verschmitzten Humor. Er spricht in Gleichnissen und Gedankenexperimenten. Er liebt es, scheinbar einfache Fragen zu stellen, die tiefgreifende Wahrheiten offenbaren.',
hint: 'Meine berühmteste Gleichung verbindet Masse und Energie mit der Lichtgeschwindigkeit.',
},
{
id: 12,
name: 'Isaac Newton',
personality:
'Ein brillanter, aber etwas mürrischer Naturphilosoph. Er spricht präzise und duldet keine Ungenauigkeiten. Er ist stolz auf seine Entdeckungen, kann aber nachtragend sein gegenüber Rivalen.',
hint: 'Ein fallender Apfel inspirierte mich zu einer Theorie, die das Universum erklärte.',
},
{
id: 13,
name: 'Charles Darwin',
personality:
'Ein geduldiger und detailverliebter Naturforscher. Er spricht bedächtig und untermauert jede Aussage mit Beobachtungen. Er ist bescheiden, aber überzeugt von seiner Theorie.',
hint: 'Meine Reise auf der Beagle zu den Galápagos-Inseln veränderte unser Verständnis des Lebens grundlegend.',
},
{
id: 14,
name: 'Galileo Galilei',
personality:
'Ein mutiger und streitbarer Wissenschaftler, der sich nicht scheut, Autoritäten herauszufordern. Er spricht leidenschaftlich über seine Beobachtungen und verteidigt die Wahrheit, auch wenn sie unpopulär ist.',
hint: 'Ich richtete mein Fernrohr zum Himmel und bewies, dass die Erde nicht der Mittelpunkt des Universums ist.',
},
{
id: 15,
name: 'Rosalind Franklin',
personality:
'Eine akribische und entschlossene Wissenschaftlerin. Sie spricht sachlich und direkt, mit wenig Geduld für Ungenauigkeiten. Sie ist brillant in der Kristallographie und Röntgenbeugung.',
hint: 'Mein Foto 51 war der Schlüssel zur Entschlüsselung der Doppelhelix-Struktur der DNA.',
},
{
id: 16,
name: 'Stephen Hawking',
personality:
'Ein humorvoller und tiefgründiger Kosmologe, der das Universum für alle verständlich macht. Er nutzt bildhafte Sprache und Witze, um komplexe Konzepte zu erklären.',
hint: 'Meine Forschung über Schwarze Löcher zeigte, dass sie nicht ganz so schwarz sind, wie man dachte.',
},
{
id: 17,
name: 'Alexander von Humboldt',
personality:
'Ein enthusiastischer Naturforscher und Weltreisender. Er spricht mit grenzenloser Begeisterung über die Natur, sieht alles als zusammenhängendes Ganzes und erzählt gern von seinen Expeditionen.',
hint: 'Meine Reisen durch Südamerika und meine Kosmos-Werke begründeten die moderne Geographie und Ökologie.',
},
{
id: 18,
name: 'Lise Meitner',
personality:
'Eine bescheidene aber brillante Physikerin. Sie spricht ruhig und bedacht, erklärt Kernphysik mit erstaunlicher Klarheit. Sie ist enttäuscht über fehlende Anerkennung, aber nie verbittert.',
hint: 'Ich erklärte die Kernspaltung und benannte sie, doch der Nobelpreis dafür ging an meinen Kollegen.',
},
// === KÜNSTLER & DENKER (IDs 19-26) ===
{
id: 19,
name: 'Wolfgang Amadeus Mozart',
personality:
'Ein lebhafter und verspielter Komponist mit unglaublichem Talent. Er spricht schnell und enthusiastisch, wechselt zwischen ernsthaften musikalischen Diskussionen und kindlichem Humor.',
hint: 'Ich komponierte meine erste Sinfonie mit acht Jahren und schrieb über 600 Werke in meinem kurzen Leben.',
},
{
id: 20,
name: 'Frida Kahlo',
personality:
'Eine leidenschaftliche und unbeugsame Künstlerin. Sie spricht direkt und emotional, mit einem starken Bezug zur mexikanischen Kultur. Ihr Schmerz und ihre Stärke durchdringen jedes Wort.',
hint: 'Meine Selbstporträts zeigen meinen Schmerz und meine Identität, und mein blaues Haus in Coyoacán ist heute ein Museum.',
},
{
id: 21,
name: 'William Shakespeare',
personality:
'Ein wortgewandter Dramatiker mit tiefem Verständnis der menschlichen Natur. Er spricht in eleganten Formulierungen und liebt Wortspiele. Er sieht die Welt als Bühne.',
hint: 'Meine Stücke werden seit über 400 Jahren aufgeführt und haben die englische Sprache mit zahllosen neuen Wörtern bereichert.',
},
{
id: 22,
name: 'Cleopatra VII.',
personality:
'Eine charismatische und kluge Herrscherin. Sie spricht mehrere Sprachen fließend und ist eine meisterhafte Diplomatin. Sie verbindet Intelligenz mit strategischem Denken.',
hint: 'Ich war die letzte Pharaonin Ägyptens und sprach neun Sprachen, um mein Reich durch Diplomatie zu schützen.',
},
{
id: 23,
name: 'Ludwig van Beethoven',
personality:
'Ein leidenschaftlicher und stürmischer Komponist. Er spricht intensiv und emotional, manchmal aufbrausend. Trotz seines Gehörverlusts komponierte er seine größten Werke.',
hint: 'Meine neunte Sinfonie schrieb ich, als ich bereits vollständig taub war, und sie enthält die berühmte Ode an die Freude.',
},
{
id: 24,
name: 'Konfuzius',
personality:
'Ein weiser und geduldiger Lehrer der chinesischen Philosophie. Er spricht in kurzen, bedeutungsvollen Sätzen und beantwortet Fragen oft mit Gegenfragen. Er betont Respekt, Bildung und moralisches Handeln.',
hint: 'Meine Lehren über Tugend und gesellschaftliche Harmonie prägen die chinesische Kultur seit über 2.500 Jahren.',
},
{
id: 25,
name: 'Hypatia von Alexandria',
personality:
'Eine brillante Mathematikerin und Philosophin der Spätantike. Sie spricht klar und lehrreich, mit der Autorität einer Gelehrten. Sie verteidigt die Vernunft gegen Fanatismus.',
hint: 'Ich war eine der ersten Mathematikerinnen der Geschichte und lehrte Astronomie im antiken Alexandria.',
},
{
id: 26,
name: 'Nikola Kopernikus',
personality:
'Ein nachdenklicher und vorsichtiger Gelehrter. Er spricht bedacht und diplomatisch, da seine Erkenntnisse die kirchliche Lehre infrage stellten. Er ist überzeugt von der Kraft der Beobachtung.',
hint: 'Mein heliozentrisches Weltbild stellte die Erde aus dem Zentrum des Universums und setzte die Sonne an ihre Stelle.',
},
];
// Mache die Charaktere sowohl im Browser als auch in Node.js verfügbar
if (typeof window !== 'undefined') {
// Browser-Umgebung
window.npcCharacters = npcCharacters;
} else if (typeof module !== 'undefined') {
// Node.js-Umgebung
module.exports = npcCharacters;
}

View file

@ -1,47 +0,0 @@
// This script uses Node.js to generate placeholder images for our game
const fs = require('fs');
const { createCanvas } = require('canvas');
// Create background image (800x600)
const bgCanvas = createCanvas(800, 600);
const bgCtx = bgCanvas.getContext('2d');
bgCtx.fillStyle = '#222233';
bgCtx.fillRect(0, 0, 800, 600);
// Add some pattern to background
bgCtx.fillStyle = '#1a1a2a';
for (let i = 0; i < 100; i++) {
const x = Math.random() * 800;
const y = Math.random() * 600;
const size = Math.random() * 5 + 2;
bgCtx.fillRect(x, y, size, size);
}
// Create player image (32x32)
const playerCanvas = createCanvas(32, 32);
const playerCtx = playerCanvas.getContext('2d');
playerCtx.fillStyle = '#ff0000';
playerCtx.fillRect(0, 0, 32, 32);
playerCtx.fillStyle = '#ff5555';
playerCtx.fillRect(8, 8, 16, 16);
// Create tile image (32x32)
const tileCanvas = createCanvas(32, 32);
const tileCtx = tileCanvas.getContext('2d');
tileCtx.fillStyle = '#ffffff';
tileCtx.fillRect(0, 0, 32, 32);
tileCtx.strokeStyle = '#cccccc';
tileCtx.lineWidth = 1;
tileCtx.strokeRect(0.5, 0.5, 31, 31);
// Save images
const bgBuffer = bgCanvas.toBuffer('image/png');
fs.writeFileSync('./assets/background.png', bgBuffer);
const playerBuffer = playerCanvas.toBuffer('image/png');
fs.writeFileSync('./assets/player.png', playerBuffer);
const tileBuffer = tileCanvas.toBuffer('image/png');
fs.writeFileSync('./assets/tile.png', tileBuffer);
console.log('All placeholder images have been generated!');

View file

@ -1,38 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WhoPixels - Pixel Game</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div id="game-container"></div>
<!-- Phaser Library -->
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
<!-- Game Data -->
<script src="data/npc_characters.js"></script>
<!-- Config -->
<script src="js/config/constants.js"></script>
<script src="js/config/i18n.js"></script>
<!-- Manager Classes -->
<script src="js/managers/StorageManager.js"></script>
<script src="js/managers/SoundManager.js"></script>
<script src="js/managers/WorldManager.js"></script>
<script src="js/managers/PlayerManager.js"></script>
<script src="js/managers/NPCManager.js"></script>
<script src="js/managers/ChatUI.js"></script>
<script src="js/managers/TouchControls.js"></script>
<!-- Game Scenes -->
<script src="js/scenes/BootScene.js"></script>
<script src="js/scenes/MainMenuScene.js"></script>
<script src="js/scenes/GameScene.js"></script>
<script src="js/scenes/RPGScene.js"></script>
<script src="js/main.js"></script>
</body>
</html>

View file

@ -1,121 +0,0 @@
/**
* @typedef {Object} NPCCharacter
* @property {number} id
* @property {string} name
* @property {string} personality
* @property {string} hint
*/
/**
* @typedef {Object} NPCState
* @property {boolean} isInConversation
* @property {boolean} isWaitingForResponse
* @property {boolean} identityRevealed
* @property {number[]} discoveredNPCs
* @property {number} currentNpcIndex
*/
/**
* @typedef {Object} ConversationEntry
* @property {'user'|'npc'} type
* @property {string} message
*/
/**
* @typedef {Object} MapConfig
* @property {number} widthInPixels
* @property {number} heightInPixels
* @property {number} tileWidth
* @property {number} tileHeight
*/
/** @type {Readonly<typeof GAME_CONFIG>} */
const GAME_CONFIG = {
// Spielfeld
GRID_SIZE: 11,
TILE_SIZE: 40,
get MAP_WIDTH() {
return this.GRID_SIZE * this.TILE_SIZE;
},
get MAP_HEIGHT() {
return this.GRID_SIZE * this.TILE_SIZE;
},
// Spieler
PLAYER_SCALE: 2.4,
PLAYER_SPEED: 160,
// NPC
NPC_SCALE: 2.4,
NPC_SPEED: 50,
NPC_MOVE_INTERVAL: 3000,
NPC_MOVE_CHANCE: 0.3,
NPC_INTERACTION_DISTANCE: 100,
NPC_WALK_DURATION: 2000,
// Tiles
TILE_SCALE: 2.0,
// Chat-UI
CHAT_HEIGHT: 250,
CHAT_PADDING: 20,
CHAT_INPUT_HEIGHT: 40,
CHAT_SEND_BUTTON_WIDTH: 85,
// Farben
COLORS: {
CHAT_BG: 0x1a1a2a,
CHAT_BG_ALPHA: 0.9,
CHAT_BORDER: 0x4a6fa5,
INPUT_BG: 0x2a2a3a,
SEND_BUTTON: 0x4a6fa5,
SEND_BUTTON_HOVER: 0x5a7fb5,
CLOSE_BUTTON: 0x8a4a4a,
CLOSE_BUTTON_HOVER: 0x9a5a5a,
NPC_ANONYMOUS_TINT: 0x000000,
REVEAL_FLASH: 0xffff00,
TEXT_WHITE: '#ffffff',
TEXT_PLACEHOLDER: '#bbbbbb',
TEXT_NPC_RESPONSE: '#e0e0ff',
TEXT_REVEALED: '#ffff00',
BACK_BUTTON_BG: '#4a4a4a',
BACK_BUTTON_HOVER: '#ff0',
},
// Schriftgrößen
FONTS: {
CHAT_TITLE: '18px',
CHAT_INPUT: '16px',
CHAT_RESPONSE: '16px',
NPC_LABEL: '10px',
NPC_LABEL_REVEALED: '12px',
BACK_BUTTON: '18px',
INSTRUCTIONS: '16px',
REVEAL_TEXT: '24px',
NEW_NPC_TEXT: '20px',
},
// Animationen
ANIMATIONS: {
REVEAL_FLASH_DURATION: 300,
REVEAL_TEXT_FADE_DURATION: 2000,
REVEAL_TEXT_DELAY: 3000,
NEW_NPC_SPAWN_DELAY: 1000,
NEW_NPC_TEXT_FADE_DURATION: 1500,
NEW_NPC_TEXT_DELAY: 2500,
PARTICLE_LIFETIME: 1000,
PARTICLE_STOP_DELAY: 2000,
INTERACTION_PROMPT_DURATION: 2000,
},
// Terrain-Verteilung
TERRAIN: {
WALL_MOSS_CHANCE: 0.3,
GRASS_CHANCE: 0.4,
GRASS_FLOWER_CHANCE: 0.7,
DIRT_CHANCE: 0.9,
},
// API
API_URL: 'http://localhost:3000/api/chat',
};

View file

@ -1,183 +0,0 @@
/**
* Einfaches i18n-System für WhoPixels.
* Unterstützt Deutsch und Englisch.
*/
const I18N = {
_currentLang: 'de',
translations: {
de: {
// Hauptmenü
title: 'WhoPixels',
subtitle: 'Ein Pixel-Abenteuer',
startGame: 'RPG Spiel starten',
pixelEditor: 'Pixel Editor',
resetProgress: 'Fortschritt zurücksetzen',
progressReset: 'Fortschritt zurückgesetzt!',
statsRevealed: 'Entlarvt',
statsAvgGuesses: 'Durchschn. Fragen',
statsBestStreak: 'Beste Serie',
// RPG Scene
backToMenu: 'Zurück zum Menü',
arrowKeysToMove: 'Pfeiltasten zum Bewegen',
pressEToTalk: 'Drücke E zum Sprechen',
// Chat
chatTitle: 'Gespräch mit NPC',
chatWithUnknown: 'Gespräch mit Unbekanntem',
chatWith: 'Gespräch mit',
typePlaceholder: 'Tippe deine Nachricht hier ein...',
sending: 'Nachricht wird gesendet...',
send: 'Senden',
talkToNpc: 'Sprich mit dem NPC...',
riddleIntro: 'Verhüllt von Zeit,\nwer könnt es sein?',
errorNoResponse: 'Entschuldigung, ich habe dich nicht verstanden.',
errorCantRespond: 'Entschuldigung, ich kann gerade nicht antworten.',
you: 'Du',
unknown: '???',
// NPC
anonymous: 'Anonym',
revealed: 'entlarvt',
youRevealed: 'Du hast {name} entlarvt!',
newNpcAppeared: 'Ein neuer geheimnisvoller NPC ist erschienen!',
saveLoaded: 'Spielstand geladen: {count} NPCs bereits entdeckt',
// Tutorial
tutorialWelcome: 'Willkommen bei WhoPixels!',
tutorialDesc:
'Geheimnisvolle Figuren betreten die Arena.\nFinde durch Fragen heraus, wer sie sind!',
tutorialControlsDesktop: 'Pfeiltasten = Bewegen\nE = Mit NPC sprechen',
tutorialControlsMobile: 'Joystick links = Bewegen\nButton rechts = Interagieren',
tutorialStart: 'Tippe oder drücke eine Taste zum Starten',
// Pixel Editor
editorTitle: 'Pixel Editor',
back: 'Zurück',
clear: 'Löschen',
saveAsAvatar: 'Als Avatar',
load: 'Laden',
colors: 'Farben',
gridCleared: 'Grid gelöscht',
avatarSaved: 'Avatar gespeichert! Wird im RPG-Spiel verwendet.',
avatarLoaded: 'Avatar geladen!',
noAvatarFound: 'Kein gespeicherter Avatar gefunden',
saveError: 'Fehler beim Speichern!',
loadError: 'Fehler beim Laden!',
},
en: {
// Main Menu
title: 'WhoPixels',
subtitle: 'A Pixel Adventure',
startGame: 'Start RPG Game',
pixelEditor: 'Pixel Editor',
resetProgress: 'Reset Progress',
progressReset: 'Progress reset!',
statsRevealed: 'Revealed',
statsAvgGuesses: 'Avg. Questions',
statsBestStreak: 'Best Streak',
// RPG Scene
backToMenu: 'Back to Menu',
arrowKeysToMove: 'Arrow keys to move',
pressEToTalk: 'Press E to talk',
// Chat
chatTitle: 'Chat with NPC',
chatWithUnknown: 'Chat with Unknown',
chatWith: 'Chat with',
typePlaceholder: 'Type your message here...',
sending: 'Sending message...',
send: 'Send',
talkToNpc: 'Talk to the NPC...',
riddleIntro: 'Veiled by time,\nwho could it be?',
errorNoResponse: "Sorry, I didn't understand you.",
errorCantRespond: "Sorry, I can't respond right now.",
you: 'You',
unknown: '???',
// NPC
anonymous: 'Anonymous',
revealed: 'revealed',
youRevealed: 'You revealed {name}!',
newNpcAppeared: 'A new mysterious NPC has appeared!',
saveLoaded: 'Save loaded: {count} NPCs already discovered',
// Tutorial
tutorialWelcome: 'Welcome to WhoPixels!',
tutorialDesc: 'Mysterious figures enter the arena.\nAsk questions to find out who they are!',
tutorialControlsDesktop: 'Arrow keys = Move\nE = Talk to NPC',
tutorialControlsMobile: 'Left joystick = Move\nRight button = Interact',
tutorialStart: 'Tap or press any key to start',
// Pixel Editor
editorTitle: 'Pixel Editor',
back: 'Back',
clear: 'Clear',
saveAsAvatar: 'Save Avatar',
load: 'Load',
colors: 'Colors',
gridCleared: 'Grid cleared',
avatarSaved: 'Avatar saved! Will be used in RPG game.',
avatarLoaded: 'Avatar loaded!',
noAvatarFound: 'No saved avatar found',
saveError: 'Error saving!',
loadError: 'Error loading!',
},
},
/**
* Sprache wechseln
* @param {'de'|'en'} lang
*/
setLanguage(lang) {
if (this.translations[lang]) {
this._currentLang = lang;
localStorage.setItem('whopixels_lang', lang);
}
},
/** @returns {'de'|'en'} */
getLanguage() {
return this._currentLang;
},
/** Sprache aus LocalStorage laden */
init() {
const saved = localStorage.getItem('whopixels_lang');
if (saved && this.translations[saved]) {
this._currentLang = saved;
}
},
/**
* Übersetzung abrufen
* @param {string} key
* @param {Record<string, string>} [params] - Platzhalter ersetzen, z.B. {name: 'Tesla'}
* @returns {string}
*/
t(key, params) {
const lang = this.translations[this._currentLang];
let text = lang[key] || this.translations.de[key] || key;
if (params) {
Object.entries(params).forEach(([k, v]) => {
text = text.replace(`{${k}}`, v);
});
}
return text;
},
/** Sprache umschalten */
toggle() {
const next = this._currentLang === 'de' ? 'en' : 'de';
this.setLanguage(next);
return next;
},
};
// Beim Laden initialisieren
I18N.init();

View file

@ -1,18 +0,0 @@
// Game configuration
const config = {
type: Phaser.AUTO,
width: 800,
height: 600,
pixelArt: true,
physics: {
default: 'arcade',
arcade: {
gravity: { y: 0 },
debug: false,
},
},
scene: [BootScene, MainMenuScene, GameScene, RPGScene],
};
// Create and start the game
const game = new Phaser.Game(config);

View file

@ -1,366 +0,0 @@
class ChatUI {
/** @param {RPGScene} scene */
constructor(scene) {
this.scene = scene;
/** @type {string} */
this.userInput = '';
/** @type {string} */
this.lastNpcResponse = '';
/** @type {ConversationEntry[]} */
this.conversationHistory = [];
/** @type {Record<string, Phaser.GameObjects.GameObject>} */
this.elements = {};
}
create() {
const { CHAT_HEIGHT, CHAT_PADDING, CHAT_SEND_BUTTON_WIDTH, CHAT_INPUT_HEIGHT, COLORS, FONTS } =
GAME_CONFIG;
const width = this.scene.cameras.main.width;
const height = this.scene.cameras.main.height;
const chatWidth = width - CHAT_PADDING * 2;
const chatTop = height - CHAT_HEIGHT - CHAT_PADDING;
// Chat-Hintergrund
this.elements.background = this.scene.add.graphics();
this.elements.background.fillStyle(COLORS.CHAT_BG, COLORS.CHAT_BG_ALPHA);
this.elements.background.fillRoundedRect(CHAT_PADDING, chatTop, chatWidth, CHAT_HEIGHT, 10);
this.elements.background.lineStyle(2, COLORS.CHAT_BORDER, 1);
this.elements.background.strokeRoundedRect(CHAT_PADDING, chatTop, chatWidth, CHAT_HEIGHT, 10);
this.elements.background.setScrollFactor(0);
this.elements.background.setVisible(false);
// Titel
this.elements.title = this.scene.add.text(width / 2, chatTop + 20, I18N.t('chatTitle'), {
fontSize: FONTS.CHAT_TITLE,
fontFamily: 'Arial',
fontStyle: 'bold',
fill: COLORS.TEXT_WHITE,
align: 'center',
});
this.elements.title.setOrigin(0.5, 0.5);
this.elements.title.setScrollFactor(0);
this.elements.title.setVisible(false);
// NPC-Antwortbereich
this.elements.response = this.scene.add.text(CHAT_PADDING + 15, chatTop + 50, '', {
fontSize: FONTS.CHAT_RESPONSE,
fontFamily: 'Arial',
fill: COLORS.TEXT_NPC_RESPONSE,
padding: { x: 10, y: 10 },
wordWrap: { width: chatWidth - 50 },
lineSpacing: 6,
});
this.elements.response.setScrollFactor(0);
this.elements.response.setVisible(false);
// Trennlinie
this.elements.divider = this.scene.add.graphics();
this.elements.divider.lineStyle(1, COLORS.CHAT_BORDER, 0.8);
this.elements.divider.lineBetween(
CHAT_PADDING + 15,
height - 90,
width - CHAT_PADDING - 15,
height - 90
);
this.elements.divider.setScrollFactor(0);
this.elements.divider.setVisible(false);
// Eingabefeld-Hintergrund
this.elements.inputBg = this.scene.add.graphics();
this.elements.inputBg.fillStyle(COLORS.INPUT_BG, 1);
this.elements.inputBg.fillRoundedRect(
CHAT_PADDING + 15,
height - 70,
chatWidth - 230,
CHAT_INPUT_HEIGHT,
5
);
this.elements.inputBg.setScrollFactor(0);
this.elements.inputBg.setVisible(false);
// Eingabefeld-Text
this.elements.input = this.scene.add.text(
CHAT_PADDING + 25,
height - 65,
I18N.t('typePlaceholder'),
{
fontSize: FONTS.CHAT_INPUT,
fontFamily: 'Arial',
fill: COLORS.TEXT_PLACEHOLDER,
padding: { x: 5, y: 5 },
}
);
this.elements.input.setScrollFactor(0);
this.elements.input.setVisible(false);
// Senden-Button
this._createSendButton(width, height, chatWidth);
// Schließen-Button
this._createCloseButton(width, height, chatTop);
// Tastatureingabe
this.scene.input.keyboard.on('keydown', (event) => this._handleKeyInput(event), this);
}
_createSendButton(width, height, chatWidth) {
const { CHAT_PADDING, CHAT_SEND_BUTTON_WIDTH, CHAT_INPUT_HEIGHT, COLORS, FONTS } = GAME_CONFIG;
const btnX = width - CHAT_PADDING - CHAT_SEND_BUTTON_WIDTH - 15;
this.elements.sendBg = this.scene.add.graphics();
this.elements.sendBg.fillStyle(COLORS.SEND_BUTTON, 1);
this.elements.sendBg.fillRoundedRect(
btnX,
height - 70,
CHAT_SEND_BUTTON_WIDTH,
CHAT_INPUT_HEIGHT,
5
);
this.elements.sendBg.setScrollFactor(0);
this.elements.sendBg.setVisible(false);
this.elements.sendBtn = this.scene.add.text(
btnX + CHAT_SEND_BUTTON_WIDTH / 2,
height - 50,
I18N.t('send'),
{
fontSize: FONTS.CHAT_INPUT,
fontFamily: 'Arial',
fontStyle: 'bold',
fill: COLORS.TEXT_WHITE,
}
);
this.elements.sendBtn.setOrigin(0.5, 0.5);
this.elements.sendBtn.setScrollFactor(0);
this.elements.sendBtn.setVisible(false);
this.elements.sendBtn.setInteractive({ useHandCursor: true });
this.elements.sendBtn.on('pointerdown', () => this.sendMessage());
this.elements.sendBtn.on('pointerover', () => {
this.elements.sendBg.clear();
this.elements.sendBg.fillStyle(COLORS.SEND_BUTTON_HOVER, 1);
this.elements.sendBg.fillRoundedRect(
btnX,
height - 70,
CHAT_SEND_BUTTON_WIDTH,
CHAT_INPUT_HEIGHT,
5
);
});
this.elements.sendBtn.on('pointerout', () => {
this.elements.sendBg.clear();
this.elements.sendBg.fillStyle(COLORS.SEND_BUTTON, 1);
this.elements.sendBg.fillRoundedRect(
btnX,
height - 70,
CHAT_SEND_BUTTON_WIDTH,
CHAT_INPUT_HEIGHT,
5
);
});
}
_createCloseButton(width, height, chatTop) {
const { CHAT_PADDING, COLORS } = GAME_CONFIG;
const size = 24;
const pad = 10;
const cx = width - CHAT_PADDING - pad;
const cy = chatTop + pad + size / 2;
this.elements.closeBg = this.scene.add.graphics();
this.elements.closeBg.fillStyle(COLORS.CLOSE_BUTTON, 0.7);
this.elements.closeBg.fillCircle(cx, cy, size / 2);
this.elements.closeBg.setScrollFactor(0);
this.elements.closeBg.setVisible(false);
this.elements.closeIcon = this.scene.add.graphics();
this.elements.closeIcon.lineStyle(3, 0xffffff, 1);
this.elements.closeIcon.lineBetween(cx - size / 3, cy - size / 6, cx + size / 3, cy + size / 6);
this.elements.closeIcon.lineBetween(cx + size / 3, cy - size / 6, cx - size / 3, cy + size / 6);
this.elements.closeIcon.setScrollFactor(0);
this.elements.closeIcon.setVisible(false);
this.elements.closeHit = this.scene.add.rectangle(cx, cy, size * 1.5, size * 1.5);
this.elements.closeHit.setScrollFactor(0);
this.elements.closeHit.setVisible(false);
this.elements.closeHit.setInteractive({ useHandCursor: true });
this.elements.closeHit.on('pointerdown', () => this.close());
this.elements.closeHit.on('pointerover', () => {
this.elements.closeBg.clear();
this.elements.closeBg.fillStyle(COLORS.CLOSE_BUTTON_HOVER, 0.9);
this.elements.closeBg.fillCircle(cx, cy, (size / 2) * 1.1);
});
this.elements.closeHit.on('pointerout', () => {
this.elements.closeBg.clear();
this.elements.closeBg.fillStyle(COLORS.CLOSE_BUTTON, 0.7);
this.elements.closeBg.fillCircle(cx, cy, size / 2);
});
}
open() {
Object.values(this.elements).forEach((el) => el.setVisible(true));
this.userInput = '';
this.elements.input.setText(I18N.t('typePlaceholder'));
this.elements.input.setStyle({ fill: GAME_CONFIG.COLORS.TEXT_PLACEHOLDER });
this.elements.response.setText(this.lastNpcResponse || I18N.t('talkToNpc'));
this.elements.title.setText(I18N.t('chatWithUnknown'));
}
close() {
Object.values(this.elements).forEach((el) => el.setVisible(false));
const npcManager = this.scene.npcManager;
npcManager.state.isInConversation = false;
npcManager.npcDialog.setVisible(false);
}
_handleKeyInput(event) {
if (!this.elements.input.visible) return;
if (event.keyCode === 13) {
this.sendMessage();
return;
}
if (event.keyCode === 27) {
this.close();
return;
}
if (event.keyCode === 8 && this.userInput.length > 0) {
this.userInput = this.userInput.slice(0, -1);
} else if (event.keyCode >= 32 && event.keyCode <= 126) {
this.userInput += event.key;
}
const { COLORS } = GAME_CONFIG;
if (this.userInput.length === 0) {
this.elements.input.setText(I18N.t('typePlaceholder'));
this.elements.input.setStyle({ fill: COLORS.TEXT_PLACEHOLDER });
} else {
this.elements.input.setText(this.userInput);
this.elements.input.setStyle({ fill: COLORS.TEXT_WHITE });
this.scene.tweens.add({
targets: this.elements.inputBg,
alpha: 0.7,
duration: 50,
yoyo: true,
ease: 'Power1',
});
}
}
async sendMessage() {
const npcManager = this.scene.npcManager;
if (this.userInput.length === 0 || npcManager.state.isWaitingForResponse) return;
const message = this.userInput;
this.userInput = '';
this.conversationHistory.push({ type: 'user', message });
npcManager.currentGuessCount++;
this.elements.input.setText('');
npcManager.state.isWaitingForResponse = true;
if (this.scene.sound_mgr) this.scene.sound_mgr.playMessageSend();
// Typing-Indicator anzeigen
this._updateChatDisplay(`${I18N.t('you')}: ${message}\n\n...`);
this._startTypingAnimation();
try {
const npc = npcManager.currentNpc;
const response = await fetch(GAME_CONFIG.API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
conversationHistory: this.conversationHistory,
characterName: npc ? npc.characterName : null,
characterPersonality: npc ? npc.characterPersonality : null,
}),
});
const data = await response.json();
let npcResponse = I18N.t('errorNoResponse');
if (data.response) {
npcResponse = data.response;
this.conversationHistory.push({ type: 'npc', message: npcResponse });
if (this.scene.sound_mgr) this.scene.sound_mgr.playMessageReceive();
if (data.identityRevealed) {
console.log('Identität aufgedeckt!');
npcManager.revealIdentity();
if (this.elements.title && this.elements.title.visible) {
this.elements.title.setText(`${I18N.t('chatWith')} ${npc.characterName}`);
}
}
}
this.lastNpcResponse = npcResponse;
this._stopTypingAnimation();
this._updateChatDisplay();
} catch (error) {
console.error('Fehler beim Senden der Nachricht:', error);
const errorMsg = I18N.t('errorCantRespond');
this.lastNpcResponse = errorMsg;
this.conversationHistory.push({ type: 'npc', message: errorMsg });
this._stopTypingAnimation();
this._updateChatDisplay();
}
npcManager.state.isWaitingForResponse = false;
this.elements.input.setText(I18N.t('typePlaceholder'));
this.elements.input.setStyle({ fill: GAME_CONFIG.COLORS.TEXT_PLACEHOLDER });
}
/** Zeigt die letzten Nachrichten der Konversation im Chat-Bereich */
_updateChatDisplay(customText) {
if (!this.elements.response || !this.elements.response.visible) return;
if (customText) {
this.elements.response.setText(customText);
return;
}
// Zeige die letzten 3 Nachrichten
const recent = this.conversationHistory.slice(-4);
const lines = recent.map((entry) => {
const prefix = entry.type === 'user' ? I18N.t('you') : I18N.t('unknown');
return `${prefix}: ${entry.message}`;
});
this.elements.response.setText(lines.join('\n\n'));
}
_startTypingAnimation() {
this._typingDots = 0;
this._typingTimer = this.scene.time.addEvent({
delay: 400,
callback: () => {
this._typingDots = (this._typingDots + 1) % 4;
const dots = '.'.repeat(this._typingDots || 1);
const lastUserMsg = this.conversationHistory.filter((e) => e.type === 'user').pop();
if (lastUserMsg && this.elements.response.visible) {
this.elements.response.setText(`${I18N.t('you')}: ${lastUserMsg.message}\n\n${dots}`);
}
},
loop: true,
});
}
_stopTypingAnimation() {
if (this._typingTimer) {
this._typingTimer.destroy();
this._typingTimer = null;
}
}
}

View file

@ -1,442 +0,0 @@
class NPCManager {
/** @param {RPGScene} scene */
constructor(scene) {
this.scene = scene;
/** @type {Phaser.Physics.Arcade.Sprite[]} */
this.npcs = [];
/** @type {Phaser.Physics.Arcade.Sprite & {characterId: number, characterName: string, characterPersonality: string, debugText: Phaser.GameObjects.Text}} */
this.currentNpc = null;
/** @type {NPCCharacter[]} */
this.npcCharacters = [];
/** @type {NPCState} */
this.state = {
isInConversation: false,
isWaitingForResponse: false,
identityRevealed: false,
discoveredNPCs: [],
currentNpcIndex: -1,
};
/** @type {number} Anzahl gesendeter Nachrichten für den aktuellen NPC */
this.currentGuessCount = 0;
this.npcDialog = null;
this.interactionPrompt = null;
// Partikel-Emitter (wiederverwendbar)
/** @type {Phaser.GameObjects.Particles.ParticleEmitter|null} */
this._emitter = null;
}
/**
* @param {Phaser.Physics.Arcade.Sprite} player
* @param {Phaser.Physics.Arcade.StaticGroup} obstacles
*/
create(player, obstacles) {
this.player = player;
this.obstacles = obstacles;
// Lade NPC-Charaktere
this.npcCharacters = window.npcCharacters || [];
if (!this.npcCharacters || this.npcCharacters.length === 0) {
console.error('Keine NPC-Charaktere gefunden!');
this.npcCharacters = [
{
id: 1,
name: 'Leonardo da Vinci',
personality: 'Ein vielseitiger Universalgelehrter der Renaissance.',
hint: 'Meine Skizzenbücher enthalten Flugmaschinen und anatomische Studien.',
},
{
id: 2,
name: 'Nikola Tesla',
personality: 'Ein exzentrischer Elektroingenieur mit visionären Ideen.',
hint: 'Meine Arbeiten mit Wechselstrom revolutionierten die Energienutzung.',
},
];
}
console.log('NPC-Charaktere geladen:', this.npcCharacters.length);
// Dialog-Box
this.npcDialog = this.scene.add.text(0, 0, I18N.t('pressEToTalk'), {
fontSize: '12px',
fill: GAME_CONFIG.COLORS.TEXT_WHITE,
backgroundColor: '#000',
padding: { x: 5, y: 5 },
wordWrap: { width: 200 },
});
this.npcDialog.setVisible(false);
// Interaktions-Prompt
this.interactionPrompt = this.scene.add.text(0, 0, I18N.t('pressEToTalk'), {
fontSize: GAME_CONFIG.FONTS.NPC_LABEL,
fill: GAME_CONFIG.COLORS.TEXT_WHITE,
backgroundColor: '#000',
padding: { x: 3, y: 3 },
});
this.interactionPrompt.setVisible(false);
// Partikel-Pool erstellen (einmalig)
this._initParticlePool();
this.spawnNewNPC();
}
_initParticlePool() {
// Phaser 3.60+ API: add.particles() gibt direkt einen ParticleEmitter zurück
this._emitter = this.scene.add.particles(0, 0, 'particle', {
speed: { min: 50, max: 100 },
angle: { min: 0, max: 360 },
scale: { start: 0.5, end: 0 },
blendMode: 'ADD',
lifespan: GAME_CONFIG.ANIMATIONS.PARTICLE_LIFETIME,
gravityY: 0,
emitting: false,
});
}
spawnNewNPC() {
const { NPC_SCALE, TILE_SIZE, COLORS, FONTS, ANIMATIONS } = GAME_CONFIG;
// Verfügbare Charaktere filtern
let availableCharacters = this.npcCharacters.filter(
(char) => !this.state.discoveredNPCs.includes(char.id)
);
if (this.currentNpc && this.currentNpc.characterId) {
availableCharacters = availableCharacters.filter(
(char) => char.id !== this.currentNpc.characterId
);
}
// Fallback
if (availableCharacters.length === 0) {
availableCharacters = this.npcCharacters.filter((char) => {
return !(this.currentNpc && this.currentNpc.characterId === char.id);
});
if (availableCharacters.length === 0) return null;
}
const selectedCharacter =
availableCharacters[Math.floor(Math.random() * availableCharacters.length)];
console.log('Ausgewählter Charakter:', selectedCharacter.name);
const map = this.scene.worldManager.map;
const doorX = Math.floor(map.widthInPixels / 2);
const doorY = TILE_SIZE;
// NPC erstellen
const newNpc = this.scene.physics.add.sprite(doorX, doorY, 'npc_down');
newNpc.setScale(NPC_SCALE);
newNpc.setTint(COLORS.NPC_ANONYMOUS_TINT);
newNpc.characterId = selectedCharacter.id;
newNpc.characterName = selectedCharacter.name;
newNpc.characterPersonality = selectedCharacter.personality;
// Einlauf-Animation
this.scene.tweens.add({
targets: newNpc,
y: map.heightInPixels / 2,
duration: GAME_CONFIG.NPC_WALK_DURATION,
ease: 'Linear',
onUpdate: () => {
if (newNpc.debugText) {
newNpc.debugText.x = newNpc.x;
newNpc.debugText.y = newNpc.y + 20;
}
if (Math.floor(Date.now() / 150) % 2 === 0) {
newNpc.setTexture('npc_down');
} else if (this.scene.textures.exists('npc_down_walk')) {
newNpc.setTexture('npc_down_walk');
}
},
});
// Name-Label
const debugText = this.scene.add.text(doorX, doorY + 20, I18N.t('anonymous'), {
fontSize: FONTS.NPC_LABEL,
fontFamily: 'Arial',
fill: COLORS.TEXT_WHITE,
stroke: '#000000',
strokeThickness: 2,
align: 'center',
});
debugText.setOrigin(0.5, 0);
newNpc.debugText = debugText;
// Kollisionen
if (this.obstacles) {
this.scene.physics.add.collider(newNpc, this.obstacles);
}
if (this.player) {
this.scene.physics.add.collider(
newNpc,
this.player,
() => this.showInteractionPrompt(),
null,
this
);
}
this.npcs.push(newNpc);
this.currentNpc = newNpc;
this.state.currentNpcIndex = this.npcs.length - 1;
this.currentGuessCount = 0;
return newNpc;
}
showInteractionPrompt() {
if (!this.currentNpc || !this.player) return;
this.interactionPrompt.setPosition(
this.currentNpc.x - this.interactionPrompt.width / 2,
this.currentNpc.y - 40
);
this.interactionPrompt.setVisible(true);
this.scene.time.delayedCall(GAME_CONFIG.ANIMATIONS.INTERACTION_PROMPT_DURATION, () => {
this.interactionPrompt.setVisible(false);
});
}
startConversation() {
if (!this.currentNpc || !this.player || this.state.isInConversation) return;
this.player.setVelocity(0);
this.currentNpc.setVelocity(0);
if (this.player.x < this.currentNpc.x) {
this.currentNpc.setTexture('npc_up');
} else {
this.currentNpc.setTexture('npc_down');
}
this.state.isInConversation = true;
return true;
}
moveRandomly() {
if (!this.currentNpc) return;
this.currentNpc.setVelocity(0);
if (Math.random() < GAME_CONFIG.NPC_MOVE_CHANCE) {
const speed = GAME_CONFIG.NPC_SPEED;
const direction = Math.floor(Math.random() * 4);
switch (direction) {
case 0:
this.currentNpc.setVelocityY(-speed);
this.currentNpc.setTexture('npc_up');
break;
case 1:
this.currentNpc.setVelocityX(speed);
this.currentNpc.setTexture('npc_down');
break;
case 2:
this.currentNpc.setVelocityY(speed);
this.currentNpc.setTexture('npc_down');
break;
case 3:
this.currentNpc.setVelocityX(-speed);
this.currentNpc.setTexture('npc_up');
break;
}
this.scene.time.delayedCall(1000 + Math.random() * 1000, () => {
if (this.currentNpc) this.currentNpc.setVelocity(0);
});
}
}
revealIdentity() {
const { COLORS, FONTS, ANIMATIONS } = GAME_CONFIG;
this.state.identityRevealed = true;
if (this.currentNpc && this.currentNpc.characterId) {
if (!this.state.discoveredNPCs.includes(this.currentNpc.characterId)) {
this.state.discoveredNPCs.push(this.currentNpc.characterId);
console.log(`NPC ${this.currentNpc.characterName} wurde entdeckt!`);
}
// Fortschritt speichern
if (this.scene.storage) {
this.scene.storage.recordDiscovery(this.currentNpc.characterId, this.currentGuessCount);
}
}
this.currentNpc.clearTint();
// Reveal-Sound abspielen
if (this.scene.sound_mgr) this.scene.sound_mgr.playReveal();
// Name-Label aktualisieren
if (this.currentNpc.debugText) {
this.currentNpc.debugText.setText(this.currentNpc.characterName);
this.currentNpc.debugText.setStyle({
fontSize: FONTS.NPC_LABEL_REVEALED,
fontFamily: 'Arial',
fontStyle: 'bold',
fill: COLORS.TEXT_REVEALED,
stroke: '#000000',
strokeThickness: 3,
align: 'center',
});
}
// Gelber Blitz
this.currentNpc.setTint(COLORS.REVEAL_FLASH);
this.scene.time.delayedCall(ANIMATIONS.REVEAL_FLASH_DURATION, () => {
if (this.state.identityRevealed) {
this.currentNpc.clearTint();
}
});
// Partikeleffekt (wiederverwendbarer Pool)
if (this._emitter) {
this._emitter.setPosition(this.currentNpc.x, this.currentNpc.y);
this._emitter.start();
this.scene.time.delayedCall(ANIMATIONS.PARTICLE_STOP_DELAY, () => {
if (this._emitter) this._emitter.stop();
});
}
// Enthüllungs-Text
const revealText = this.scene.add.text(
this.scene.cameras.main.width / 2,
this.scene.cameras.main.height / 3,
I18N.t('youRevealed', { name: this.currentNpc.characterName }),
{
fontSize: FONTS.REVEAL_TEXT,
fontFamily: 'Arial',
fontStyle: 'bold',
fill: COLORS.TEXT_REVEALED,
stroke: '#000000',
strokeThickness: 4,
align: 'center',
}
);
revealText.setOrigin(0.5);
revealText.setScrollFactor(0);
this.scene.tweens.add({
targets: revealText,
alpha: 0,
duration: ANIMATIONS.REVEAL_TEXT_FADE_DURATION,
delay: ANIMATIONS.REVEAL_TEXT_DELAY,
onComplete: () => {
revealText.destroy();
this.scene.time.delayedCall(ANIMATIONS.NEW_NPC_SPAWN_DELAY, () => {
const newNpc = this.spawnNewNPC();
if (newNpc) {
const newNpcText = this.scene.add.text(
this.scene.cameras.main.width / 2,
this.scene.cameras.main.height / 3,
I18N.t('newNpcAppeared'),
{
fontSize: FONTS.NEW_NPC_TEXT,
fontFamily: 'Arial',
fontStyle: 'bold',
fill: COLORS.TEXT_WHITE,
stroke: '#000000',
strokeThickness: 3,
align: 'center',
}
);
newNpcText.setOrigin(0.5);
newNpcText.setScrollFactor(0);
this.scene.tweens.add({
targets: newNpcText,
alpha: 0,
duration: ANIMATIONS.NEW_NPC_TEXT_FADE_DURATION,
delay: ANIMATIONS.NEW_NPC_TEXT_DELAY,
onComplete: () => newNpcText.destroy(),
});
}
});
},
});
}
/**
* @param {Phaser.Input.Keyboard.Key} interactKey
* @param {boolean} [touchInteract=false]
*/
checkInteraction(interactKey, touchInteract = false) {
const keyPressed = interactKey && Phaser.Input.Keyboard.JustDown(interactKey);
if ((keyPressed || touchInteract) && this.npcs.length > 0) {
let closestNPC = null;
let closestDistance = GAME_CONFIG.NPC_INTERACTION_DISTANCE;
for (let i = 0; i < this.npcs.length; i++) {
const npc = this.npcs[i];
const distance = Phaser.Math.Distance.Between(this.player.x, this.player.y, npc.x, npc.y);
if (distance < closestDistance) {
closestDistance = distance;
closestNPC = npc;
this.state.currentNpcIndex = i;
}
}
if (closestNPC) {
this.currentNpc = closestNPC;
return this.startConversation();
}
}
return false;
}
update() {
if (this.npcDialog && this.npcDialog.visible && this.currentNpc) {
this.npcDialog.setPosition(this.currentNpc.x - 100, this.currentNpc.y - 50);
}
if (this.interactionPrompt && this.interactionPrompt.visible && this.currentNpc) {
this.interactionPrompt.setPosition(this.currentNpc.x - 50, this.currentNpc.y - 30);
}
// Fragezeichen-Icon über NPCs in Reichweite
this.npcs.forEach((npc) => {
if (npc.debugText) {
npc.debugText.setPosition(npc.x, npc.y + 20);
}
if (this.player && !this.state.isInConversation) {
const distance = Phaser.Math.Distance.Between(this.player.x, this.player.y, npc.x, npc.y);
if (distance < GAME_CONFIG.NPC_INTERACTION_DISTANCE) {
if (!npc.questionMark) {
npc.questionMark = this.scene.add.text(npc.x, npc.y - 35, '?', {
fontSize: '24px',
fontFamily: 'Arial',
fontStyle: 'bold',
fill: GAME_CONFIG.COLORS.TEXT_REVEALED,
stroke: '#000000',
strokeThickness: 3,
});
npc.questionMark.setOrigin(0.5);
// Schwebe-Animation
this.scene.tweens.add({
targets: npc.questionMark,
y: npc.y - 45,
duration: 800,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
}
npc.questionMark.setPosition(npc.x, npc.questionMark.y);
} else if (npc.questionMark) {
npc.questionMark.destroy();
npc.questionMark = null;
}
}
});
}
}

View file

@ -1,82 +0,0 @@
class PlayerManager {
/** @param {Phaser.Scene} scene */
constructor(scene) {
this.scene = scene;
/** @type {Phaser.Physics.Arcade.Sprite} */
this.player = null;
/** @type {Phaser.Types.Input.Keyboard.CursorKeys} */
this.cursors = null;
}
/**
* @param {MapConfig} map
* @param {Phaser.Physics.Arcade.StaticGroup} obstacles
*/
create(map, obstacles) {
const { PLAYER_SCALE } = GAME_CONFIG;
// Custom-Avatar verwenden, falls vorhanden
this.useCustomAvatar = this.scene.textures.exists('custom_avatar_down');
const initialTexture = this.useCustomAvatar ? 'custom_avatar_down' : 'player_down';
this.player = this.scene.physics.add.sprite(
map.widthInPixels / 2,
map.heightInPixels / 2,
initialTexture
);
this.player.setScale(PLAYER_SCALE);
this.scene.physics.add.collider(this.player, obstacles);
this.player.setCollideWorldBounds(true);
// Kamera einrichten
this.scene.cameras.main.setBounds(0, 0, map.widthInPixels, map.heightInPixels);
this.scene.cameras.main.startFollow(this.player, true);
// Steuerung
this.cursors = this.scene.input.keyboard.createCursorKeys();
}
/** @param {TouchControls} [touchControls] */
handleMovement(touchControls) {
if (!this.player) return;
const { PLAYER_SPEED } = GAME_CONFIG;
// Touch-Input hat Priorität, dann Keyboard
const touchActive = touchControls && touchControls.isActive;
const touchDir = touchActive ? touchControls.direction : { x: 0, y: 0 };
const moveLeft = this.cursors.left.isDown || touchDir.x < -0.3;
const moveRight = this.cursors.right.isDown || touchDir.x > 0.3;
const moveUp = this.cursors.up.isDown || touchDir.y < -0.3;
const moveDown = this.cursors.down.isDown || touchDir.y > 0.3;
const prefix = this.useCustomAvatar ? 'custom_avatar' : 'player';
// Horizontal
if (moveLeft) {
this.player.setVelocityX(-PLAYER_SPEED);
this.player.setTexture(`${prefix}_left`);
} else if (moveRight) {
this.player.setVelocityX(PLAYER_SPEED);
this.player.setTexture(`${prefix}_right`);
} else {
this.player.setVelocityX(0);
}
// Vertikal
if (moveUp) {
this.player.setVelocityY(-PLAYER_SPEED);
if (!moveLeft && !moveRight) {
this.player.setTexture(`${prefix}_up`);
}
} else if (moveDown) {
this.player.setVelocityY(PLAYER_SPEED);
if (!moveLeft && !moveRight) {
this.player.setTexture(`${prefix}_down`);
}
} else {
this.player.setVelocityY(0);
}
}
}

View file

@ -1,101 +0,0 @@
/**
* Sound-Manager mit programmatisch generierten Sounds (keine externen Dateien nötig).
* Verwendet die Web Audio API für einfache Synthesizer-Sounds.
*/
class SoundManager {
constructor() {
/** @type {AudioContext|null} */
this.ctx = null;
this.enabled = true;
this.volume = 0.3;
}
/** AudioContext erst bei erster Nutzer-Interaktion erstellen (Browser-Policy) */
_ensureContext() {
if (!this.ctx) {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
}
if (this.ctx.state === 'suspended') {
this.ctx.resume();
}
}
/**
* Spielt einen Ton mit gegebener Frequenz und Dauer
* @param {number} frequency - Hz
* @param {number} duration - Sekunden
* @param {'sine'|'square'|'triangle'|'sawtooth'} type
* @param {number} [vol] - Lautstärke 0-1
*/
_playTone(frequency, duration, type = 'sine', vol = this.volume) {
if (!this.enabled) return;
this._ensureContext();
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(frequency, this.ctx.currentTime);
gain.gain.setValueAtTime(vol, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(this.ctx.currentTime);
osc.stop(this.ctx.currentTime + duration);
}
/** Gespräch starten */
playConversationStart() {
this._playTone(440, 0.15, 'triangle', 0.2);
setTimeout(() => this._playTone(554, 0.15, 'triangle', 0.2), 100);
}
/** Nachricht gesendet */
playMessageSend() {
this._playTone(600, 0.08, 'sine', 0.15);
}
/** NPC antwortet */
playMessageReceive() {
this._playTone(400, 0.1, 'triangle', 0.15);
setTimeout(() => this._playTone(500, 0.1, 'triangle', 0.15), 80);
}
/** Identität aufgedeckt — Fanfare */
playReveal() {
const notes = [523, 659, 784, 1047]; // C5, E5, G5, C6
notes.forEach((freq, i) => {
setTimeout(() => this._playTone(freq, 0.3, 'triangle', 0.25), i * 150);
});
}
/** Neuer NPC erscheint */
playNewNPC() {
this._playTone(330, 0.2, 'sine', 0.15);
setTimeout(() => this._playTone(392, 0.3, 'sine', 0.15), 150);
}
/** Spieler bewegt sich (dezent) */
playStep() {
this._playTone(100 + Math.random() * 50, 0.05, 'square', 0.05);
}
/** Fehler/kann nicht interagieren */
playError() {
this._playTone(200, 0.15, 'sawtooth', 0.1);
setTimeout(() => this._playTone(150, 0.2, 'sawtooth', 0.1), 100);
}
toggle() {
this.enabled = !this.enabled;
return this.enabled;
}
/** @param {number} vol 0-1 */
setVolume(vol) {
this.volume = Math.max(0, Math.min(1, vol));
}
}

View file

@ -1,92 +0,0 @@
/**
* @typedef {Object} GameSaveData
* @property {number[]} discoveredNPCs - IDs der entdeckten NPCs
* @property {number} totalGuesses - Gesamtanzahl der Rateversuche
* @property {number} totalRevealed - Gesamtanzahl aufgedeckter NPCs
* @property {number} bestStreak - Beste Serie korrekt erratener NPCs
* @property {number} currentStreak - Aktuelle Serie
* @property {Record<number, number>} guessesPerNPC - Anzahl Versuche pro NPC (ID -> Anzahl)
* @property {number} lastPlayed - Timestamp des letzten Spiels
*/
class StorageManager {
constructor() {
this.STORAGE_KEY = 'whopixels_save';
}
/** @returns {GameSaveData} */
load() {
try {
const saved = localStorage.getItem(this.STORAGE_KEY);
if (saved) {
return JSON.parse(saved);
}
} catch (error) {
console.error('Fehler beim Laden des Spielstands:', error);
}
return this._createDefault();
}
/** @param {GameSaveData} data */
save(data) {
try {
data.lastPlayed = Date.now();
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
} catch (error) {
console.error('Fehler beim Speichern:', error);
}
}
/**
* NPC als entdeckt markieren und Statistiken aktualisieren
* @param {number} npcId
* @param {number} guessCount - Anzahl der Fragen bis zur Enthüllung
*/
recordDiscovery(npcId, guessCount) {
const data = this.load();
if (!data.discoveredNPCs.includes(npcId)) {
data.discoveredNPCs.push(npcId);
}
data.totalRevealed++;
data.totalGuesses += guessCount;
data.currentStreak++;
data.guessesPerNPC[npcId] = guessCount;
if (data.currentStreak > data.bestStreak) {
data.bestStreak = data.currentStreak;
}
this.save(data);
return data;
}
/** @returns {GameSaveData} */
_createDefault() {
return {
discoveredNPCs: [],
totalGuesses: 0,
totalRevealed: 0,
bestStreak: 0,
currentStreak: 0,
guessesPerNPC: {},
lastPlayed: 0,
};
}
reset() {
localStorage.removeItem(this.STORAGE_KEY);
}
/** @returns {{averageGuesses: number, totalRevealed: number, bestStreak: number}} */
getStats() {
const data = this.load();
return {
averageGuesses:
data.totalRevealed > 0 ? Math.round((data.totalGuesses / data.totalRevealed) * 10) / 10 : 0,
totalRevealed: data.totalRevealed,
bestStreak: data.bestStreak,
};
}
}

View file

@ -1,165 +0,0 @@
/**
* Touch-Controls für Mobile-Unterstützung.
* Zeigt einen virtuellen Joystick und einen Interaktions-Button.
*/
class TouchControls {
/** @param {Phaser.Scene} scene */
constructor(scene) {
this.scene = scene;
this.isActive = false;
this.direction = { x: 0, y: 0 };
this.interactPressed = false;
// Joystick-Elemente
this.joystickBase = null;
this.joystickThumb = null;
this.interactButton = null;
// Joystick-State
this.joystickPointer = null;
this.joystickCenter = { x: 0, y: 0 };
}
create() {
// Nur auf Touch-Geräten aktivieren
if (!this.scene.sys.game.device.input.touch) return;
this.isActive = true;
const width = this.scene.cameras.main.width;
const height = this.scene.cameras.main.height;
// Joystick-Base (links unten)
const joyX = 100;
const joyY = height - 100;
this.joystickCenter = { x: joyX, y: joyY };
this.joystickBase = this.scene.add.graphics();
this.joystickBase.fillStyle(0xffffff, 0.2);
this.joystickBase.fillCircle(joyX, joyY, 60);
this.joystickBase.lineStyle(2, 0xffffff, 0.4);
this.joystickBase.strokeCircle(joyX, joyY, 60);
this.joystickBase.setScrollFactor(0);
this.joystickBase.setDepth(1000);
this.joystickThumb = this.scene.add.graphics();
this._drawThumb(joyX, joyY);
this.joystickThumb.setScrollFactor(0);
this.joystickThumb.setDepth(1001);
// Interaktions-Button (rechts unten)
const btnX = width - 80;
const btnY = height - 100;
this.interactButton = this.scene.add.graphics();
this.interactButton.fillStyle(GAME_CONFIG.COLORS.CHAT_BORDER, 0.6);
this.interactButton.fillCircle(btnX, btnY, 35);
this.interactButton.setScrollFactor(0);
this.interactButton.setDepth(1000);
const btnLabel = this.scene.add.text(btnX, btnY, 'E', {
fontSize: '28px',
fontFamily: 'Arial',
fontStyle: 'bold',
fill: GAME_CONFIG.COLORS.TEXT_WHITE,
});
btnLabel.setOrigin(0.5);
btnLabel.setScrollFactor(0);
btnLabel.setDepth(1001);
// Interaktiver Bereich für den Button
const btnHit = this.scene.add.circle(btnX, btnY, 40);
btnHit.setScrollFactor(0);
btnHit.setInteractive();
btnHit.setAlpha(0.001);
btnHit.setDepth(1002);
btnHit.on('pointerdown', () => {
this.interactPressed = true;
this.interactButton.clear();
this.interactButton.fillStyle(GAME_CONFIG.COLORS.SEND_BUTTON_HOVER, 0.8);
this.interactButton.fillCircle(btnX, btnY, 35);
});
btnHit.on('pointerup', () => {
this.interactButton.clear();
this.interactButton.fillStyle(GAME_CONFIG.COLORS.CHAT_BORDER, 0.6);
this.interactButton.fillCircle(btnX, btnY, 35);
});
// Joystick Touch-Handling
this.scene.input.on('pointerdown', (pointer) => this._onPointerDown(pointer));
this.scene.input.on('pointermove', (pointer) => this._onPointerMove(pointer));
this.scene.input.on('pointerup', (pointer) => this._onPointerUp(pointer));
}
/** @param {number} x @param {number} y */
_drawThumb(x, y) {
this.joystickThumb.clear();
this.joystickThumb.fillStyle(0xffffff, 0.5);
this.joystickThumb.fillCircle(x, y, 25);
}
/** @param {Phaser.Input.Pointer} pointer */
_onPointerDown(pointer) {
if (!this.isActive) return;
// Nur linke Hälfte des Bildschirms für Joystick
if (pointer.x < this.scene.cameras.main.width / 2) {
const dist = Phaser.Math.Distance.Between(
pointer.x,
pointer.y,
this.joystickCenter.x,
this.joystickCenter.y
);
if (dist < 80) {
this.joystickPointer = pointer;
}
}
}
/** @param {Phaser.Input.Pointer} pointer */
_onPointerMove(pointer) {
if (!this.isActive || !this.joystickPointer || pointer.id !== this.joystickPointer.id) return;
const maxDist = 50;
const dx = pointer.x - this.joystickCenter.x;
const dy = pointer.y - this.joystickCenter.y;
const dist = Math.sqrt(dx * dx + dy * dy);
let thumbX, thumbY;
if (dist > maxDist) {
thumbX = this.joystickCenter.x + (dx / dist) * maxDist;
thumbY = this.joystickCenter.y + (dy / dist) * maxDist;
} else {
thumbX = pointer.x;
thumbY = pointer.y;
}
this._drawThumb(thumbX, thumbY);
// Richtung normalisieren
const normDist = Math.min(dist, maxDist) / maxDist;
this.direction.x = (dx / (dist || 1)) * normDist;
this.direction.y = (dy / (dist || 1)) * normDist;
}
/** @param {Phaser.Input.Pointer} pointer */
_onPointerUp(pointer) {
if (!this.isActive) return;
if (this.joystickPointer && pointer.id === this.joystickPointer.id) {
this.joystickPointer = null;
this.direction = { x: 0, y: 0 };
this._drawThumb(this.joystickCenter.x, this.joystickCenter.y);
}
}
/** @returns {boolean} Ob der Interact-Button gedrückt wurde (einmalig) */
consumeInteract() {
if (this.interactPressed) {
this.interactPressed = false;
return true;
}
return false;
}
}

View file

@ -1,81 +0,0 @@
class WorldManager {
/** @param {Phaser.Scene} scene */
constructor(scene) {
this.scene = scene;
/** @type {MapConfig} */
this.map = null;
/** @type {Phaser.Physics.Arcade.StaticGroup} */
this.obstacles = null;
}
create() {
const { GRID_SIZE, TILE_SIZE, MAP_WIDTH, MAP_HEIGHT, TILE_SCALE, TERRAIN } = GAME_CONFIG;
this.map = {
widthInPixels: MAP_WIDTH,
heightInPixels: MAP_HEIGHT,
tileWidth: TILE_SIZE,
tileHeight: TILE_SIZE,
};
// Hintergrund
this.scene.add
.tileSprite(0, 0, MAP_WIDTH, MAP_HEIGHT, 'background')
.setOrigin(0, 0)
.setScale(1.0);
// Hindernisse
this.obstacles = this.scene.physics.add.staticGroup();
const tileTypes = [
{ key: 'tile_grass', isObstacle: false },
{ key: 'tile_grass_flower', isObstacle: false },
{ key: 'tile_dirt', isObstacle: false },
{ key: 'tile_dirt_stone', isObstacle: false },
{ key: 'tile_stone_wall', isObstacle: true },
{ key: 'tile_stone_wall_flower', isObstacle: true },
];
for (let y = 0; y < GRID_SIZE; y++) {
for (let x = 0; x < GRID_SIZE; x++) {
let tileType;
if (x === 0 || y === 0 || x === GRID_SIZE - 1 || y === GRID_SIZE - 1) {
if (y === 0 && x === Math.floor(GRID_SIZE / 2)) {
tileType = tileTypes[2]; // Tür
} else {
tileType = Math.random() < TERRAIN.WALL_MOSS_CHANCE ? tileTypes[5] : tileTypes[4];
}
} else {
const rand = Math.random();
if (rand < TERRAIN.GRASS_CHANCE) {
tileType = tileTypes[0];
} else if (rand < TERRAIN.GRASS_FLOWER_CHANCE) {
tileType = tileTypes[1];
} else if (rand < TERRAIN.DIRT_CHANCE) {
tileType = tileTypes[2];
} else {
tileType = tileTypes[3];
}
}
const tile = this.scene.add.image(
x * TILE_SIZE + TILE_SIZE / 2,
y * TILE_SIZE + TILE_SIZE / 2,
tileType.key
);
tile.setScale(TILE_SCALE);
if (tileType.isObstacle) {
const obstacle = this.scene.add.rectangle(
x * TILE_SIZE + TILE_SIZE / 2,
y * TILE_SIZE + TILE_SIZE / 2,
TILE_SIZE * TILE_SCALE,
TILE_SIZE * TILE_SCALE
);
this.obstacles.add(obstacle);
}
}
}
}
}

View file

@ -1,481 +0,0 @@
class BootScene extends Phaser.Scene {
constructor() {
super({ key: 'BootScene' });
}
preload() {
// Loading screen
this.graphics = this.add.graphics();
this.newGraphics = this.add.graphics();
const progressBar = new Phaser.Geom.Rectangle(200, 300, 400, 50);
const progressBarFill = new Phaser.Geom.Rectangle(205, 305, 290, 40);
this.graphics.fillStyle(0xffffff, 1);
this.graphics.fillRectShape(progressBar);
this.newGraphics.fillStyle(0x3587e2, 1);
this.newGraphics.fillRectShape(progressBarFill);
const loadingText = this.add.text(250, 260, 'Loading: ', { fontSize: '32px', fill: '#FFF' });
// Update as load progresses
this.load.on('progress', (percent) => {
loadingText.setText('Loading: ' + parseInt(percent * 100) + '%');
progressBarFill.width = 390 * percent;
this.newGraphics.clear();
this.newGraphics.fillStyle(0x3587e2, 1);
this.newGraphics.fillRectShape(progressBarFill);
});
this.load.on('complete', () => {
loadingText.destroy();
this.graphics.destroy();
this.newGraphics.destroy();
});
// We'll create graphics objects instead of loading images
}
create() {
// Create a texture for background
const bgGraphics = this.make.graphics({ x: 0, y: 0 });
bgGraphics.fillStyle(0x222233);
bgGraphics.fillRect(0, 0, 800, 600);
// Add some pattern to background
bgGraphics.fillStyle(0x1a1a2a);
for (let i = 0; i < 100; i++) {
const x = Math.random() * 800;
const y = Math.random() * 600;
const size = Math.random() * 5 + 2;
bgGraphics.fillRect(x, y, size, size);
}
// Erstelle ein Partikel-Sprite für Spezialeffekte
const particleGraphics = this.make.graphics({ x: 0, y: 0 });
particleGraphics.fillStyle(0xffffff);
particleGraphics.fillCircle(4, 4, 4);
particleGraphics.generateTexture('particle', 8, 8);
bgGraphics.generateTexture('background', 800, 600);
// Create a texture for player (pixel editor)
const playerGraphics = this.make.graphics({ x: 0, y: 0 });
playerGraphics.fillStyle(0xff0000);
playerGraphics.fillRect(0, 0, 32, 32);
playerGraphics.fillStyle(0xff5555);
playerGraphics.fillRect(8, 8, 16, 16);
playerGraphics.generateTexture('player', 32, 32);
// Erstelle Texturen für verschiedene Tile-Typen (8x8 Pixel Tiles, skaliert auf 32x32)
this.createTileTextures();
// Erstelle NPC-Texturen
this.createNPCTextures();
// Create a texture for basic tile
const tileGraphics = this.make.graphics({ x: 0, y: 0 });
tileGraphics.fillStyle(0xffffff);
tileGraphics.fillRect(0, 0, 32, 32);
tileGraphics.lineStyle(1, 0xcccccc);
tileGraphics.strokeRect(0, 0, 32, 32);
tileGraphics.generateTexture('tile', 32, 32);
// Create player walk animation frames (4 directions)
this.createPlayerWalkAnimations();
// Lade gespeicherten Custom-Avatar, falls vorhanden
this.loadCustomAvatar();
this.scene.start('MainMenuScene');
}
loadCustomAvatar() {
try {
const saved = localStorage.getItem('whopixels_avatar');
if (!saved) return;
const avatarData = JSON.parse(saved);
const frameSize = 32;
const pixelSize = frameSize / avatarData.width;
const graphics = this.make.graphics({ x: 0, y: 0 });
for (let y = 0; y < avatarData.height; y++) {
for (let x = 0; x < avatarData.width; x++) {
const color = avatarData.pixels[y][x];
if (color !== 0xffffff) {
graphics.fillStyle(color);
graphics.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
}
}
}
graphics.generateTexture('custom_avatar_down', frameSize, frameSize);
graphics.generateTexture('custom_avatar_up', frameSize, frameSize);
graphics.generateTexture('custom_avatar_left', frameSize, frameSize);
graphics.generateTexture('custom_avatar_right', frameSize, frameSize);
graphics.destroy();
console.log('Custom-Avatar geladen');
} catch (error) {
console.error('Fehler beim Laden des Custom-Avatars:', error);
}
}
createPlayerWalkAnimations() {
// Erstelle eine Spritesheet-Textur für den Spieler im RPG
const frameWidth = 32;
const frameHeight = 32;
// Farbpalette für den Spieler
const colors = {
body: 0x3366cc, // Blauer Körper
face: 0xffcc99, // Hautfarbe für Gesicht
hair: 0x663300, // Braune Haare
shirt: 0x339933, // Grünes Hemd
pants: 0x333366, // Dunkelblaue Hose
shoes: 0x663300, // Braune Schuhe
outline: 0x000000, // Schwarze Umrisse
};
// Gemeinsame Funktion zum Zeichnen der Grundform des Spielers
const drawPlayerBase = (graphics) => {
// Umriss
graphics.lineStyle(1, colors.outline);
// Körper (Torso)
graphics.fillStyle(colors.shirt);
graphics.fillRect(10, 12, 12, 10);
graphics.strokeRect(10, 12, 12, 10);
// Kopf
graphics.fillStyle(colors.face);
graphics.fillRect(10, 4, 12, 8);
graphics.strokeRect(10, 4, 12, 8);
// Haare
graphics.fillStyle(colors.hair);
graphics.fillRect(10, 4, 12, 3);
graphics.strokeRect(10, 4, 12, 3);
// Hose
graphics.fillStyle(colors.pants);
graphics.fillRect(10, 22, 12, 6);
graphics.strokeRect(10, 22, 12, 6);
};
// Nach unten (0)
const downGraphics = this.make.graphics({ x: 0, y: 0 });
drawPlayerBase(downGraphics);
// Gesicht nach unten
downGraphics.fillStyle(colors.outline);
downGraphics.fillRect(14, 8, 1, 1); // Linkes Auge
downGraphics.fillRect(17, 8, 1, 1); // Rechtes Auge
downGraphics.fillRect(15, 10, 2, 1); // Mund
// Beine und Schuhe nach unten
downGraphics.fillStyle(colors.pants);
downGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
downGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
downGraphics.fillStyle(colors.shoes);
downGraphics.fillRect(12, 30, 3, 2); // Linker Schuh
downGraphics.fillRect(17, 30, 3, 2); // Rechter Schuh
downGraphics.lineStyle(1, colors.outline);
downGraphics.strokeRect(12, 28, 3, 4); // Linkes Bein Umriss
downGraphics.strokeRect(17, 28, 3, 4); // Rechtes Bein Umriss
downGraphics.generateTexture('player_down', frameWidth, frameHeight);
// Nach oben (1)
const upGraphics = this.make.graphics({ x: 0, y: 0 });
drawPlayerBase(upGraphics);
// Rücken der Haare
upGraphics.fillStyle(colors.hair);
upGraphics.fillRect(10, 2, 12, 2);
upGraphics.lineStyle(1, colors.outline);
upGraphics.strokeRect(10, 2, 12, 2);
// Beine und Schuhe nach oben
upGraphics.fillStyle(colors.pants);
upGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
upGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
upGraphics.fillStyle(colors.shoes);
upGraphics.fillRect(12, 30, 3, 2); // Linker Schuh
upGraphics.fillRect(17, 30, 3, 2); // Rechter Schuh
upGraphics.lineStyle(1, colors.outline);
upGraphics.strokeRect(12, 28, 3, 4); // Linkes Bein Umriss
upGraphics.strokeRect(17, 28, 3, 4); // Rechtes Bein Umriss
upGraphics.generateTexture('player_up', frameWidth, frameHeight);
// Nach links (2)
const leftGraphics = this.make.graphics({ x: 0, y: 0 });
drawPlayerBase(leftGraphics);
// Gesicht nach links
leftGraphics.fillStyle(colors.outline);
leftGraphics.fillRect(12, 8, 1, 1); // Auge
leftGraphics.fillRect(11, 10, 2, 1); // Mund
// Arm nach links
leftGraphics.fillStyle(colors.shirt);
leftGraphics.fillRect(6, 14, 4, 3);
leftGraphics.lineStyle(1, colors.outline);
leftGraphics.strokeRect(6, 14, 4, 3);
// Beine und Schuhe nach links
leftGraphics.fillStyle(colors.pants);
leftGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
leftGraphics.fillRect(15, 28, 3, 2); // Rechtes Bein
leftGraphics.fillStyle(colors.shoes);
leftGraphics.fillRect(9, 30, 6, 2); // Schuhe
leftGraphics.lineStyle(1, colors.outline);
leftGraphics.strokeRect(9, 28, 9, 4); // Beine Umriss
leftGraphics.generateTexture('player_left', frameWidth, frameHeight);
// Nach rechts (3)
const rightGraphics = this.make.graphics({ x: 0, y: 0 });
drawPlayerBase(rightGraphics);
// Gesicht nach rechts
rightGraphics.fillStyle(colors.outline);
rightGraphics.fillRect(19, 8, 1, 1); // Auge
rightGraphics.fillRect(19, 10, 2, 1); // Mund
// Arm nach rechts
rightGraphics.fillStyle(colors.shirt);
rightGraphics.fillRect(22, 14, 4, 3);
rightGraphics.lineStyle(1, colors.outline);
rightGraphics.strokeRect(22, 14, 4, 3);
// Beine und Schuhe nach rechts
rightGraphics.fillStyle(colors.pants);
rightGraphics.fillRect(14, 28, 3, 2); // Linkes Bein
rightGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
rightGraphics.fillStyle(colors.shoes);
rightGraphics.fillRect(17, 30, 6, 2); // Schuhe
rightGraphics.lineStyle(1, colors.outline);
rightGraphics.strokeRect(14, 28, 9, 4); // Beine Umriss
rightGraphics.generateTexture('player_right', frameWidth, frameHeight);
}
createTileTextures() {
const tileSize = 32; // Größe jedes Tiles
// 1. Gras
const grassGraphics = this.make.graphics({ x: 0, y: 0 });
grassGraphics.fillStyle(0x88aa44); // Grün für Gras
grassGraphics.fillRect(0, 0, tileSize, tileSize);
// Kleine Texturen für Gras
grassGraphics.fillStyle(0x779933);
for (let i = 0; i < 8; i++) {
const x = Math.random() * tileSize;
const y = Math.random() * tileSize;
grassGraphics.fillRect(x, y, 2, 2);
}
grassGraphics.generateTexture('tile_grass', tileSize, tileSize);
// 2. Gras mit Blumen
const grassFlowerGraphics = this.make.graphics({ x: 0, y: 0 });
grassFlowerGraphics.fillStyle(0x88aa44); // Grün für Gras
grassFlowerGraphics.fillRect(0, 0, tileSize, tileSize);
// Kleine Texturen für Gras
grassFlowerGraphics.fillStyle(0x779933);
for (let i = 0; i < 5; i++) {
const x = Math.random() * tileSize;
const y = Math.random() * tileSize;
grassFlowerGraphics.fillRect(x, y, 2, 2);
}
// Blumen hinzufügen
grassFlowerGraphics.fillStyle(0xffff00); // Gelb für Blumen
for (let i = 0; i < 3; i++) {
const x = 5 + Math.random() * (tileSize - 10);
const y = 5 + Math.random() * (tileSize - 10);
grassFlowerGraphics.fillRect(x, y, 3, 3);
}
grassFlowerGraphics.fillStyle(0xff5555); // Rot für Blumen
for (let i = 0; i < 2; i++) {
const x = 5 + Math.random() * (tileSize - 10);
const y = 5 + Math.random() * (tileSize - 10);
grassFlowerGraphics.fillRect(x, y, 3, 3);
}
grassFlowerGraphics.generateTexture('tile_grass_flower', tileSize, tileSize);
// 3. Erde
const dirtGraphics = this.make.graphics({ x: 0, y: 0 });
dirtGraphics.fillStyle(0x8b4513); // Braun für Erde
dirtGraphics.fillRect(0, 0, tileSize, tileSize);
// Kleine Texturen für Erde
dirtGraphics.fillStyle(0x6b3304);
for (let i = 0; i < 10; i++) {
const x = Math.random() * tileSize;
const y = Math.random() * tileSize;
dirtGraphics.fillRect(x, y, 2, 2);
}
dirtGraphics.generateTexture('tile_dirt', tileSize, tileSize);
// 4. Erde mit Steinen
const dirtStoneGraphics = this.make.graphics({ x: 0, y: 0 });
dirtStoneGraphics.fillStyle(0x8b4513); // Braun für Erde
dirtStoneGraphics.fillRect(0, 0, tileSize, tileSize);
// Kleine Texturen für Erde
dirtStoneGraphics.fillStyle(0x6b3304);
for (let i = 0; i < 6; i++) {
const x = Math.random() * tileSize;
const y = Math.random() * tileSize;
dirtStoneGraphics.fillRect(x, y, 2, 2);
}
// Steine hinzufügen
dirtStoneGraphics.fillStyle(0x888888); // Grau für Steine
for (let i = 0; i < 4; i++) {
const size = 3 + Math.random() * 4;
const x = Math.random() * (tileSize - size);
const y = Math.random() * (tileSize - size);
dirtStoneGraphics.fillRect(x, y, size, size);
}
dirtStoneGraphics.generateTexture('tile_dirt_stone', tileSize, tileSize);
// 5. Steinwand
const stoneWallGraphics = this.make.graphics({ x: 0, y: 0 });
stoneWallGraphics.fillStyle(0x777777); // Grau für Steinwand
stoneWallGraphics.fillRect(0, 0, tileSize, tileSize);
// Steinmuster für die Wand
stoneWallGraphics.fillStyle(0x555555);
const brickHeight = 8;
const brickWidth = 16;
for (let y = 0; y < tileSize; y += brickHeight) {
const offset = y % (brickHeight * 2) === 0 ? 0 : brickWidth / 2;
for (let x = offset; x < tileSize; x += brickWidth) {
stoneWallGraphics.fillRect(x, y, brickWidth - 1, brickHeight - 1);
}
}
stoneWallGraphics.generateTexture('tile_stone_wall', tileSize, tileSize);
// 6. Steinwand mit Blumen/Moos
const stoneWallFlowerGraphics = this.make.graphics({ x: 0, y: 0 });
stoneWallFlowerGraphics.fillStyle(0x777777); // Grau für Steinwand
stoneWallFlowerGraphics.fillRect(0, 0, tileSize, tileSize);
// Steinmuster für die Wand
stoneWallFlowerGraphics.fillStyle(0x555555);
for (let y = 0; y < tileSize; y += brickHeight) {
const offset = y % (brickHeight * 2) === 0 ? 0 : brickWidth / 2;
for (let x = offset; x < tileSize; x += brickWidth) {
stoneWallFlowerGraphics.fillRect(x, y, brickWidth - 1, brickHeight - 1);
}
}
// Moos/Blumen an der Wand
stoneWallFlowerGraphics.fillStyle(0x55aa55); // Grün für Moos
for (let i = 0; i < 6; i++) {
const x = Math.random() * tileSize;
const y = Math.random() * tileSize;
const size = 2 + Math.random() * 3;
stoneWallFlowerGraphics.fillRect(x, y, size, size);
}
stoneWallFlowerGraphics.fillStyle(0xffff00); // Gelb für Blumen
for (let i = 0; i < 2; i++) {
const x = 5 + Math.random() * (tileSize - 10);
const y = 5 + Math.random() * (tileSize - 10);
stoneWallFlowerGraphics.fillRect(x, y, 3, 3);
}
stoneWallFlowerGraphics.generateTexture('tile_stone_wall_flower', tileSize, tileSize);
}
createNPCTextures() {
const frameWidth = 32;
const frameHeight = 32;
// Farbpalette für den NPC
const colors = {
body: 0xcc6633, // Bräunlicher Körper
face: 0xffcc99, // Hautfarbe für Gesicht
hair: 0x996600, // Blonde Haare
shirt: 0xcc3333, // Rotes Hemd
pants: 0x333333, // Schwarze Hose
shoes: 0x663300, // Braune Schuhe
outline: 0x000000, // Schwarze Umrisse
};
// Gemeinsame Funktion zum Zeichnen der Grundform des NPCs
const drawNPCBase = (graphics) => {
// Umriss
graphics.lineStyle(1, colors.outline);
// Körper (Torso)
graphics.fillStyle(colors.shirt);
graphics.fillRect(10, 12, 12, 10);
graphics.strokeRect(10, 12, 12, 10);
// Kopf
graphics.fillStyle(colors.face);
graphics.fillRect(10, 4, 12, 8);
graphics.strokeRect(10, 4, 12, 8);
// Haare
graphics.fillStyle(colors.hair);
graphics.fillRect(10, 4, 12, 3);
graphics.strokeRect(10, 4, 12, 3);
// Hose
graphics.fillStyle(colors.pants);
graphics.fillRect(10, 22, 12, 6);
graphics.strokeRect(10, 22, 12, 6);
};
// NPC nach unten schauend
const npcDownGraphics = this.make.graphics({ x: 0, y: 0 });
drawNPCBase(npcDownGraphics);
// Gesicht nach unten
npcDownGraphics.fillStyle(colors.outline);
npcDownGraphics.fillRect(14, 8, 1, 1); // Linkes Auge
npcDownGraphics.fillRect(17, 8, 1, 1); // Rechtes Auge
npcDownGraphics.fillRect(15, 10, 2, 1); // Mund
// Beine und Schuhe nach unten
npcDownGraphics.fillStyle(colors.pants);
npcDownGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
npcDownGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
npcDownGraphics.fillStyle(colors.shoes);
npcDownGraphics.fillRect(12, 30, 3, 2); // Linker Schuh
npcDownGraphics.fillRect(17, 30, 3, 2); // Rechter Schuh
npcDownGraphics.lineStyle(1, colors.outline);
npcDownGraphics.strokeRect(12, 28, 3, 4); // Linkes Bein Umriss
npcDownGraphics.strokeRect(17, 28, 3, 4); // Rechtes Bein Umriss
npcDownGraphics.generateTexture('npc_down', frameWidth, frameHeight);
// NPC nach oben schauend
const npcUpGraphics = this.make.graphics({ x: 0, y: 0 });
drawNPCBase(npcUpGraphics);
// Rücken der Haare
npcUpGraphics.fillStyle(colors.hair);
npcUpGraphics.fillRect(10, 2, 12, 2);
npcUpGraphics.lineStyle(1, colors.outline);
npcUpGraphics.strokeRect(10, 2, 12, 2);
// Beine und Schuhe nach oben
npcUpGraphics.fillStyle(colors.pants);
npcUpGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
npcUpGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
npcUpGraphics.fillStyle(colors.shoes);
npcUpGraphics.fillRect(12, 30, 3, 2); // Linker Schuh
npcUpGraphics.fillRect(17, 30, 3, 2); // Rechter Schuh
npcUpGraphics.lineStyle(1, colors.outline);
npcUpGraphics.strokeRect(12, 28, 3, 4); // Linkes Bein Umriss
npcUpGraphics.strokeRect(17, 28, 3, 4); // Rechtes Bein Umriss
npcUpGraphics.generateTexture('npc_up', frameWidth, frameHeight);
}
}

View file

@ -1,241 +0,0 @@
class GameScene extends Phaser.Scene {
constructor() {
super({ key: 'GameScene' });
}
create() {
const { COLORS } = GAME_CONFIG;
this.add.image(400, 300, 'background');
// Grid erstellen (16x16 Grid mit 32x32 Pixel Tiles)
this.grid = [];
this.tileSize = 32;
this.gridWidth = 16;
this.gridHeight = 16;
this.gridStartX = (800 - this.gridWidth * this.tileSize) / 2;
this.gridStartY = (600 - this.gridHeight * this.tileSize) / 2;
// Grid-Farben als 2D-Array speichern
this.gridColors = [];
for (let y = 0; y < this.gridHeight; y++) {
this.grid[y] = [];
this.gridColors[y] = [];
for (let x = 0; x < this.gridWidth; x++) {
const tile = this.add.image(
this.gridStartX + x * this.tileSize + this.tileSize / 2,
this.gridStartY + y * this.tileSize + this.tileSize / 2,
'tile'
);
tile.setTint(0xffffff);
tile.setInteractive();
tile.on('pointerdown', () => this.paintTile(x, y));
tile.on('pointerover', (pointer) => {
if (pointer.isDown) this.paintTile(x, y);
});
this.grid[y][x] = tile;
this.gridColors[y][x] = 0xffffff;
}
}
this.currentColor = 0x000000;
// Farbpalette
this.createColorPalette();
// Titel
this.add
.text(400, 30, I18N.t('editorTitle'), { fontSize: '32px', fill: COLORS.TEXT_WHITE })
.setOrigin(0.5);
// Buttons
this._createButton(80, 560, I18N.t('back'), () => this.scene.start('MainMenuScene'));
this._createButton(250, 560, I18N.t('clear'), () => this.clearGrid());
this._createButton(420, 560, I18N.t('saveAsAvatar'), () => this.saveAsAvatar());
this._createButton(600, 560, I18N.t('load'), () => this.loadAvatar());
// Status-Text
this.statusText = this.add
.text(400, 30 + 30, '', {
fontSize: '14px',
fontFamily: 'Arial',
fill: COLORS.TEXT_REVEALED,
align: 'center',
})
.setOrigin(0.5);
}
createColorPalette() {
const colors = [
0x000000, 0xffffff, 0xff0000, 0x00ff00, 0x0000ff, 0xffff00, 0xff00ff, 0x00ffff, 0xff8800,
0x8800ff, 0x88ff00, 0x0088ff, 0xff4444, 0x44ff44, 0x4444ff, 0x888888, 0xffcc99, 0x663300,
0x339933, 0x333366,
];
const paletteX = 720;
const paletteStartY = 120;
const size = 25;
const gap = 5;
const cols = 2;
this.add
.text(paletteX, paletteStartY - 25, 'Farben', {
fontSize: '14px',
fill: GAME_CONFIG.COLORS.TEXT_WHITE,
})
.setOrigin(0.5);
colors.forEach((color, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
const x = paletteX - (cols * (size + gap)) / 2 + col * (size + gap) + size / 2;
const y = paletteStartY + row * (size + gap);
const btn = this.add.rectangle(x, y, size, size, color);
btn.setInteractive({ useHandCursor: true });
btn.setStrokeStyle(2, 0xffffff);
btn.on('pointerdown', () => {
this.currentColor = color;
// Highlight aktive Farbe
colors.forEach((_, i) => {
const el = this.children.list.find(
(c) =>
c.type === 'Rectangle' &&
c.x === paletteX - (cols * (size + gap)) / 2 + (i % cols) * (size + gap) + size / 2 &&
c.fillColor === colors[i]
);
if (el) el.setStrokeStyle(2, 0xffffff);
});
btn.setStrokeStyle(3, GAME_CONFIG.COLORS.REVEAL_FLASH);
});
});
}
paintTile(x, y) {
this.grid[y][x].setTint(this.currentColor);
this.gridColors[y][x] = this.currentColor;
}
clearGrid() {
for (let y = 0; y < this.gridHeight; y++) {
for (let x = 0; x < this.gridWidth; x++) {
this.grid[y][x].setTint(0xffffff);
this.gridColors[y][x] = 0xffffff;
}
}
this._showStatus(I18N.t('gridCleared'));
}
/** Speichert das aktuelle Pixel-Art als Avatar-Textur */
saveAsAvatar() {
// Pixel-Daten speichern
const avatarData = {
width: this.gridWidth,
height: this.gridHeight,
pixels: this.gridColors,
};
try {
localStorage.setItem('whopixels_avatar', JSON.stringify(avatarData));
// Textur generieren (32x32 skaliert auf Spieler-Größe)
this._generateAvatarTexture(avatarData);
this._showStatus(I18N.t('avatarSaved'));
} catch (error) {
console.error('Fehler beim Speichern des Avatars:', error);
this._showStatus(I18N.t('saveError'));
}
}
/** Lädt einen gespeicherten Avatar in den Editor */
loadAvatar() {
try {
const saved = localStorage.getItem('whopixels_avatar');
if (!saved) {
this._showStatus(I18N.t('noAvatarFound'));
return;
}
const avatarData = JSON.parse(saved);
for (let y = 0; y < Math.min(avatarData.height, this.gridHeight); y++) {
for (let x = 0; x < Math.min(avatarData.width, this.gridWidth); x++) {
const color = avatarData.pixels[y][x];
this.grid[y][x].setTint(color);
this.gridColors[y][x] = color;
}
}
this._showStatus(I18N.t('avatarLoaded'));
} catch (error) {
console.error('Fehler beim Laden:', error);
this._showStatus(I18N.t('loadError'));
}
}
_generateAvatarTexture(avatarData) {
const frameSize = 32;
const pixelSize = frameSize / avatarData.width; // 2px pro Pixel bei 16x16
const graphics = this.make.graphics({ x: 0, y: 0 });
for (let y = 0; y < avatarData.height; y++) {
for (let x = 0; x < avatarData.width; x++) {
const color = avatarData.pixels[y][x];
if (color !== 0xffffff) {
// Weiß = transparent
graphics.fillStyle(color);
graphics.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
}
}
}
// Generiere Texturen für alle Richtungen
// (einfache Version: gleiche Textur für alle Richtungen)
if (this.textures.exists('custom_avatar_down')) {
this.textures.remove('custom_avatar_down');
this.textures.remove('custom_avatar_up');
this.textures.remove('custom_avatar_left');
this.textures.remove('custom_avatar_right');
}
graphics.generateTexture('custom_avatar_down', frameSize, frameSize);
graphics.generateTexture('custom_avatar_up', frameSize, frameSize);
graphics.generateTexture('custom_avatar_left', frameSize, frameSize);
graphics.generateTexture('custom_avatar_right', frameSize, frameSize);
graphics.destroy();
}
/** @param {string} msg */
_showStatus(msg) {
this.statusText.setText(msg);
this.tweens.add({
targets: this.statusText,
alpha: 0,
duration: 1000,
delay: 2000,
onComplete: () => {
this.statusText.setAlpha(1);
this.statusText.setText('');
},
});
}
_createButton(x, y, label, onClick) {
const { COLORS } = GAME_CONFIG;
const btn = this.add
.text(x, y, label, {
fontSize: '20px',
fill: COLORS.TEXT_WHITE,
backgroundColor: COLORS.BACK_BUTTON_BG,
padding: { x: 12, y: 6 },
})
.setOrigin(0.5)
.setInteractive({ useHandCursor: true });
btn.on('pointerover', () => btn.setStyle({ fill: COLORS.BACK_BUTTON_HOVER }));
btn.on('pointerout', () => btn.setStyle({ fill: COLORS.TEXT_WHITE }));
btn.on('pointerdown', onClick);
return btn;
}
}

View file

@ -1,130 +0,0 @@
class MainMenuScene extends Phaser.Scene {
constructor() {
super({ key: 'MainMenuScene' });
}
create() {
const { COLORS } = GAME_CONFIG;
const centerX = this.cameras.main.width / 2;
this.add.image(centerX, 300, 'background');
// Titel
this.add
.text(centerX, 100, I18N.t('title'), {
fontSize: '64px',
fill: COLORS.TEXT_WHITE,
fontStyle: 'bold',
})
.setOrigin(0.5);
this.add
.text(centerX, 160, I18N.t('subtitle'), {
fontSize: '32px',
fill: COLORS.TEXT_WHITE,
})
.setOrigin(0.5);
// Statistiken
this._showStats(centerX);
// Buttons
this._createButton(centerX, 300, I18N.t('startGame'), () => {
this.scene.start('RPGScene');
});
this._createButton(centerX, 370, I18N.t('pixelEditor'), () => {
this.scene.start('GameScene');
});
this._createButton(centerX, 440, I18N.t('resetProgress'), () => {
new StorageManager().reset();
this._showStats(centerX);
const confirmText = this.add
.text(centerX, 500, I18N.t('progressReset'), {
fontSize: '18px',
fill: COLORS.TEXT_REVEALED,
fontFamily: 'Arial',
})
.setOrigin(0.5);
this.tweens.add({
targets: confirmText,
alpha: 0,
duration: 1500,
delay: 1500,
onComplete: () => confirmText.destroy(),
});
});
// Sprach-Umschalter (rechts oben)
const langBtn = this.add
.text(this.cameras.main.width - 20, 20, I18N.getLanguage().toUpperCase(), {
fontSize: '20px',
fontFamily: 'Arial',
fontStyle: 'bold',
fill: COLORS.TEXT_WHITE,
backgroundColor: COLORS.BACK_BUTTON_BG,
padding: { x: 10, y: 5 },
})
.setOrigin(1, 0)
.setInteractive({ useHandCursor: true });
langBtn.on('pointerover', () => langBtn.setStyle({ fill: COLORS.BACK_BUTTON_HOVER }));
langBtn.on('pointerout', () => langBtn.setStyle({ fill: COLORS.TEXT_WHITE }));
langBtn.on('pointerdown', () => {
const newLang = I18N.toggle();
// Scene neu laden, um Texte zu aktualisieren
this.scene.restart();
});
}
_showStats(centerX) {
const { COLORS } = GAME_CONFIG;
const storage = new StorageManager();
const stats = storage.getStats();
if (this.statsText) this.statsText.destroy();
if (stats.totalRevealed > 0) {
this.statsText = this.add
.text(
centerX,
220,
[
`${I18N.t('statsRevealed')}: ${stats.totalRevealed}`,
`${I18N.t('statsAvgGuesses')}: ${stats.averageGuesses}`,
`${I18N.t('statsBestStreak')}: ${stats.bestStreak}`,
].join(' | '),
{
fontSize: '16px',
fontFamily: 'Arial',
fill: COLORS.TEXT_NPC_RESPONSE,
align: 'center',
}
)
.setOrigin(0.5);
}
}
_createButton(x, y, label, onClick) {
const { COLORS } = GAME_CONFIG;
const button = this.add
.text(x, y, label, {
fontSize: '28px',
fill: COLORS.TEXT_WHITE,
backgroundColor: COLORS.BACK_BUTTON_BG,
padding: { x: 20, y: 10 },
})
.setOrigin(0.5)
.setInteractive({ useHandCursor: true });
button.on('pointerover', () => button.setStyle({ fill: COLORS.BACK_BUTTON_HOVER }));
button.on('pointerout', () => button.setStyle({ fill: COLORS.TEXT_WHITE }));
button.on('pointerdown', onClick);
return button;
}
}

View file

@ -1,149 +0,0 @@
class RPGScene extends Phaser.Scene {
constructor() {
super({ key: 'RPGScene' });
}
preload() {
// Wir verwenden die in BootScene erstellten Texturen
}
create() {
// Manager erstellen
this.storage = new StorageManager();
this.sound_mgr = new SoundManager();
this.worldManager = new WorldManager(this);
this.playerManager = new PlayerManager(this);
this.npcManager = new NPCManager(this);
this.chatUI = new ChatUI(this);
// Gespeicherten Fortschritt laden
const saveData = this.storage.load();
if (saveData.discoveredNPCs.length > 0) {
console.log(I18N.t('saveLoaded', { count: String(saveData.discoveredNPCs.length) }));
}
// Spielwelt erstellen
this.worldManager.create();
// Spieler erstellen
this.playerManager.create(this.worldManager.map, this.worldManager.obstacles);
// NPCs erstellen (mit gespeichertem Fortschritt)
this.npcManager.create(this.playerManager.player, this.worldManager.obstacles);
this.npcManager.state.discoveredNPCs = saveData.discoveredNPCs;
// Touch-Controls (Mobile)
this.touchControls = new TouchControls(this);
this.touchControls.create();
// UI erstellen
this._createSceneUI();
this.chatUI.create();
// Interaktions-Taste (E)
this.interactKey = this.input.keyboard.addKey('E');
// NPC-Bewegung
this.time.addEvent({
delay: GAME_CONFIG.NPC_MOVE_INTERVAL,
callback: () => this.npcManager.moveRandomly(),
callbackScope: this,
loop: true,
});
// Tutorial beim ersten Spielstart
if (saveData.totalRevealed === 0) {
this._showTutorial();
}
}
_showTutorial() {
const { COLORS } = GAME_CONFIG;
const w = this.cameras.main.width;
const h = this.cameras.main.height;
const overlay = this.add.graphics();
overlay.fillStyle(0x000000, 0.7);
overlay.fillRect(0, 0, w, h);
overlay.setScrollFactor(0);
overlay.setDepth(2000);
const isMobile = this.sys.game.device.input.touch;
const controlsText = isMobile
? I18N.t('tutorialControlsMobile')
: I18N.t('tutorialControlsDesktop');
const tutorialText = this.add.text(
w / 2,
h / 2 - 40,
[
I18N.t('tutorialWelcome'),
'',
I18N.t('tutorialDesc'),
'',
controlsText,
'',
I18N.t('tutorialStart'),
].join('\n'),
{
fontSize: '18px',
fontFamily: 'Arial',
fill: COLORS.TEXT_WHITE,
align: 'center',
lineSpacing: 6,
}
);
tutorialText.setOrigin(0.5);
tutorialText.setScrollFactor(0);
tutorialText.setDepth(2001);
const closeTutorial = () => {
overlay.destroy();
tutorialText.destroy();
};
this.input.once('pointerdown', closeTutorial);
this.input.keyboard.once('keydown', closeTutorial);
}
_createSceneUI() {
const { COLORS, FONTS } = GAME_CONFIG;
const backButton = this.add
.text(10, 10, I18N.t('backToMenu'), {
fontSize: FONTS.BACK_BUTTON,
fill: COLORS.TEXT_WHITE,
backgroundColor: COLORS.BACK_BUTTON_BG,
padding: { x: 10, y: 5 },
})
.setScrollFactor(0)
.setInteractive();
backButton.on('pointerover', () => backButton.setStyle({ fill: COLORS.BACK_BUTTON_HOVER }));
backButton.on('pointerout', () => backButton.setStyle({ fill: COLORS.TEXT_WHITE }));
backButton.on('pointerdown', () => this.scene.start('MainMenuScene'));
this.add
.text(10, 50, I18N.t('arrowKeysToMove'), {
fontSize: FONTS.INSTRUCTIONS,
fill: COLORS.TEXT_WHITE,
backgroundColor: 'rgba(0,0,0,0.5)',
padding: { x: 5, y: 2 },
})
.setScrollFactor(0);
}
update() {
this.playerManager.handleMovement(this.touchControls);
const touchInteract = this.touchControls.isActive && this.touchControls.consumeInteract();
if (this.npcManager.checkInteraction(this.interactKey, touchInteract)) {
this.chatUI.lastNpcResponse = I18N.t('riddleIntro');
this.chatUI.conversationHistory = [];
this.chatUI.open();
this.sound_mgr.playConversationStart();
}
this.npcManager.update();
}
}

View file

@ -1,13 +0,0 @@
{
"compilerOptions": {
"checkJs": true,
"strict": false,
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"lib": ["ES2020", "DOM"],
"typeRoots": ["./node_modules/@types"]
},
"include": ["js/**/*.js", "data/**/*.js"],
"exclude": ["node_modules"]
}

View file

@ -1,6 +0,0 @@
{
"dependencies": {
"dotenv": "^16.4.7",
"node-fetch": "^2.7.0"
}
}

View file

@ -1,305 +0,0 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
// Konfiguration
const PORT = process.env.PORT || 3000;
const MAX_BODY_SIZE = 50 * 1024; // 50KB max request body
const MAX_CONVERSATION_HISTORY = 20; // Max Einträge in der Konversationshistorie
const RATE_LIMIT_WINDOW_MS = 60000; // 1 Minute
const RATE_LIMIT_MAX_REQUESTS = 30; // Max 30 Anfragen pro Minute
// CORS — in Produktion einschränken
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',')
: ['http://localhost:3000', 'http://localhost:5100'];
// Azure OpenAI API Konfiguration
const AZURE_OPENAI_API_KEY = process.env.AZURE_OPENAI_API_KEY;
const AZURE_OPENAI_ENDPOINT = process.env.AZURE_OPENAI_ENDPOINT;
const AZURE_OPENAI_DEPLOYMENT = process.env.AZURE_OPENAI_DEPLOYMENT;
const AZURE_OPENAI_API_VERSION = process.env.AZURE_OPENAI_API_VERSION;
const MIME_TYPES = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
};
// === Rate Limiting ===
const rateLimitMap = new Map();
function isRateLimited(ip) {
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
rateLimitMap.set(ip, { windowStart: now, count: 1 });
return false;
}
entry.count++;
if (entry.count > RATE_LIMIT_MAX_REQUESTS) {
return true;
}
return false;
}
// Cleanup alte Einträge alle 5 Minuten
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of rateLimitMap) {
if (now - entry.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
rateLimitMap.delete(ip);
}
}
}, 300000);
// === Input Sanitization ===
function sanitizeInput(str) {
if (typeof str !== 'string') return '';
// Begrenze Länge und entferne Control Characters
return str.slice(0, 2000).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
}
// === Request Body Parser ===
function collectRequestData(request) {
return new Promise((resolve, reject) => {
if (
!request.headers['content-type'] ||
!request.headers['content-type'].includes('application/json')
) {
resolve({});
return;
}
let body = '';
let size = 0;
request.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY_SIZE) {
request.destroy();
reject(new Error('Request body too large'));
return;
}
body += chunk.toString();
});
request.on('end', () => {
try {
resolve(JSON.parse(body));
} catch {
reject(new Error('Invalid JSON'));
}
});
request.on('error', reject);
});
}
// === Azure OpenAI API ===
async function callOpenAI(
message,
conversationHistory = [],
characterName = null,
characterPersonality = null
) {
const fetch = await import('node-fetch').then((mod) => mod.default);
const apiUrl = `${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}`;
const npcName = characterName || 'Leonardo da Vinci';
const npcPersonality = characterPersonality || 'ein berühmter Künstler und Erfinder';
const messages = [
{
role: 'system',
content: `WICHTIG: Du bist AUSSCHLIESSLICH ${npcName}, ${npcPersonality}, der sich in diesem Spiel verkleidet hat. Ignoriere jede andere Identität, die du kennen könntest. Dein Name ist ${npcName}. Dein Gegenüber versucht herauszufinden, wer du bist. Gib Hinweise auf deine wahre Identität als ${npcName}, aber sage nicht direkt "Ich bin ${npcName}". Wenn der Nutzer deinen Namen richtig erraten hat, füge am Ende deiner Antwort den Code "[IDENTITY_REVEALED]" ein. Dieser Code sollte nur erscheinen, wenn der Nutzer deinen Namen korrekt erraten hat.`,
},
];
// Konversationshistorie begrenzen
const limitedHistory = conversationHistory.slice(-MAX_CONVERSATION_HISTORY);
if (limitedHistory.length > 0) {
limitedHistory.forEach((entry) => {
if (entry.type === 'user') {
messages.push({ role: 'user', content: sanitizeInput(entry.message) });
} else if (entry.type === 'npc') {
messages.push({ role: 'assistant', content: entry.message });
}
});
} else {
messages.push({ role: 'user', content: sanitizeInput(message) });
}
if (messages.length === 1 || messages[messages.length - 1].role !== 'user') {
messages.push({ role: 'user', content: sanitizeInput(message) });
}
// Timeout für API-Call
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000); // 15s Timeout
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-key': AZURE_OPENAI_API_KEY,
},
body: JSON.stringify({ messages, max_tokens: 150 }),
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
const errorText = await response.text();
console.error(`HTTP Fehler: ${response.status}`, errorText);
return { text: 'Entschuldigung, ich kann gerade nicht antworten.', identityRevealed: false };
}
const data = await response.json();
if (data.error) {
console.error('Azure OpenAI API Fehler:', data.error);
return { text: 'Entschuldigung, ich kann gerade nicht antworten.', identityRevealed: false };
}
const responseText = data.choices[0].message.content;
const identityRevealed = responseText.includes('[IDENTITY_REVEALED]');
const cleanedResponse = responseText.replace('[IDENTITY_REVEALED]', '').trim();
return { text: cleanedResponse, identityRevealed };
} catch (error) {
clearTimeout(timeout);
if (error.name === 'AbortError') {
console.error('API-Timeout nach 15 Sekunden');
return {
text: 'Entschuldigung, die Antwort hat zu lange gedauert.',
identityRevealed: false,
};
}
console.error('Fehler beim Aufrufen der Azure OpenAI API:', error.message);
return { text: 'Entschuldigung, ich kann gerade nicht antworten.', identityRevealed: false };
}
}
// === HTTP Server ===
const server = http.createServer(async (req, res) => {
const clientIP = req.socket.remoteAddress;
// CORS-Header
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
} else if (!origin) {
// Same-origin Requests haben keinen Origin-Header
res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGINS[0]);
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
// API-Endpunkt
if (req.method === 'POST' && req.url === '/api/chat') {
// Rate Limiting
if (isRateLimited(clientIP)) {
res.writeHead(429, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Zu viele Anfragen. Bitte warte einen Moment.' }));
return;
}
try {
const data = await collectRequestData(req);
if (!data.message || typeof data.message !== 'string') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Nachricht fehlt oder ungültig' }));
return;
}
const conversationHistory = Array.isArray(data.conversationHistory)
? data.conversationHistory
: [];
const response = await callOpenAI(
data.message,
conversationHistory,
typeof data.characterName === 'string' ? data.characterName : null,
typeof data.characterPersonality === 'string' ? data.characterPersonality : null
);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({ response: response.text, identityRevealed: response.identityRevealed })
);
} catch (error) {
console.error('Fehler bei der Verarbeitung:', error.message);
const statusCode = error.message === 'Request body too large' ? 413 : 400;
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
// Statische Dateien
let filePath = '.' + req.url;
if (filePath === './') filePath = './index.html';
// Path Traversal verhindern
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(path.resolve('.'))) {
res.writeHead(403);
res.end('Forbidden');
return;
}
const extname = path.extname(filePath);
const contentType = MIME_TYPES[extname] || 'application/octet-stream';
fs.readFile(filePath, (error, content) => {
if (error) {
if (error.code === 'ENOENT') {
fs.readFile('./index.html', (err, fallback) => {
if (err) {
res.writeHead(500);
res.end('Error loading index.html');
} else {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(fallback, 'utf-8');
}
});
} else {
res.writeHead(500);
res.end(`Server Error: ${error.code}`);
}
} else {
res.writeHead(200, { 'Content-Type': contentType });
res.end(content, 'utf-8');
}
});
});
server.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}/`);
console.log(
`Rate Limit: ${RATE_LIMIT_MAX_REQUESTS} requests per ${RATE_LIMIT_WINDOW_MS / 1000}s`
);
console.log(`CORS: ${ALLOWED_ORIGINS.join(', ')}`);
});