feat(whopixels): major refactor with 20 improvements across architecture, gameplay, UX, security, and i18n

Split monolithic RPGScene.js (1210 lines) into modular manager classes:
- WorldManager, PlayerManager, NPCManager, ChatUI, StorageManager,
  SoundManager, TouchControls

Key improvements:
- Constants config (GAME_CONFIG) replacing all magic numbers
- JSDoc types + jsconfig.json for IDE type-safety
- LocalStorage persistence for progress, stats, and custom avatars
- Synthesized sound effects via Web Audio API
- 26 NPCs (up from 10) in 3 categories
- Stats/leaderboard in main menu
- Pixel editor avatar integration with RPG game
- Mobile touch controls (virtual joystick + interact button)
- Chat UI with typing indicator and conversation history
- Interactive tutorial overlay for first-time players
- Floating question mark over NPCs in range
- Server hardened: rate limiting, input sanitization, CORS restrictions,
  API timeouts, conversation history cap
- Particle effect object pooling
- i18n framework with DE/EN and language switcher

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-21 15:26:40 +01:00
parent 161f10596f
commit c0c11c325a
39 changed files with 2900 additions and 1463 deletions

View file

@ -34,6 +34,7 @@
"dependencies": {
"@chat/types": "workspace:*",
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",

View file

@ -0,0 +1,61 @@
import { createAppOnboardingStore, type AppOnboardingStep } from '@manacore/shared-app-onboarding';
import { userSettings } from './user-settings.svelte';
/**
* Chat-specific onboarding steps
*/
const chatOnboardingSteps: AppOnboardingStep[] = [
{
id: 'defaultModel',
type: 'select',
question: 'Welches Modell bevorzugst du?',
emoji: '💬',
gradient: { from: 'blue-500', to: 'blue-700' },
options: [
{
id: 'local',
label: 'Lokale Modelle',
description: 'Kostenlos, läuft auf eigenem Server',
emoji: '🏠',
},
{
id: 'cloud',
label: 'Cloud-Modelle',
description: 'Höhere Qualität, kostenpflichtig',
emoji: '☁️',
},
{
id: 'auto',
label: 'Automatisch',
description: 'Bestes Modell je nach Aufgabe (Empfohlen)',
emoji: '🤖',
},
],
defaultValue: 'auto',
},
{
id: 'welcome',
type: 'info',
question: 'Dein Chat ist bereit!',
description: 'Hier sind einige Tipps:',
emoji: '🎉',
gradient: { from: 'primary', to: 'primary/70' },
bullets: [
'Wähle verschiedene KI-Modelle je nach Aufgabe',
'Nutze Konversationen, um Chatverläufe zu organisieren',
'Lokale Modelle sind kostenlos und datenschutzfreundlich',
'Cloud-Modelle bieten höhere Qualität für komplexe Aufgaben',
],
},
];
/**
* Chat app onboarding store
*/
export const chatOnboarding = createAppOnboardingStore({
appId: 'chat',
steps: chatOnboardingSteps,
userSettings,
onComplete: async () => {},
onSkip: async () => {},
});

View file

@ -22,6 +22,8 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
import type { LayoutData } from './$types';
import { chatOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
// App switcher items
const appItems = getPillAppItems('chat');
@ -235,6 +237,11 @@
</div>
{/if}
</main>
<!-- Onboarding Modal -->
{#if chatOnboarding.shouldShow}
<MiniOnboardingModal store={chatOnboarding} appName="Chat" appEmoji="💬" />
{/if}
</div>
{/if}

View file

@ -37,6 +37,7 @@
"dependencies": {
"@clock/shared": "workspace:*",
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",

View file

@ -0,0 +1,68 @@
import { createAppOnboardingStore, type AppOnboardingStep } from '@manacore/shared-app-onboarding';
import { userSettings } from './user-settings.svelte';
/**
* Clock-specific onboarding steps
*/
const clockOnboardingSteps: AppOnboardingStep[] = [
{
id: 'defaultTimer',
type: 'select',
question: 'Welche Timer-Dauer nutzt du am häufigsten?',
description: 'Du kannst Timer jederzeit individuell einstellen.',
emoji: '⏱️',
gradient: { from: 'blue-500', to: 'blue-700' },
options: [
{
id: '5',
label: '5 Minuten',
description: 'Für kurze Pausen',
emoji: '⚡',
},
{
id: '15',
label: '15 Minuten',
description: 'Für konzentrierte Einheiten',
emoji: '🎯',
},
{
id: '25',
label: '25 Minuten',
description: 'Pomodoro-Technik (Empfohlen)',
emoji: '🍅',
},
{
id: '45',
label: '45 Minuten',
description: 'Für längere Arbeitsphasen',
emoji: '🧘',
},
],
defaultValue: '25',
},
{
id: 'welcome',
type: 'info',
question: 'Deine Uhr ist bereit!',
description: 'Hier sind einige Tipps:',
emoji: '🎉',
gradient: { from: 'primary', to: 'primary/70' },
bullets: [
'Nutze die Stoppuhr für freie Zeitmessung',
'Stelle Wecker für wichtige Erinnerungen',
'Die Weltuhr zeigt mehrere Zeitzonen gleichzeitig',
'Drücke Cmd/Ctrl+K für die Schnellsuche',
],
},
];
/**
* Clock app onboarding store
*/
export const clockOnboarding = createAppOnboardingStore({
appId: 'clock',
steps: clockOnboardingSteps,
userSettings,
onComplete: async () => {},
onSkip: async () => {},
});

View file

@ -30,6 +30,8 @@
import { setLocale, supportedLocales } from '$lib/i18n';
import { alarmsApi } from '$lib/api/alarms';
import { timersApi } from '$lib/api/timers';
import { clockOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
// App switcher items
const appItems = getPillAppItems('clock');
@ -314,6 +316,11 @@
emptyText="Keine Ergebnisse"
searchingText="Suche..."
/>
<!-- Onboarding Modal -->
{#if clockOnboarding.shouldShow}
<MiniOnboardingModal store={clockOnboarding} appName="Uhr" appEmoji="⏰" />
{/if}
</div>
<style>

View file

@ -33,6 +33,7 @@
},
"dependencies": {
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",

View file

@ -0,0 +1,62 @@
import { createAppOnboardingStore, type AppOnboardingStep } from '@manacore/shared-app-onboarding';
import { userSettings } from './user-settings.svelte';
/**
* Context-specific onboarding steps
*/
const contextOnboardingSteps: AppOnboardingStep[] = [
{
id: 'useCase',
type: 'select',
question: 'Wofür nutzt du Context?',
description: 'Hilft uns, die beste Erfahrung zu bieten.',
emoji: '📄',
gradient: { from: 'blue-500', to: 'blue-700' },
options: [
{
id: 'docs',
label: 'Dokumentation',
description: 'Technische Dokumente und Anleitungen',
emoji: '📖',
},
{
id: 'knowledge',
label: 'Wissensdatenbank',
description: 'Wissen sammeln und organisieren',
emoji: '🧠',
},
{
id: 'personal',
label: 'Persönliche Notizen',
description: 'Ideen und Gedanken festhalten',
emoji: '✍️',
},
],
defaultValue: 'knowledge',
},
{
id: 'welcome',
type: 'info',
question: 'Context ist bereit!',
description: 'Hier sind einige Tipps:',
emoji: '🎉',
gradient: { from: 'primary', to: 'primary/70' },
bullets: [
'Nutze KI, um Dokumente zusammenzufassen und umzuschreiben',
'Organisiere Inhalte in Spaces für bessere Übersicht',
'Versionierung speichert automatisch frühere Fassungen',
'Drücke Cmd/Ctrl+K für die Schnellsuche',
],
},
];
/**
* Context app onboarding store
*/
export const contextOnboarding = createAppOnboardingStore({
appId: 'context',
steps: contextOnboardingSteps,
userSettings,
onComplete: async () => {},
onSkip: async () => {},
});

View file

@ -26,6 +26,8 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { contextOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
const appItems = getPillAppItems('context');
@ -266,6 +268,11 @@
emptyText="Keine Ergebnisse"
searchingText="Suche..."
/>
<!-- Onboarding Modal -->
{#if contextOnboarding.shouldShow}
<MiniOnboardingModal store={contextOnboarding} appName="Context" appEmoji="📄" />
{/if}
</div>
<style>

View file

@ -30,6 +30,7 @@
"vite": "^7.1.10"
},
"dependencies": {
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",

View file

@ -0,0 +1,62 @@
import { createAppOnboardingStore, type AppOnboardingStep } from '@manacore/shared-app-onboarding';
import { userSettings } from './user-settings.svelte';
/**
* ManaDeck-specific onboarding steps
*/
const manadeckOnboardingSteps: AppOnboardingStep[] = [
{
id: 'startAction',
type: 'select',
question: 'Wie möchtest du starten?',
description: 'Du kannst alles jederzeit nachholen.',
emoji: '🃏',
gradient: { from: 'blue-500', to: 'blue-700' },
options: [
{
id: 'create',
label: 'Neues Deck erstellen',
description: 'Starte mit eigenen Lernkarten',
emoji: '✏️',
},
{
id: 'explore',
label: 'Decks entdecken',
description: 'Finde geteilte Lernsets',
emoji: '🔍',
},
{
id: 'later',
label: 'Erstmal umschauen',
description: 'Die App in Ruhe erkunden',
emoji: '👀',
},
],
defaultValue: 'later',
},
{
id: 'welcome',
type: 'info',
question: 'ManaDeck ist bereit!',
description: 'Hier sind einige Tipps:',
emoji: '🎉',
gradient: { from: 'primary', to: 'primary/70' },
bullets: [
'Erstelle Decks mit Lernkarten für Spaced Repetition',
'Nutze die tägliche Lernrunde für optimales Lernen',
'Entdecke öffentliche Decks anderer Nutzer',
"Drücke 'F' für den Fokus-Modus beim Lernen",
],
},
];
/**
* ManaDeck app onboarding store
*/
export const manadeckOnboarding = createAppOnboardingStore({
appId: 'manadeck',
steps: manadeckOnboardingSteps,
userSettings,
onComplete: async () => {},
onSkip: async () => {},
});

View file

@ -19,6 +19,8 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { manadeckOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
// App switcher items
const appItems = getPillAppItems('manadeck');
@ -213,5 +215,10 @@
{@render children()}
</div>
</main>
<!-- Onboarding Modal -->
{#if manadeckOnboarding.shouldShow}
<MiniOnboardingModal store={manadeckOnboarding} appName="ManaDeck" appEmoji="🃏" />
{/if}
</div>
{/if}

View file

@ -10,13 +10,14 @@
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"type-check": "echo 'Skipping type-check: @picture/web needs shared-ui component fixes'",
"type-check": "svelte-check --tsconfig ./tsconfig.json",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"clean": "rm -rf .svelte-kit build node_modules"
},
"dependencies": {
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",

View file

@ -0,0 +1,62 @@
import { createAppOnboardingStore, type AppOnboardingStep } from '@manacore/shared-app-onboarding';
import { userSettings } from './user-settings.svelte';
/**
* Picture-specific onboarding steps
*/
const pictureOnboardingSteps: AppOnboardingStep[] = [
{
id: 'viewMode',
type: 'select',
question: 'Wie möchtest du deine Bilder sehen?',
description: 'Du kannst die Ansicht jederzeit wechseln.',
emoji: '🎨',
gradient: { from: 'indigo-500', to: 'indigo-700' },
options: [
{
id: 'single',
label: 'Einzelansicht',
description: 'Große Vorschau, ein Bild pro Zeile',
emoji: '🖼️',
},
{
id: 'grid-2',
label: 'Grid (2 Spalten)',
description: 'Mittlere Übersicht (Empfohlen)',
emoji: '📐',
},
{
id: 'grid-3',
label: 'Grid (3 Spalten)',
description: 'Kompakte Übersicht',
emoji: '📊',
},
],
defaultValue: 'grid-2',
},
{
id: 'welcome',
type: 'info',
question: 'Deine Galerie ist bereit!',
description: 'Hier sind einige Tipps:',
emoji: '🎉',
gradient: { from: 'primary', to: 'primary/70' },
bullets: [
'Generiere KI-Bilder mit verschiedenen Stilen',
'Erstelle Moodboards, um Bilder zu gruppieren',
'3 kostenlose Generierungen sind inklusive',
'Nutze Tags für bessere Organisation',
],
},
];
/**
* Picture app onboarding store
*/
export const pictureOnboarding = createAppOnboardingStore({
appId: 'picture',
steps: pictureOnboardingSteps,
userSettings,
onComplete: async () => {},
onSkip: async () => {},
});

View file

@ -19,6 +19,8 @@
import KeyboardShortcutsModal from '$lib/components/ui/KeyboardShortcutsModal.svelte';
import { theme } from '$lib/stores/theme';
import { isUIVisible, toggleUI, showKeyboardShortcuts } from '$lib/stores/ui';
import { pictureOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { viewMode, setViewMode } from '$lib/stores/view';
import type { ViewMode } from '$lib/stores/view';
import { browser } from '$app/environment';
@ -287,6 +289,11 @@
<!-- Keyboard Shortcuts Modal -->
<KeyboardShortcutsModal />
<!-- Onboarding Modal -->
{#if pictureOnboarding.shouldShow}
<MiniOnboardingModal store={pictureOnboarding} appName="Picture" appEmoji="🎨" />
{/if}
</div>
{/if}

View file

@ -32,6 +32,7 @@
},
"dependencies": {
"@presi/shared": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",

View file

@ -0,0 +1,33 @@
import { createAppOnboardingStore, type AppOnboardingStep } from '@manacore/shared-app-onboarding';
import { userSettings } from './user-settings.svelte';
/**
* Presi-specific onboarding steps
*/
const presiOnboardingSteps: AppOnboardingStep[] = [
{
id: 'welcome',
type: 'info',
question: 'Willkommen bei Presi!',
description: 'Hier sind einige Tipps für den Start:',
emoji: '🎉',
gradient: { from: 'primary', to: 'primary/70' },
bullets: [
'Erstelle Präsentationen mit verschiedenen Folientypen',
'Nutze den Vollbild-Modus für Vorträge',
'Teile Präsentationen über öffentliche Links',
'Wende Themes an, um das Design anzupassen',
],
},
];
/**
* Presi app onboarding store
*/
export const presiOnboarding = createAppOnboardingStore({
appId: 'presi',
steps: presiOnboardingSteps,
userSettings,
onComplete: async () => {},
onSkip: async () => {},
});

View file

@ -13,6 +13,8 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { presiOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
// App switcher items
const appItems = getPillAppItems('presi');
@ -173,6 +175,11 @@
{@render children()}
</div>
</main>
<!-- Onboarding Modal -->
{#if presiOnboarding.shouldShow}
<MiniOnboardingModal store={presiOnboarding} appName="Presi" appEmoji="📊" />
{/if}
</div>
{/if}

View file

@ -0,0 +1,33 @@
import { createAppOnboardingStore, type AppOnboardingStep } from '@manacore/shared-app-onboarding';
import { userSettings } from './user-settings.svelte';
/**
* Storage-specific onboarding steps
*/
const storageOnboardingSteps: AppOnboardingStep[] = [
{
id: 'welcome',
type: 'info',
question: 'Willkommen bei Storage!',
description: 'Hier sind einige Tipps für den Start:',
emoji: '🎉',
gradient: { from: 'primary', to: 'primary/70' },
bullets: [
'Lade Dateien per Drag & Drop hoch',
'Organisiere Inhalte in Ordnern',
'Teile Dateien über sichere Links mit Passwortschutz',
'Nutze Favoriten und Tags für schnellen Zugriff',
],
},
];
/**
* Storage app onboarding store
*/
export const storageOnboarding = createAppOnboardingStore({
appId: 'storage',
steps: storageOnboardingSteps,
userSettings,
onComplete: async () => {},
onSkip: async () => {},
});

View file

@ -14,6 +14,8 @@
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { ToastContainer } from '@manacore/shared-ui';
import { storageOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import '../app.css';
// App switcher items
@ -219,6 +221,11 @@
{@render children()}
</div>
</main>
<!-- Onboarding Modal -->
{#if storageOnboarding.shouldShow}
<MiniOnboardingModal store={storageOnboarding} appName="Storage" appEmoji="☁️" />
{/if}
</div>
{/if}

View file

@ -0,0 +1,42 @@
# 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
- [ ] **20. Phaser-Version updaten** — Von 3.55.2 auf 3.80+ (Breaking Changes bei Particle-API, benötigt umfassende Tests)
## Lokalisierung
- [x] **21. i18n-Framework**`I18N`-System mit Deutsch/Englisch, Sprach-Umschalter im Hauptmenü, alle Texte lokalisiert

View file

@ -1,6 +1,7 @@
// Liste der NPC-Charaktere mit Namen und Persönlichkeiten
// NPC-Charaktere: Berühmte Erfinder durch die Historie
// Kategorien: Erfinder, Wissenschaftler, Künstler, Entdecker, Vordenker
const npcCharacters = [
// === ERFINDER (IDs 1-10) ===
{
id: 1,
name: 'Leonardo da Vinci',
@ -71,6 +72,122 @@ const npcCharacters = [
'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

View file

@ -15,7 +15,20 @@
<!-- Game Data -->
<script src="data/npc_characters.js"></script>
<!-- Game Scripts -->
<!-- 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>

View file

@ -0,0 +1,121 @@
/**
* @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

@ -0,0 +1,183 @@
/**
* 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

@ -0,0 +1,366 @@
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

@ -0,0 +1,446 @@
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-Pool (wiederverwendbar)
/** @type {Phaser.GameObjects.Particles.ParticleEmitterManager|null} */
this._particlePool = null;
/** @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() {
this._particlePool = this.scene.add.particles('particle');
if (this._particlePool.createEmitter) {
this._emitter = this._particlePool.createEmitter({
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,
on: false, // Startet deaktiviert
});
}
}
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

@ -0,0 +1,82 @@
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

@ -0,0 +1,101 @@
/**
* 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

@ -0,0 +1,92 @@
/**
* @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

@ -0,0 +1,165 @@
/**
* 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

@ -0,0 +1,81 @@
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

@ -84,9 +84,45 @@ class BootScene extends Phaser.Scene {
// 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;

View file

@ -4,10 +4,11 @@ class GameScene extends Phaser.Scene {
}
create() {
// Add background
const { COLORS } = GAME_CONFIG;
this.add.image(400, 300, 'background');
// Create grid for pixel art (16x16 grid of 32x32 pixel tiles)
// Grid erstellen (16x16 Grid mit 32x32 Pixel Tiles)
this.grid = [];
this.tileSize = 32;
this.gridWidth = 16;
@ -15,99 +16,226 @@ class GameScene extends Phaser.Scene {
this.gridStartX = (800 - this.gridWidth * this.tileSize) / 2;
this.gridStartY = (600 - this.gridHeight * this.tileSize) / 2;
// Create grid of tiles
// 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); // Default white color
tile.setTint(0xffffff);
tile.setInteractive();
tile.on('pointerdown', () => {
this.paintTile(x, y);
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;
}
}
// Current selected color (default: black)
this.currentColor = 0x000000;
// Create color palette
// Farbpalette
this.createColorPalette();
// Add UI text
// Titel
this.add
.text(400, 50, 'Pixel Editor', {
fontSize: '32px',
fill: '#fff',
})
.text(400, 30, I18N.t('editorTitle'), { fontSize: '32px', fill: COLORS.TEXT_WHITE })
.setOrigin(0.5);
// Add back button
const backButton = this.add
.text(100, 50, 'Zurück', {
fontSize: '24px',
fill: '#fff',
backgroundColor: '#4a4a4a',
padding: { x: 10, y: 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)
.setInteractive();
backButton.on('pointerover', () => {
backButton.setStyle({ fill: '#ff0' });
});
backButton.on('pointerout', () => {
backButton.setStyle({ fill: '#fff' });
});
backButton.on('pointerdown', () => {
this.scene.start('MainMenuScene');
});
.setOrigin(0.5);
}
createColorPalette() {
const colors = [
0x000000, // Black
0xffffff, // White
0xff0000, // Red
0x00ff00, // Green
0x0000ff, // Blue
0xffff00, // Yellow
0xff00ff, // Magenta
0x00ffff, // Cyan
0x000000, 0xffffff, 0xff0000, 0x00ff00, 0x0000ff, 0xffff00, 0xff00ff, 0x00ffff, 0xff8800,
0x8800ff, 0x88ff00, 0x0088ff, 0xff4444, 0x44ff44, 0x4444ff, 0x888888, 0xffcc99, 0x663300,
0x339933, 0x333366,
];
const paletteX = 700;
const paletteY = 150;
const paletteSize = 30;
const paletteGap = 10;
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 colorButton = this.add.rectangle(
paletteX,
paletteY + index * (paletteSize + paletteGap),
paletteSize,
paletteSize,
color
);
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);
colorButton.setInteractive();
colorButton.on('pointerdown', () => {
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);
});
// Add stroke around the button
colorButton.setStrokeStyle(2, 0xffffff);
});
}
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

@ -4,95 +4,127 @@ class MainMenuScene extends Phaser.Scene {
}
create() {
// Add background
this.add.image(400, 300, 'background');
const { COLORS } = GAME_CONFIG;
const centerX = this.cameras.main.width / 2;
// Add title
this.add.image(centerX, 300, 'background');
// Titel
this.add
.text(400, 120, 'WhoPixels', {
.text(centerX, 100, I18N.t('title'), {
fontSize: '64px',
fill: '#fff',
fill: COLORS.TEXT_WHITE,
fontStyle: 'bold',
})
.setOrigin(0.5);
// Add subtitle
this.add
.text(400, 180, 'Ein Pixel-Abenteuer', {
.text(centerX, 160, I18N.t('subtitle'), {
fontSize: '32px',
fill: '#fff',
fill: COLORS.TEXT_WHITE,
})
.setOrigin(0.5);
// Create buttons
const startButton = this.add
.text(400, 280, 'RPG Spiel starten', {
fontSize: '32px',
fill: '#fff',
backgroundColor: '#4a4a4a',
padding: { x: 20, y: 10 },
})
.setOrigin(0.5)
.setInteractive();
// Statistiken
this._showStats(centerX);
const editorButton = this.add
.text(400, 350, 'Pixel Editor', {
fontSize: '32px',
fill: '#fff',
backgroundColor: '#4a4a4a',
padding: { x: 20, y: 10 },
})
.setOrigin(0.5)
.setInteractive();
const optionsButton = this.add
.text(400, 420, 'Optionen', {
fontSize: '32px',
fill: '#fff',
backgroundColor: '#4a4a4a',
padding: { x: 20, y: 10 },
})
.setOrigin(0.5)
.setInteractive();
// Button interactions - RPG Game
startButton.on('pointerover', () => {
startButton.setStyle({ fill: '#ff0' });
});
startButton.on('pointerout', () => {
startButton.setStyle({ fill: '#fff' });
});
startButton.on('pointerdown', () => {
// Buttons
this._createButton(centerX, 300, I18N.t('startGame'), () => {
this.scene.start('RPGScene');
});
// Button interactions - Pixel Editor
editorButton.on('pointerover', () => {
editorButton.setStyle({ fill: '#ff0' });
});
editorButton.on('pointerout', () => {
editorButton.setStyle({ fill: '#fff' });
});
editorButton.on('pointerdown', () => {
this._createButton(centerX, 370, I18N.t('pixelEditor'), () => {
this.scene.start('GameScene');
});
// Button interactions - Options
optionsButton.on('pointerover', () => {
optionsButton.setStyle({ fill: '#ff0' });
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(),
});
});
optionsButton.on('pointerout', () => {
optionsButton.setStyle({ fill: '#fff' });
});
// 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 });
optionsButton.on('pointerdown', () => {
// Options functionality would go here
console.log('Options button clicked');
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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
{
"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,18 +1,22 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
// Lade Umgebungsvariablen aus .env-Datei
require('dotenv').config();
// Für die Verarbeitung von POST-Anfragen
const { parse } = require('querystring');
// 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
// Azure OpenAI API Konfiguration aus Umgebungsvariablen
// 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;
@ -30,245 +34,262 @@ const MIME_TYPES = {
'.ico': 'image/x-icon',
};
// Funktion zum Abrufen von Daten aus einer POST-Anfrage
const collectRequestData = (request, callback) => {
const FORM_URLENCODED = 'application/x-www-form-urlencoded';
const JSON_TYPE = 'application/json';
// === Rate Limiting ===
const rateLimitMap = new Map();
if (request.headers['content-type'] === FORM_URLENCODED) {
let body = '';
request.on('data', (chunk) => {
body += chunk.toString();
});
request.on('end', () => {
callback(parse(body));
});
} else if (
request.headers['content-type'] &&
request.headers['content-type'].includes(JSON_TYPE)
) {
let body = '';
request.on('data', (chunk) => {
body += chunk.toString();
});
request.on('end', () => {
callback(JSON.parse(body));
});
} else {
callback({});
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;
}
};
// Funktion zum Senden einer Anfrage an die Azure OpenAI API
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 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}`;
console.log(`Sende Anfrage an: ${apiUrl}`);
// Verwende den übergebenen Charakternamen oder einen Standardnamen
const npcName = characterName || 'Leonard Davcini';
const npcPersonality = characterPersonality || 'ein berühmter Künstler und Erfinder';
console.log(`Verwende NPC: ${npcName} mit Persönlichkeit: ${npcPersonality}`);
// Erstelle die Nachrichtenliste für die API mit dem dynamischen Charakternamen
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.`,
},
];
// Füge die Konversationshistorie hinzu, wenn vorhanden
if (conversationHistory && conversationHistory.length > 0) {
conversationHistory.forEach((entry) => {
if (entry.type === 'user') {
messages.push({
role: 'user',
content: entry.message,
});
} else if (entry.type === 'npc') {
messages.push({
role: 'assistant',
content: entry.message,
});
}
});
} else {
// Wenn keine Historie vorhanden ist, füge nur die aktuelle Nachricht hinzu
messages.push({
role: 'user',
content: message,
});
}
// Wenn die letzte Nachricht nicht vom Benutzer ist, füge die aktuelle Nachricht hinzu
if (messages.length === 1 || messages[messages.length - 1].role !== 'user') {
messages.push({
role: 'user',
content: message,
});
}
console.log('Gesendete Nachrichten:', JSON.stringify(messages, null, 2));
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-key': AZURE_OPENAI_API_KEY,
},
body: JSON.stringify({
messages: messages,
max_tokens: 150,
}),
body: JSON.stringify({ messages, max_tokens: 150 }),
signal: controller.signal,
});
// Prüfe den HTTP-Status
clearTimeout(timeout);
if (!response.ok) {
const errorText = await response.text();
console.error(`HTTP Fehler: ${response.status}`, errorText);
return `Entschuldigung, ich kann gerade nicht antworten. (HTTP ${response.status})`;
return { text: 'Entschuldigung, ich kann gerade nicht antworten.', identityRevealed: false };
}
const data = await response.json();
console.log('API-Antwort:', JSON.stringify(data, null, 2));
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, ich kann gerade nicht antworten. Versuche es später noch einmal.',
text: 'Entschuldigung, die Antwort hat zu lange gedauert.',
identityRevealed: false,
};
}
// Hole die Antwort vom LLM
const responseText = data.choices[0].message.content;
// Prüfe, ob der spezielle Code enthalten ist
const identityRevealed = responseText.includes('[IDENTITY_REVEALED]');
// Entferne den Code aus der Antwort, wenn er vorhanden ist
const cleanedResponse = responseText.replace('[IDENTITY_REVEALED]', '').trim();
console.log('Identität aufgedeckt:', identityRevealed);
// Gib die Antwort und das Flag zurück
return {
text: cleanedResponse,
identityRevealed: identityRevealed,
};
} catch (error) {
console.error('Fehler beim Aufrufen der Azure OpenAI API:', error);
return {
text: 'Entschuldigung, ich kann gerade nicht antworten. Versuche es später noch einmal.',
identityRevealed: false,
};
console.error('Fehler beim Aufrufen der Azure OpenAI API:', error.message);
return { text: 'Entschuldigung, ich kann gerade nicht antworten.', identityRevealed: false };
}
}
const server = http.createServer((req, res) => {
console.log(`${req.method} ${req.url}`);
// === HTTP Server ===
const server = http.createServer(async (req, res) => {
const clientIP = req.socket.remoteAddress;
// CORS-Header hinzufügen für Cross-Origin-Anfragen
res.setHeader('Access-Control-Allow-Origin', '*');
// 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');
// OPTIONS-Anfragen für CORS-Preflight behandeln
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
// API-Endpunkt für OpenAI-Anfragen
// API-Endpunkt
if (req.method === 'POST' && req.url === '/api/chat') {
collectRequestData(req, async (data) => {
try {
if (!data.message) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Nachricht fehlt' }));
return;
}
// 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;
}
// Verwende die Konversationshistorie, wenn vorhanden
const conversationHistory = data.conversationHistory || [];
console.log(
'Erhaltene Konversationshistorie:',
JSON.stringify(conversationHistory, null, 2)
);
try {
const data = await collectRequestData(req);
// Extrahiere Charakterinformationen, wenn vorhanden
const characterName = data.characterName;
const characterPersonality = data.characterPersonality;
if (characterName) {
console.log(`NPC-Charakter in der Anfrage: ${characterName}`);
}
const response = await callOpenAI(
data.message,
conversationHistory,
characterName,
characterPersonality
);
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 der Chat-Anfrage:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Interner Serverfehler' }));
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 behandeln
// Statische Dateien
let filePath = '.' + req.url;
if (filePath === './') {
filePath = './index.html';
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;
}
// Get the file extension
const extname = path.extname(filePath);
const contentType = MIME_TYPES[extname] || 'application/octet-stream';
// Read the file
fs.readFile(filePath, (error, content) => {
if (error) {
if (error.code === 'ENOENT') {
// Page not found
fs.readFile('./index.html', (err, content) => {
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(content, 'utf-8');
res.end(fallback, 'utf-8');
}
});
} else {
// Server error
res.writeHead(500);
res.end(`Server Error: ${error.code}`);
}
} else {
// Success
res.writeHead(200, { 'Content-Type': contentType });
res.end(content, 'utf-8');
}
@ -277,6 +298,8 @@ const server = http.createServer((req, res) => {
server.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}/`);
console.log('Press Ctrl+C to stop the server');
console.log('Azure OpenAI API ist konfiguriert und bereit!');
console.log(
`Rate Limit: ${RATE_LIMIT_MAX_REQUESTS} requests per ${RATE_LIMIT_WINDOW_MS / 1000}s`
);
console.log(`CORS: ${ALLOWED_ORIGINS.join(', ')}`);
});

21
pnpm-lock.yaml generated
View file

@ -719,6 +719,9 @@ importers:
'@manacore/shared-api-client':
specifier: workspace:*
version: link:../../../../packages/shared-api-client
'@manacore/shared-app-onboarding':
specifier: workspace:*
version: link:../../../../packages/shared-app-onboarding
'@manacore/shared-auth':
specifier: workspace:*
version: link:../../../../packages/shared-auth
@ -988,6 +991,9 @@ importers:
'@manacore/shared-api-client':
specifier: workspace:*
version: link:../../../../packages/shared-api-client
'@manacore/shared-app-onboarding':
specifier: workspace:*
version: link:../../../../packages/shared-app-onboarding
'@manacore/shared-auth':
specifier: workspace:*
version: link:../../../../packages/shared-auth
@ -1652,6 +1658,9 @@ importers:
'@manacore/shared-api-client':
specifier: workspace:*
version: link:../../../../packages/shared-api-client
'@manacore/shared-app-onboarding':
specifier: workspace:*
version: link:../../../../packages/shared-app-onboarding
'@manacore/shared-auth':
specifier: workspace:*
version: link:../../../../packages/shared-auth
@ -2398,6 +2407,9 @@ importers:
apps/manadeck/apps/web:
dependencies:
'@manacore/shared-app-onboarding':
specifier: workspace:*
version: link:../../../../packages/shared-app-onboarding
'@manacore/shared-auth':
specifier: workspace:*
version: link:../../../../packages/shared-auth
@ -3895,6 +3907,9 @@ importers:
'@manacore/shared-api-client':
specifier: workspace:*
version: link:../../../../packages/shared-api-client
'@manacore/shared-app-onboarding':
specifier: workspace:*
version: link:../../../../packages/shared-app-onboarding
'@manacore/shared-auth':
specifier: workspace:*
version: link:../../../../packages/shared-auth
@ -4530,6 +4545,9 @@ importers:
apps/presi/apps/web:
dependencies:
'@manacore/shared-app-onboarding':
specifier: workspace:*
version: link:../../../../packages/shared-app-onboarding
'@manacore/shared-auth':
specifier: workspace:*
version: link:../../../../packages/shared-auth
@ -5128,6 +5146,9 @@ importers:
'@manacore/shared-api-client':
specifier: workspace:*
version: link:../../../../packages/shared-api-client
'@manacore/shared-app-onboarding':
specifier: workspace:*
version: link:../../../../packages/shared-app-onboarding
'@manacore/shared-auth':
specifier: workspace:*
version: link:../../../../packages/shared-auth