diff --git a/dev/lobby-backend/src/controllers/sector.controller.js b/dev/lobby-backend/src/controllers/sector.controller.js index 192f146..1406299 100644 --- a/dev/lobby-backend/src/controllers/sector.controller.js +++ b/dev/lobby-backend/src/controllers/sector.controller.js @@ -1,63 +1,69 @@ import pool from '../db.js'; +import * as sectorService from '../services/sector.service.js'; /** * Liefert alle Sektoren für das Frontend. - * Unterteilt in "Meine Sektoren" (wo der User schon spielt) - * und "Alle Sektoren" (die generell verfügbar sind) + * Übergibt die HTTP-Anfrage an den zuständigen Service und liefert das Ergebnis aus. */ -export const getSectorsList = async(req, res) => { +export const getSectorsList = async (req, res) => { const accountId = req.user.accountId; - const client = await pool.connect(); try { - // "Meine Sektoren" abfragen - const mySectorsResult = await client.query(` - SELECT - s.id, s.name, s.status, s.speed_fleet, s.speed_economy, - las.local_username, - las.created_at as joined_at, - las.updated_at as last_played - FROM sectors s - JOIN lobby_accounts_sectors las ON s.id = las.sector_id - WHERE las.lobby_account_id = $1 - ORDER BY las.updated_at DESC - `, [accountId]); - - // "Neue Sektoren" - const newSectorsResult = await client.query(` - SELECT - id, name, status, speed_fleet, speed_economy - FROM sectors - WHERE status != 'offline' - AND id NOT IN ( - SELECT sector_id - FROM lobby_accounts_sectors - WHERE lobby_account_id = $1 - ) - ORDER BY created_at ASC - `, [accountId]); - - - // Daten platzhalter - const mapSectorData = (sector) => ({ - ...sector, - online_players: Math.floor(Math.random() * 150) + 10, - planets_count: (Math.floor(Math.random() * 150) + 10) * 12 - }); - + const sectorData = await sectorService.getSectorsList(accountId); return res.status(200).json({ success: true, - mySectors: mySectorsResult.rows.map(mapSectorData), - newSectors: newSectorsResult.rows.map(mapSectorData) + ...sectorData }); } catch (error) { - console.error('[SectorController] Fehler beim Abrufen der Sektoren:', error); + console.error('[SectorController] Fehler in getSectorsList:', error); + + return res.status(500).json({ + error: 'ERR_INTERNAL_SERVER' + }); + } +}; + +/** + * Controller-Methode für den Sektor-Beitritt (Charaktererstellung). + * Nimmt die Request an, validiert grob und leitet an den Service weiter. + */ +export const joinSector = async (req, res) => { + const accountId = req.user.accountId; + const { sectorId, localUsername, gender } = req.body; + + if (!sectorId || !localUsername || !gender) { + return res.status(400).json({ + error: 'ERR_MISSING_FIELDS' + }); + } + + if (localUsername.length < 3) { + return res.status(400).json({ + error: 'ERR_USERNAME_TOO_SHORT' + }); + } + + try { + await sectorService.joinSector(accountId, sectorId, localUsername, gender); + + return res.status(201).json({ + success: true, + message: 'MSG_SECTOR_JOINED_SUCCESS' + }); + + } catch (error) { + if (error.code === 'ERR_ALREADY_IN_SECTOR' || error.code === 'ERR_LOCAL_USERNAME_TAKEN') { + return res.status(409).json({ + error: error.code + }); + } + + // Generischer Fallback für echte Systemabstürze + console.error('[SectorController] Fehler beim Sektor-Beitritt:', error); return res.status(500).json({ error: 'ERR_INTERNAL_SERVER' }); - } finally { - client.release(); } }; \ No newline at end of file diff --git a/dev/lobby-backend/src/routes/sector.routes.js b/dev/lobby-backend/src/routes/sector.routes.js index 193a053..690770f 100644 --- a/dev/lobby-backend/src/routes/sector.routes.js +++ b/dev/lobby-backend/src/routes/sector.routes.js @@ -5,5 +5,6 @@ import { requireAuth } from '../middlewares/auth.middleware.js'; const router = express.Router(); router.get('/list', requireAuth, sectorController.getSectorsList); +router.post('/join', requireAuth, sectorController.joinSector); export default router; \ No newline at end of file diff --git a/dev/lobby-backend/src/scripts/initDb.js b/dev/lobby-backend/src/scripts/initDb.js index dff1241..e75deca 100644 --- a/dev/lobby-backend/src/scripts/initDb.js +++ b/dev/lobby-backend/src/scripts/initDb.js @@ -58,11 +58,13 @@ CREATE TABLE IF NOT EXISTS lobby_accounts_sectors ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), lobby_account_id UUID REFERENCES lobby_accounts(id) ON DELETE CASCADE, sector_id UUID REFERENCES sectors(id) ON DELETE CASCADE, - local_username VARCHAR(50) UNIQUE NOT NULL, + local_username VARCHAR(50) NOT NULL, + gender VARCHAR(20) NOT NULL, res_premium INT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(lobby_account_id, sector_id) + UNIQUE(lobby_account_id, sector_id), + UNIQUE(sector_id, local_username) ); -- ------------------------------------------------------------------------------ diff --git a/dev/lobby-backend/src/services/sector.service.js b/dev/lobby-backend/src/services/sector.service.js new file mode 100644 index 0000000..f792831 --- /dev/null +++ b/dev/lobby-backend/src/services/sector.service.js @@ -0,0 +1,107 @@ +import pool from '../db.js'; + +/** + * Ruft die Sektorlisten für einen bestimmten Account ab. + * Unterteilt das Ergebnis in bereits beigetretene Sektoren ("Meine Sektoren") + * und noch verfügbare Sektoren ("Neue Sektoren"). + * + * @param {string} accountId - Die UUID des aktuell eingeloggten Spielers + * + * @returns {Promise} - Ein Objekt mit den Arrays 'mySectors' und 'newSectors' + */ +export const getSectorsList = async (accountId) => { + const client = await pool.connect(); + + try { + const mySectorsResult = await client.query(` + SELECT + s.id, s.name, s.status, s.speed_fleet, s.speed_economy, + las.local_username, las.gender, + las.created_at as joined_at, + las.updated_at as last_played + FROM sectors s + JOIN lobby_accounts_sectors las ON s.id = las.sector_id + WHERE las.lobby_account_id = $1 + ORDER BY las.updated_at DESC + `, [accountId]); + + const newSectorsResult = await client.query(` + SELECT + id, name, status, speed_fleet, speed_economy + FROM sectors + WHERE status != 'offline' + AND id NOT IN ( + SELECT sector_id + FROM lobby_accounts_sectors + WHERE lobby_account_id = $1 + ) + ORDER BY created_at ASC + `, [accountId]); + + const mapSectorData = (sector) => ({ + ...sector, + online_players: Math.floor(Math.random() * 150) + 10, + planets_count: (Math.floor(Math.random() * 150) + 10) * 12 + }); + + return { + mySectors: mySectorsResult.rows.map(mapSectorData), + newSectors: newSectorsResult.rows.map(mapSectorData) + }; + + } catch (error) { + console.error('[SectorService] Fehler beim Auslesen der Sektordaten:', error); + throw error; + + } finally { + client.release(); + } +}; + +/** + * Trägt einen Spieler in einen neuen Sektor ein. + * Führt die eigentliche Datenbankoperation durch und wirft spezifische + * Fehler-Objekte, falls Constraints (UNIQUE-Regeln) verletzt werden. + * + * @param {string} accountId - Die UUID des Nutzers + * @param {string} sectorId - Die UUID des Sektors + * @param {string} localUsername - Der Commander-Name für diesen Sektor + * @param {string} gender - Das gewählte Geschlecht ('male' oder 'female') + * + * @returns {boolean} - true bei Erfolg + */ +export const joinSector = async (accountId, sectorId, localUsername, gender) => { + const client = await pool.connect(); + + try { + // Wir versuchen, den Datensatz direkt einzufügen. + await client.query(` + INSERT INTO lobby_accounts_sectors (lobby_account_id, sector_id, local_username, gender) + VALUES ($1, $2, $3, $4) + `, [accountId, sectorId, localUsername, gender]); + + return true; + + } catch (error) { + // Fehlercode 23505 = Postgres "unique_violation" + if (error.code === '23505') { + + if (error.constraint === 'lobby_accounts_sectors_lobby_account_id_sector_id_key') { + const customErr = new Error('Already in sector'); + customErr.code = 'ERR_ALREADY_IN_SECTOR'; + throw customErr; + } + + if (error.constraint === 'lobby_accounts_sectors_sector_id_local_username_key') { + const customErr = new Error('Local username taken'); + customErr.code = 'ERR_LOCAL_USERNAME_TAKEN'; + throw customErr; + } + } + + throw error; + + } finally { + client.release(); + } +}; \ No newline at end of file diff --git a/dev/lobby-frontend/index.html b/dev/lobby-frontend/index.html index a4fce10..92a54b1 100644 --- a/dev/lobby-frontend/index.html +++ b/dev/lobby-frontend/index.html @@ -55,20 +55,35 @@ - + +
diff --git a/dev/lobby-frontend/src/api/sectors.js b/dev/lobby-frontend/src/api/sectors.js index 81c552d..0cfa6b9 100644 --- a/dev/lobby-frontend/src/api/sectors.js +++ b/dev/lobby-frontend/src/api/sectors.js @@ -30,3 +30,33 @@ export async function fetchSectorsList(token) { throw error; } } + +/** + * Sendet die Anfrage an das Backend, um einem neuen Sektor beizutreten. + * + * @param {string} token - Das JWT Access Token + * @param {string} sectorId - Die UUID des Sektors + * @param {string} localUsername - Der gewünschte Commander-Name + * @param {string} gender - Das gewählte Geschlecht ('male' oder 'female') + * + * @returns {Promise} - Ein Objekt mit 'ok' (boolean) und den 'data' (JSON) + */ +export async function joinSectorUser(token, sectorId, localUsername, gender) { + try { + const response = await fetch(`${API_BASE_URL}/join`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ sectorId, localUsername, gender }) + }); + + const data = await response.json(); + return { ok: response.ok, data }; + + } catch (error) { + console.error("Netzwerkfehler beim Sektor-Beitritt:", error); + throw error; + } +} \ No newline at end of file diff --git a/dev/lobby-frontend/src/assets/ui-atlas.json b/dev/lobby-frontend/src/assets/ui-atlas.json index 0743330..cc7554d 100644 --- a/dev/lobby-frontend/src/assets/ui-atlas.json +++ b/dev/lobby-frontend/src/assets/ui-atlas.json @@ -192,7 +192,7 @@ "y": 0.5 } }, - "SPR_SciFiMenus_Menu_Item_Selected_20": { + "SPR_SciFiMenus_Menu_Item_Background_05": { "frame": { "x": 3737, "y": 262, @@ -216,10 +216,34 @@ "y": 0.5 } }, - "SPR_SciFiMenus_Menu_Item_Selected_10": { + "SPR_SciFiMenus_Menu_Item_Selected_20": { "frame": { "x": 3737, "y": 522, + "w": 256, + "h": 256 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "sourceSize": { + "w": 256, + "h": 256 + }, + "pivot": { + "x": 0.5, + "y": 0.5 + } + }, + "SPR_SciFiMenus_Menu_Item_Selected_10": { + "frame": { + "x": 3737, + "y": 782, "w": 228, "h": 234 }, @@ -243,7 +267,7 @@ "ICON_SciFiMenus_Menu_Solo_01_Clean": { "frame": { "x": 3969, - "y": 522, + "y": 782, "w": 125, "h": 194 }, @@ -264,30 +288,6 @@ "y": 0.5 } }, - "SPR_SciFiMenus_Menu_Item_Selected_19": { - "frame": { - "x": 3737, - "y": 760, - "w": 228, - "h": 234 - }, - "rotated": false, - "trimmed": true, - "spriteSourceSize": { - "x": 14, - "y": 10, - "w": 228, - "h": 234 - }, - "sourceSize": { - "w": 256, - "h": 256 - }, - "pivot": { - "x": 0.5, - "y": 0.5 - } - }, "SPR_SciFiMenus_Background_Hexagons_01": { "frame": { "x": 2834, @@ -312,20 +312,20 @@ "y": 0.5 } }, - "ICON_Social_Discord": { + "SPR_SciFiMenus_Menu_Item_Selected_19": { "frame": { "x": 3350, "y": 859, - "w": 226, - "h": 175 + "w": 228, + "h": 234 }, "rotated": false, "trimmed": true, "spriteSourceSize": { "x": 14, - "y": 41, - "w": 226, - "h": 175 + "y": 10, + "w": 228, + "h": 234 }, "sourceSize": { "w": 256, @@ -338,7 +338,7 @@ }, "ICON_SciFiMenus_Menu_Lock_Closed_01_Clean": { "frame": { - "x": 3580, + "x": 3582, "y": 859, "w": 132, "h": 164 @@ -360,20 +360,20 @@ "y": 0.5 } }, - "SPR_SciFiMenus_Menu_Item_Background_07": { + "ICON_Social_Discord": { "frame": { - "x": 3716, - "y": 998, - "w": 224, - "h": 230 + "x": 3718, + "y": 1020, + "w": 226, + "h": 175 }, "rotated": false, "trimmed": true, "spriteSourceSize": { - "x": 16, - "y": 12, - "w": 224, - "h": 230 + "x": 14, + "y": 41, + "w": 226, + "h": 175 }, "sourceSize": { "w": 256, @@ -386,7 +386,7 @@ }, "ICON_SciFi_Map_Message": { "frame": { - "x": 3580, + "x": 3582, "y": 1027, "w": 130, "h": 88 @@ -408,10 +408,34 @@ "y": 0.5 } }, - "ICON_SciFiMenus_Menu_Warning_02_Clean": { + "SPR_SciFiMenus_Menu_Item_Background_07": { "frame": { "x": 3350, - "y": 1038, + "y": 1097, + "w": 224, + "h": 230 + }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { + "x": 16, + "y": 12, + "w": 224, + "h": 230 + }, + "sourceSize": { + "w": 256, + "h": 256 + }, + "pivot": { + "x": 0.5, + "y": 0.5 + } + }, + "ICON_SciFiMenus_Menu_Warning_02_Clean": { + "frame": { + "x": 3578, + "y": 1199, "w": 222, "h": 193 }, @@ -434,8 +458,8 @@ }, "ICON_SciFiMenus_Menu_Multiplayer_01_Clean": { "frame": { - "x": 3576, - "y": 1232, + "x": 3350, + "y": 1331, "w": 218, "h": 141 }, @@ -458,8 +482,8 @@ }, "ICON_SciFiMenus_Menu_Planet_02_Clean": { "frame": { - "x": 3350, - "y": 1235, + "x": 3804, + "y": 1199, "w": 216, "h": 149 }, @@ -482,8 +506,8 @@ }, "ICON_SciFiMenus_Menu_Tick_01_Clean": { "frame": { - "x": 3798, - "y": 1232, + "x": 3804, + "y": 1352, "w": 191, "h": 144 }, @@ -506,8 +530,8 @@ }, "ICON_SciFiMenus_Menu_Settings_01_Clean": { "frame": { - "x": 3570, - "y": 1377, + "x": 3572, + "y": 1396, "w": 182, "h": 199 }, @@ -530,8 +554,8 @@ }, "ICON_SciFiMenus_Menu_Message_01_Clean": { "frame": { - "x": 3570, - "y": 1580, + "x": 3758, + "y": 1500, "w": 177, "h": 218 }, @@ -560,7 +584,7 @@ "format": "RGBA8888", "size": { "w": 4096, - "h": 1800 + "h": 1720 }, "scale": 1 } diff --git a/dev/lobby-frontend/src/assets/ui-atlas.png b/dev/lobby-frontend/src/assets/ui-atlas.png index ecf810e..e6cb62d 100644 Binary files a/dev/lobby-frontend/src/assets/ui-atlas.png and b/dev/lobby-frontend/src/assets/ui-atlas.png differ diff --git a/dev/lobby-frontend/src/core/pixi-app.js b/dev/lobby-frontend/src/core/pixi-app.js index 92a4e68..8217048 100644 --- a/dev/lobby-frontend/src/core/pixi-app.js +++ b/dev/lobby-frontend/src/core/pixi-app.js @@ -30,7 +30,7 @@ export function setupResize(refs) { const { background, discordContainer, logoContainer, uiContainer, panelFrame, lobbyPixiContainer, footerBackground, footerPattern, - footerBackgroundContainer + footerBackgroundContainer, onboardingPixiContainer } = refs; function resize() { @@ -133,12 +133,15 @@ export function setupResize(refs) { sectorListContainer.style.height = `${targetMaskHeight}px`; } + if (onboardingPixiContainer) { + onboardingPixiContainer.x = (screenWidth - 450) / 2; + onboardingPixiContainer.y = (screenHeight - 550) / 2; + } + footerBackground.height = actualFooterHeight; footerPattern.height = actualFooterHeight; - footerBackground.width = screenWidth; footerPattern.width = screenWidth; - footerBackgroundContainer.y = screenHeight - footerHeight; } diff --git a/dev/lobby-frontend/src/css/lobby.css b/dev/lobby-frontend/src/css/lobby.css index b350542..8c929e7 100644 --- a/dev/lobby-frontend/src/css/lobby.css +++ b/dev/lobby-frontend/src/css/lobby.css @@ -162,8 +162,8 @@ top: 50%; transform: translate(-50%, -50%); pointer-events: auto; + transition: opacity 0.3s ease; } - #sector-tab-container { position: absolute; top: 20px; @@ -190,15 +190,19 @@ height: 540px; overflow-y: auto; overflow-x: hidden; - padding: 20px 40px; + padding: 20px 40px; /* Zurück zu den perfekten 40px */ box-sizing: border-box; + + /* Zurück zum Grid für eine saubere Matrix-Optik */ display: grid; grid-template-columns: repeat(4, 270px); grid-auto-rows: 90px; row-gap: 20px; column-gap: 25px; align-content: start; - justify-content: flex-start; + + /* Zentriert den gesamten Grid-Block (inkl. Lücken) mittig im Container */ + justify-content: center; } .sector-card-link { diff --git a/dev/lobby-frontend/src/css/onboarding.css b/dev/lobby-frontend/src/css/onboarding.css new file mode 100644 index 0000000..6b56c08 --- /dev/null +++ b/dev/lobby-frontend/src/css/onboarding.css @@ -0,0 +1,108 @@ +/* Haupt-Ebene abdunklung entfernen, das macht jetzt PixiJS */ +#sector-onboarding-layer { + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + z-index: 50; + display: flex; + justify-content: center; + align-items: center; + pointer-events: none; +} + +#onboarding-form { + position: relative; + width: 450px; + height: 550px; + background: transparent; + pointer-events: auto; +} + +#local-username { + position: absolute; + left: 75px; + top: 250px; + width: 300px; + height: 50px; + background: transparent; + border: none; + color: var(--color-text-main); + padding: 0 15px; + font-size: 16px; + font-family: inherit; + outline: none; + box-sizing: border-box; +} + +#local-username::placeholder { + color: var(--color-text-placeholder); +} +#local-username:focus { + text-shadow: 0 0 8px var(--color-cyan-dark); +} + +#btn-gender-male { + position: absolute; + left: 75px; + top: 320px; + width: 140px; + height: 50px; +} + +#btn-gender-female { + position: absolute; + left: 235px; + top: 320px; + width: 140px; + height: 50px; +} + +#btn-cancel-onboarding { + position: absolute; + left: 75px; + top: 460px; + width: 140px; + height: 50px; +} + +#btn-submit-onboarding { + position: absolute; + left: 235px; + top: 460px; + width: 140px; + height: 50px; +} + +#onboarding-form button { + background: transparent; + border: none; + color: var(--color-text-inactive); + font-size: 14px; + font-family: inherit; + text-transform: uppercase; + cursor: pointer; + outline: none; + transition: color 0.2s ease, text-shadow 0.2s ease; +} + +#onboarding-form button:hover, +#onboarding-form button.active { + color: var(--color-text-main); + text-shadow: 0 0 8px var(--color-cyan-bright); +} + +#onboarding-msg-box { + position: absolute; + top: 400px; /* Zwischen Geschlecht und Buttons */ + width: 100%; + text-align: center; + color: var(--color-error); + font-size: 14px; + font-weight: bold; + display: flex; + justify-content: center; + align-items: center; + height: 30px; +} \ No newline at end of file diff --git a/dev/lobby-frontend/src/i18n.js b/dev/lobby-frontend/src/i18n.js index 76f23b7..5032d5a 100644 --- a/dev/lobby-frontend/src/i18n.js +++ b/dev/lobby-frontend/src/i18n.js @@ -15,6 +15,10 @@ export const translations = { btn_reset_password: "Passwort ändern", discord_text: "Offizieller Discord Server", link_back: "« Zurück", + btn_male: "Männlich", + btn_female: "Weiblich", + btn_cancel: "Abbrechen", + btn_join: "Beitreten", // --- Platzhalter (Placeholders) --- ph_username_login: "Benutzername / E-Mail", @@ -35,6 +39,11 @@ export const translations = { msg_verify_success: "Verifizierung erfolgreich! Du kannst dich nun einloggen.", link_resend_otp: "Code erneut senden", + // --- Sektor-Onboarding --- + ERR_ALREADY_IN_SECTOR: "Du bist diesem Sektor bereits beigetreten.", + ERR_LOCAL_USERNAME_TAKEN: "Dieser Commander-Name ist in diesem Sektor leider schon vergeben.", + MSG_SECTOR_JOINED_SUCCESS: "Sektor erfolgreich beigetreten!", + // --- Footer --- link_imprint: "Impressum", link_privacy: "Datenschutz", @@ -71,6 +80,10 @@ export const translations = { btn_reset_request: "REQUEST CODE", btn_reset_password: "CHANGE PASSWORD", link_back: "« back", + btn_male: "Male", + btn_female: "Femail", + btn_cancel: "Cancel", + btn_join: "Join Sector", // --- Placeholders --- ph_username_login: "Username / E-Mail", @@ -91,6 +104,11 @@ export const translations = { msg_verify_success: "Verification successful! You can now log in.", link_resend_otp: "Resend Code", + // --- Sektor-Onboarding --- + ERR_ALREADY_IN_SECTOR: "You have already joined this sector.", + ERR_LOCAL_USERNAME_TAKEN: "This Commander name is already taken in this sector.", + MSG_SECTOR_JOINED_SUCCESS: "Successfully joined the sector!", + // --- Footer --- link_imprint: "Imprint", link_privacy: "Privacy Policy", diff --git a/dev/lobby-frontend/src/main.js b/dev/lobby-frontend/src/main.js index 8caa9b3..8c5610d 100644 --- a/dev/lobby-frontend/src/main.js +++ b/dev/lobby-frontend/src/main.js @@ -3,10 +3,10 @@ import { Assets, Sprite, NineSliceSprite, Container, TilingSprite, Text, wordWra import { loginUser, registerUser, verifyEmailUser, resendOTPCode, requestPasswordResetUser, resetPasswordUser, refreshAccessTokenUser } from './api/auth.js'; import { initDropdowns, updateTranslations } from './ui/dropdowns.js'; import { initForms, formState, setOtpMode, setResetPasswordMode, startVisualCountdown, setRegisterMode } from './ui/forms.js'; -import { fetchSectorsList } from './api/sectors.js'; +import { fetchSectorsList, joinSectorUser } from './api/sectors.js'; import { app, initPixi, setupResize, triggerResize } from './core/pixi-app.js'; import { buildLoginScene, LOGIN_COLORS } from './scenes/login-scene.js'; -import { buildLobbyBase, createSectorTabPixi, createSectorCardPixi } from './scenes/lobby-scene.js'; +import { buildLobbyBase, createSectorTabPixi, createSectorCardPixi, buildOnboardingModalPixi } from './scenes/lobby-scene.js'; let globalSheet = null; let background = null; @@ -17,6 +17,10 @@ let sectorTabsPixiContainer = null; let footerBackground = null; let footerPattern = null; let footerBackgroundContainer = null; +let onboardingPixiContainer = null; +let onboardingPixiRefs = null; +let selectedSectorId = null; +let selectedOnboardingGender = 'male'; async function initGame() { await initPixi('pixi-container'); @@ -96,6 +100,9 @@ function buildScene(bgTexture, sheet) { app.stage.addChild(footerBackgroundContainer); + onboardingPixiRefs = buildOnboardingModalPixi(app.stage, sheet); + onboardingPixiContainer = onboardingPixiRefs.container; + const loginRefs = buildLoginScene(uiContainer, sheet); initForms({ @@ -123,7 +130,8 @@ function buildScene(bgTexture, sheet) { lobbyPixiContainer, footerBackground, footerPattern, - footerBackgroundContainer + footerBackgroundContainer, + onboardingPixiContainer }); formState.isLoginMode = true; @@ -263,7 +271,7 @@ form.addEventListener('submit', async (e) => { setTimeout(() => { setOtpMode(emailVal); - }, 3000); + }, 1500); } catch (error) { msgBox.style.color = 'var(--color-error)'; @@ -378,6 +386,7 @@ async function loadAndRenderSectorBrowser() { const layer = document.getElementById('sector-browser-layer'); layer.style.display = 'block'; + const tabLastSector = document.getElementById('tab-last-sector'); const tabMySectors = document.getElementById('tab-my-sectors'); const tabNewSectors = document.getElementById('tab-new-sectors'); @@ -392,43 +401,111 @@ async function loadAndRenderSectorBrowser() { const centerPos = isMobilePortrait ? 325 : 620; if (data.mySectors.length === 0) { + // Szenario A: Keine eigenen Sektoren + tabLastSector.style.display = 'none'; tabMySectors.style.display = 'none'; tabNewSectors.classList.add('active'); - + const singleTabX = centerPos - 90; - const tabNewBg = createSectorTabPixi(globalSheet, singleTabX, 20, 0x00f0ff, 0.6); - sectorTabsPixiContainer.addChild(tabNewBg); + const tabNewBg = createSectorTabPixi(globalSheet, singleTabX, 20, LOGIN_COLORS.TAB_ACTIVE, 0.6); + // ACHTUNG: Hier jetzt .container verwenden! + sectorTabsPixiContainer.addChild(tabNewBg.container); renderSectorList(data.newSectors, 'new'); + } else { + // Szenario B: Eigene Sektoren vorhanden + tabLastSector.style.display = 'inline-block'; tabMySectors.style.display = 'inline-block'; tabNewSectors.style.display = 'inline-block'; - tabMySectors.classList.add('active'); - tabNewSectors.classList.remove('active'); - const leftTabX = centerPos - 190; - const rightTabX = centerPos + 10; + // --- NEU: Daten für die Tabs filtern und aufteilen --- + + // 1. "Zuletzt gespielt" + // Da das Backend bereits nach Datum absteigend sortiert, ist das Element + // an Index 0 garantiert der Sektor, in dem der Spieler zuletzt aktiv war. + // Wir packen ihn in ein neues Array, da unsere renderSectorList-Funktion ein Array erwartet. + const lastPlayedSectorArray = [data.mySectors[0]]; + + // 2. "Meine Sektoren" (Der Rest) + // Wir nutzen die JavaScript-Funktion 'slice(1)', um alle Sektoren ab dem zweiten Element (Index 1) + // bis zum Ende des Arrays zu kopieren. + // Hinweis: Wenn der Spieler insgesamt nur 1 Sektor besitzt, gibt slice(1) automatisch + // ein leeres Array [] zurück, was perfekt ist! + const otherMySectorsArray = data.mySectors.slice(1); - const tabMyBg = createSectorTabPixi(globalSheet, leftTabX, 20, 0x00f0ff, 0.6); - const tabNewBg = createSectorTabPixi(globalSheet, rightTabX, 20, 0xaaaaaa, 0.4); - sectorTabsPixiContainer.addChild(tabMyBg, tabNewBg); + // --- PixiJS-Hintergründe berechnen (unverändert) --- + const leftTabX = centerPos - 290; + const midTabX = centerPos - 90; + const rightTabX = centerPos + 110; - renderSectorList(data.mySectors, 'my'); + const tabLastBg = createSectorTabPixi(globalSheet, leftTabX, 20, LOGIN_COLORS.TAB_ACTIVE, 0.6); + const tabMyBg = createSectorTabPixi(globalSheet, midTabX, 20, LOGIN_COLORS.TAB_INACTIVE, 0.4); + const tabNewBg = createSectorTabPixi(globalSheet, rightTabX, 20, LOGIN_COLORS.TAB_INACTIVE, 0.4); + + sectorTabsPixiContainer.addChild(tabLastBg.container, tabMyBg.container, tabNewBg.container); + + const updateTabVisuals = (activeHtmlId) => { + tabLastSector.classList.remove('active'); + tabMySectors.classList.remove('active'); + tabNewSectors.classList.remove('active'); + + document.getElementById(activeHtmlId).classList.add('active'); + + tabLastBg.fill.tint = LOGIN_COLORS.TAB_INACTIVE; tabLastBg.fill.alpha = 0.4; + tabMyBg.fill.tint = LOGIN_COLORS.TAB_INACTIVE; tabMyBg.fill.alpha = 0.4; + tabNewBg.fill.tint = LOGIN_COLORS.TAB_INACTIVE; tabNewBg.fill.alpha = 0.4; + + if (activeHtmlId === 'tab-last-sector') { tabLastBg.fill.tint = LOGIN_COLORS.TAB_ACTIVE; tabLastBg.fill.alpha = 0.6; } + if (activeHtmlId === 'tab-my-sectors') { tabMyBg.fill.tint = LOGIN_COLORS.TAB_ACTIVE; tabMyBg.fill.alpha = 0.6; } + if (activeHtmlId === 'tab-new-sectors') { tabNewBg.fill.tint = LOGIN_COLORS.TAB_ACTIVE; tabNewBg.fill.alpha = 0.6; } + }; + + // --- ÄNDERUNG: Klick-Events mit den gefilterten Arrays versorgen --- + tabLastSector.onclick = () => { + updateTabVisuals('tab-last-sector'); + // Wir übergeben hier ausschließlich das Array mit dem EINEN, letzten Sektor + renderSectorList(lastPlayedSectorArray, 'my'); + }; + + tabMySectors.onclick = () => { + updateTabVisuals('tab-my-sectors'); + // Wir übergeben hier alle Sektoren AUSSER dem letzten + renderSectorList(otherMySectorsArray, 'my'); + }; + + tabNewSectors.onclick = () => { + updateTabVisuals('tab-new-sectors'); + // Neue Sektoren bleiben natürlich unangetastet + renderSectorList(data.newSectors, 'new'); + }; + + const setupTabHover = (htmlEl, pixiRef) => { + htmlEl.onmouseenter = () => { + if (!htmlEl.classList.contains('active')) { + pixiRef.fill.tint = LOGIN_COLORS.TAB_HOVER; + pixiRef.fill.alpha = 0.8; + } + }; + htmlEl.onmouseleave = () => { + if (!htmlEl.classList.contains('active')) { + pixiRef.fill.tint = LOGIN_COLORS.TAB_INACTIVE; + pixiRef.fill.alpha = 0.4; + } + }; + }; + + setupTabHover(tabLastSector, tabLastBg); + setupTabHover(tabMySectors, tabMyBg); + setupTabHover(tabNewSectors, tabNewBg); + + // --- ÄNDERUNG: Initialer Aufruf --- + // Wenn die Lobby lädt, starten wir standardmäßig im "Zuletzt gespielt"-Tab, + // also übergeben wir auch hier das gefilterte 1-Element-Array. + renderSectorList(lastPlayedSectorArray, 'my'); } - tabMySectors.onclick = () => { - tabMySectors.classList.add('active'); - tabNewSectors.classList.remove('active'); - renderSectorList(data.mySectors, 'my'); - }; - - tabNewSectors.onclick = () => { - tabNewSectors.classList.add('active'); - tabMySectors.classList.remove('active'); - renderSectorList(data.newSectors, 'new'); - }; - } catch (error) { - console.error("Netfwerkfehler beim Sektor-Abruf:", error); + console.error("Netzwerkfehler beim Sektor-Abruf:", error); } } @@ -463,7 +540,7 @@ function renderSectorList(sectors, type) { let bottomRowHtml = ''; if (type === 'my' && sector.local_username) { - bottomRowHtml = `
Commander: ${sector.local_username}
`; + bottomRowHtml = `
Spieler: ${sector.local_username}
`; } cardLink.innerHTML = ` @@ -484,19 +561,62 @@ function renderSectorList(sectors, type) { cardLink.onclick = (e) => { e.preventDefault(); - console.log(`Betrete Sektor: ${sector.name} (ID: ${sector.id})`); + console.log(`Starte Onboarding für Sektor: ${sector.name} (ID: ${sector.id})`); + + selectedSectorId = sector.id; + + const sectorBrowserLayer = document.getElementById('sector-browser-layer'); + if (sectorBrowserLayer) { + sectorBrowserLayer.style.opacity = 0.05; + sectorBrowserLayer.style.pointerEvents = 'none'; + } + + const onboardingHtmlLayer = document.getElementById('sector-onboarding-layer'); + if (onboardingHtmlLayer) onboardingHtmlLayer.style.display = 'flex'; + + if (onboardingPixiContainer) onboardingPixiContainer.visible = true; + + const localUsernameInput = document.getElementById('local-username'); + if (localUsernameInput) { + // Den Nutzer-String aus dem SessionStorage abrufen, den wir beim Login gespeichert haben + const userDataStr = sessionStorage.getItem('void_user') || localStorage.getItem('void_user'); + if (userDataStr) { + try { + // Den JSON-String wieder in ein nutzbares JavaScript-Objekt umwandeln + const userData = JSON.parse(userDataStr); + if (userData.username) { + // Wir tragen den Namen als echten Wert ein, damit das 'required' Attribut erfüllt ist + localUsernameInput.value = userData.username; + } + } catch (err) { + console.error("Fehler beim Auslesen der User-Daten für das Onboarding:", err); + } + } + } }; listContainer.appendChild(cardLink); const isMobilePortrait = window.innerWidth <= 800 && window.innerHeight > window.innerWidth; + // Im Hochformat haben wir 2 Spalten (durch unsere Media Query), sonst 4 const columns = isMobilePortrait ? 2 : 4; const cardWidth = 270; const cardHeight = 90; const gapX = 25; const gapY = 20; - const startX = isMobilePortrait ? 42 : 40; - const startY = 80 + 20; + + // Wir lesen die echte Breite des HTML-Containers ab (das zieht die Scrollbar automatisch ab!) + const listContainerDOM = document.getElementById('sector-list-container'); + const containerClientWidth = listContainerDOM ? listContainerDOM.clientWidth : (isMobilePortrait ? 650 : 1240); + + // Wir berechnen die Breite des GESAMTEN Grid-Blocks. + // Bei 4 Spalten: (4 * 270) + (3 * 25) = 1155px Gesamtbreite. + const gridBlockWidth = (columns * cardWidth) + ((columns - 1) * gapX); + + // Der Start-Punkt X ist exakt die verbleibende Restbreite geteilt durch 2. + // Das entspricht mathematisch 1:1 dem 'justify-content: center' aus dem CSS. + const startX = (containerClientWidth - gridBlockWidth) / 2; + const startY = 100; // 80px Top-Bar-Offset + 20px Padding oben const col = index % columns; const row = Math.floor(index / columns); @@ -504,7 +624,7 @@ function renderSectorList(sectors, type) { const posX = startX + col * (cardWidth + gapX); const posY = startY + row * (cardHeight + gapY); - // PixiJS-Hintergründe & Icons über unsere ausgelagerte Szene generieren lassen + // PixiJS-Hintergründe & Icons generieren lassen const cardRefs = createSectorCardPixi(globalSheet, sectorPixiContainer, posX, posY); cardLink.addEventListener('mouseenter', () => { @@ -562,4 +682,153 @@ initDropdowns(() => { if (passField) passField.placeholder = formState.isOtpMode || formState.isResetPasswordMode ? t('ph_otp') : t('ph_password'); }); +const btnGenderMale = document.getElementById('btn-gender-male'); +const btnGenderFemale = document.getElementById('btn-gender-female'); +const btnSubmitOnboarding = document.getElementById('btn-submit-onboarding'); +const onboardingForm = document.getElementById('onboarding-form'); +const btnCancelOnboarding = document.getElementById('btn-cancel-onboarding'); + +if (btnCancelOnboarding) { + btnCancelOnboarding.addEventListener('click', (e) => { + e.preventDefault(); + + // 1. Sektor-Browser HTML wieder voll sichtbar machen + const sectorBrowserLayer = document.getElementById('sector-browser-layer'); + if (sectorBrowserLayer) { + sectorBrowserLayer.style.opacity = '1.0'; + sectorBrowserLayer.style.pointerEvents = 'auto'; // Klicks wieder erlauben + } + + // 2. HTML-Layer des Modals ausblenden + const onboardingHtmlLayer = document.getElementById('sector-onboarding-layer'); + if (onboardingHtmlLayer) onboardingHtmlLayer.style.display = 'none'; + + // 3. PixiJS-Layer ausblenden + if (onboardingPixiContainer) onboardingPixiContainer.visible = false; + + // 4. Formular zurücksetzen (damit alte Eingaben verschwinden) + const onboardingForm = document.getElementById('onboarding-form'); + if (onboardingForm) onboardingForm.reset(); + + // Optional: Hier auch noch die visuelle 'active' Klasse von deinen + // Geschlechter-Buttons entfernen, falls nötig. + }); +} + +// --- Hilfsfunktion: Hover-Effekte synchronisieren --- +// Genau wie beim Login übertragen wir den Mauszeiger vom unsichtbaren HTML auf PixiJS +function syncHover(htmlElement, pixiRef, defaultTint, hoverTint = LOGIN_COLORS.TAB_HOVER) { + if (!htmlElement || !pixiRef) return; + + htmlElement.addEventListener('mouseenter', () => { + // Wenn der Button gerade "aktiv" ist (z.B. gewähltes Geschlecht), ignorieren wir den Hover + if (!htmlElement.classList.contains('active')) { + pixiRef.fill.tint = hoverTint; + pixiRef.fill.alpha = 0.8; + } + }); + + htmlElement.addEventListener('mouseleave', () => { + if (!htmlElement.classList.contains('active')) { + pixiRef.fill.tint = defaultTint; + pixiRef.fill.alpha = 0.6; // Zurück zur Standard-Transparenz + } + }); +} + +// Hover-Effekte auf die Buttons anwenden (falls onboardingPixiRefs schon existiert) +// Wir prüfen hier sicherheitshalber ab, da die Refs asynchron generiert werden +const applyHoverEffects = setInterval(() => { + if (onboardingPixiRefs) { + clearInterval(applyHoverEffects); // Sobald geladen, Interval stoppen + + // Geschlechter-Buttons (Standard-Farbe ist Inactive, Hover ist Weiß) + syncHover(btnGenderMale, onboardingPixiRefs.btnMale, LOGIN_COLORS.TAB_INACTIVE); + syncHover(btnGenderFemale, onboardingPixiRefs.btnFemale, LOGIN_COLORS.TAB_INACTIVE); + + // Aktions-Buttons (Standard-Farbe ist Default-Grün) + syncHover(btnCancelOnboarding, onboardingPixiRefs.btnCancel, LOGIN_COLORS.BTN_DEFAULT, LOGIN_COLORS.BTN_HOVER); + syncHover(btnSubmitOnboarding, onboardingPixiRefs.btnJoin, LOGIN_COLORS.BTN_DEFAULT, LOGIN_COLORS.BTN_HOVER); + } +}, 100); + +// --- Geschlecht Umschalten (Toggle-Logik) --- +if (btnGenderMale && btnGenderFemale) { + btnGenderMale.addEventListener('click', (e) => { + e.preventDefault(); + selectedOnboardingGender = 'male'; + + // HTML Klassen updaten (für Text-Farbe) + btnGenderMale.classList.add('active'); + btnGenderFemale.classList.remove('active'); + + // PixiJS Farben updaten + if (onboardingPixiRefs) { + onboardingPixiRefs.btnMale.fill.tint = LOGIN_COLORS.TAB_ACTIVE; + onboardingPixiRefs.btnMale.fill.alpha = 0.6; + + onboardingPixiRefs.btnFemale.fill.tint = LOGIN_COLORS.TAB_INACTIVE; + onboardingPixiRefs.btnFemale.fill.alpha = 0.4; + } + }); + + btnGenderFemale.addEventListener('click', (e) => { + e.preventDefault(); + selectedOnboardingGender = 'female'; + + // HTML Klassen updaten + btnGenderFemale.classList.add('active'); + btnGenderMale.classList.remove('active'); + + // PixiJS Farben updaten + if (onboardingPixiRefs) { + onboardingPixiRefs.btnFemale.fill.tint = LOGIN_COLORS.TAB_ACTIVE; + onboardingPixiRefs.btnFemale.fill.alpha = 0.6; + + onboardingPixiRefs.btnMale.fill.tint = LOGIN_COLORS.TAB_INACTIVE; + onboardingPixiRefs.btnMale.fill.alpha = 0.4; + } + }); +} + +// --- Formular Absenden (Beitreten) --- +if (onboardingForm) { + onboardingForm.addEventListener('submit', async (e) => { + e.preventDefault(); // Verhindert das Neuladen der Seite + + if (!selectedSectorId) return; + + const localUsername = document.getElementById('local-username').value; + const msgBox = document.getElementById('onboarding-msg-box'); + + msgBox.textContent = '...'; + msgBox.style.color = 'var(--color-text-main)'; + + const token = sessionStorage.getItem('void_access_token') || localStorage.getItem('void_access_token'); + + try { + + const result = await joinSectorUser(token, selectedSectorId, localUsername, selectedOnboardingGender); + + if (!result.ok) { + msgBox.style.color = 'var(--color-error)'; + msgBox.textContent = t(result.data.error) || t('ERR_INTERNAL_SERVER'); + return; + } + + msgBox.style.color = 'var(--color-green-bright)'; + msgBox.textContent = t(result.data.message); + + setTimeout(() => { + if (btnCancelOnboarding) btnCancelOnboarding.click(); + + loadAndRenderSectorBrowser(); + }, 1500); + + } catch (error) { + msgBox.style.color = 'var(--color-error)'; + msgBox.textContent = t('ERR_INTERNAL_SERVER'); + } + }); +} initGame(); \ No newline at end of file diff --git a/dev/lobby-frontend/src/raw-assets/ui/SPR_SciFiMenus_Menu_Item_Background_05.png b/dev/lobby-frontend/src/raw-assets/ui/SPR_SciFiMenus_Menu_Item_Background_05.png new file mode 100644 index 0000000..929a679 Binary files /dev/null and b/dev/lobby-frontend/src/raw-assets/ui/SPR_SciFiMenus_Menu_Item_Background_05.png differ diff --git a/dev/lobby-frontend/src/scenes/lobby-scene.js b/dev/lobby-frontend/src/scenes/lobby-scene.js index e8b877a..38c96bb 100644 --- a/dev/lobby-frontend/src/scenes/lobby-scene.js +++ b/dev/lobby-frontend/src/scenes/lobby-scene.js @@ -65,7 +65,7 @@ export function createSectorTabPixi(sheet, x, y, tint, alpha) { frame.alpha = 0.8; itemContainer.addChild(frame); - return itemContainer; + return { container: itemContainer, fill, frame }; } /** @@ -128,4 +128,126 @@ export function createSectorCardPixi(sheet, sectorPixiContainer, posX, posY) { sectorPixiContainer.addChild(iconPlanets); return { bgSprite, frameSprite, iconDetails, iconPlayers, iconPlanets, iconAge }; +} + +/** + * Baut die PixiJS-Elemente für das Sektor-Onboarding-Modal (Charaktererstellung) + * + * @param {Container} parentContainer - Die Hauptebene (app.stage, auf die das Modal gelegt wird + * @param {Object} sheet - Das globale Sprite-Sheet für die Texturen + * + * @returns {Container} - Die Referenz auf den fertigen Pixi Container + */ +export function buildOnboardingModalPixi(parentContainer, sheet) { + const modalContainer = new Container(); + modalContainer.visible = false; + + const darkOverlay = new Graphics(); + darkOverlay.rect(-3000, -3000, 6000, 6000); + darkOverlay.fill(0x000000); + darkOverlay.alpha = 0.85; + darkOverlay.eventMode = 'static'; + modalContainer.addChild(darkOverlay); + + function createPixiUIItem(x, y, width, height, fillTint, fillAlpha, isBtn) { + const itemContainer = new Container(); + itemContainer.position.set(x, y); + + const fill = new NineSliceSprite({ + texture: sheet.textures['SPR_SciFiMenus_Menu_Item_Background_07'], + leftWidth: 120, + topHeight: 120, + rightWidth: 120, + bottomHeight: 120 + }); + fill.width = width; + fill.height = height; + fill.tint = fillTint; + fill.alpha = fillAlpha; + itemContainer.addChild(fill); + + let frame = null; + if (isBtn) { + frame = new NineSliceSprite({ + texture: sheet.textures['SPR_SciFiMenus_Menu_Item_Selected_10'], + leftWidth: 100, + topHeight: 120, + rightWidth: 140, + bottomHeight: 120 + }); + } else { + frame = new NineSliceSprite({ + texture: sheet.textures['SPR_SciFiMenus_Menu_Item_Selected_19'], + leftWidth: 100, + topHeight: 120, + rightWidth: 140, + bottomHeight: 120 + }); + } + + frame.width = width; + frame.height = height; + frame.alpha = 0.8; + itemContainer.addChild(frame); + + modalContainer.addChild(itemContainer); + return { container: itemContainer, fill, frame }; + } + + const modalBG = new NineSliceSprite({ + texture: sheet.textures['SPR_SciFiMenus_Frame_Box_Large_12_Background'], + leftWidth: 200, + topHeight: 200, + rightWidth: 200, + bottomHeight: 200 + }); + modalBG.width = 450; + modalBG.height = 550; + modalBG.tint = LOGIN_COLORS.PANEL_BG; + modalBG.alpha = 1.0; + modalContainer.addChild(modalBG); + + const modalFrame = new NineSliceSprite({ + texture: sheet.textures['SPR_SciFiMenus_Frame_Box_Large_12'], + leftWidth: 512, + topHeight: 256, + rightWidth: 512, + bottomHeight: 256 + }); + modalFrame.width = 450; + modalFrame.height = 550; + modalContainer.addChild(modalFrame); + + const avatarBox = new NineSliceSprite({ + texture: sheet.textures['SPR_SciFiMenus_Menu_Item_Background_05'], + leftWidth: 50, + topHeight: 50, + rightWidth: 50, + bottomHeight: 50 + }); + avatarBox.width = 150; + avatarBox.height = 150; + avatarBox.position.set(150, 60); + avatarBox.tint = 0x00f0ff; + avatarBox.alpha = 0.2; + modalContainer.addChild(avatarBox); + + const iconAvatar = new Sprite(sheet.textures['ICON_SciFiMenus_Menu_Solo_01_Clean']); + iconAvatar.anchor.set(0.5); + iconAvatar.position.set(225, 132.5); + iconAvatar.scale.set(0.7); + iconAvatar.tint = 0xaaaaaa; + modalContainer.addChild(iconAvatar); + + const inputName = createPixiUIItem(75, 250, 300, 50, LOGIN_COLORS.PANEL_BG, 0.8, false); + const btnMale = createPixiUIItem(75, 320, 140, 50, LOGIN_COLORS.TAB_ACTIVE, 0.6, true); + const btnFemale = createPixiUIItem(235, 320, 140, 50, LOGIN_COLORS.TAB_INACTIVE, 0.4, true); + const btnCancel = createPixiUIItem(75, 460, 140, 50, LOGIN_COLORS.BTN_DEFAULT, 0.6, true); + const btnJoin = createPixiUIItem(235, 460, 140, 50, LOGIN_COLORS.BTN_DEFAULT, 0.6, true); + + parentContainer.addChild(modalContainer); + return { + container: modalContainer, + inputName, btnMale, btnFemale, btnCancel, btnJoin + }; } \ No newline at end of file diff --git a/dev/lobby-frontend/style.css b/dev/lobby-frontend/style.css index 30f1a49..8796fc6 100644 --- a/dev/lobby-frontend/style.css +++ b/dev/lobby-frontend/style.css @@ -2,7 +2,7 @@ @import url('./src/css/layout.css'); @import url('./src/css/auth.css'); @import url('./src/css/lobby.css'); - +@import url('./src/css/onboarding.css'); @media (max-width: 600px) { /* --- 1. Login/Registrierungs-Formular skalieren --- */ @@ -95,8 +95,9 @@ } } -@media (max-width: 800px)and (orientation: portrait) { +@media (max-width: 800px) and (orientation: portrait) { #sector-list-container { + /* Erzwingt 2 Spalten auf dem Handy */ grid-template-columns: repeat(2, 270px) !important; justify-content: center !important; }