diff --git a/dev/lobby-backend/src/controllers/auth.controller.js b/dev/lobby-backend/src/controllers/auth.controller.js index cf808d2..84dc2cc 100644 --- a/dev/lobby-backend/src/controllers/auth.controller.js +++ b/dev/lobby-backend/src/controllers/auth.controller.js @@ -146,7 +146,8 @@ export const login = async (req, res) => { } if (error.code === 'ERR_EMAIL_NOT_VERIFIED') { return res.status(403).json({ - error: 'ERR_EMAIL_NOT_VERIFIED' + error: 'ERR_EMAIL_NOT_VERIFIED', + email: error.email }); } @@ -179,7 +180,8 @@ export const refresh = async (req, res) => { // Das neue Access-Token an das Frontend zurück geben return res.status(200).json({ success: true, - accessToken: result.accessToken + accessToken: result.accessToken, + user: result.user }); } catch (error) { @@ -198,4 +200,119 @@ export const refresh = async (req, res) => { error: 'ERR_INTERNAL_SERVER' }); } -} \ No newline at end of file +} + +/** + * Controller-Methode für die Resend-OTP-Route + * Wird aufgerufen, wenn der Nutzer im Frontend auf "Code erneut senden" klickt + */ +export const resendOTP = async (req, res) => { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ + error: 'ERR_MISSING_FIELDS' + }); + } + + // Aufruf unseres Service + await authService.resendOTP(email); + + return res.status(200).json({ + success: true, + // MSG_OTP_RESENT: Ein neuer Code wurde an deine E-Mail gesendet. + message: 'MSG_OTP_RESENT' + }); + + } catch (error) { + if (['ERR_USER_NOT_FOUND', 'ERR_ALREADY_VERIFIED', 'ERR_COOLDOWN_ACTIVE'].includes(error.code)) { + const statusCode = error.code === 'ERR_COOLDOWN_ACTIVE' ? 429 : 400; + return res.status(error.code).json({ + error: error.code + }); + } + + console.error('[AuthController] Fehler beim erneuten Senden des OTP:', error); + return res.status(500).json({ + error: 'ERR_INTERNAL_SERVER' + }); + } +}; + +/** + * Controller-Methode für die Passwort vergessen anfrage + */ +export const requestPasswordReset = async (req, res) => { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ + error: 'ERR_MISSING_FIELDS' + }); + } + + // Auch wenn die E-Mail nicht existiert, geben wir oft ein Success zurück, + // damit Angreifer nicht testen können, welche E-Mails registriert sind. + // In unserem Service werfen wir aber ERR_USER_NOT_FOUND. Wir fangen das unten ab. + return res.status(200).json({ + success: true, + message: 'MSG_RESET_EMAIL_SENT' // Muss später ins i18n + }); + + } catch (error) { + // Wenn der Nutzer nicht existiert, tun wir für das Frontend trotzdem so, als wäre + // die E-Mail rausgegangen (Sicherheitsbest-Practice). + if (error.code === 'ERR_USER_NOT_FOUND') { + return res.status(200).json({ + success: true, + message: 'MSG_RESET_EMAIL_SENT' + }); + } + + if (error.code === 'ERR_COOLDOWN_ACTIVE') { + return res.status(429).json({ + error: error.code + }); + } + + console.error('[AuthController] Fehler bei requestPasswordReset:', error); + return res.status(500).json({ error: 'ERR_INTERNAL_SERVER' }); + } +}; + +/** + * Controller-Methode für das finale Setzen des neuen Passworts. + */ +export const resetPassword = async (req, res) => { + try { + const { email, otpCode, newPassword } = req.body; + + // Basis-Validierung + if (!email || !otpCode || !newPassword) { + return res.status(400).json({ error: 'ERR_MISSING_FIELDS' }); + } + + // Passwort-Richtlinie prüfen (wie bei der Registrierung) + if (newPassword.length < 8) { + return res.status(400).json({ error: 'ERR_PASSWORD_TOO_SHORT' }); + } + + // Service aufrufen + await authService.resetPassword(email, otpCode, newPassword); + + return res.status(200).json({ + success: true, + message: 'MSG_PASSWORD_RESET_SUCCESS' // Muss später ins i18n + }); + + } catch (error) { + if (['ERR_USER_NOT_FOUND', 'ERR_INVALID_OTP', 'ERR_OTP_EXPIRED'].includes(error.code)) { + return res.status(400).json({ error: error.code }); + } + + console.error('[AuthController] Fehler bei resetPassword:', error); + return res.status(500).json({ error: 'ERR_INTERNAL_SERVER' }); + } +}; \ No newline at end of file diff --git a/dev/lobby-backend/src/routes/auth.routes.js b/dev/lobby-backend/src/routes/auth.routes.js index abf5be7..d76be3c 100644 --- a/dev/lobby-backend/src/routes/auth.routes.js +++ b/dev/lobby-backend/src/routes/auth.routes.js @@ -7,5 +7,8 @@ router.post('/register', authController.register); router.post('/verify-email', authController.verifyEmail); router.post('/login', authController.login); router.post('/refresh', authController.refresh); +router.post('/resend-otp', authController.resendOTP); +router.post('/forgot-password', authController.requestPasswordReset); +router.post('/reset-password', authController.resetPassword); export default router; \ No newline at end of file diff --git a/dev/lobby-backend/src/services/auth.service.js b/dev/lobby-backend/src/services/auth.service.js index db035b4..94107ea 100644 --- a/dev/lobby-backend/src/services/auth.service.js +++ b/dev/lobby-backend/src/services/auth.service.js @@ -202,7 +202,7 @@ export const loginUser = async (identifier, password) => { try { // Nutzer suchen const accountResult = await client.query(` - SELECT id, username, email, password_hash, is_email_verified, is_admin + SELECT id, username, email, password_hash, is_email_verified, is_admin, res_premium FROM lobby_accounts WHERE LOWER(username) = LOWER($1) OR email = LOWER($1)`, [identifier] @@ -223,6 +223,7 @@ export const loginUser = async (identifier, password) => { // ERR_EMAIL_NOT_VERIFIED: Bitte bestätige zuerst deine E-Mail-Addresse const error = new Error('Email not verified'); error.code = 'ERR_EMAIL_NOT_VERIFIED'; + error.email = account.email; throw error; } @@ -267,7 +268,9 @@ export const loginUser = async (identifier, password) => { refreshToken, user: { id: account.id, - username: account.username + username: account.username, + email: account.email, + res_premium: account.res_premium } }; } finally { @@ -308,7 +311,7 @@ export const refreshAccessToken = async (refreshToken) => { // Das behebt das "Stale Data" Problem. Falls der Admin Status geändert wurde, // steht er im neuen Token sofort korrekt drin, ohne die DB bei jedem API.Call zu belasten const accountResult = await client.query( - 'SELECT id, username, is_admin FROM lobby_accounts WHERE id = $1', + 'SELECT id, username, email, is_admin, res_premium FROM lobby_accounts WHERE id = $1', [accountId] ); @@ -333,7 +336,15 @@ export const refreshAccessToken = async (refreshToken) => { { expiresIn: '15m' } ); - return { accessToken }; + return { + accessToken, + user: { + id: account.id, + username: account.username, + email: account.email, + res_premium: res_premium + } + }; } catch (error) { // Abfangen von JWT-spezifischen Fehlern (z.B. Token abgelaufen oder Signatur falsch) if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError' || error.name === 'SyntaxError') { @@ -347,4 +358,249 @@ export const refreshAccessToken = async (refreshToken) => { } finally { client.release(); } -} \ No newline at end of file +} + +/** + * Sendet einen neuen OTP-Code an den Nutzer, falls der alte abgelaufen ist oder nicht ankam. + * Vorherige, ungenutzte Codes werdeb zur Sicherheit invalidiert (gelöscht) + * + * @param {string} email - Die E-Mail Addresse des Nutzers + * @returns {boolean} - true, wenn die E-Mail erfolgreich versendet wurde. + */ +export const resendOTP = async (email) => { + const client = await pool.connect(); + const lowerCaseEmail = email.toLowerCase(); + + try { + await client.query('BEGIN'); + + // Prüfen, ob der Nutzer überhaupt existiert + const accountResult = await client.query( + 'SELECT id, username, is_email_verified FROM lobby_accounts WHERE email = $1', + [lowerCaseEmail] + ); + + if (accountResult.rows.length === 0) { + // Aus Sicherheitsgründen verraten wir dem Frontend nicht zwingend, + // dass die E-Mail nicht existieren, aber für unser internes Error-Handling + // werfen wir einen spezifischen Fehler. + const error = new Error('User nor found'); + error.code = 'ERR:USER_NOT_FOUND'; + throw error; + } + + const account = accountResult.rows[0]; + + // Prüfen, ob der Account nicht vielleicht schon verifiziert ist + // Wenn ja, macht ein neuer Code keinen Sinn. + if (account.is_email_verified) { + const error = new Error('Already verified'); + error.code = 'ERR_ALREADY_VERIFIED'; + throw error; + } + + const lastOtpResult = await client.query(` + SELECT created_at FROM auth_otps WHERE account_id = $1 AND type = 'register' ORDER BY created_at DESC LIMIT 1`, + [account.id] + ); + + if (lastOtpResult.rows.length > 0) { + const lastCreatedTime = new Date(lastOtpResult.rows[0].created_at).getTime(); + const currentTime = new Date().getTime(); + const timeDifference = currentTime - lastCreatedTime; + + if (timeDifference < 120000) { + const error = new Error('Cooldown active'); + error.code = 'ERR_COOLDOWN_ACTIVE'; + throw error; + } + } + + // Zur Sichherheit alle alten, noch aktiven Registierungs-OTPs dieses Nutzers löschen. + // So verhindern wir, dass alte Codes in Postfächern noch funktionieren und minimieren + // Spam-Risiken. + await client.query( + `DELETE FROM auth_otps WHERE account_id = $1 AND type = 'register'`, + [account.id] + ); + + // Einen frischen, 6-stelligen Cide generieren + const newOtpCode = generateOTP(); + + // Den neuen Code in die Datenbank eintragen + await client.query(` + INSERT INTO auth_otps (account_id, otp_code, type, expires_at) + VALUES ($1, $2, 'register', NOW() + INTERVAL '15 minutes')`, + [account.id, newOtpCode] + ); + + // 6. Die neue E-Mail zusammenbauen und versenden + const mailHtml = ` +

Hallo ${account.username}, hier ist dein neuer Code!

+

Du hast einen neuen Verifizierungscode für Void-Genesis angefordert:

+

${newOtpCode}

+

Dieser Code ist ab jetzt wieder 15 Minuten lang gültig.

+ `; + + await sendEmail(lowerCaseEmail, account.username, 'Void-Genesis - Dein neue Code', mailHtml); + + await client.query('COMMIT'); + return true; + + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + +/** + * Startet den Prozess zum Zurücksetzten des Passworts. + * Generiert einen speziellen OTP-Code und sendet ihn per E-Mail. + * + * @param {string} email - Die Email-Adresse des Nutzers + * @returns {boolean} - true, wenn die E-Mail erfolgreich versendet wurde. + */ +export const requestPasswordReset = async (email) => { + const client = await pool.connect(); + const lowerCaseEmail = email.toLowerCase(); + + try { + await client.query('BEGIN'); + + const accountResult = await client.query( + 'SELECT id, username FROM lobby_accounts WHERE email = $1', + [lowerCaseEmail] + ); + + if (accountResult.rows.length === 0) { + const error = new Error('User not found'); + error.code = 'ERR_USER_NOT_FOUND'; + throw error; + } + + const lastOtpResult = await client.query(` + SELECT created_at FROM auth_otps WHERE account_id = $1 AND type = 'password_reset' ORDER BY created_at DESC LIMIT 1`, + [account.id] + ); + + if (lastOtpResult.rows.length > 0) { + const lastCreatedTime = new Date(lastOtpResult.rows[0].created_at).getTime(); + const currentTime = new Date().getTime(); + const timeDifference = currentTime - lastCreatedTime; + + if (timeDifference < 120000) { + const error = new Error('Cooldown active'); + error.code = 'ERR_COOLDOWN_ACTIVE'; + throw error; + } + } + + const account = accountResult.rows[0]; + + await client.query( + `DELETE FROM auth_otps WHERE account_id = $1 AND type = 'password_reset'`, + [account.id] + ); + + const resetCode = generateOTP(); + + await client.query(` + INSERT INTO auth_otps (account_id, otp_code, type, expires_at) + VALUES ($1, $2m 'password_reset' NOW() + INTERVAL '15 minutes')`, + [account.id, resetCode] + ); + + const mailHtml = ` +

Hallo ${account.username},

+

Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.

+

Dein Code lautet:

+

${resetCode}

+

Dieser Code ist 15 Minuten lang gültig. Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.

+ `; + + await sendEmail(lowerCaseEmail, account.username, 'Void-Genesis - Passwort zurücksetzen', mailHtml); + + await client.query('COMMIT'); + return true; + + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + + +/** + * Setzt das neue Passwort, soffern der OTP-Code korrekt und nicht abgelaufen ist + * + * @param {string} email - Die E-Mail Addresse des Nutzers + * @param {string} otpCode - Der 6-stellige Code aus der E-Mail + * @param {string} newPassword - das neue Klartext passwort + * @returns {boolean} - true wenn das Passwort erfolgreich geändert wurde + */ +export const resetPassword = async (email, otpCode, newPassword) => { + const client = await pool.connect(); + const lowerCaseEmail = email.toLowerCase(); + + try { + await client.query('BEGIN'); + + const accountResult = await client.query( + 'SELECT id FROM lobby_accounts WHERE email = $1' + [lowerCaseEmail] + ); + + if (accountResult.rows.length === 0) { + const error = new Error('User not found'); + error.code = 'ERR_USER_NOT_FOUND'; + throw error; + } + + const account = accountResult.rows[0]; + + const otpResult = await client.query(` + SELECT id, expires_at FROM auth_otps + WHERE account_id = $1, otp_code = $2, AND type = 'password_reset'`, + [account.id, otpCo0de] + ); + + if (otpResult.rows.length === 0) { + const error = new Error('Invalid OTP'); + error.code = 'ERR_INVALID_OTP'; + throw error; + } + + const otpRecord = otpResult.rows[0]; + + if (new Date() > new Date(otpRecord.expires_at)) { + const error = new Error('OTP expired'); + error.code = 'ERR_OTP_EXPIRED'; + throw error; + } + + const passwordHash = await argon2.hash(newPassword); + + await client.query( + 'UPDATE lobby_accounts SET password_hash = $1 WHERE id = $2', + [passwordHash, account.id] + ); + + await client.query( + 'DELETE FROM refresh_tokens WHERE account_id = $1', + [account.id] + ); + + await client.query('COMMIT'); + return true; + + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; diff --git a/dev/lobby-frontend/index.html b/dev/lobby-frontend/index.html index fc598ba..e1c4564 100644 --- a/dev/lobby-frontend/index.html +++ b/dev/lobby-frontend/index.html @@ -17,45 +17,110 @@
- - + +
+ +

+ Bitte gib den 6-stelligen Code aus deiner E-Mail ein. +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+ + +
+ +
+ + + + + + + + + +
+ + +
+
+ +
+ +
+ + + +
+ + +
+ +
- -
- - -
- -
- - -
- -
- - -
- - - -
- - - - - - -
- + + diff --git a/dev/lobby-frontend/src/i18n.js b/dev/lobby-frontend/src/i18n.js new file mode 100644 index 0000000..76f23b7 --- /dev/null +++ b/dev/lobby-frontend/src/i18n.js @@ -0,0 +1,145 @@ +/** + * Dieses Objekt enthält alle Textbausteine für das Frontend. + * Wenn wir später weitere Sprachen hinzufügen (z.B. 'es' für Spanisch), + * müssen wir nur dieses Objekt erweitern, ohne den restlichen Code anzufassen. + */ +export const translations = { + de: { + // --- UI Elemente --- + tab_login: "Login", + tab_register: "Registrieren", + btn_login: "Login", + btn_register: "Jetzt kostenlos registrieren", + link_forgot_password: "Passwort vergessen?", + btn_reset_request: "Code anfordern", + btn_reset_password: "Passwort ändern", + discord_text: "Offizieller Discord Server", + link_back: "« Zurück", + + // --- Platzhalter (Placeholders) --- + ph_username_login: "Benutzername / E-Mail", + ph_username_reg: "Benutzername eingeben", + ph_email: "E-Mail eingeben", + ph_password: "Passwort eingeben", + ph_otp: "6-stelliger Code", + + // --- Labels --- + lbl_agb: "Ich akzeptiere die AGB & Spielregeln", + lbl_remember: "Angemeldet bleiben", + + // --- OTP Modus --- + txt_otp_instruction: "Bitte gib den 6-stelligen Code aus deiner E-Mail ein.", + txt_reset_instruction_1: "Um dein Passwort zurückzusetzen, gib bitte deine E-Mail-Adresse ein.", + txt_reset_instruction_2: "Bitte gib den Code aus der E-Mail sowie dein neues Passwort ein.", + btn_verify: "BESTÄTIGEN", + msg_verify_success: "Verifizierung erfolgreich! Du kannst dich nun einloggen.", + link_resend_otp: "Code erneut senden", + + // --- Footer --- + link_imprint: "Impressum", + link_privacy: "Datenschutz", + + // --- Backend Fehlermeldungen (Mapping) --- + ERR_MISSING_FIELDS: "Bitte alle Felder ausfüllen.", + ERR_PASSWORD_TOO_SHORT: "Das Passwort muss mindestens 8 Zeichen lang sein.", + ERR_USERNAME_TOO_SHORT: "Der Benutzername muss mindestens 3 Zeichen lang sein.", + ERR_INVALID_EMAIL: "Das E-Mail-Format ist ungültig.", + ERR_USERNAME_TAKEN: "Dieser Benutzername ist bereits vergeben.", + ERR_EMAIL_TAKEN: "Diese E-Mail-Adresse ist bereits vergeben.", + ERR_INVALID_CREDENTIALS: "Benutzername/E-Mail oder Passwort falsch.", + ERR_EMAIL_NOT_VERIFIED: "Bitte bestätige zuerst deine E-Mail-Adresse.", + ERR_INTERNAL_SERVER: "Ein interner Serverfehler ist aufgetreten.", + ERR_INVALID_OTP: "Der eingegebene Code ist falsch. Bitte überprüfe deine Eingabe.", + ERR_OTP_EXPIRED: "Dieser Code ist abgelaufen. Bitte fordere einen neuen Code an.", + ERR_COOLDOWN_ACTIVE: "Bitte warte 2 Minuten, bevor du einen neuen Code anforderst.", + + // --- Erfolgsmeldungen --- + MSG_REGISTER_SUCCESS: "Registrierung erfolgreich. Bitte prüfe deine E-Mails.", + MSG_LOGIN_SUCCESS: "Erfolgreich eingeloggt!", + MSG_OTP_RESENT: "Ein neuer Code wurde an deine E-Mail gesendet.", + MSG_RESET_EMAIL_SENT: "Code gesendet. Bitte prüfe deine E-Mails.", + MSG_PASSWORD_RESET_SUCCESS: "Passwort geändert! Du kannst dich nun einloggen.", + }, + en: { + // --- UI Elements --- + tab_login: "Login", + tab_register: "Register", + btn_login: "LOGIN", + btn_register: "REGISTER", + discord_text: "Official Discord Server", + link_forgot_password: "Forgot Password?", + btn_reset_request: "REQUEST CODE", + btn_reset_password: "CHANGE PASSWORD", + link_back: "« back", + + // --- Placeholders --- + ph_username_login: "Username / E-Mail", + ph_username_reg: "Enter Username", + ph_email: "Enter E-Mail", + ph_password: "Enter Password", + ph_otp: "6-digit code", + + // --- Labels --- + lbl_agb: "I accept the Terms & Rules", + lbl_remember: "Keep me logged in", + + // --- OTP Modus --- + txt_otp_instruction: "Please enter the 6-digit code from your e-mail.", + txt_reset_instruction_1: "To reset your password, please enter your e-mail address.", + txt_reset_instruction_2: "Please enter the code from the e-mail and your new password.", + btn_verify: "VERIFY", + msg_verify_success: "Verification successful! You can now log in.", + link_resend_otp: "Resend Code", + + // --- Footer --- + link_imprint: "Imprint", + link_privacy: "Privacy Policy", + + // --- Backend Error Messages (Mapping) --- + ERR_MISSING_FIELDS: "Please fill in all fields.", + ERR_PASSWORD_TOO_SHORT: "Password must be at least 8 characters long.", + ERR_USERNAME_TOO_SHORT: "Username must be at least 3 characters long.", + ERR_INVALID_EMAIL: "Invalid e-mail format.", + ERR_USERNAME_TAKEN: "This username is already taken.", + ERR_EMAIL_TAKEN: "This e-mail address is already taken.", + ERR_INVALID_CREDENTIALS: "Invalid username/e-mail or password.", + ERR_EMAIL_NOT_VERIFIED: "Please verify your e-mail address first.", + ERR_INTERNAL_SERVER: "An internal server error occurred.", + ERR_INVALID_OTP: "The entered code is incorrect. Please check your input.", + ERR_OTP_EXPIRED: "This code has expired. Please request a new code.", + ERR_COOLDOWN_ACTIVE: "Please wait 2 minutes before requesting a new code.", + + // --- Success Messages --- + MSG_REGISTER_SUCCESS: "Registration successful. Please check your e-mails.", + MSG_LOGIN_SUCCESS: "Successfully logged in!", + MSG_OTP_RESENT: "A new code has been sent to your e-mail.", + MSG_RESET_EMAIL_SENT: "Code sent. Please check your e-mails.", + MSG_PASSWORD_RESET_SUCCESS: "Password changed! You can now log in.", + } +}; + +/** + * Die aktuell gewählte Sprache. + * Später können wir das auch im LocalStorage des Browsers speichern, + * damit sich das Spiel die Sprache des Nutzers merkt. + */ +export let currentLang = localStorage.getItem('void_language') || 'de'; + +/** + * Hilfsfunktion, um die Sprache zu wechseln. + */ +export function setLanguage(lang) { + if (translations[lang]) { + currentLang = lang; + localStorage.setItem('void_language', lang); + } +} + +/** + * Holt den passenden Text für den übergebenen Schlüssel (Key). + * Fällt auf den Key selbst zurück, falls keine Übersetzung gefunden wird + * (hilft beim Debuggen, um fehlende Übersetzungen zu erkennen). + */ +export function t(key) { + return translations[currentLang][key] || key; +} \ No newline at end of file diff --git a/dev/lobby-frontend/src/main.js b/dev/lobby-frontend/src/main.js index fb8a04d..ef59a58 100644 --- a/dev/lobby-frontend/src/main.js +++ b/dev/lobby-frontend/src/main.js @@ -1,4 +1,5 @@ -import { Application, Assets, Sprite, NineSliceSprite, Container, TilingSprite, Text, wordWrap } from 'pixi.js'; +import { t, setLanguage, currentLang } from './i18n.js'; +import { Application, Assets, Sprite, NineSliceSprite, Container, TilingSprite, Text, wordWrap, fragmentGPUTemplate } from 'pixi.js'; async function initGame() { const app = new Application(); @@ -16,7 +17,8 @@ async function initGame() { try { const assetsToLoad = { //background: 'src/raw-assets/backgrounds/SPR_SciFiMenus_Example_Image_SpookyComputer_01.png', - background: 'src/raw-assets/backgrounds/SPR_SciFiMenus_Example_Image_SpaceStation_01.png', + //background: 'src/raw-assets/backgrounds/SPR_SciFiMenus_Example_Image_SpaceStation_01.png', + background: 'src/raw-assets/backgrounds/Background_A.png', uiAtlas: 'src/assets/ui-atlas.json' }; @@ -27,13 +29,28 @@ async function initGame() { await document.fonts.load('16px "UI Font"'); await document.fonts.load('16px "Logo Font"'); + updateTranslations(); + buildScene(app, backgroundTexture, uiSpritesheet); + document.getElementById('username').placeholder = t('ph_username_reg'); + document.getElementById('email').placeholder = t('ph_email'); + document.getElementById('password').placeholder = t('ph_password'); + document.getElementById('btn-register').textContent = t('btn_register'); + } catch (error) { console.error("Fehler beim Laden der Assets:", error); } } +function updateTranslations() { + const elements = document.querySelectorAll('[data-i18n]'); + elements.forEach(el => { + const key = el.getAttribute('data-i18n'); + el.textContent = t(key); + }); +} + function buildScene(app, bgTexture, sheet) { // --- Farb-Variablen (pixiJS) --- const COLOR_PANEL_BG = 0x0b192c; // Dunkelblau für Panel & Inputs @@ -109,11 +126,11 @@ function buildScene(app, bgTexture, sheet) { // --- Hexagon pattern const pattern = new TilingSprite({ texture: sheet.textures[patternBgName], - width: 500, + width: 430, height: 520, }); pattern.tileScale.set(0.3); - pattern.position.set(0, -10); + pattern.position.set(25, -10); pattern.alpha = 0.05; pattern.tint = COLOR_PATTERN; uiContainer.addChild(pattern); @@ -163,7 +180,7 @@ function buildScene(app, bgTexture, sheet) { const inputEmail = createUIItem(115, 200, 300, 50, frameInput, iconEmailName, COLOR_PANEL_BG, 0.8); const inputLock = createUIItem(115, 265, 300, 50, frameInput, iconLockName, COLOR_PANEL_BG, 0.8); - const btnRegister = createUIItem(65, 370, 370, 55, frameBtn, null, COLOR_BTN_DEFAULT, 0.6); + const btnRegister = createUIItem(65, 390, 370, 55, frameBtn, null, COLOR_BTN_DEFAULT, 0.6); // --- Discord Panel --- const discordContainer = new Container(); @@ -210,16 +227,16 @@ function buildScene(app, bgTexture, sheet) { discordContainer.addChild(discordIcon); const discordText = new Text({ - text: 'Offizieller Discord Server', + text: t('discord_text'), style: { fontFamily: 'UI Font', - fontSize: 14, + fontSize: 20, fill: COLOR_TAB_INACTIVE, wordWrap: false, wordWrapWidth: 120 } }); - discordText.position.set(60, 25); + discordText.position.set(60, 20); discordContainer.addChild(discordText); uiContainer.addChild(discordContainer); @@ -228,6 +245,12 @@ function buildScene(app, bgTexture, sheet) { const htmlTabRegistert = document.getElementById('tab-register'); const htmlButton = document.getElementById('btn-register'); let isLoginMode = false; + let isForgotPasswordMode = false; + let isResetPasswordMode = false; + let isOtpMode = false; + let pendingVerificationEmail = ''; + let resetEmail = ''; + let cooldownTimer = null; htmlTabLogin.addEventListener('mouseenter', () => { if (!isLoginMode) { @@ -282,6 +305,7 @@ function buildScene(app, bgTexture, sheet) { function setLoginMode() { if (isLoginMode) return; isLoginMode = true; + clearForm(); tabLogin.fill.tint = COLOR_TAB_ACTIVE; tabLogin.fill.alpha = 0.6; @@ -295,11 +319,17 @@ function buildScene(app, bgTexture, sheet) { inputLock.container.y = 200; document.getElementById('password').style.top = '200px'; + document.getElementById('forgot-password-link').style.display = 'block'; + + document.getElementById('email').required = false; document.getElementById('group-remember').style.display = 'flex'; + document.getElementById('group-agb').style.display = 'none'; + document.getElementById('agb').required = false; - htmlButton.textContent = 'LOGIN'; - document.getElementById('username').placeholder = 'Username / E-Mail'; + htmlButton.textContent = t('btn_login'); + document.getElementById('username').placeholder = t('ph_username_login'); + document.getElementById('password').placeholder = t('ph_password'); document.getElementById('msg-box').textContent = ''; } @@ -307,6 +337,7 @@ function buildScene(app, bgTexture, sheet) { function setRegisterMode() { if (!isLoginMode) return; isLoginMode = false; + clearForm(); tabRegister.fill.tint = COLOR_TAB_ACTIVE; tabRegister.fill.alpha = 0.6; @@ -321,11 +352,160 @@ function buildScene(app, bgTexture, sheet) { inputLock.container.y = 265; document.getElementById('password').style.top = '265px'; + document.getElementById('email').required = true; + + document.getElementById('group-remember').style.display = 'none'; - htmlButton.textContent = 'REGISTRIEREN'; - document.getElementById('username').placeholder = 'Enter Username'; + document.getElementById('group-agb').style.display = 'flex'; + document.getElementById('agb').required = true; + htmlButton.textContent = t('btn_register'); + document.getElementById('username').placeholder = t('ph_username_reg'); + document.getElementById('email').placeholder = t('ph_email'); + document.getElementById('password').placeholder = t('ph_password'); + + document.getElementById('msg-box').textContent = ''; + } + + function setOtpMode(email) { + isOtpMode = true; + pendingVerificationEmail = email; + clearForm(); + + // Tabs verstecken + document.getElementById('tab-container').style.display = 'none'; + tabLogin.container.visible = false; + tabRegister.container.visible = false; + + // Username-Feld verstecken + document.getElementById('username').style.display = 'none'; + document.getElementById('username').required = false; + inputUser.container.visible = false; + + // Email-Feld verstecken + document.getElementById('group-email').style.display = 'none'; + document.getElementById('email').required = false; + inputEmail.container.visible = false; + + // Das Erklärungstext-Feld einblenden + const instructionEl = document.getElementById('otp-instruction'); + instructionEl.style.display = 'block'; + instructionEl.textContent = t('txt_otp_instruction'); + + // Den "Code erneut senden" Link einblenden + document.getElementById('resend-otp-link').style.display = 'block'; + + // Das Passwort-Feld in die Mitte rücken und zum OTP-Feld umwandeln + // Wir nutzen das Schluss-Icon, das passt perfekt zum otp + inputLock.container.y = 200; + document.getElementById('password').style.top = '200px'; + + // WICHTIG: Typ auf 'text äbderb damit der Nutzer die Zahlen sieht + document.getElementById('password').type = 'text'; + document.getElementById('password').placeholder = t('ph_otp'); + document.getElementById('password').value = ''; + + // Checkboxen aufräumen + document.getElementById('group-agb').style.display = 'none'; + document.getElementById('agb').required = false; + + // Die "Angemeldet bleiben" Checkbox lassen wir optional drin + document.getElementById('group-remember').style.display = 'none'; + + // Button aktuallisieren + htmlButton.textContent = t('btn_verify'); + document.getElementById('msg-box').textContent = ''; + + document.getElementById('back-link').style.display = 'block'; + + // Debug Only + //console.log("OTP EMAIL", email); + } + + function setForgotPasswordMode() { + isLoginMode = false; + isOtpMode = false; + isForgotPasswordMode = true; + isResetPasswordMode = false; + + clearForm(); + + // Tabs verstecken + document.getElementById('tab-container').style.display = 'none'; + tabLogin.container.visible = false; + tabRegister.container.visible = false; + + // Username-Feld verstecken + document.getElementById('username').style.display = 'none'; + document.getElementById('username').required = false; + inputUser.container.visible = false; + + // Passwort-Feld verstecken + document.getElementById('password').style.display = 'none'; + document.getElementById('password').required = false; + inputLock.container.visible = false; + + document.getElementById('group-email').style.display = 'block'; + document.getElementById('email').type = 'email'; + document.getElementById('email').required = true; + document.getElementById('email').style.top = '200px'; + inputEmail.container.visible = true; + inputEmail.container.y = 200; + + document.getElementById('group-remember').style.display = 'none'; + document.getElementById('group-agb').style.display = 'none'; + document.getElementById('forgot-password-link').style.display = 'none'; + + const instructionEl = document.getElementById('otp-instruction'); + instructionEl.style.display = 'block'; + instructionEl.textContent = t('txt_reset_instruction_1'); + + document.getElementById('back-link').style.display = 'block'; + + htmlButton.textContent = t('btn_reset_request'); + document.getElementById('msg-box').textContent = ''; + } + + function setResetPasswordMode(email) { + isForgotPasswordMode = false; + isResetPasswordMode = true; + resetEmail = email; + + clearForm(); + + // Tabs verstecken + document.getElementById('tab-container').style.display = 'none'; + tabLogin.container.visible = false; + tabRegister.container.visible = false; + + // Username-Feld verstecken + document.getElementById('username').style.display = 'none'; + document.getElementById('username').required = false; + inputUser.container.visible = false; + + document.getElementById('group-email').style.display = 'block'; + document.getElementById('email').type = 'text'; + document.getElementById('email').placeholder = t('ph_otp'); + document.getElementById('email').required = true; + document.getElementById('email').style.top = '200px'; + inputEmail.container.y = 200; + + document.getElementById('password').style.display = 'block'; + document.getElementById('password').required = true; + document.getElementById('password').placeholder = t('ph_password'); + document.getElementById('password').style.top = '265px'; + inputLock.container.visible = true; + inputLock.container.y = 265; + + const instructionEl = document.getElementById('otp-instruction'); + instructionEl.style.display = 'block'; + instructionEl.style.top = '150px'; + instructionEl.textContent = t('txt_reset_instruction_2'); + + document.getElementById('back-link').style.display = 'block'; + + htmlButton.textContent = t('btn_reset_password'); document.getElementById('msg-box').textContent = ''; } @@ -373,8 +553,8 @@ function buildScene(app, bgTexture, sheet) { } function resize() { - const screenWidth = app.screen.width; - const screenHeight = app.screen.height; + const screenWidth = window.innerWidth; + const screenHeight = window.innerHeight; const scaleX = screenWidth / background.texture.width; const scaleY = screenHeight / background.texture.height; @@ -383,18 +563,608 @@ function buildScene(app, bgTexture, sheet) { background.x = (screenWidth - background.width) / 2; background.y = (screenHeight - background.height) / 2; - uiContainer.x = (screenWidth - panelFrame.width) / 2; - uiContainer.y = (screenHeight - panelFrame.height) / 2; + let panelScale = 1.0; + let panelOffsetY = 0; + + if (screenHeight <= 500){ + panelScale = 0.4; + panelOffsetY = 0; + discordContainer.position.set(-420, 350); + logoContainer.position.set(-300, 150); + logoContainer.scale.set(1.3); + } else if (screenWidth <= 600) { + panelScale = 0.7; + panelOffsetY = 0; + discordContainer.position.set(125, 520); + logoContainer.position.set(250, -120); + logoContainer.scale.set(0.7); + } else { + panelScale = 1.0; + panelOffsetY = 0; + discordContainer.position.set(640, 380); + logoContainer.position.set(250, -120); + logoContainer.scale.set(1.0); + + if (screenHeight <= 1050) { + panelOffsetY = 80; + } + } + + uiContainer.scale.set(panelScale); + uiContainer.x = (screenWidth - (panelFrame.width * panelScale)) / 2; + uiContainer.y = ((screenHeight - (panelFrame.height * panelScale)) / 2) + panelOffsetY; + + const htmlFooter = document.getElementById('game-footer'); + const footerHeight = (htmlFooter && htmlFooter.style.display !== 'none') ? htmlFooter.offsetHeight : 0; + + const targetFooterHeight = Math.max(footerHeight, 40); + footerBackground.height = targetFooterHeight; + footerPattern.height = targetFooterHeight; footerBackground.width = screenWidth; footerPattern.width = screenWidth; - footerBackgroundContainer.y = screenHeight - 40; + + footerBackgroundContainer.y = screenHeight - footerHeight; } htmlTabLogin.addEventListener('click', setLoginMode); htmlTabRegistert.addEventListener('click', setRegisterMode); app.renderer.on('resize', resize); resize(); + + // API-Anbindung (login & Registrierung) + const API_BASE_URL = 'https://api-dev.void-genesis.de/api/auth'; + const form = document.getElementById('register-form'); + const msgBox = document.getElementById('msg-box'); + + // Wir fangen das 'submit' Event des HTML-Formulars ab + // Das Event feuert, wenn der Nutzer auf den Button klickt oder Enter drückt + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + const usernameVal = document.getElementById('username').value; + const emailVal = document.getElementById('email').value; + const passwordVal = document.getElementById('password').value; + + msgBox.textContent = '...'; + msgBox.style.color = 'var(--color-text-main)'; + + if (isForgotPasswordMode) { + try { + const response = await fetch (`${API_BASE_URL}/forgot-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: emailVal + }) + }); + const data = await response.json(); + + + if (!response.ok) { + msgBox.style.color = 'var(--color-error)'; + msgBox.textContent = t(data.error) || t('ERR_INTERNAL_SERVER'); + return; + } + + msgBox.style.color = 'var(--color-green-bright)'; + msgBox.textContent = t(data.message); + + setTimeout(() => { + setResetPasswordMode(emailVal); + }, 1500); + + } catch (error) { + console.error("Netzwerkfehler bei Passwort-Reset-Anfrage:". error); + msgBox.style.color = 'var(--color-error)'; + msgBox.textContent = t('ERR_INTERNAL_SERVER'); + } + return; + } + + if (isResetPasswordMode) { + try { + const response = await fetch (`${API_BASE_URL}/reset-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: resetEmail, + newPassword: passwordVal + }) + }); + const data = await response.json(); + + + if (!response.ok) { + msgBox.style.color = 'var(--color-error)'; + msgBox.textContent = t(data.error) || t('ERR_INTERNAL_SERVER'); + return; + } + + msgBox.style.color = 'var(--color-green-bright)'; + msgBox.textContent = t(data.message); + + setTimeout(() => { + window.location.reload(); + }, 1500); + + } catch (error) { + console.error("Netzwerkfehler bei Passwort-Reset-Anfrage:". error); + msgBox.style.color = 'var(--color-error)'; + msgBox.textContent = t('ERR_INTERNAL_SERVER'); + } + return; + } + + if (isOtpMode) { + try { + const response = await fetch (`${API_BASE_URL}/verify-email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: pendingVerificationEmail, + otpCode: passwordVal + }) + }); + const data = await response.json(); + + + if (!response.ok) { + msgBox.style.color = 'var(--color-error)'; + msgBox.textContent = t(data.error) || t('ERR_INTERNAL_SERVER'); + return; + } + + msgBox.style.color = 'var(--color-green-bright)'; + msgBox.textContent = t('msg_verify_success'); + + setTimeout(() => { + window.location.reload(); + }, 1500); + + } catch (error) { + console.error("Netzwerkfehler bei Verifizieung:". error); + msgBox.style.color = 'var(--color-error)'; + msgBox.textContent = t('ERR_INTERNAL_SERVER'); + } + return; + } + + if (isLoginMode) { + try { + const response = await fetch (`${API_BASE_URL}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identifier: usernameVal, + password: passwordVal + }) + }); + + const data = await response.json(); + + if (!response.ok) { + msgBox.style.color = 'var(--color-error)'; + msgBox.textContent = t(data.error) || t('ERR_INTERNAL_SERVER'); + + if (data.error === 'ERR_EMAIL_NOT_VERIFIED') { + setTimeout(() => { + setOtpMode(data.email); + }, 1000); + } + return; + } + + msgBox.style.color = 'var(--color-green-bright)'; + msgBox.textContent = t(data.message); + + + // TODO JWT Tokens im LocalStorage/SessionStorage ablegen und Szene wechseln + const rememberMe = document.getElementById('remember').checked; + sessionStorage.setItem('void_access_token', data.accessToken); + sessionStorage.setItem('void_user', JSON.stringify(data.user)); + + if (rememberMe) { + localStorage.setItem('void_refresh_token', data.refreshToken); + sessionStorage.removeItem('void_refresh_token'); + } else { + sessionStorage.setItem('void_refresh_token', data.refreshToken); + localStorage.removeItem('void_refresh_token'); + } + + // Debug only + //console.log("Login erfolgreich! Tokens wurden verarbeitet."); + + transitionToLobby(); + + } catch (error) { + console.error("Netzwerkfehler beim Login:". error); + msgBox.style.color = 'var(--color-error)'; + msgBox.textContent = t('ERR_INTERNAL_SERVER'); + } + } else { // Register Mode + try { + const response = await fetch (`${API_BASE_URL}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: usernameVal, + email: emailVal, + password: passwordVal + }) + }); + + const data = await response.json(); + + if (!response.ok) { + msgBox.style.color = 'var(--color-error)'; + msgBox.textContent = t(data.error) || t('ERR_INTERNAL_SERVER'); + return; + } + + msgBox.style.color = 'var(--color-green-bright)'; + msgBox.textContent = t(data.message); + + setTimeout(() => { + setOtpMode(emailVal); + }, 1000); + + // Debug only + console.log("Registrierung erfolgreich! Account ID:", data.accountId); + + } catch (error) { + console.error("Netzwerkfehler bei Registrierung:". error); + msgBox.style.color = 'var(--color-error)'; + msgBox.textContent = t('ERR_INTERNAL_SERVER'); + } + } + }); + + function clearForm() { + document.getElementById('username').value = ''; + document.getElementById('email').value = ''; + document.getElementById('password').value = ''; + document.getElementById('remember').checked = false; + document.getElementById('agb').checked = false; + + document.getElementById('tab-container').style.display = 'block'; + tabLogin.container.visible = true; + tabRegister.container.visible = true; + + document.getElementById('username').style.display = 'block'; + inputUser.container.visible = true; + + const pwField = document.getElementById('password'); + pwField.style.display = 'block'; + pwField.type = 'password'; + inputLock.container.visible = true; + + document.getElementById('otp-instruction').style.display = 'none'; + document.getElementById('resend-otp-link').style.display = 'none'; + document.getElementById('forgot-password-link').style.display = 'none'; + document.getElementById('back-link').style.display = 'none'; + + if (cooldownTimer) { + clearInterval(cooldownTimer); + cooldownTimer = null; + } + const resendLink = document.getElementById('resend-otp-link'); + if(resendLink) { + resendLink.style.pointerEvents = 'auto'; + resendLink.style.opacity = '1.0'; + resendLink.textContent = t('link_resend_otp'); + } + + const msgBox = document.getElementById('msg-box'); + msgBox.textContent = ''; + msgBox.style.color = 'var(--color-text-main)'; + } + + const resendOtpLink = document.getElementById('resend-otp-link'); + if (resendOtpLink) { + resendOtpLink.addEventListener('click', async (e) => { + e.preventDefault(); + + if (!pendingVerificationEmail) return; + + msgBox.textContent = '...'; + msgBox.style.color = 'var(--color-text-main)'; + + try { + const response = await fetch(`${API_BASE_URL}/resend-otp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: pendingVerificationEmail }) + }); + + const data = await response.json(); + + if (!response.ok) { + msgBox.style.color = 'var(--color-error)'; + msgBox.textContent = t(data.error) || t('ERR_INTERNAL_SERVER'); + return; + } + + msgBox.style.color = 'var(--color-green-bright)'; + msgBox.textContent = t(data.message); + + startVisualCountdown(resendOtpLink, 'link_resend_otp', 120); + + } catch (error) { + console.error("Netzwerkfehler beim erneuten Senden des OTP:", error); + msgBox.style.color = 'var(--color-error)'; + msgBox.textContent = t('ERR_INTERNAL_SERVER'); + } + }); + } + + const forgotPasswordLink = document.getElementById('forgot-password-link'); + if (forgotPasswordLink) { + forgotPasswordLink.addEventListener('click', (e) => { + e.preventDefault(); + setForgotPasswordMode(); + }); + } + + const backLink = document.getElementById('back-link'); + if (backLink) { + backLink.addEventListener('click', (e) => { + e.preventDefault(); + + pendingVerificationEmail = ''; + resetEmail = ''; + + isOtpMode = false; + isForgotPasswordMode = false; + isResetPasswordMode = false; + isLoginMode = false; + + setLoginMode(); + }); + } + + /** + * Startet einen visuellen Countdown auf einem übergebenen Link-Element. + * Sperrt währenddessen die Klickbarkeit + * + * @param {HTMLElement} linkElement - Das HTML A-Tag (zB resendOtpLink) + * @param {string} orginalTextKey - Der i18n Key für den ursprünglichen Text + * @param {number} durationSconds - Die Dauer in Sekunden (hier 120 für 2Minuten) + */ + function startVisualCountdown(linkElement, orginalTextKey, durationSconds) { + if (cooldownTimer) clearInterval(cooldownTimer); + let remaining = durationSconds; + + linkElement.style.pointerEvents = 'none'; + linkElement.style.opacity = '0.5'; + + const updateText = () => { + const minutes = Math.floor(remaining / 60); + const seconds = remaining % 60; + const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` + linkElement.textContent = `${t(orginalTextKey)} (${timeString})`; + }; + + updateText(); + + cooldownTimer = setInterval(() => { + remaining--; + + if (remaining <= 0) { + clearInterval(cooldownTimer); + cooldownTimer = null; + linkElement.style.pointerEvents = 'auto'; + linkElement.style.opacity = '1.0'; + linkElement.textContent = t(orginalTextKey); + } else { + updateText(); + } + }, 1000); + } + + /** + * Diese Funktion wird aufgerufenm wenn der Login erfolgreich war und die Tokens + * sicher gespeichert sind, Sie baut das HTML-Formulat ab und bereitet PixiJS + * auf die Lobby-Ansicht vor + */ + async function transitionToLobby() { + const formElement = document.getElementById('register-form'); + if (formElement) { + formElement.style.display = 'none'; + } + + //const footerElement = document.getElementById('game-footer'); + //if (footerElement) { + // footerElement.style.display = 'none'; + //} + + uiContainer.visible = false; + //footerBackgroundContainer.visible = false; + + try { + const lobbyBGPath = 'src/raw-assets/backgrounds/SPR_SciFiMenus_Example_Image_SpookyComputer_01.png'; + const lobbyBGTexture = await Assets.load(lobbyBGPath); + background.texture = lobbyBGTexture; + resize(); + + //console.log("Szenenwechsel zur Lobby erfolgreich abgeschlossen!"); + buildLobbyUI(); + + } catch (error) { + console.error("Fehler beim Laden des Lobby-Hintergrunds:", error); + } + } + + /** + * Blendet die Top-Bar ein, setzt den Spilernamen und aktiviert den Logout + */ + function buildLobbyUI() { + // User-Menü (das beim Login versteckt war) einblenden + const userDropdown = document.getElementById('user-dropdown'); + if (userDropdown) { + userDropdown.style.display = 'block'; + } + + // Gespeicherte User-Daten einsetzen + const userDataStr = sessionStorage.getItem('void_user'); + if (userDataStr) { + try { + const userData = JSON.parse(userDataStr); + const usernameEl = document.getElementById('lobby-username'); + if (usernameEl && userData.username) { + usernameEl.textContent = userData.username; + } + + // Platzhalter, bis das Backend die echten Werte im JWT/Login mitsendet + const emailEl = document.getElementById('lobby-email'); + if (emailEl && userData.email) { + emailEl.textContent = userData.email; + } + + const premiumEl = document.getElementById('lobby-premium'); + if (premiumEl && userData.res_premium !== undefined) { + premiumEl.textContent = userData.res_premium; + } + + } catch (e) { + console.error("Fehler beim Auslesen der User-Daten:", e); + } + } + } + + /** + * Prüft beim Start der Anwendung, ob der Nutzer bereits eingeloggt ist. + * Nutz das Refresh-Token, um ein neues Access-Token anzufordern. + */ + async function checkAuthOnStartup() { + const refreshToken = localStorage.getItem('void_refresh_token') || sessionStorage.getItem('void_refresh_token'); + + if (!refreshToken) { return } + + try { + const response = await fetch (`${API_BASE_URL}/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: refreshToken }) + }); + + const data = await response.json(); + if (response.ok && data.accessToken) { + sessionStorage.setItem('void_access_token', data.accessToken); + + if (data.user) { + sessionStorage.setItem('void_user', JSON.stringify(data.user)); + } + + transitionToLobby(); + } else { + // Fallback: Das Backend hat das Token abgelehnt (abgelaufen, ungültig oder in der DB gelöscht) + // Wir putzen die ungültigen Tokens aus dem Speicher, um Fehler zu vermeiden. + console.warn("Auto-Login fehlgeschlagen. Token sind ungültig und werden gelöscht."); + sessionStorage.removeItem('void_access_token'); + sessionStorage.removeItem('void_refresh_token'); + localStorage.removeItem('void_refresh_token'); + sessionStorage.removeItem('void_user'); + } + } catch (error) { + console.error("Netzwerkfehler beim Auto-Login:", error); + } + } + + /** + * Initialisiert alle Dropdowns und die Sprachauswahl. + * Diese Funktion wird direkt beim Start der App ausgeführt, + * damit das Sprach-Menü auch auf dem Login-Screen funktioniert. + */ + function initDropdowns() { + const langToggle = document.getElementById('lang-toggle'); + const langMenu = document.getElementById('lang-menu'); + const userToggle = document.getElementById('user-toggle'); + const userMenu = document.getElementById('user-menu'); + + const closeAllDropdowns = () => { + if (langMenu) langMenu.classList.remove('show'); + if (userMenu) userMenu.classList.remove('show'); + }; + + if (langToggle && langMenu) { + langToggle.onclick = (e) => { + e.stopPropagation(); // Stoppt das Event, damit der document-Klick (unten) es nicht sofort wieder schließt + const isShowing = langMenu.classList.contains('show'); + closeAllDropdowns(); + if (!isShowing) langMenu.classList.add('show'); + }; + // Ein Klick IN das geöffnete Menü soll es nicht sofort schließen + langMenu.onclick = (e) => e.stopPropagation(); + } + + if (userToggle && userMenu) { + userToggle.onclick = (e) => { + e.stopPropagation(); + const isShowing = userMenu.classList.contains('show'); + closeAllDropdowns(); + if (!isShowing) userMenu.classList.add('show'); + }; + userMenu.onclick = (e) => e.stopPropagation(); + } + + document.addEventListener('click', closeAllDropdowns); + + if (langMenu) { + const langItems = langMenu.querySelectorAll('.dropdown-item'); + langItems.forEach(item => { + item.onclick = () => { + const selectedLang = item.getAttribute('data-lang'); + + document.getElementById('current-lang-text').textContent = selectedLang.toUpperCase(); + document.getElementById('current-flag').textContent = item.querySelector('.flag-icon').textContent; + + setLanguage(selectedLang); + + updateTranslations(); + + // 4. Platzhaltertexte der Input-Felder dynamisch aktualisieren + const userField = document.getElementById('username'); + if (userField) userField.placeholder = isLoginMode ? t('ph_username_login') : t('ph_username_reg'); + const emailField = document.getElementById('email'); + if (emailField) emailField.placeholder = t('ph_email'); + const passField = document.getElementById('password'); + if (passField) passField.placeholder = isOtpMode || isResetPasswordMode ? t('ph_otp') : t('ph_password'); + + closeAllDropdowns(); + //console.log("Sprache live gewechselt zu:", selectedLang); + }; + }); + } + + // --- Logout-Logik --- + const btnLogout = document.getElementById('btn-logout'); + if (btnLogout) { + btnLogout.onclick = () => { + sessionStorage.removeItem('void_access_token'); + sessionStorage.removeItem('void_refresh_token'); + localStorage.removeItem('void_refresh_token'); + sessionStorage.removeItem('void_user'); + window.location.reload(); + }; + } + + if (langMenu) { + const activeLangItem = langMenu.querySelector(`.dropdown-item[data-lang="${currentLang}"]`); + if (activeLangItem) { + // Flagge und Text auslesen und in die sichtbare Top-Bar setzen + const flag = activeLangItem.querySelector('.flag-icon').textContent; + const langText = document.getElementById('current-lang-text'); + const flagIcon = document.getElementById('current-flag'); + + if (langText) langText.textContent = currentLang.toUpperCase(); + if (flagIcon) flagIcon.textContent = flag; + } + } + } + + checkAuthOnStartup(); + initDropdowns(); } initGame(); \ No newline at end of file diff --git a/dev/lobby-frontend/src/raw-assets/backgrounds/Background_A.png b/dev/lobby-frontend/src/raw-assets/backgrounds/Background_A.png new file mode 100644 index 0000000..88be743 Binary files /dev/null and b/dev/lobby-frontend/src/raw-assets/backgrounds/Background_A.png differ diff --git a/dev/lobby-frontend/src/raw-assets/backgrounds/Background_B.png b/dev/lobby-frontend/src/raw-assets/backgrounds/Background_B.png new file mode 100644 index 0000000..79b0165 Binary files /dev/null and b/dev/lobby-frontend/src/raw-assets/backgrounds/Background_B.png differ diff --git a/dev/lobby-frontend/src/raw-assets/backgrounds/Background_C.png b/dev/lobby-frontend/src/raw-assets/backgrounds/Background_C.png new file mode 100644 index 0000000..5eb8599 Binary files /dev/null and b/dev/lobby-frontend/src/raw-assets/backgrounds/Background_C.png differ diff --git a/dev/lobby-frontend/src/raw-assets/backgrounds/Background_D.png b/dev/lobby-frontend/src/raw-assets/backgrounds/Background_D.png new file mode 100644 index 0000000..cfdd3b5 Binary files /dev/null and b/dev/lobby-frontend/src/raw-assets/backgrounds/Background_D.png differ diff --git a/dev/lobby-frontend/src/raw-assets/backgrounds/Background_E.png b/dev/lobby-frontend/src/raw-assets/backgrounds/Background_E.png new file mode 100644 index 0000000..90d3308 Binary files /dev/null and b/dev/lobby-frontend/src/raw-assets/backgrounds/Background_E.png differ diff --git a/dev/lobby-frontend/src/raw-assets/backgrounds/Background_F.png b/dev/lobby-frontend/src/raw-assets/backgrounds/Background_F.png new file mode 100644 index 0000000..165ca31 Binary files /dev/null and b/dev/lobby-frontend/src/raw-assets/backgrounds/Background_F.png differ diff --git a/dev/lobby-frontend/src/raw-assets/backgrounds/Background_G.png b/dev/lobby-frontend/src/raw-assets/backgrounds/Background_G.png new file mode 100644 index 0000000..1f9e008 Binary files /dev/null and b/dev/lobby-frontend/src/raw-assets/backgrounds/Background_G.png differ diff --git a/dev/lobby-frontend/style.css b/dev/lobby-frontend/style.css index a451b36..3534bd0 100644 --- a/dev/lobby-frontend/style.css +++ b/dev/lobby-frontend/style.css @@ -2,7 +2,7 @@ @font-face { font-family: 'UI Font'; /* .otf Dateien bekommen das Format 'opentype', .ttf Dateien 'truetype' */ - src: url('src/assets/fonts/Gugi-Regular.ttf') format('truetype'); + src: url('src/assets/fonts/WDXLLubrifontSC-Regular.ttf') format('truetype'); font-weight: normal; font-style: normal; } @@ -40,17 +40,19 @@ body, html { padding: 0; width: 100%; height: 100%; - overflow: hidden; + overflow: hidden; background-color: var(--color-bg-page); + font-family: var(--font-main); - /* Wir nutzen nun unsere lokale Variable */ - font-family: var(--font-main); + /* NEU: Verbietet mobilen Browsern das eigenmächtige Vergrößern der Schrift */ + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; } #game-wrapper { position: relative; - width: 100vw; - height: 100vh; + width: 100%; + height: 100%; } #pixi-container { @@ -70,16 +72,16 @@ body, html { height: 100%; z-index: 10; pointer-events: none; - display: flex; - justify-content: center; - align-items: center; } #register-form { pointer-events: auto; width: 500px; height: 500px; - position: relative; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); } .input-group label { @@ -130,7 +132,7 @@ body, html { width: 370px; height: 55px; left: 65px; - top: 370px; + top: 390px; background: transparent; border: none; @@ -138,7 +140,7 @@ body, html { font-size: 18px; font-weight: bold; font-family: inherit; - text-transform: uppercase; + /*text-transform: uppercase;*/ letter-spacing: 2px; cursor: pointer; transition: all 0.2s ease; @@ -187,12 +189,25 @@ body, html { .checkbox-group { position: absolute; left: 115px; - top: 275px; + /*top: 275px; */ display: flex; align-items: center; gap: 10px; } +#group-remember { + /* Position für den Login-Modus. + Das Passwortfeld liegt hier bei 200px, 275px passt also perfekt. */ + top: 300px; +} + +#group-agb { + /* Position für den Registrierungs-Modus. + Das Passwortfeld liegt hier tiefer (265px + 50px Höhe). + Wir setzen die Checkbox darunter. */ + top: 325px; +} + .checkbox-group label { color: var(--color-text-main); font-size: 14px; @@ -218,9 +233,21 @@ body, html { left: 2px; } +#otp-instruction { + display: none; + position: absolute; + top: 120px; + left: 65px; + width: 370px; + text-align: center; + color: var(--color-text-main); + font-size: 15px; + margin: 0; +} + #msg-box { position: absolute; - top: 330px; + top: 350px; left: 65px; width: 370px; height: 30px; @@ -233,6 +260,48 @@ body, html { justify-content: center; } +/* --- NEU: RESEND OTP LINK --- */ +#resend-otp-link { + position: absolute; + top: 250px; + left: 65px; + width: 370px; + + text-align: center; + color: var(--color-text-inactive); + font-size: 14px; + text-decoration: underline; /* Unterstrichen, damit es als Link erkennbar ist */ + cursor: pointer; + + /* WICHTIG: Erlaubt das Anklicken, da der Container darüber (ui-layer) pointer-events: none hat */ + pointer-events: auto; + transition: color 0.2s ease, text-shadow 0.2s ease; +} + +#resend-otp-link:hover { + color: var(--color-cyan-bright); + text-shadow: 0 0 8px var(--color-cyan-bright); +} + +#forgot-password-link { + position: absolute; + /* Wir positionieren ihn unter dem Passwort-Feld (200px) auf Höhe der Checkboxen */ + top: 255px; + left: 120px; + + color: var(--color-text-inactive); + font-size: 14px; + text-decoration: underline; + cursor: pointer; + pointer-events: auto; /* Wichtig für die Klickbarkeit durch den ui-layer */ + transition: color 0.2s ease, text-shadow 0.2s ease; +} + +#forgot-password-link:hover { + color: var(--color-cyan-bright); + text-shadow: 0 0 8px var(--color-cyan-bright); +} + /* --- NEU: DISCORD LINK --- */ #discord-link { position: absolute; @@ -296,4 +365,249 @@ body, html { #game-footer a:hover { color: var(--color-cyan-bright); text-shadow: 0 0 8px var(--color-cyan-bright); -} \ No newline at end of file +} + +#back-link { + position: absolute; + /* Wir positionieren ihn oben links, wo sonst die Tabs sitzen */ + top: 70px; + left: 65px; + + color: var(--color-text-inactive); + font-size: 15px; + text-decoration: none; + cursor: pointer; + pointer-events: auto; /* Wichtig für Klickbarkeit */ + transition: color 0.2s ease, text-shadow 0.2s ease; +} + +#back-link:hover { + color: var(--color-cyan-bright); + text-shadow: 0 0 8px var(--color-cyan-bright); +} + +#lobby-ui { + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; + pointer-events: none; + z-index: 15; +} + +#top-bar { + width: 100%; height: 50px; + background: transparent; + border-bottom: none; + box-shadow: none; + display: flex; justify-content: space-between; align-items: center; + padding: 0 20px; box-sizing: border-box; + pointer-events: auto; +} +.top-bar-right { + display: flex; align-items: center; gap: 20px; +} + +/* --- Grundgerüst der Custom Dropdowns --- */ +.custom-dropdown { + position: relative; + font-family: var(--font-main); + color: var(--color-text-main); +} + +/* Der sichtbare Klick-Bereich (Toggle) */ +.dropdown-toggle { + display: flex; align-items: center; gap: 8px; + cursor: pointer; + padding: 5px 10px; + border: 1px solid transparent; + transition: all 0.2s ease; + user-select: none; /* Verhindert das versehentliche Markieren von Text */ +} + +.dropdown-toggle:hover { + border: 1px solid var(--color-cyan-dark); + background: rgba(0, 240, 255, 0.05); +} + +.dropdown-arrow { + font-size: 10px; + color: var(--color-cyan-dark); +} + +.flag-icon { + font-size: 16px; +} + +/* --- Das aufklappbare Menü --- */ +.dropdown-menu { + position: absolute; + top: 100%; /* Direkt unter dem Toggle anheften */ + right: 0; /* Rechtsbündig abschließen */ + margin-top: 10px; + background: rgba(11, 25, 44, 0.95); + border: 1px solid var(--color-cyan-dark); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); + list-style: none; /* Aufzählungszeichen entfernen */ + padding: 5px 0; + min-width: 150px; + + /* Standardmäßig unsichtbar und keine Klicks annehmend */ + opacity: 0; + pointer-events: none; + transform: translateY(-10px); + transition: all 0.2s ease; +} + +/* Wenn die Klasse 'show' per JS hinzugefügt wird, klappt das Menü auf */ +.dropdown-menu.show { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +/* Das User-Menü braucht etwas mehr Platz für die langen Texte */ +.user-menu { + min-width: 220px; +} + +/* --- Einzelne Menü-Einträge --- */ +.dropdown-item { + padding: 10px 15px; + cursor: pointer; + display: flex; align-items: center; gap: 10px; + font-size: 14px; + transition: background 0.2s ease, color 0.2s ease; +} + +.dropdown-item:hover:not(.disabled) { + background: rgba(0, 240, 255, 0.1); + color: var(--color-cyan-bright); +} + +/* --- Ausgegraute Einträge (Disabled) --- */ +.dropdown-item.disabled, .dropdown-header.disabled { + color: var(--color-text-inactive); + cursor: not-allowed; +} + +.dropdown-header { + padding: 10px 15px; + font-size: 14px; + line-height: 1.4; +} + +.premium-text { + color: var(--color-cyan-bright); + font-weight: bold; +} + +/* --- Trennlinie im Menü --- */ +.dropdown-divider { + height: 1px; + background: var(--color-cyan-dark); + margin: 5px 0; + opacity: 0.5; +} + +/* --- Spezielles Styling für den Logout-Button --- */ +.logout-item { + color: var(--color-error); + font-weight: bold; +} + +.logout-item:hover { + background: rgba(255, 68, 68, 0.1) !important; + color: var(--color-error) !important; +} + +@media (max-width: 600px) { + /* --- 1. Login/Registrierungs-Formular skalieren --- */ + #register-form { + transform: translate(-50%, -50%) scale(0.7); + } + + /* --- 2. Top-Bar in der Lobby optimieren --- */ + #top-bar { + padding: 0 10px; + } + + .top-bar-left .lobby-logo { + font-size: 16px; + } + + #user-info { + gap: 10px; + } + + #lobby-username { + font-size: 14px; + max-width: 80px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + #btn-logout { + padding: 5px 10px; + font-size: 12px; + } + + /* --- 3. Footer aufräumen --- */ + #game-footer { + flex-direction: column; + height: auto; + padding: 10px 0; + gap: 8px; + background: rgba(0, 0, 0, 0.7); + } + + .footer-left, .footer-center, .footer-right { + text-align: center; + width: 100%; + } + + #discord-link { + left: 125px; + top: 520px; + } +} + +@media (max-height: 1050px) and (min-width: 601px) { + #register-form { + /* Wir schieben das HTML-Formular aus der exakten Mitte um 80px nach unten. + So hat das Logo (das in PixiJS darüber liegt) genug Luft zum Atmen. */ + top: calc(50% + 80px); + } +} + +@media (max-height: 500px) { + /* --- Formular noch kleiner machen, damit es vertikal passt --- */ + #register-form { + /* scale(0.5) macht das Formular klein genug für Handys im Querformat */ + transform: translate(-50%, -50%) scale(0.4); + top: 50%; + } + + /* --- Discord-Button wie im normalen Handy-Modus nach unten klappen --- */ + #discord-link { + left: -420px; + top: 350px; + } + + /* --- Footer extrem flach machen --- */ + /* Im Querformat haben wir in der Breite Platz, aber in der Höhe nicht. + Daher ordnen wir den Text wieder als Reihe (row) an. */ + #game-footer { + flex-direction: row; + height: 30px; + padding: 0 20px; + background: rgba(0, 0, 0, 0.7); + } + + .footer-left, .footer-center, .footer-right { + text-align: center; + width: 33%; + font-size: 10px; /* Schrift minimal verkleinern */ + } +} +