BaseFrontendBackend

This commit is contained in:
2026-05-26 20:18:12 +02:00
commit 7fadab8cd6
49 changed files with 6186 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
# ==============================================================================
# Void-Genesis - Lobby Dev-Umgebung
# ==============================================================================
services:
# ----------------------------------------------------------------------------
# 1. Lobby-Datenbank (PostgreSQL)
# ----------------------------------------------------------------------------
lobby-db:
image: postgres:16-alpine
container_name: void_lobby_db
restart: unless-stopped
# Hier übergeben wir die Zugangsdaten aus der .env Datei an den Container
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USER: ${POSTGRES_USER}
# Da der Container standardmäßig nach POSTGRES_PASSWORD sucht, leiten wir
# unser spezifisches User-Passwort für die Initialisierung hier weiter.
PGPASSWORD: ${POSTGRES_PASSWORD_LOBBY}
POSTGRES_DB: ${POSTGRES_DB}
# Wir binden den Port nur an Localhost. So kann man per SSH-Tunnel
# (z.B. mit DBeaver/PgAdmin) sicher darauf zugreifen.
ports:
- "${PORT_DB}:5432"
# Wir mounten den lokalen Ordner in den Container. Wenn der Container
# gelöscht wird, bleiben unsere Tabellen und Nutzerdaten erhalten.
volumes:
- ./data/postgres:/var/lib/postgresql/data
# Verbindet die Datenbank mit unserem internen Projekt-Netzwerk
networks:
- void_dev_network
# Ein Healthcheck stellt sicher, dass die DB wirklich Anfragen annehmen kann,
# bevor das Backend startet.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
# ----------------------------------------------------------------------------
# 2. Lobby-Backend (Node.js)
# ----------------------------------------------------------------------------
lobby-backend:
# Wir bauen das Image dynamisch aus unserem Unterordner
build:
context: ./lobby-backend
dockerfile: Dockerfile.dev
container_name: void_lobby_backend
restart: unless-stopped
# Das Backend darf erst starten, wenn der Healthcheck der Datenbank 'healthy' ist.
depends_on:
lobby-db:
condition: service_healthy
# Wir mounten den lokalen Ordner direkt in das Arbeitsverzeichnis des Containers.
# Dadurch greifen Code-Änderungen sofort (Hot-Reload via Nodemon).
volumes:
- ./lobby-backend:/app
# Wir schließen node_modules vom lokalen Mount aus, damit der Container
# seine eigenen, nativen Linux-Binaries behält.
- /app/node_modules
ports:
- "${PORT_BACKEND}:3000"
# Übergabe der DB-Daten an Node.js, um die Connection-String zu bauen
environment:
- DB_HOST=lobby-db
- DB_PORT=5432
- DB_USER=${POSTGRES_USER}
- DB_PASS=${POSTGRES_PASSWORD}
- DB_NAME=${POSTGRES_DB}
- NODE_ENV=development
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- BREVO_API_KEY=${BREVO_API_KEY}
- BREVO_SENDER_EMAIL=${BREVO_SENDER_EMAIL}
- BREVO_SENDER_NAME=${BREVO_SENDER_NAME}
- MAIL_MODE=${MAIL_MODE}
networks:
- void_dev_network
- npm_network
# ----------------------------------------------------------------------------
# 3. Dev-Frontend (Vite + PixiJS)
# ----------------------------------------------------------------------------
lobby-frontend:
build:
context: ./lobby-frontend
dockerfile: Dockerfile.dev
container_name: void_lobby_frontend
restart: unless-stopped
# Auch hier mounten wir den Code für Vite's HMR (Hot Module Replacement)
volumes:
- ./lobby-frontend:/app
- /app/node_modules
# Wir exposen den Vite-Standardport. Nginx Proxy Manager (NPM) kann dann
# dev.void-genesis.de auf diesen lokalen Port routen.
ports:
- "${PORT_FRONTEND}:5173"
environment:
- NODE_ENV=development
# Vite benötigt diese Einstellung oft in Docker, um File-Changes zu bemerken
- CHOKIDAR_USEPOLLING=true
networks:
- void_dev_network
- npm_network
# ------------------------------------------------------------------------------
# Netzwerke
# ------------------------------------------------------------------------------
networks:
void_dev_network:
driver: bridge
# Wir binden das externe NPM-Netzwerk ein
npm_network:
external: true
name: nginx-proxy-manager_default
+32
View File
@@ -0,0 +1,32 @@
# ==============================================================================
# Void-Genesis - Lobby Backend (Dev Environment)
# ==============================================================================
# Wir nutzen eine leichtgewichtige Alpine-Version der aktuellen Node.js LTS (z.B. 22)
# Alpine-Images sind extrem klein und reduzieren die Angriffsfläche.
FROM node:22-alpine
# Setze das Arbeitsverzeichnis innerhalb des Containers auf /app.
# Alle folgenden Befehle (RUN, CMD, COPY) werden in diesem Ordner ausgeführt.
WORKDIR /app
# Wir kopieren zuerst NUR die package.json (und package-lock.json, falls vorhanden).
# Das ist ein Best-Practice für Docker: Docker speichert diesen Schritt im Cache.
# 'npm install' wird beim erneuten Bauen nur dann ausgeführt, wenn sich an den
# Abhängigkeiten in der package.json etwas geändert hat. Das spart enorm Zeit!
COPY package*.json ./
# Installiere alle Abhängigkeiten (inklusive Dev-Dependencies wie nodemon)
RUN npm install
# HINWEIS: Den restlichen Quellcode (src/) kopieren wir hier im Dev-Dockerfile
# absichtlich nicht mit COPY. Der Code wird stattdessen über die docker-compose.yml
# als Volume gemountet. So greifen deine Änderungen am Root-Server sofort im Container.
# Wir dokumentieren hier, dass der Container auf Port 3000 lauscht.
# Das Backend wird diesen Port später nutzen.
EXPOSE 3000
# Der Standardbefehl, wenn der Container startet. Wir rufen das Script "dev" auf,
# welches wir gleich im nächsten Schritt in der package.json definieren werden.
CMD ["npm", "run", "dev"]
+1618
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "void-genesis-lobby-backend",
"version": "1.0.0",
"description": "Backend für die Master-Account-Lobby",
"main": "src/server.js",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js"
},
"dependencies": {
"argon2": "^0.44.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.3",
"pg": "^8.11.5"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
@@ -0,0 +1,201 @@
import * as authService from '../services/auth.service.js';
// Ein Standard-Regulärer-Ausdruck (Regex) zur Prüfung eines gültigen E-Mail-Formats
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Controller-Methode für die Registierung-Route.
*/
export const register = async (req, res) => {
try {
// Daten aus dem Body extrahieren
const { username, email, password } = req.body;
// Basis-Validierung (leere Felder, Passwortlänge)
if (!username || !email || !password) {
return res.status(400).json({
// ERR_MISSING_FIELDS: Bitte alle Felder ausfüllen.
error: 'ERR_MISSING_FIELDS'
});
}
if (password.length < 8) {
return res.status(400).json({
// ERR_PASSWORD_TOO_SHORT: Das Passwort muss mindestens 8 Zeichen lang sein.
error: 'ERR_PASSWORD_TOO_SHORT'
});
}
if (username.length < 3) {
return res.status(400).json({
// ERR_USERNAME_TOO_SHORT: Der Benutzername muss mindestens 3 Zeichen lang sein.
error: 'ERR_USERNAME_TOO_SHORT'
});
}
if (!EMAIL_REGEX.test(email)) {
return res.status(400).json({
/// ERR_INVALID_EMAIL: Das E-Mail-Format ist ungültig.
error: 'ERR_INVALID_EMAIL'
});
}
// Den Auth-Service aufrufen (die eigenliche Arbeit)
const result = await authService.registerUser(username, email, password);
// Erfolgreiche Antwort an der Frontend senden
return res.status(201).json({
success: true,
// MSG_REGISTER_SUCCESS: Registrierung erfolgreich. Bitte prüfe deine E-Mails.
message: 'MSG_REGISTER_SUCCESS',
accountId: result.accountId
});
} catch (error) {
// Fehlerbehandlung: Wenn es unser eigener Conflict-Fehler ist (zB Email existiert)
if (error.code === 'ERR_USERNAME_TAKEN') {
return res.status(409).json({
// ERR_USERNAME_TAKEN: Dieser Benutzername ist bereits vergeben.
error: 'ERR_USERNAME_TAKEN'
});
}
if (error.code === 'ERR_EMAIL_TAKEN') {
return res.status(409).json({
// ERR_USERNAME_TAKEN: Diese E-Mail-Addresse ist bereits vergeben.
error: 'ERR_EMAIL_TAKEN'
});
}
// Bei unerwarteten Fehlern (z.B. Datenbank oder Brovo nicht erreichbar.)
console.error('[AuthController] Fehler bei der Registierung:', error);
return res.status(500).json({
// ERR_INTERNAL_SERVER: Ein interner Serverfehler ist aufgetreten.
error: 'ERR_INTERNAL_SERVER'
});
}
};
/**
* Controller-Methode für die E-Mail-Verifizierungs-Route
*/
export const verifyEmail = async (req, res) => {
try {
// Daten aus dem Request-Body extrahieren
const { email , otpCode } = req.body;
// Vasis-Validierung
if (!email || !otpCode) {
return res.status(400).json({
error: 'ERR_MISSING_FIELDS'
});
}
// Den Service aufrufen, um die eigenliche Arbeit zu machen
await authService.verifyEmail(email, otpCode);
// Erfolgreiche Antwort ans Frontend senden
return res.status(200).json({
success: true,
message: 'MSG_EMAIL_VERIFIED'
});
} catch (error) {
if (['ERR_USER_NOT_FOUND', 'ERR_ALREADY_VERIFIED', 'ERR_INVALID_OTP', 'ERR_OTP_EXPIRED'].includes(error.code)) {
return res.status(400).json({
error: error.code
});
}
console.error('[AuthController] Fehler bei E-Mail-Verifizierung:', error);
return res.status(500).json({
error: 'ERR_INTERNAL_SERVER'
});
}
};
/**
* Controller-Methode für die Login-Route
*/
export const login = async (req, res) => {
try {
const { identifier, password } = req.body;
// Basis-Validierung
if (!identifier || !password) {
return res.status(400).json({
error: 'ERR_MISSING_FIELDS'
});
}
// Service aufrufen
const result = await authService.loginUser(identifier, password);
// Erfolgreiche Antwort mit den Tokens
return res.status(200).json({
success: true,
// MSG_LOGIN_SUCCESS: Erfolgreich eingeloggt
message: 'MSG_LOGIN_SUCCESS',
accessToken: result.accessToken,
refreshToken: result.refreshToken,
user: result.user
});
} catch (error) {
if (error.code === 'ERR_INVALID_CREDENTIALS') {
return res.status(401).json({
error: 'ERR_INVALID_CREDENTIALS'
});
}
if (error.code === 'ERR_EMAIL_NOT_VERIFIED') {
return res.status(403).json({
error: 'ERR_EMAIL_NOT_VERIFIED'
});
}
console.error('[AuthController] Fehler beim Login:', error);
return res.status(500).json({
error: 'ERR_INTERNAL_SERVER'
});
}
}
/**
* Controller-Methode für die Token-Refresh-Route
*/
export const refresh = async (req, res) => {
try {
// Das Refresh-Token aus dem Request-Body holen
const { refreshToken } = req.body;
// Validierung: Is überhaupt ein Token mitgesendet worden ?
if (!refreshToken) {
// ERR_MISSING_TOKEN: Es wurde kein Refresh-Token übergeben
return res.status(400).json({
error: 'ERR_MISSING_TOKEN'
});
}
// Den Service aufrufen, um das Token zu prüfen und ein neues zu generieren
const result = await authService.refreshAccessToken(refreshToken);
// Das neue Access-Token an das Frontend zurück geben
return res.status(200).json({
success: true,
accessToken: result.accessToken
});
} catch (error) {
// Ungültige, abgelaufene oder in der DB gelöschte Tokens abfangen
if (error.code === 'ERR_UNAUTHORIZED') {
return res.status(401).json({
// ERR_UNAUTHORIZED: Zugriff verweigert. Die Sitzung ist ungültig.
error: 'ERR_UNAUTHORIZED'
});
}
// Bei unerwarteten Fehlern (z.B. Datenbank oder Brovo nicht erreichbar.)
console.error('[AuthController] Fehler beim Token-Refresh:', error);
return res.status(500).json({
// ERR_INTERNAL_SERVER: Ein interner Serverfehler ist aufgetreten.
error: 'ERR_INTERNAL_SERVER'
});
}
}
+36
View File
@@ -0,0 +1,36 @@
// ==============================================================================
// Void-Genesis - Datenbank-Konfiguration
// ==============================================================================
import pg from 'pg';
// Wir nutzen einen Connection Pool. Das ist deutlich effizienter als für jede
// Anfrage eine neue Verbindung zur Datenbank aufzubauen. Der Pool hält im
// Hintergrund mehrere Verbindungen offen und verteilt sie dynamisch.
const { Pool } = pg;
const pool = new Pool({
// Diese Variablen kommen direkt aus unserer docker-compose.yml
// (Bereich 'environment' des Backend-Containers)
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
});
// Wir testen beim Start einmalig, ob die Verbindung klappt.
// So sehen wir im Docker-Log sofort, falls z.B. das Passwort falsch ist.
pool.connect((err, client, release) => {
if (err) {
console.error('❌ Fehler beim Verbinden mit der PostgreSQL-Datenbank:', err.stack);
} else {
console.log('✅ Erfolgreich mit der PostgreSQL-Datenbank (void_lobby) verbunden!');
// WICHTIG: Den Client immer direkt wieder freigeben, wenn wir fertig sind
release();
}
});
// Wir exportieren den Pool, damit wir ihn in anderen Dateien (wie unseren Routes)
// für Datenbankabfragen nutzen können.
export default pool;
@@ -0,0 +1,11 @@
import express from 'express';
import * as authController from '../controllers/auth.controller.js';
const router = express.Router();
router.post('/register', authController.register);
router.post('/verify-email', authController.verifyEmail);
router.post('/login', authController.login);
router.post('/refresh', authController.refresh);
export default router;
+109
View File
@@ -0,0 +1,109 @@
import pool from '../db.js';
// ==============================================================================
// Void-Genesis - Datenbank Initialisierungs-Skript
// Führt alle nötigen CREATE TABLE Befehle aus, falls die Tabellen nicht existieren.
// ==============================================================================
const createTablesQuery = `
DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS auth_otps;
DROP TABLE IF EXISTS lobby_accounts;
-- ------------------------------------------------------------------------------
-- TABLE: lobby_accounts
-- Base user data for the entire Void-Genesis universe.
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS lobby_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
-- Status flags and balances (English naming)
is_email_verified BOOLEAN DEFAULT false,
res_premium INT DEFAULT 0,
is_admin BOOLEAN DEFAULT false,
-- Timestamps for record keeping
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ------------------------------------------------------------------------------
-- TABLE: auth_otps
-- Stores temporary 6-digit codes for registration or password resets.
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS auth_otps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID REFERENCES lobby_accounts(id) ON DELETE CASCADE,
otp_code VARCHAR(6) NOT NULL,
-- Defines the purpose of the OTP (e.g., 'register', 'password_reset')
type VARCHAR(20) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ------------------------------------------------------------------------------
-- TABLE: refresh_tokens
-- Stores long-lived tokens for the "Keep me logged in" and single-session feature.
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID REFERENCES lobby_accounts(id) ON DELETE CASCADE,
token VARCHAR(500) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ------------------------------------------------------------------------------
-- TRIGGER: Auto-update 'updated_at' timestamp
-- ------------------------------------------------------------------------------
-- This function sets the 'updated_at' field to the current time whenever a row is modified.
CREATE OR REPLACE FUNCTION update_modified_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- We only create the trigger if it doesn't exist to avoid errors on multiple runs.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_lobby_accounts_modtime') THEN
CREATE TRIGGER update_lobby_accounts_modtime
BEFORE UPDATE ON lobby_accounts
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();
END IF;
END $$;
`;
// ==============================================================================
// Ausführungs-Logik
// ==============================================================================
async function initDatabase() {
console.log('⏳ Starte Datenbank-Initialisierung...');
// Wir holen uns eine Verbindung aus dem Pool
const client = await pool.connect();
try {
// Wir senden den kompletten SQL-String an die Datenbank
await client.query(createTablesQuery);
console.log('✅ Tabellen wurden erfolgreich erstellt (oder existierten bereits).');
} catch (error) {
console.error('❌ Fehler beim Erstellen der Tabellen:', error);
} finally {
// Ganz wichtig: Verbindung wieder freigeben
client.release();
// Den Node-Prozess beenden, damit das Skript nicht in der Konsole hängen bleibt
process.exit();
}
}
// Skript direkt aufrufen
initDatabase();
+53
View File
@@ -0,0 +1,53 @@
// ==============================================================================
// Void-Genesis - Lobby Backend Server (Einstiegspunkt)
// ==============================================================================
import express from 'express';
import cors from 'cors';
import pool from './db.js';
import authRoutes from './routes/auth.routes.js';
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
app.use('/api/auth', authRoutes);
// Unsere bestehende Health-Route
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
message: 'Void-Genesis Lobby Backend läuft!',
timestamp: new Date().toISOString()
});
});
// Eine Test-Route für die Datenbank
app.get('/api/db-test', async (req, res) => {
try {
const result = await pool.query('SELECT NOW()');
res.json({
status: 'success',
message: 'Datenbankverbindung steht!',
db_time: result.rows[0].now
});
} catch (err) {
console.error('Fehler bei der DB-Abfrage:', err);
res.status(500).json({
status: 'error',
message: 'Datenbankfehler',
error: err.message
});
}
});
// Server starten
app.listen(PORT, () => {
console.log(`🚀 Void-Genesis Lobby Backend gestartet auf Port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV}`);
});
@@ -0,0 +1,350 @@
import argon2 from 'argon2';
import jwt from 'jsonwebtoken';
import pool from '../db.js';
import { sendEmail } from './email.service.js';
/**
* Generiert einen zufälligen 6-stelligen Code als String
*/
const generateOTP = () => {
return Math.floor(100000 + Math.random() * 900000).toString();
};
/**
* Registriert einen neuen Nutzer, speichert das OTP und löst den E-Mail-Versand aus.
*
* @param {string} username - Der gewünschte Benutzername
* @param {string} email - Die E-Mail-Addresse
* @param {string} password - Das Klartext-Passwort
* @returns {object} - Die accountId des neuen Nutzers
*/
export const registerUser = async (username, email, password) => {
// Wir holen uns einen dedizierten Client aus dem pool für unsere Transaktion
const client = await pool.connect();
const lowerCaseEmail = email.toLowerCase();
try {
// Transaktion starten: Entweder alles klappt, oder nichts wird gespeichert
await client.query('BEGIN');
// Prüfen, ob der Nutzer oder die E-Mail schon existiert
const checkResult = await client.query(`
SELECT username, email FROM lobby_accounts
WHERE LOWER(username) = LOWER($1) OR email = $2`,
[username, lowerCaseEmail]
);
if (checkResult.rows.length > 0) {
const existingRecord = checkResult.rows[0];
if (existingRecord.email === lowerCaseEmail) {
const error = new Error('Email already exists');
error.code = 'ERR_EMAIL_TAKEN';
throw error;
} else {
const error = new Error('Username already exists');
error.code = 'ERR_USERNAME_TAKEN';
throw error;
}
}
// Passwort sicher hashen
const passwordHash = await argon2.hash(password);
// Nutzer in de Datenbank eintragen
const insertAccount = await client.query(`
INSERT INTO lobby_accounts (username, email, password_hash)
VALUES ($1, $2, $3) RETURNING id`,
[username, lowerCaseEmail, passwordHash]
);
const accountId = insertAccount.rows[0].id;
// OTP generieren uns in der auth_otps Tabelle speichern (15min Gültigkeit)
const otpCode = generateOTP();
await client.query(`
INSERT INTO auth_otps (account_id, otp_code, type, expires_at)
VALUES ($1, $2, 'register', NOW() + INTERVAL '15 minutes')`,
[accountId, otpCode]
);
// Registierungs E-Mail über unseren E-Mail Service versenden
const mailHtml = `
<h2>Willkommen bei Void-Genesis, ${username}!</h2>
<p>Dein Verifizierungscode lautet:</p>
<h1 style="color: #00f0ff; letter-spacing: 5px;">${otpCode}</h1>
<p>Dieser Code ist 15 Minuten lang gültig.</p>
`;
await sendEmail(lowerCaseEmail, username, 'Void-Genesis - Aktiviere deinen Account', mailHtml);
// Wenn wir hier ankommen, lief alles fehlerfrei, wir committen die Daten.
await client.query('COMMIT');
return { accountId };
} catch (error) {
// bei JEDEM Fehler machen wir einen Rollback. Die Datenbank bleibt sauber.
await client.query('ROLLBACK');
if (error.code === '23505') {
if (error.constraint === 'lobby_accounts_username_key') {
const customErr = new Error('Username already exists');
customErr.code = 'ERR_USERNAME_TAKEN';
throw customErr;
}
if (error.constraint === 'lobby_accounts_email_key') {
const customErr = new Error('Email already exists');
customErr.code = 'ERR_EMAIL_TAKEN';
throw customErr;
}
}
throw error;
} finally {
client.release();
}
}
/**
* Verifiziert die E-Mail-Adresse eines Nutzers
*
* @param {string} email - Die E-Mail-Addresse des Nutzers
* @param {string} otpCode - Der 6-stellige Code aus der E-Mail
* @returns {boolean} - true wenn erfolgreich
*/
export const verifyEmail = async (email, otpCode) => {
const client = await pool.connect();
const lowerCaseEmail = email.toLowerCase();
try {
await client.query('BEGIN');
// Nutzer anhand der E-Mail suchen un die ID sowie Verifizierungsstatus abfragen
const accountResult = await client.query(
'SELECT id, is_email_verified FROM lobby_accounts WHERE email = $1',
[lowerCaseEmail]
);
if (accountResult.rows.length === 0) {
// ERR_USER_NOT_FOUND: Es existiert kein Account mit dieser E-Mail
const error = new Error('User not found');
error.code = 'ERR_USER_NOT_FOUND';
throw error;
}
const account = accountResult.rows[0];
// Prüfen, ob der Nutzer nicht ohnehin schon verifiziert ist
if (account.is_email_verified) {
// ERR_ALREADY_VERIFIED: Diese E-Mail-Addresse wurde bereits bestätigt
const error = new Error('Already verified');
error.code = 'ERR_ALREADY_VERIFIED';
throw error;
}
// Den passenden OTP-Code in der Datenbank suchen
const otpResult = await client.query(`
SELECT id, expires_at FROM auth_otps
WHERE account_id = $1 AND otp_code = $2 AND type = 'register'`,
[account.id, otpCode]
);
if (otpResult.rows.length === 0) {
// ERR_INVALID_OTP: Der eingegebene Code ist falsch.
const error = new Error('Invalid OTP');
error.code = 'ERR_INVALID_OTP';
throw error;
}
const otpRecord = otpResult.rows[0];
// Prüfen, ob der Cide bereits abgelaufen ist (älter als 15 Minuten)
if (new Date() > new Date(otpRecord.expires_at)) {
// ERR_OTP_EXPIRED: Der Code ist abgelaufen. Bitte fordere einen neuen an.
const error = new Error('OTP expired');
error.code = 'ERR_OTP_EXPIRED';
throw error;
}
// Account als verifiziert markieren
await client.query(
'UPDATE lobby_accounts SET is_email_verified = true WHERE id = $1',
[account.id]
);
// Den genutzten OTP-Code aus der Datenbank löschen (Sicherheit und Sauberkeit)
await client.query(
'DELETE FROM auth_otps WHERE id = $1',
[otpRecord.id]
);
// Alles lief perfekt, Änderungen speichern
await client.query('COMMIT');
return true;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
/**
* Loggt einen Nutzer ein, verifiziert das Passwort uns erstellt JWT-Tokens
*
* @param {string} identifier - Kann der Benutzername oder die E-Mail-Address sein
* @param {string} password - Das eingegebene Klartext-Passwort
* @returns {object} - Enthält accessToken, refreshToken und grundlegende Nutzerdaten
*/
export const loginUser = async (identifier, password) => {
const client = await pool.connect();
try {
// Nutzer suchen
const accountResult = await client.query(`
SELECT id, username, email, password_hash, is_email_verified, is_admin
FROM lobby_accounts
WHERE LOWER(username) = LOWER($1) OR email = LOWER($1)`,
[identifier]
);
// Wenn kein Nutzer gefunden wurde
if (accountResult.rows.length === 0) {
// ERR_INVALID_CREDENTIALS: Benutzername/E-Mail oder Passwort falsch
const error = new Error('Invalid credentials');
error.code = 'ERR_INVALID_CREDENTIALS';
throw error;
}
const account = accountResult.rows[0];
// Prüfen, ob die Email bereits bestätigt wurde
if (!account.is_email_verified) {
// 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';
throw error;
}
// Passwort vergleichen (argon2)
const isPasswordValid = await argon2.verify(account.password_hash, password);
if (!isPasswordValid) {
const error = new Error('Invalied credentials');
error.code = 'ERR_INVALID_CREDENTIALS';
throw error;
}
// JWT-Tokens geneieren
// Das Access-Token speichert die wichtigsten Infos für schnelle Abfragen
const tokenPayload = {
accountId: account.id,
username: account.username,
isAdmin: account.is_admin
};
const accessToken = jwt.sign(
tokenPayload,
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ accountId: account.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '30d' }
);
// Refresh-Token in der Datenbank speichern (Vorbereitung für das Single-Session System)
await client.query(`
INSERT INTO refresh_tokens (account_id, token, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '30 days')`,
[account.id, refreshToken]
);
// Erfolgreiche Rückgabe der Daten
return {
accessToken,
refreshToken,
user: {
id: account.id,
username: account.username
}
};
} finally {
client.release();
}
};
/**
* Prüft ein Refresh-Token und generiert ein neues, aktuelles Access-Token
*
* @param {string} refreshToken - Das langlebige Refresh-Token des Nutzers.
* @returns {object} - Enthält das neue accessToken
*/
export const refreshAccessToken = async (refreshToken) => {
const client = await pool.connect();
try {
// JWT-Signatur und Ablaufdatum des Refresh-Tokens kryptografisch verifizieren
// Wenn das Token manipuliert oder abgelaufen ist, wirft jwt.verify sofort einen Fehler.
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const { accountId } = payload;
// Sicherheits-Check: Existiert diese Token noch in unserer Datenbank?
// Wenn sich ein zweites Gerät eingeloggt hat, wurden die altenm Tokens gelöscht.
// Dieser Check schlägt dasnn fehl und blockiert den unerlaubten Zugriff!
const tokenResult = await client.query(
'SELECT id FROM refresh_tokens WHERE account_id = $1 AND token = $2',
[accountId, refreshToken]
);
if (tokenResult.rows.length === 0) {
const error = new Error('Refresh token was revoked or does not exist');
error.code = 'ERR_UNAUTHORIZED';
throw error;
}
// Aktuelle Benutzerdaten frosch aus der Datenbank abfragen
// 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',
[accountId]
);
if (accountResult.rows.length === 0) {
const error = new Error('Account no longer exists');
error.code = 'ERR_UNAUTHORIZED';
throw error;
}
const account = accountResult.rows[0];
// Neues kurzlebiges Access-Token erstellen (wieder für 15 min)
const tokenPayload = {
accountId: account.id,
username: account.username,
isAdmin: account.is_admin
};
const accessToken = jwt.sign(
tokenPayload,
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
return { accessToken };
} catch (error) {
// Abfangen von JWT-spezifischen Fehlern (z.B. Token abgelaufen oder Signatur falsch)
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError' || error.name === 'SyntaxError') {
const customErr = new Error('Invalid, expired or malformed token');
customErr.code = 'ERR_UNAUTHORIZED';
throw customErr;
}
// Alle anderen Fehhler (zB Datenbank Fehler) weiterwerfen
throw error;
} finally {
client.release();
}
}
@@ -0,0 +1,55 @@
/**
* Sendet eine E-Mail über die Brevo REST API.
*
* @param {string} toEmail - Die Ziel E-Mail Adresse
* @param {string} toName - Der Benutzername des Empfängers
* @param {string} subject - Der Betreff der E-Mail
* @param {string} htmlContent - Der HTML-Inhalt der E-Mail
* @returns {boolean} - true wenn erfolgreich, wirft einen Fehler wenn nicht.
*/
export const sendEmail = async (toEmail, toName, subject, htmlContent) => {
const mailMode = process.env.MAIL_MODE || 'development';
// Im Development-Modus loggen wir nur, um das API-Limit nicht zu belasten
if (mailMode === 'development') {
console.log(`\n[DEV-MAIL-SIMULATION] An: ${toEmail}`);
console.log(`[DEV-MAIL-SIMULATION] Betreff: ${subject}`);
console.log(`[DEV-MAIL-SIMULATION] Inhalt:\n${htmlContent}\n`);
return true;
}
// Echter Versand über Brevo
try {
const response = await fetch('https://api.brevo.com/v3/smtp/email', {
method: 'POST',
headers: {
'accept': 'application/json',
'api-key': process.env.BREVO_API_KEY,
'content-type': 'application/json'
},
body: JSON.stringify({
sender: {
name: process.env.BREVO_SENDER_NAME,
email: process.env.BREVO_SENDER_EMAIL
},
to: [{
email: toEmail,
name: toName
}],
subject: subject,
htmlContent: htmlContent
})
});
if (!response.ok) {
const errorData = await response.json();
console.error('Brevo API Fehler-Details:', errorData);
throw new Error('Fehler bei der Kommunikation mit dem E-Mail-Provider.');
}
return true;
} catch (error) {
console.error('Fehler im email.service:', error);
throw error; // Den Fehler werfen wir weiter, damit der Aufrufer ihn behandeln kann.
}
}
+29
View File
@@ -0,0 +1,29 @@
# ==============================================================================
# Void-Genesis - Lobby Frontend (Dev Environment)
# ==============================================================================
# Auch für das Frontend nutzen wir die kleine Alpine-Version von Node.js
FROM node:22-alpine
# Arbeitsverzeichnis für das Frontend
WORKDIR /app
# Auch hier: Erst die package-Dateien kopieren für optimales Docker-Caching
COPY package*.json ./
# Installiere die Frontend-Abhängigkeiten (Vite, PixiJS, etc.)
RUN npm install
# Vite nutzt im Hintergrund 'Chokidar', um Dateiänderungen zu überwachen (Hot Reload).
# In Docker-Umgebungen klappt das manchmal nicht sofort. Durch USEPOLLING zwingen
# wir Chokidar, aktiv nach Änderungen zu suchen. Das macht das Hot-Reloading robuster.
ENV CHOKIDAR_USEPOLLING=true
# Wir dokumentieren den Standard-Port von Vite
EXPOSE 5173
# Starte den Vite Dev-Server.
# WICHTIG: Das '--host' Flag ist entscheidend! Standardmäßig lauscht Vite nur auf
# localhost (127.0.0.1) im Container. Mit '--host' lauscht Vite auf 0.0.0.0 und
# erlaubt so, dass Anfragen von außerhalb des Containers (z.B. vom Browser) durchkommen.
CMD ["npm", "run", "dev", "--", "--host"]
+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Void-Genesis | Login</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="game-wrapper">
<div id="pixi-container"></div>
<div id="ui-layer">
<form id="register-form" autocomplete="off">
<div id="tab-container">
<button type="button" class="tab-btn active" id="tab-login">Login</button>
<button type="button" class="tab-btn" id="tab-register">Registrieren</button>
</div>
<div class="input-group">
<label for="username">Benutzername</label>
<input type="text" id="username" placeholder="Benutzername / E-Mail" required>
</div>
<div class="input-group" id="group-email">
<label for="email">E-Mail</label>
<input type="email" id="email" placeholder="Enter E-Mail" required>
</div>
<div class="input-group">
<label for="password">Passwort</label>
<input type="password" id="password" placeholder="Enter Password" required>
</div>
<div class="checkbox-group" id="group-remember" style="display: none;">
<input type="checkbox" id="remember">
<label for="remember">Angemeldet bleiben</label>
</div>
<div id="msg-box"></div>
<button type="submit" id="btn-register">REGISTRIEREN</button>
<a href="https://discord.gg/ppjXBE4DbP" target="_blank" id="discord-link"></a>
</form>
</div>
<footer id="game-footer">
<div class="footer-left">v 0.0.1.0-dev</div>
<div class="footer-center">&copy; 2026 Void-Genesis</div>
<div class="footer-right">
<a href="https://void-genesis.de/" target="_blank">Impressum</a>
</div>
</footer>
</div>
<script type="module" src="src/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "void-genesis-lobby-frontend",
"version": "1.0.0",
"description": "Frontend für die Master-Account-Lobby",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"pixi.js": "^8.0.4"
},
"devDependencies": {
"free-tex-packer-core": "^0.3.5",
"vite": "^5.2.8"
}
}
@@ -0,0 +1,98 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import texturePacker from 'free-tex-packer-core';
// ----------------------------------------------------------------------
// ES-Modul Workaround für __dirname
// Da Vite standardmäßig ES-Module verwendet (type: "module" in package.json),
// müssen wir __dirname manuell nachbauen, um absolute Pfade zu generieren.
// ----------------------------------------------------------------------
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ----------------------------------------------------------------------
// Pfad-Konfiguration
// ----------------------------------------------------------------------
// Hier definieren wir, woher die rohen Bilder kommen und wohin der
// fertige Textur-Atlas gespeichert werden soll.
const RAW_ASSETS_DIR = path.resolve(__dirname, '../src/raw-assets/ui');
const OUTPUT_DIR = path.resolve(__dirname, '../src/assets');
const ATLAS_NAME = 'ui-atlas'; // Der Basisname für unsere Ausgabe (ui-atlas.png / ui-atlas.json)
// ----------------------------------------------------------------------
// Hauptfunktion zum Generieren des Sprite-Sheets
// ----------------------------------------------------------------------
async function buildSpriteSheet() {
console.log(`[Packer] Lese Bilder aus: ${RAW_ASSETS_DIR}`);
// Wir prüfen zunächst, ob der Quellordner existiert. Falls nicht,
// legen wir ihn an und brechen ab, da noch keine Bilder da sind.
if (!fs.existsSync(RAW_ASSETS_DIR)) {
fs.mkdirSync(RAW_ASSETS_DIR, { recursive: true });
console.log('[Packer] Quellordner existierte nicht und wurde erstellt. Bitte füge Bilder hinzu und starte neu.');
return;
}
// Wir lesen alle Dateien im Quellordner aus.
const files = fs.readdirSync(RAW_ASSETS_DIR);
// Wir filtern nach Bilddateien (z. B. PNG) und bereiten sie für den Packer vor.
const images = [];
for (const file of files) {
if (file.endsWith('.png')) {
const filePath = path.join(RAW_ASSETS_DIR, file);
// Der Packer benötigt den Dateipfad als "path" und den Dateiinhalt als "contents"
images.push({
path: file,
contents: fs.readFileSync(filePath)
});
}
}
if (images.length === 0) {
console.log('[Packer] Keine PNG-Bilder im Quellordner gefunden. Abbruch.');
return;
}
// ----------------------------------------------------------------------
// Packer-Konfiguration
// ----------------------------------------------------------------------
const options = {
textureName: ATLAS_NAME,
width: 4096, // Maximale Breite des Atlases
height: 4096, // Maximale Höhe des Atlases
padding: 2, // Pixel-Abstand zwischen den Bildern (verhindert Rand-Bluten)
allowRotation: false, // Für UI-Elemente oft besser deaktiviert, damit sie in PixiJS nicht gedreht geladen werden
exporter: 'Pixi', // Generiert direkt ein Format, das PixiJS nativ lesen kann
removeFileExtension: true // Entfernt das ".png" in den JSON-Keys (leichter in PixiJS aufzurufen)
};
console.log(`[Packer] Verpacke ${images.length} Bilder...`);
// Wir rufen den Packer asynchron auf
texturePacker(images, options, (files, error) => {
if (error) {
console.error('[Packer] Fehler beim Erstellen des Sprite-Sheets:', error);
return;
}
// Zielordner erstellen, falls er nicht existiert
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
// Der Packer gibt ein Array von Dateien zurück (Bild + JSON).
// Wir speichern diese direkt im Zielordner.
for (const item of files) {
const outPath = path.join(OUTPUT_DIR, item.name);
fs.writeFileSync(outPath, item.buffer);
console.log(`[Packer] Erfolgreich gespeichert: ${outPath}`);
}
console.log('[Packer] Sprite-Sheet erfolgreich erstellt!');
});
}
// Skript ausführen
buildSpriteSheet();
Binary file not shown.
+423
View File
@@ -0,0 +1,423 @@
{
"frames": {
"SPR_SciFiMenus_Frame_Box_Large_02": {
"frame": {
"x": 2,
"y": 2,
"w": 997,
"h": 984
},
"rotated": false,
"trimmed": true,
"spriteSourceSize": {
"x": 14,
"y": 21,
"w": 997,
"h": 984
},
"sourceSize": {
"w": 1024,
"h": 1024
},
"pivot": {
"x": 0.5,
"y": 0.5
}
},
"SPR_SciFiMenus_Frame_Box_Large_12": {
"frame": {
"x": 1003,
"y": 2,
"w": 984,
"h": 481
},
"rotated": false,
"trimmed": true,
"spriteSourceSize": {
"x": 20,
"y": 17,
"w": 984,
"h": 481
},
"sourceSize": {
"w": 1024,
"h": 512
},
"pivot": {
"x": 0.5,
"y": 0.5
}
},
"SPR_SciFiMenus_Frame_Box_Large_12_Background": {
"frame": {
"x": 1991,
"y": 2,
"w": 898,
"h": 393
},
"rotated": false,
"trimmed": true,
"spriteSourceSize": {
"x": 64,
"y": 73,
"w": 898,
"h": 393
},
"sourceSize": {
"w": 1024,
"h": 512
},
"pivot": {
"x": 0.5,
"y": 0.5
}
},
"SPR_SciFiMenus_Ring_Medium_13": {
"frame": {
"x": 2893,
"y": 2,
"w": 826,
"h": 853
},
"rotated": false,
"trimmed": true,
"spriteSourceSize": {
"x": 97,
"y": 85,
"w": 826,
"h": 853
},
"sourceSize": {
"w": 1024,
"h": 1024
},
"pivot": {
"x": 0.5,
"y": 0.5
}
},
"SPR_SciFiMenus_Frame_Box_Large_02_Background": {
"frame": {
"x": 1991,
"y": 399,
"w": 825,
"h": 895
},
"rotated": false,
"trimmed": true,
"spriteSourceSize": {
"x": 98,
"y": 63,
"w": 825,
"h": 895
},
"sourceSize": {
"w": 1024,
"h": 1024
},
"pivot": {
"x": 0.5,
"y": 0.5
}
},
"SPR_SciFiMenus_Background_01": {
"frame": {
"x": 3723,
"y": 2,
"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_20": {
"frame": {
"x": 3723,
"y": 262,
"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": 3723,
"y": 522,
"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
}
},
"ICON_SciFiMenus_Menu_Lock_Closed_01_Clean": {
"frame": {
"x": 3955,
"y": 522,
"w": 132,
"h": 164
},
"rotated": false,
"trimmed": true,
"spriteSourceSize": {
"x": 62,
"y": 45,
"w": 132,
"h": 164
},
"sourceSize": {
"w": 256,
"h": 256
},
"pivot": {
"x": 0.5,
"y": 0.5
}
},
"ICON_SciFi_Map_Message": {
"frame": {
"x": 3955,
"y": 690,
"w": 130,
"h": 88
},
"rotated": false,
"trimmed": true,
"spriteSourceSize": {
"x": 64,
"y": 84,
"w": 130,
"h": 88
},
"sourceSize": {
"w": 256,
"h": 256
},
"pivot": {
"x": 0.5,
"y": 0.5
}
},
"SPR_SciFiMenus_Menu_Item_Selected_19": {
"frame": {
"x": 3723,
"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
}
},
"ICON_SciFiMenus_Menu_Solo_01_Clean": {
"frame": {
"x": 3955,
"y": 782,
"w": 125,
"h": 194
},
"rotated": false,
"trimmed": true,
"spriteSourceSize": {
"x": 65,
"y": 31,
"w": 125,
"h": 194
},
"sourceSize": {
"w": 256,
"h": 256
},
"pivot": {
"x": 0.5,
"y": 0.5
}
},
"SPR_SciFiMenus_Background_Hexagons_01": {
"frame": {
"x": 2820,
"y": 859,
"w": 512,
"h": 512
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 512,
"h": 512
},
"sourceSize": {
"w": 512,
"h": 512
},
"pivot": {
"x": 0.5,
"y": 0.5
}
},
"ICON_Social_Discord": {
"frame": {
"x": 3336,
"y": 859,
"w": 226,
"h": 175
},
"rotated": false,
"trimmed": true,
"spriteSourceSize": {
"x": 14,
"y": 41,
"w": 226,
"h": 175
},
"sourceSize": {
"w": 256,
"h": 256
},
"pivot": {
"x": 0.5,
"y": 0.5
}
},
"SPR_SciFiMenus_Menu_Item_Background_07": {
"frame": {
"x": 3566,
"y": 998,
"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": 3336,
"y": 1038,
"w": 222,
"h": 193
},
"rotated": false,
"trimmed": true,
"spriteSourceSize": {
"x": 17,
"y": 31,
"w": 222,
"h": 193
},
"sourceSize": {
"w": 256,
"h": 256
},
"pivot": {
"x": 0.5,
"y": 0.5
}
},
"ICON_SciFiMenus_Menu_Tick_01_Clean": {
"frame": {
"x": 3794,
"y": 998,
"w": 191,
"h": 144
},
"rotated": false,
"trimmed": true,
"spriteSourceSize": {
"x": 32,
"y": 56,
"w": 191,
"h": 144
},
"sourceSize": {
"w": 256,
"h": 256
},
"pivot": {
"x": 0.5,
"y": 0.5
}
}
},
"meta": {
"app": "http://github.com/odrick/free-tex-packer-core",
"version": "0.3.5",
"image": "ui-atlas.png",
"format": "RGBA8888",
"size": {
"w": 4089,
"h": 1373
},
"scale": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 795 KiB

+400
View File
@@ -0,0 +1,400 @@
import { Application, Assets, Sprite, NineSliceSprite, Container, TilingSprite, Text, wordWrap } from 'pixi.js';
async function initGame() {
const app = new Application();
await app.init({
resizeTo: window,
backgroundAlpha: 0,
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
});
document.getElementById('pixi-container').appendChild(app.canvas);
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',
uiAtlas: 'src/assets/ui-atlas.json'
};
const backgroundTexture = await Assets.load(assetsToLoad.background);
const uiSpritesheet = await Assets.load(assetsToLoad.uiAtlas);
await document.fonts.load('16px "UI Font"');
await document.fonts.load('16px "Logo Font"');
buildScene(app, backgroundTexture, uiSpritesheet);
} catch (error) {
console.error("Fehler beim Laden der Assets:", error);
}
}
function buildScene(app, bgTexture, sheet) {
// --- Farb-Variablen (pixiJS) ---
const COLOR_PANEL_BG = 0x0b192c; // Dunkelblau für Panel & Inputs
const COLOR_PATTERN = 0x00f0ff; // Cyan für das Hexagon-Muster
const COLOR_TAB_ACTIVE = 0x00f0ff; // Cyan für aktiven Tab
const COLOR_TAB_INACTIVE = 0xaaaaaa; // Grau für inaktiven Tab
const COLOR_TAB_HOVER = 0xffffff; // Weiß beim Hovern der Tabs
const COLOR_BTN_DEFAULT = 0x00aa00; // Dunkelgrün für Registrieren Button
const COLOR_BTN_HOVER = 0x00ff00; // Hellgrün beim Hovern des Buttons
const COLOR_LOGO = 0x00f0ff; // Farbe des Logos
const COLOR_DISCORD_TEXT = 0xdaffde; // Hellgrün für Discord Text
const COLOR_DISCORD_TEXT_HOVER = 0xffffff; // Weiß beim Hovern des Discord Links
// --- Asset-Namen ---
const frameInput = 'SPR_SciFiMenus_Menu_Item_Selected_19'; // Rahmen für Eingabefelder
const frameBtn = 'SPR_SciFiMenus_Menu_Item_Selected_10'; // Rahmen für Tabs/Buttons
const menuBgName = 'SPR_SciFiMenus_Menu_Item_Background_07'; // Füll Asset für die Rahmen
const panelBgName = 'SPR_SciFiMenus_Frame_Box_Large_02_Background'; // Füll Asset für das große Panel
const patternBgName = 'SPR_SciFiMenus_Background_Hexagons_01'; // Kachelbares Pattern
const panelFmName = 'SPR_SciFiMenus_Frame_Box_Large_02'; // Rahmen für das große Panel
// --- Icons ---
const iconUserName = 'ICON_SciFiMenus_Menu_Solo_01_Clean';
const iconEmailName = 'ICON_SciFi_Map_Message';
const iconLockName = 'ICON_SciFiMenus_Menu_Lock_Closed_01_Clean';
// --- Logo Ring ---
const logoRingName = 'SPR_SciFiMenus_Ring_Medium_13';
// --- Discord Panel ---
const discordFrameName = 'SPR_SciFiMenus_Frame_Box_Large_12';
const discordBgName = 'SPR_SciFiMenus_Frame_Box_Large_12_Background';
const discordIconName = 'ICON_Social_Discord';
// --- Hintergrund ---
const background = new Sprite(bgTexture);
app.stage.addChild(background);
// --- Haupt-UI-Container ---
const uiContainer = new Container();
app.stage.addChild(uiContainer);
const panelBackground = new Sprite(sheet.textures[panelBgName]);
panelBackground.tint = COLOR_PANEL_BG;
panelBackground.width = 500;
panelBackground.height = 520;
panelBackground.position.set(0, -10);
uiContainer.addChild(panelBackground);
// --- Footer Hintergund in PixiJS kacheln
const footerBackgroundContainer = new Container();
const footerBackground = new TilingSprite({
texture: sheet.textures['SPR_SciFiMenus_Background_01'],
height: 40,
});
footerBackground.tileScale.set(1.0);
footerBackground.tint = 0x000000;
footerBackground.alpha = 1.0;
footerBackgroundContainer.addChild(footerBackground);
app.stage.addChild(footerBackgroundContainer);
const footerPattern = new TilingSprite({
texture: sheet.textures[patternBgName],
height: 40,
});
footerPattern.tileScale.set(0.3);
footerPattern.tint = COLOR_PATTERN;
footerPattern.alpha = 0.01;
footerBackgroundContainer.addChild(footerPattern);
app.stage.addChild(footerBackgroundContainer);
// --- Hexagon pattern
const pattern = new TilingSprite({
texture: sheet.textures[patternBgName],
width: 500,
height: 520,
});
pattern.tileScale.set(0.3);
pattern.position.set(0, -10);
pattern.alpha = 0.05;
pattern.tint = COLOR_PATTERN;
uiContainer.addChild(pattern);
// --- Main UI
const panelFrame = new NineSliceSprite({
texture: sheet.textures[panelFmName],
leftWidth: 510,
topHeight: 510,
rightWidth: 510,
bottomHeight: 510
});
panelFrame.width = 500;
panelFrame.height = 500;
uiContainer.addChild(panelFrame);
// --- Logo ---
const logoContainer = new Container();
logoContainer.position.set(250, -120);
const logoRing = new Sprite(sheet.textures[logoRingName]);
logoRing.anchor.set(0.5);
logoRing.tint = COLOR_LOGO;
logoRing.alpha = 1.0;
logoRing.scale.set(0.2);
logoContainer.addChild(logoRing);
const logoText = new Text({
text: 'Void-Genesis',
style: {
fontFamily: 'Logo Font',
fontSize: 50,
fill: COLOR_LOGO,
letterSpacing: 8,
}
});
logoText.anchor.set(0.5);
logoContainer.addChild(logoText);
uiContainer.addChild(logoContainer);
// --- UI Elemente ---
const tabLogin = createUIItem(65, 60, 180, 40, frameBtn, null, COLOR_TAB_INACTIVE, 0.6);
const tabRegister = createUIItem(255, 60, 180, 40, frameBtn, null, COLOR_TAB_ACTIVE, 0.4);
const inputUser = createUIItem(115, 135, 300, 50, frameInput, iconUserName, COLOR_PANEL_BG, 0.8);
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);
// --- Discord Panel ---
const discordContainer = new Container();
discordContainer.position.set(640, 380);
const discordBackground = new NineSliceSprite({
texture: sheet.textures[discordBgName],
leftWidth: 0,
topHeight: 0,
rightWidth: 0,
bottomHeight: 0
});
discordBackground.tint = COLOR_PANEL_BG;
discordBackground.width = 255;
discordBackground.height = 75;
discordContainer.addChild(discordBackground);
const discordPattern = new TilingSprite({
texture: sheet.textures[patternBgName],
width: 255,
height: 75,
});
discordPattern.tileScale.set(0.3);
discordPattern.tint = COLOR_PATTERN;
discordPattern.alpha = 0.05;
discordContainer.addChild(discordPattern);
const discordFrame = new NineSliceSprite({
texture: sheet.textures[discordFrameName],
leftWidth: 0,
topHeight: 0,
rightWidth: 0,
bottomHeight: 0
});
discordFrame.width = 280;
discordFrame.height = 85;
discordFrame.position.set(-15, -10);
discordContainer.addChild(discordFrame);
const discordIcon = new Sprite(sheet.textures[discordIconName]);
discordIcon.position.set(10, 15);
discordIcon.scale.set(0.15);
discordIcon.tint = COLOR_TAB_INACTIVE;
discordContainer.addChild(discordIcon);
const discordText = new Text({
text: 'Offizieller Discord Server',
style: {
fontFamily: 'UI Font',
fontSize: 14,
fill: COLOR_TAB_INACTIVE,
wordWrap: false,
wordWrapWidth: 120
}
});
discordText.position.set(60, 25);
discordContainer.addChild(discordText);
uiContainer.addChild(discordContainer);
const htmlTabLogin = document.getElementById('tab-login');
const htmlTabRegistert = document.getElementById('tab-register');
const htmlButton = document.getElementById('btn-register');
let isLoginMode = false;
htmlTabLogin.addEventListener('mouseenter', () => {
if (!isLoginMode) {
tabLogin.fill.tint = COLOR_TAB_HOVER;
tabLogin.fill.alpha = 0.8;
}
});
htmlTabLogin.addEventListener('mouseleave', () => {
if (!isLoginMode) {
tabLogin.fill.tint = COLOR_TAB_INACTIVE;
tabLogin.fill.alpha = 0.4;
}
});
htmlTabRegistert.addEventListener('mouseenter', () => {
if (isLoginMode) {
tabRegister.fill.tint = COLOR_TAB_HOVER;
tabRegister.fill.alpha = 0.8;
}
});
htmlTabRegistert.addEventListener('mouseleave', () => {
if (isLoginMode) {
tabRegister.fill.tint = COLOR_TAB_INACTIVE;
tabRegister.fill.alpha = 0.4;
}
});
htmlButton.addEventListener('mouseenter', () => {
btnRegister.fill.tint = COLOR_BTN_HOVER;
btnRegister.fill.alpha = 1.0;
});
htmlButton.addEventListener('mouseleave', () => {
btnRegister.fill.tint = COLOR_BTN_DEFAULT;
btnRegister.fill.alpha = 0.6;
});
const htmlDiscord = document.getElementById('discord-link');
if (htmlDiscord) {
htmlDiscord.addEventListener('mouseenter', () => {
discordIcon.tint = COLOR_TAB_HOVER;
discordText.style.fill = COLOR_TAB_HOVER;
});
htmlDiscord.addEventListener('mouseleave', () => {
discordIcon.tint = COLOR_TAB_INACTIVE;
discordText.style.fill = COLOR_TAB_INACTIVE;
});
}
function setLoginMode() {
if (isLoginMode) return;
isLoginMode = true;
tabLogin.fill.tint = COLOR_TAB_ACTIVE;
tabLogin.fill.alpha = 0.6;
tabRegister.fill.tint = COLOR_TAB_INACTIVE;
tabRegister.fill.alpha = 0.4;
htmlTabLogin.classList.add('active');
htmlTabRegistert.classList.remove('active');
inputEmail.container.visible = false;
document.getElementById('group-email').style.display = 'none';
inputLock.container.y = 200;
document.getElementById('password').style.top = '200px';
document.getElementById('group-remember').style.display = 'flex';
htmlButton.textContent = 'LOGIN';
document.getElementById('username').placeholder = 'Username / E-Mail';
document.getElementById('msg-box').textContent = '';
}
function setRegisterMode() {
if (!isLoginMode) return;
isLoginMode = false;
tabRegister.fill.tint = COLOR_TAB_ACTIVE;
tabRegister.fill.alpha = 0.6;
tabLogin.fill.tint = COLOR_TAB_INACTIVE;
tabLogin.fill.alpha = 0.4;
htmlTabRegistert.classList.add('active');
htmlTabLogin.classList.remove('active');
inputEmail.container.visible = true;
document.getElementById('group-email').style.display = 'block';
inputLock.container.y = 265;
document.getElementById('password').style.top = '265px';
document.getElementById('group-remember').style.display = 'none';
htmlButton.textContent = 'REGISTRIEREN';
document.getElementById('username').placeholder = 'Enter Username';
document.getElementById('msg-box').textContent = '';
}
function createUIItem(x, y, width, height, frameTextureName, iconTextureName, fillTint, fillAlpha) {
const itemContainer = new Container();
itemContainer.position.set(x, y);
const fill = new NineSliceSprite({
texture: sheet.textures[menuBgName],
leftWidth: 120,
topHeight: 120,
rightWidth: 120,
bottomHeight: 120
});
fill.width = width;
fill.height = height;
fill.tint = fillTint;
fill.alpha = fillAlpha;
itemContainer.addChild(fill);
const frame = new NineSliceSprite({
texture: sheet.textures[frameTextureName],
leftWidth: 100,
topHeight: 120,
rightWidth: 140,
bottomHeight: 120
});
frame.width = width;
frame.height = height;
frame.alpha = 0.8;
itemContainer.addChild(frame);
if (iconTextureName) {
const icon = new Sprite(sheet.textures[iconTextureName]);
icon.position.set(-45, (height - (icon.height * 0.15)) / 2 - 5);
icon.scale.set(0.15);
itemContainer.addChild(icon);
}
uiContainer.addChild(itemContainer);
return { container: itemContainer, fill, frame };
}
function resize() {
const screenWidth = app.screen.width;
const screenHeight = app.screen.height;
const scaleX = screenWidth / background.texture.width;
const scaleY = screenHeight / background.texture.height;
const scale = Math.max(scaleX, scaleY);
background.scale.set(scale);
background.x = (screenWidth - background.width) / 2;
background.y = (screenHeight - background.height) / 2;
uiContainer.x = (screenWidth - panelFrame.width) / 2;
uiContainer.y = (screenHeight - panelFrame.height) / 2;
footerBackground.width = screenWidth;
footerPattern.width = screenWidth;
footerBackgroundContainer.y = screenHeight - 40;
}
htmlTabLogin.addEventListener('click', setLoginMode);
htmlTabRegistert.addEventListener('click', setRegisterMode);
app.renderer.on('resize', resize);
resize();
}
initGame();
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+299
View File
@@ -0,0 +1,299 @@
/* --- LOKALE SCHRIFTART LADEN --- */
@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');
font-weight: normal;
font-style: normal;
}
/* 2. Die exklusive Schrift für das Void-Genesis Logo */
@font-face {
font-family: 'Logo Font';
src: url('src/assets/fonts/Quantico-Bold.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
/* --- FARB- & FONT-VARIABLEN (CSS) --- */
:root {
/* ... deine bisherigen Farb-Variablen ... */
--color-bg-page: #000000;
--color-text-main: #ffffff;
--color-text-placeholder: rgba(255, 255, 255, 0.4);
--color-text-inactive: rgba(255, 255, 255, 0.5);
--color-cyan-bright: #00f0ff;
--color-cyan-dark: #00a0aa;
--color-green-bright: #00ff00;
--color-error: #ff4444;
--color-checkbox-bg: rgba(11, 25, 44, 0.8);
/* NEU: Wir definieren die Schriftart zentral */
--font-main: 'UI Font', sans-serif;
}
/* Reset von Standard-Abständen */
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: var(--color-bg-page);
/* Wir nutzen nun unsere lokale Variable */
font-family: var(--font-main);
}
#game-wrapper {
position: relative;
width: 100vw;
height: 100vh;
}
#pixi-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
#ui-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
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;
}
.input-group label {
display: none;
}
.input-group input {
position: absolute;
width: 300px;
height: 50px;
left: 115px;
box-sizing: border-box;
padding: 10px 15px;
background: transparent;
border: none;
outline: none;
color: var(--color-text-main);
font-size: 16px;
font-family: inherit;
transition: text-shadow 0.2s ease;
}
#username {
top: 135px;
}
#email {
top: 200px;
}
#password {
top: 265px;
}
.input-group input::placeholder {
color: var(--color-text-placeholder);
}
.input-group input:focus {
text-shadow: 0 0 8px var(--color-cyan-dark);
background: transparent;
box-shadow: none;
}
#btn-register {
position: absolute;
width: 370px;
height: 55px;
left: 65px;
top: 370px;
background: transparent;
border: none;
color: var(--color-text-main);
font-size: 18px;
font-weight: bold;
font-family: inherit;
text-transform: uppercase;
letter-spacing: 2px;
cursor: pointer;
transition: all 0.2s ease;
}
#btn-register:hover {
background: transparent;
color: var(--color-text-main);
text-shadow: 0 0 15px var(--color-text-main), 0 0 30px var(--color-green-bright);
}
.tab-btn {
position: absolute;
height: 40px;
width: 180px;
top: 60px;
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;
}
#tab-login {
left: 65px;
}
#tab-register {
left: 255px;
}
.tab-btn.active {
color: var(--color-text-main);
text-shadow: 0 0 8px var(--color-cyan-bright);
}
.tab-btn:hover:not(.active) {
color: var(--color-text-main);
}
.checkbox-group {
position: absolute;
left: 115px;
top: 275px;
display: flex;
align-items: center;
gap: 10px;
}
.checkbox-group label {
color: var(--color-text-main);
font-size: 14px;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
appearance: none;
width: 18px;
height: 18px;
border: 1px solid var(--color-cyan-dark);
background: var(--color-checkbox-bg);
cursor: pointer;
position: relative;
}
.checkbox-group input[type="checkbox"]:checked::after {
content: '✔';
position: absolute;
color: var(--color-cyan-bright);
font-size: 14px;
top: -2px;
left: 2px;
}
#msg-box {
position: absolute;
top: 330px;
left: 65px;
width: 370px;
height: 30px;
color: var(--color-error);
font-size: 14px;
text-align: center;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
/* --- NEU: DISCORD LINK --- */
#discord-link {
position: absolute;
/* WICHTIG: Trage hier exakt die gleichen Werte ein,
die du in der main.js beim 'discordContainer.position.set(X, Y)' hast! */
left: 640px; /* Positiver Wert, da es jetzt rechts vom 500px Panel liegt */
top: 380px; /* Y-Koordinate deines Panels */
/* Die Größe deines PixiJS Discord-Panels */
width: 280px;
height: 85px;
/* Komplett unsichtbar machen, wir wollen nur das Pixi-Bild darunter sehen */
background: transparent;
text-decoration: none;
/* Sorgt dafür, dass die Maus zur Klick-Hand wird */
cursor: pointer;
/* Aktiviert die Klickbarkeit für diese Schicht */
pointer-events: auto;
}
/* --- NEU: FOOTER STYLING --- */
#game-footer {
position: absolute; bottom: 0; left: 0; width: 100%;
/* Höhe des Footers (wird auch im Pixi Kachel-Sprite genutzt) */
height: 40px;
/* Z-Index hochsetzen, damit der Text über dem Pixi-Hintergrund liegt */
z-index: 20;
/* pointer-events: none deaktivieren, damit wir den Impressum-Link anklicken können! */
pointer-events: none;
display: flex; justify-content: space-between; align-items: center;
padding: 0 40px; box-sizing: border-box;
color: var(--color-text-inactive);
font-size: 12px;
/* Wir nutzen nicht mehr 'Courier New', sondern unsere UI-Schrift */
font-family: inherit;
/* GRAFISCHE ABSETZUNG: Wir fügen eine leuchtende Cyan-Neon-Linie oben hinzu. */
border-top: 1px solid var(--color-text-inactive);
/* Und nutzen den Pixi Kachel-Hintergrund, um den Sci-Fi Look zu übernehmen. */
background: transparent;
}
/* Flexbox-Verteilung für die drei Footer-Bereiche */
.footer-left { flex: 1; text-align: left; }
.footer-center { flex: 1; text-align: center; }
.footer-right { flex: 1; text-align: right; }
#game-footer a {
color: var(--color-text-inactive); text-decoration: none;
transition: color 0.2s ease, text-shadow 0.2s ease;
/* Wir reaktivieren Klicks für den Link */
pointer-events: auto;
}
#game-footer a:hover {
color: var(--color-cyan-bright);
text-shadow: 0 0 8px var(--color-cyan-bright);
}
+44
View File
@@ -0,0 +1,44 @@
// ==============================================================================
// Void-Genesis - Vite Konfiguration
// ==============================================================================
import { defineConfig } from 'vite';
export default defineConfig({
server: {
// Erlaubt Vite, Verbindungen über diese spezifische Domain anzunehmen.
// Verhindert den "Blocked request" Fehler hinter dem Nginx Proxy Manager.
allowedHosts: [
'dev.void-genesis.de'
],
// 'host: true' stellt sicher, dass Vite auf 0.0.0.0 lauscht
// (haben wir zwar im Dockerfile schon als Flag, ist hier aber sicherer)
host: true,
},
build: {
// ----------------------------------------------------------------------
// EULA-Konformität: Base64-Zwang für alle Assets
// ----------------------------------------------------------------------
// Wir setzen das Limit für das Inlining von Assets auf einen extrem hohen
// Wert (hier ca. 100 MB). Standardmäßig liegt dieser Wert bei 4096 (4 KB).
// Alles, was größer ist, wird von Vite normalerweise als separate Datei
// (z. B. .png oder .jpg) im Ausgabeordner gespeichert.
//
// Durch diesen hohen Wert zwingen wir Vite dazu, sämtliche importierten
// Assets zwingend als Base64-Strings direkt in die generierten JavaScript-
// oder CSS-Dateien einzubetten. Dadurch liegen nach dem Build absolut
// keine rohen, extrahierbaren Bilddateien mehr auf unserem Server.
// ----------------------------------------------------------------------
assetsInlineLimit: 100000000,
// Wir deaktivieren das Generieren von Source Maps für den produktiven Build.
// Das ist wichtig, da Source Maps extrem groß werden können, wenn sie
// riesige Base64-Strings enthalten. Außerdem erschwert es das Auslesen
// unserer Struktur von außen.
sourcemap: false,
// Optional: Falls wir den Output noch weiter minimieren wollen,
// können wir hier auch die CSS-Code-Splitting-Optionen anpassen,
// aber für den Base64-Zwang reicht das assetsInlineLimit völlig aus.
}
});