BaseFrontendBackend
@@ -0,0 +1,57 @@
|
||||
# ==============================================================================
|
||||
# Void-Genesis - Globale .gitignore
|
||||
# ==============================================================================
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 1. Lokale Umgebungsvariablen & Secrets (SEHR WICHTIG!)
|
||||
# ------------------------------------------------------------------------------
|
||||
# .env Dateien enthalten Passwörter, API-Keys und Datenbank-Zugänge (wie unsere
|
||||
# POSTGRES_PASSWORD_LOBBY). Diese dürfen niemals ins Git-Repository (Gitea)
|
||||
# gepusht werden, da sonst die Infrastruktur kompromittiert ist!
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 2. Datenbank & Persistente Daten
|
||||
# ------------------------------------------------------------------------------
|
||||
# Wir ignorieren alle Ordner, die "data" heißen (z.B. dev/data/postgres).
|
||||
# Hier liegen die physischen Datenbank-Dateien von PostgreSQL. Diese ändern sich
|
||||
# ständig und würden das Repository extrem aufblähen und unbrauchbar machen.
|
||||
**/data/
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 3. Node.js & Paketmanager-Logs
|
||||
# ------------------------------------------------------------------------------
|
||||
# Der 'node_modules' Ordner enthält zehntausende Dateien fremder Pakete.
|
||||
# Diese sind oft betriebssystemabhängig und werden stattdessen immer frisch über
|
||||
# die package.json (via npm install) bzw. im Docker-Build installiert.
|
||||
node_modules/
|
||||
# Log-Dateien, falls npm oder yarn bei einem lokalen Test abstürzen.
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.log
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 4. Build-Outputs (Frontend Vite / Backend)
|
||||
# ------------------------------------------------------------------------------
|
||||
# Die fertig kompilierten Dateien (z.B. minimiertes HTML/JS/CSS von Vite).
|
||||
# Diese generieren wir im Produktivbetrieb später on-the-fly, sie gehören
|
||||
# nicht in die Versionskontrolle des Quellcodes.
|
||||
dist/
|
||||
dist-ssr/
|
||||
build/
|
||||
out/
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 5. Betriebssystem-spezifische Dateien & Editor-Configs
|
||||
# ------------------------------------------------------------------------------
|
||||
# Automatisch generierte Metadaten von Windows oder macOS, die anderen
|
||||
# Entwicklern oder dem Server nur im Weg sind.
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# (Optional) Lokale VS Code Einstellungen, die nicht für das Team gelten
|
||||
.vscode/
|
||||
|
||||
gem_*.txt
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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">© 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>
|
||||
@@ -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();
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 795 KiB |
@@ -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();
|
||||
|
After Width: | Height: | Size: 8.8 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 116 KiB |
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
});
|
||||