managarten/games/whopixels/js/managers/ChatUI.js
Till JS c0c11c325a 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>
2026-03-21 15:26:40 +01:00

366 lines
11 KiB
JavaScript

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;
}
}
}