Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank

- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark,
  Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI)
- aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource,
  an aether-core gekoppelt (Rechte über aether_users.admin_group), mit
  WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU)
- Prototypen admin/ vervollständigt
- SQL-Schema aether_admin.sql
- Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jerrit Fritzsche 2026-07-27 18:55:32 +02:00
parent 0c370eeab8
commit e4b82dd685
202 changed files with 67050 additions and 0 deletions

21
.env.example Normal file
View File

@ -0,0 +1,21 @@
# =====================================================================
# Aether — Datenbank-Zugangsdaten für docker-compose
# Kopieren nach `.env` und anpassen. Die .env NICHT ins Git committen.
# =====================================================================
# Root-Passwort (nur der Migrator nutzt es)
DB_ROOT_PASSWORD=aether_root
# Datenbank und Anwendungsbenutzer (für oxmysql)
DB_NAME=aether
DB_USER=aether
DB_PASSWORD=aether
# Host-Port, unter dem die DB erreichbar ist (für den FiveM-Server)
DB_PORT=3306
# Port der optionalen Adminer-Oberfläche
ADMINER_PORT=8080
# -> Passende Zeile in server.cfg (Benutzer/Passwort/Port wie oben):
# set mysql_connection_string "mysql://aether:aether@127.0.0.1:3306/aether?charset=utf8mb4"

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
# Lokale Zugangsdaten (aus .env.example erzeugt) — niemals committen
.env
# Node-Abhängigkeiten des Media-Servers
resources/[aether]/aether-admin/media-server/node_modules/
# Übliches
*.log
.DS_Store

682
README.md Normal file
View File

@ -0,0 +1,682 @@
# Aether Framework
Deutschsprachiges FiveM GTA-RP-Framework (in Entwicklung).
Das Projekt besteht aus zwei Teilen:
1. **Lauffähiger FiveM-Server** (`server.cfg`, `resources/[aether]/`) — im Aufbau
2. **UI-Prototypen** (die restlichen Ordner) — reine HTML-Entwürfe, die modulweise
in echte Resources überführt werden
---
## Server einrichten
**Voraussetzungen:** FiveM-Server-Artifacts, MySQL/MariaDB, ein Lizenzschlüssel von
[keymaster.fivem.net](https://keymaster.fivem.net).
1. **Datenbank anlegen** — Schema einspielen:
```bash
mysql -u root -p < sql/aether.sql
```
2. **oxmysql herunterladen** (nicht in diesem Repo enthalten) von
[github.com/overextended/oxmysql](https://github.com/overextended/oxmysql/releases)
und nach `resources/[standalone]/oxmysql/` entpacken.
3. **`server.cfg` anpassen** — Lizenzschlüssel und `mysql_connection_string` eintragen.
4. **Server starten:**
```bash
./run.sh +exec server.cfg # Linux
FXServer.exe +exec server.cfg # Windows
```
### Aufbau
| Pfad | Inhalt |
|---|---|
| `server.cfg` | Serverkonfiguration, Startreihenfolge der Ressourcen |
| `sql/aether.sql` | Datenbankschema (Benutzer, Charaktere, Inventar, Fahrzeuge, Transaktionen) |
| `resources/[aether]/aether-core/` | **Fundament**: Spielerverwaltung, Charaktere, Geld, Zustand, NUI-Helfer |
| `resources/[aether]/aether-loading/` | Ladebildschirm (migriert) |
### aether-core
Das Fundament, von dem alle Module abhängen. Es übernimmt:
- **Verbindungsaufbau** mit Bannprüfung und Benutzeranlage (erkannt an der Rockstar-Lizenz)
- **Charakterverwaltung** (Multicharacter vorbereitet; legt vorerst automatisch einen
Startcharakter an, bis `aether-character` migriert ist)
- **Spawn** an der zuletzt gespeicherten Position, Ped-Modell nach Geschlecht
- **Geld** (Bargeld / Konto / Schwarzgeld) inkl. Deckungsprüfung und Transaktionsprotokoll
- **Zustand** (Leben, Hunger, Durst, Ausdauer) mit langsamem Verbrauch und Schaden bei Leere
- **Automatisches Speichern** alle 5 Minuten sowie bei Disconnect und Ressourcen-Stopp
- **NUI-Helfer**, damit Module Oberflächen einheitlich öffnen/schließen
**Schnittstelle für andere Module (Exports):**
```lua
-- Serverseite
local spieler = exports['aether-core']:HoleSpieler(source)
exports['aether-core']:FuegeGeldHinzu(source, 'bargeld', 500, 'Lohn')
exports['aether-core']:EntferneGeld(source, 'konto', 250, 'Einkauf')
-- Clientseite
local daten = exports['aether-core']:HoleSpielerDaten()
exports['aether-core']:OeffneNui('aether-phone', { aktion = 'oeffnen' })
exports['aether-core']:SchliesseNui()
```
Konfiguration liegt zentral in `aether-core/shared/config.lua` (Spawnpunkt, Startgeld,
Verbrauchsraten, Speicherintervall, Rechtegruppen).
---
## Systeme
### 🖥️ Ladebildschirm (`resources/[aether]/aether-loading/`) ✅ migriert
Bereits als FiveM-Ressource eingebunden (`loadscreen`). Bleibt sichtbar, bis der Core den
Charakter gespawnt hat (`loadscreen_manual_shutdown`).
Zum Ansehen im Browser: `resources/[aether]/aether-loading/html/index.html`.
**Enthaltene Elemente:**
- **Animierter Hintergrund**: Aurora-Verlauf + aufsteigende Partikel (Canvas)
- **Aether-Branding** mit Titel und Server-Statistiken (Spieler online/max, Voice, Discord)
- **Fortschrittsbalken** mit Prozentanzeige und wechselndem Status („Lade Ressource …"),
am Ende **„Bereit"-Zustand**
- **Info-Karussell**: News, Serverregeln und Spieltipps im automatischen Wechsel (klickbare Punkte)
- **Musik-Player**: sanftes Ambient-Pad per **WebAudio** erzeugt (kein externes Audio),
Play/Pause, nächster Titel, Lautstärke
> Der Fortschritt ist hier simuliert — in FiveM von echten Lade-Events getrieben. Server-Daten
> (`server`), Karussell-Inhalte (`karussell`) und Playlist (`playlist`) sind vom UI getrennt.
### ❤️ HUD (`resources/[aether]/aether-hud/`) ✅ migriert
Läuft als dauerhaftes Overlay im Spiel. Die Werte kommen aus dem Core und vom Fahrzeug —
es wird nur gesendet, wenn sich tatsächlich etwas geändert hat.
**Datenquellen:**
- **Geld** aus `aether:client:geldGeaendert` (Schwarzgeld erscheint nur, wenn vorhanden)
- **Hunger/Durst** aus `aether:client:zustandGeaendert` (Core-Verbrauchsschleife)
- **Leben** aus `GetEntityHealth` (GTA zählt ab 100 → wird in 0–100 % umgerechnet)
- **Ausdauer** aus dem Sprint-Vorrat des Spielers
- **Voice** aus dem eingebauten Mumble (`NetworkIsPlayerTalking`, Reichweite → Flüstern/Normal/Rufen)
- **Fahrzeug** (`client/fahrzeug.lua`): Tempo, Tank, Motor sowie ein **Sicherheitsgurt** als
Skriptmechanik — Taste `B`, verhindert bei angelegtem Gurt das Herausschleudern
**Export für andere Module:**
```lua
exports['aether-hud']:SetzeSichtbar(false) -- HUD ausblenden, z. B. bei offenen Menüs
```
> **Browser-Fallback:** Ohne FiveM blendet die Seite Kulisse und **Demo-Panel** wieder ein und
> lässt die Verbrauchssimulation laufen — weiterhin eigenständig ansehbar unter
> `resources/[aether]/aether-hud/html/index.html`.
**Anzeigen:**
- **Statusringe**: Leben, Hunger, Durst, Ausdauer (SVG-Ringe mit Zahl)
- **Voice-Anzeige** mit Reichweite (Flüstern/Normal/Rufen) und Sprech-Indikator
- **Geld**: Bargeld, Konto und optional **Schwarzgeld** (blinkt bei Änderung)
- **Fahrzeug-HUD**: Tacho (270°-Bogen), Gang, Tankanzeige, Warnleuchten für Gurt & Motor
**Simulation (Prototyp):**
- Hunger/Durst sinken langsam; leerer Magen/Durst zieht am Leben; Tank sinkt bei Fahrt
- Schaden geht auf das Leben; Aktionen wie Essen/Trinken/Heilen
- Fahrzeug-Modus mit Tempo-/Tank-Slider, Gurt- und Motor-Umschaltung
> Der komplette Zustand liegt getrennt im `hud`-Objekt (Status, Geld, Fahrzeugwerte). In FiveM
> per NUI vom Client gesetzt; das Demo-Panel entfällt dort. Geld-/Voice-Konzepte passen zu
> `bank/` und dem Voice-System.
### 💀 Tod / Respawn (`resources/[aether]/aether-death/`) ✅ migriert
Löst automatisch aus, sobald die Lebensenergie fällt. Der Spieler geht zu Boden statt zu
sterben — GTAs eigener Respawn wird unterbunden.
**Server führt den Countdown:** Verblutungszeit (5:00) und Respawn-Sperre (45 s) laufen
serverseitig. Der Client zeigt sie nur an und kann sie nicht beschleunigen.
**Ablauf:**
- **Rettungsdienst rufen** → löst `aether:death:notruf` aus; ein Rettungsdienst-Modul kann
daraus einen Einsatz erzeugen. Die Position wird aktuell gehalten (man kann kriechen)
- **Um Hilfe rufen** → hörbarer Schmerzlaut für Umstehende
- **Aufgeben** erst nach Ablauf der Sperre; danach Aufwachen im **nächstgelegenen Krankenhaus**
(drei Standorte) gegen **Behandlungspauschale** (erst Bargeld, dann Konto)
- **Verbluten** führt zum selben Ergebnis
- **Wiederbelebung** durch den Rettungsdienst über einen Export — mit Entfernungsprüfung
**Exports für den Rettungsdienst:**
```lua
exports['aether-death']:Wiederbeleben(quelle, helferQuelle) -- prüft die Entfernung
exports['aether-death']:IstVerletzt(source)
exports['aether-death']:HoleVerletzte() -- für eine Einsatzkarte
```
**Verwundet-Zustand:**
- **Verblutungs-Countdown** (5:00) mit animierter **EKG-Linie**, deren Herzschlag mit der
Restzeit langsamer wird; pulsierende blutrote Vignette (bei < 60 s „kritisch")
- **Rettungsdienst rufen** → Notruf mit zufälliger **Eintreff-ETA**, danach Behandlung → wiederbelebt
- **Um Hilfe rufen** (Ruf an Umstehende), **Aufgeben** erst nach **Respawn-Sperre** (45 s)
**Folgezustände:**
- **Behandelt** — rechtzeitig vom Sanitäter wiederbelebt (Leben 100 %)
- **Krankenhaus** — nach Aufgeben oder Verbluten; **Behandlungspauschale** als EMS-Rechnung
> Zustand liegt getrennt im `tod`-Objekt (Phase, Countdowns, EMS-Status). Der Notruf koppelt
> konzeptionell an den **Dispatch/EMS-Teil** von `faction/`, die Behandlungskosten an dessen
> **EMS-Finanzen**. In FiveM per NUI von echten Lebens-Events getrieben.
### 🎯 Radial-Menü (`radial/`)
Web-Prototyp des **kreisförmigen Interaktionsmenüs** — reines HTML/CSS/JS.
Zum Ansehen `radial/index.html` im Browser öffnen. **Taste `G`** öffnet/schließt das Rad, `Esc` schließt.
**Funktionen:**
- **SVG-Kuchenrad** mit farbigen Kategorie-Segmenten, Hover-Highlight und zentraler **Nabe**,
die das überfahrene Segment anzeigt
- **Untermenüs** (▸): Interaktion (Hände hoch, Ausweis zeigen …), Fahrzeug (Ab-/Aufschließen,
Motor, Kofferraum …), Emotes, Job, Notdienst — **Nabe = Zurück**
- **Direktaktionen**: Inventar, Handy (öffnen)
- Auswahl bestätigt per **Toast** und schließt das Rad; abdunkelnder Hintergrund bei geöffnetem Menü
> Menüstruktur liegt getrennt in `menue` (Kategorien mit `kinder`/`aktion`), Rad-Geometrie
> parametrisch (Segmentzahl beliebig). Aktionen sind kosmetisch — in FiveM NUI-Callbacks, die
> auf `inventory/`, `phone/`, `documents/`, `jobs/` und den Dispatch von `faction/` verweisen.
### ⛽ Aether Fuel — Tankstelle (`fuel/`)
Web-Prototyp der **Zapfsäulen-Oberfläche** — reines HTML/CSS/JS.
Zum Ansehen `fuel/index.html` im Browser öffnen. Ein **Demo-Panel** leert den Tank / füllt Geld auf.
**Funktionen:**
- **Digitale Zähler** (Liter + Preis) und **Tankanzeige** (E → F) mit Live-Zapf-Animation
- **Fahrzeugkarte** (Modell, Kennzeichen, Tankgröße) und **Spritsorten** mit Literpreis
(Benzin E10, Super Plus, Diesel)
- **Drei Tankmodi**: Volltanken, nach **Litern**, nach **Betrag (€)** — Zielmenge automatisch durch
Tankgröße **und** Guthaben begrenzt
- **Zahlung** per Bargeld oder Karte (Deckungsprüfung), **Stopp** mitten im Vorgang möglich
- **Kassenbeleg** nach dem Tanken; Geld und Füllstand werden aktualisiert
> Zustand getrennt: `spieler` (Geld), `fahrzeug` (Tank in Litern), `spritSorten` (Preise). Der
> Tank-Füllstand passt konzeptionell zum Tank-Wert im **Fahrzeug-HUD** (`hud/`); Geld später an
> `bank/` koppeln.
### 🕹️ Minispiele (`minigames/`)
Web-Prototyp einer **Skill-Check-Sammlung** — reines HTML/CSS/JS.
Zum Ansehen `minigames/index.html` im Browser öffnen. Übersicht + Nav wie beim Casino.
**Enthaltene Minispiele (je mit Erfolg/Fehlschlag-Ausgang):**
- **🎯 Skill-Check** — rotierenden Zeiger in der grünen Zone stoppen; mehrere Runden, wird
schneller und enger (Leertaste/Klick)
- **🔓 Lockpick** — Marker je Pin in der Zone treffen; 4 Pins, 3 Versuche, Zone schrumpft
- **🧠 Memory** — wachsende Feld-Sequenz merken und fehlerfrei wiederholen (Simon-artig)
- **🔢 Code knacken** — 4-stelligen Code per Deduktion (🟩 richtige Stelle / 🟨 richtige Ziffer)
gegen Zeit und begrenzte Versuche erraten
> Gemeinsame Shell mit Timer-Verwaltung (`stopAlle`) und Ergebnis-Banner (`spielEnde`). In FiveM
> per NUI ausgelöst und **`success`/`fail` zurückgebend** — nutzbar für Fahrzeug-Lockpick,
> Türen/ATM-Hacks, Reparaturen u. a. quer durch die anderen Systeme.
### 📱 Handy (`resources/[aether]/aether-phone/`) ✅ teilweise migriert
Öffnen mit **`F1`** — setzt das **Handy-Item** voraus. Ohne SIM-Karte gibt es kein Netz
(keine Anrufe, keine Nachrichten), bei leerem Akku geht es gar nicht erst an.
**Server-gestützt (echt):**
- **Telefonnummer** je Charakter (`555-XXXX`, automatisch vergeben und eindeutig)
- **Kontakte** (anlegen, bearbeiten, löschen)
- **Nachrichten** zwischen Spielern — werden zugestellt, gespeichert und auch bei
geschlossenem Handy als Hinweis gemeldet
- **Anrufe** zwischen Spielern mit vollem Ablauf: klingeln, annehmen, auflegen, besetzt,
nicht erreichbar; verpasste Anrufe landen im Verlauf. Notrufe (110/112) lösen ein Event
für die spätere Leitstelle aus
- **Bank**-Kachel zeigt echten Kontostand und IBAN
- **Sicherheit:** PIN und Face-ID liegen in der Datenbank, die **PIN verlässt den Server nie**
- **Akku** läuft langsam leer (1 % alle 3 Minuten) und lässt sich mit einem **Akku-Item** tauschen
- Einstellungen (Theme, Wallpaper, Dock) werden gespeichert
> **Noch lokal (bewusst):** Wetter, Rechner, Uhr/Wecker, Notizen, Musik, Kamera/Galerie, Birdy,
> InstaPic, Staat, Dispatch, Gelbe Seiten und Darkchat laufen weiterhin ohne Server. Jede
> dieser Apps wäre ein eigenes Feature — sie funktionieren, halten ihre Daten aber nur
> innerhalb einer Sitzung.
**Export für andere Module:**
```lua
exports['aether-phone']:HoleNummer(source)
exports['aether-phone']:SendeNachricht('555-0182', 'EMS', 'Deine Rechnung: 1.500 €')
```
**Enthaltene Apps & Funktionen:**
- Telefon (Wähler, Verlauf, Kontakte, animierter Anruf)
- Nachrichten (Chats, Senden)
- Bank (Kontostand, Überweisen, Transaktionen)
- Staat (Staatskasse mit Graph, Einnahmen/Ausgaben, Projekt-Abstimmungen)
- Dispatch (Notfall an die Leitstelle melden, vordefinierte Gründe)
- Birdy (Social Media), InstaPic/Galerie, Kamera
- Uhr (Live, Wecker stellen/löschen), Notizen (bearbeiten/löschen)
- Wetter, Rechner, Garage, Gelbe Seiten, Musik, Darkchat (freischaltbar)
- Einstellungen (Theme, Wallpaper, anpassbares Dock, Sicherheit)
**RP-Mechaniken (als Prototyp angelegt):**
- **Ersteinrichtung** beim Erststart: Code festlegen + Face-ID einrichten
- **Entsperren** per Face-ID (Apple-artige Scan-Animation) oder Code
- **Hardware als Items**: Handy, SIM-Karte und Akku (leer werdend) — später ingame kombinierbar
- **Push-Benachrichtigungen** (Banner + Sperrbildschirm, wegwischbar)
- **Gesperrte Apps** per Gamemechanik freischaltbar (z. B. Darkchat)
> Alle "Server-Daten" (Kontakte, Konten, Chats, Hardware-Zustand …) sind bewusst
> vom UI getrennt im `spielerZustand`/Datenlisten gehalten, um später an
> NUI-Callbacks und die Datenbank angebunden zu werden.
### 🏧 Bankautomat (`resources/[aether]/aether-bank/`) ✅ migriert
Öffnet sich an **echten Geldautomaten** in der Welt (`prop_atm_01/02/03`, `prop_fleeca_atm`) —
in die Nähe gehen und **`E`** drücken. Schließen mit `Esc`.
**Sicherheit:**
- Die **PIN verlässt den Server nie.** Sie wird ausschließlich serverseitig gegen
`aether_characters.bank_pin` geprüft; die Oberfläche kennt sie nicht.
- Jede Sitzung beginnt mit der PIN-Eingabe; Buchungen sind erst nach erfolgreicher
Anmeldung möglich (der Server merkt sich das je Spieler).
- Beträge werden auf positive Ganzzahlen und ein Maximum geprüft, Deckung prüft der Core.
**Funktionen:** Auszahlen (Konto → Bargeld), Einzahlen (Bargeld → Konto), Überweisen per **IBAN**
(funktioniert auch an **Offline-Spieler** — dann direkt in der Datenbank), PIN ändern, Kontoauszug
und Statistik.
> **Echte Daten statt erfundener Kurve:** Der Kontostand-Verlauf wird aus den tatsächlichen
> Buchungen abgeleitet (jede Buchung speichert den Stand danach). Bei zu wenig Historie bleibt
> die Linie flach — es wird nichts dazuerfunden.
> **Browser-Fallback:** Ohne FiveM zeigt die Seite Beispieldaten mit Test-PIN `1234` —
> `resources/[aether]/aether-bank/html/index.html` bleibt eigenständig ansehbar.
**Funktionen:**
- **PIN-Eingabe** (Login) und **PIN-Änderung** (aktuelle → neue → bestätigen)
- **Auszahlen** (Schnellbeträge + eigener Betrag, „Geld entnehmen"-Animation)
- **Einzahlen** und **Überweisen** (mit Bestätigung)
- **Kontoauszug** (laufender Saldo, Filter Eingang/Ausgang)
- **Statistiken**: Aktien-artiges Verlaufsdiagramm (glatte Linie + Flächenverlauf,
Hover-Fadenkreuz, Zeitraum 7T/30T/12M), Einnahmen/Ausgaben, Sparquote
Besonderheit: Das Display ist standardmäßig durchscheinend und wird erst bei
Maus-Hover voll sichtbar (verlässt die Maus das Display, sinkt die Opazität).
> Kontodaten (Saldo, IBAN, PIN, Transaktionen) liegen getrennt im `konto`-Objekt,
> bereit für die Anbindung an DB/Server.
### 🎒 Inventar (`resources/[aether]/aether-inventory/`) ✅ migriert
**Vollständig server-geprüft.** Die Oberfläche entscheidet nichts mehr selbst — sie schickt nur
Wünsche („verschiebe Slot 3 auf Slot 7"), der Server prüft und antwortet mit dem neuen Zustand.
Öffnen im Spiel mit **Taste `I`**, schließen mit `Esc`.
**Slot-Aufteilung** (alles in einer Tabelle, die Nummer bestimmt den Bereich):
Tasche `0–24` · Hotbar `100–104` · Ausrüstung `200–204`
**Serverseitige Prüfungen:**
- Gültigkeit jeder Slotnummer, Passform der Ausrüstungsplätze, Tauschbarkeit
- **Traglast** (30 kg) bei Aufheben, Geben und Hinzufügen
- **Entfernung** beim Aufheben (3 m) und Geben (3 m) — kein Zugriff quer über die Karte
- Beim Geben wird **zuerst** geprüft, ob der Empfänger tragen kann; schlägt die Übergabe fehl,
geht das Item an den Absender zurück (nichts verschwindet)
**Item-Registry** (`shared/items.lua`) wird von Client *und* Server geladen, damit beide
garantiert dieselben Werte verwenden. Benutzen wertet der Server aus (Essen/Trinken über
`AendereZustand` im Core, Heilung über ein Client-Event).
**Exports für andere Module** (Shop, Jobs, Dokumente …):
```lua
exports['aether-inventory']:GebeItem(source, 'wasser', 2)
exports['aether-inventory']:ZaehleItem(source, 'dietrich')
exports['aether-inventory']:EntferneItem(source, 'bargeld', 500)
```
**Fallengelassene Items** liegen als sichtbare **Kartons** in der Welt und sind **dauerhaft**
gespeichert (Tabelle `aether_drops`) — sie überleben Serverneustarts:
- Beim Fallenlassen wird zufällig einer von drei Kartons gewählt
(`prop_cardbordbox_03a/04a/05a`), damit alle Drops einheitlich aussehen
- Props werden **nur lokal** je Client erzeugt und ab 30 m Entfernung wieder entfernt
- Aufheben per **`E`** am Karton (mit Hinweis über dem Objekt) oder per Drag & Drop im Inventar
- Drops älter als 48 h werden beim Serverstart aufgeräumt (`DROP_MAX_STUNDEN`)
> **Browser-Fallback:** Ohne FiveM zeigt die Seite Beispieldaten und rechnet das Gewicht selbst —
> `resources/[aether]/aether-inventory/html/index.html` bleibt eigenständig ansehbar.
**Funktionen:**
- **Drag & Drop** zwischen Tasche, Ausrüstung, Hotbar, Boden und Spielern
- **Gewichtssystem** (getragene kg / Limit), Einzelgewicht je Item
- **Hover-Details** (Name nach Seltenheit gefärbt, Kategorie, Beschreibung, Gewicht)
- **3D-artiger Avatar** mit Ausrüstungs-Slots (Helm, Brust, Hose, Schuhe, Schmuck) + Hotbar 1–5
- **Fallenlassen** (auf Boden ziehen), **Geben** (auf Spieler ziehen)
- **Splitten** per Rechtsklick → Slider; Rechtsklick-Menü (Benutzen, Ausrüsten, Geben, Fallenlassen)
- **Doppelklick** = Benutzen / Ausrüsten
- **Assets & Waffen**: Handy zeigt verbaute Komponenten (SIM, Akku), Waffen zeigen
Seriennummer + Aufsätze (Basis für späteren Waffenschein)
- **Anonyme Spieler**: Namen erscheinen erst, wenn die Person bekannt ist
(im Spiel via Ausweis/Visitenkarte) — sonst „Unbekannt · ID #xxxx"
> Item-Registry (`itemDefs`) und Orte (Tasche, Ausrüstung, Hotbar, Boden) sind
> getrennt gehalten, bereit für die Anbindung an das Server-Inventar.
### 👛 Börse & Dokumente (`resources/[aether]/aether-documents/`) ✅ migriert
Öffnen mit **`F2`** — setzt voraus, dass du mindestens eine **Börse** (Item aus dem 24/7)
dabei hast. Die Kartendesigns des Prototyps sind unverändert übernommen.
**Datenmodell:** Tabelle `aether_documents`. Name und Geburtsdatum kommen **live aus dem
Charakter**, nur die dokumentspezifischen Felder (Nummern, Fahrklassen, registrierte Waffen)
liegen im Dokument — so muss nichts doppelt gepflegt werden.
**Automatisch** beim ersten Laden: Personalausweis, Gesundheitskarte, Visitenkarte.
**Behördlich** (nur über Exports ausstellbar): Führerschein, Waffenschein.
**Anzahl Börsen = Anzahl `boerse`-Items im Inventar** — kaufst du eine zweite Börse im 24/7,
erscheint sie als weiterer Reiter (je max. 10 Dokumente).
**Vorzeigen** an Personen in 4 m Reichweite; der Empfänger sieht die Karte als Vollbild.
Dabei werden die Daten des *Zeigenden* gerendert, nicht die eigenen.
**Exports:**
```lua
-- Wird vom Shop (Ammu-Nation) abgefragt:
exports['aether-documents']:HatWaffenschein(source)
exports['aether-documents']:HatDokument(source, 'fs')
-- Für ein Polizei-Modul (stellt aus / entzieht):
exports['aether-documents']:StelleAus(charakterId, 'ws', { waffen = {...} }, 'LSPD')
exports['aether-documents']:Entziehe(charakterId, 'ws')
```
> **Damit greift die Waffenschein-Prüfung im Shop.** Ammu-Nation gibt lizenzpflichtige Waren
> jetzt nur noch an Spieler mit gültigem, nicht entzogenem Waffenschein heraus.
**Dokumente (jeweils eigenes Design):**
- **Personalausweis** (Guilloché, Wappen, MRZ-Zeile)
- **Führerschein** (EU-Stil, Sternenkreis, Fahrklassen)
- **Waffenschein** (Behörden-Look, Stempel, Tabelle mehrerer registrierter Waffen mit Seriennummer)
- **Gesundheitskarte** (eGK mit Chip)
- **Visitenkarte** (nur Name + Telefonnummer, für Handykontakt)
**Funktionen:**
- **Börse = ein Item**, das alle Karten enthält
- **Multi-Börse**: mehrere Börsen (im Shop kaufbar), je **max. 10 Slots**, Wechsel per Tabs
- **Verschieben** von Dokumenten zwischen Börsen
- **Vorzeigen** (präsentieren, optional gezielt an eine — anonymisierte — Person)
- **Verlieren** (Dokument aus der Börse entfernen)
> Stammdaten liegen zentral im `person`-Objekt, Karten-Designs pro Typ in
> `kartenRenderer`, Börsen in `boersen[]` (mit `MAX_SLOTS`). Die Waffen-Seriennummern
> passen zu den Items im Inventar — Basis für spätere Waffenschein-Prüfungen.
### 🛒 Läden (`resources/[aether]/aether-shop/`) ✅ migriert
Öffnet sich an echten Filialen in der Welt — **10 24/7-Läden** und **9 Ammu-Nations** mit
Karten-Markierungen. In die Nähe gehen und **`E`** drücken, schließen mit `Esc`.
**Der Server rechnet den Warenkorb komplett neu durch:**
- **Preise kommen immer vom Server** — was der Client schickt, sind nur Produkt-IDs und Mengen
- Führt der Laden das Produkt überhaupt? Ist der **Bestand** da? Ist die **Menge** plausibel?
- **Waffenschein** bei lizenzpflichtigen Waren, **Deckung** über den Core, **Traglast** über das Inventar
- Steht die Entfernung zur Filiale? (kein Einkaufen aus der Ferne)
- Passt etwas nicht ins Inventar, wird der Anteil **automatisch erstattet**
**Bestand & Nachschub:** Jede Filiale hat eigene Bestände im Speicher, die alle 15 Minuten um
10 % aufgefüllt werden.
**Gekaufte Waren landen im Inventar** — dafür wurde die Item-Registry um alle fehlenden Waren
erweitert (Getränke, Snacks, Elektronik, Zubehör, Waffen, Munition, Aufsätze). Munition wird
paketweise verkauft (z. B. „9mm ×30" liefert 30 Stück).
> **Browser-Fallback:** Ohne FiveM rechnet die Seite lokal mit Beispieldaten und zeigt beide
> Läden zum Umschalten — `resources/[aether]/aether-shop/html/index.html`.
**Enthaltene Läden (oben umschaltbar, Akzentfarbe passt sich an):**
- **LS 24/7** — Getränke, Snacks, Gesundheit, Werkzeug, Elektronik (Handy, SIM, Akku), Zubehör (Börse, Rucksack)
- **Ammu-Nation** — Waffen, Munition, Aufsätze, Schutz (mit „Waffenschein nötig"-Badge)
**Funktionen:**
- Kategorien-Sidebar (mit Anzahl) + Live-Suche
- Produktkarten mit Bestand, Angeboten (Rabatt), Menge-im-Korb
- Warenkorb mit Mengen-Steppern, Zwischensummen, Gesamtpreis
- Zahlung per Bargeld oder Karte (Guthaben-Anzeige + Deckungsprüfung)
- Kassenbeleg nach dem Kauf; Bestand/Geld werden aktualisiert
> Zentrale Produkt-Registry (`produkte`) + `shops[]`, die nur Produkt-IDs referenzieren —
> beliebige weitere Läden mit wenigen Zeilen anlegbar. Geld liegt in `spieler`
> (später an Bank/Item-System koppeln).
### 🚓 Polizei & Rettungsdienst — *neu zu bauen*
Das gemeinsame Fraktions-CAD wurde entfernt. Polizei und Rettungsdienst sollen als
**zwei getrennte Module** entstehen (`aether-police`, `aether-ems`).
Die Datenbanktabellen aus dem CAD bleiben bestehen und sind weiterverwendbar:
`aether_records` (Personenakten), `aether_wanted` (Fahndungen), `aether_jail` (Haft),
`aether_treasury` (Staatskasse).
Offene Anschlusspunkte, die auf ein solches Modul warten:
- **Notruf-Events** `aether:phone:notruf` (110/112 aus dem Handy) und `aether:death:notruf`
(Verletzt-Screen) werden ausgelöst, aber derzeit von niemandem verarbeitet
- **Waffenschein**: `exports['aether-documents']:StelleAus(charakterId, 'ws', …)` — solange
niemand ausstellt, bleiben Waffen bei Ammu-Nation gesperrt
- **Kennzeichenabfrage**: `exports['aether-garage']:HoleHalter(kennzeichen)`
- **Wiederbelebung**: `exports['aether-death']:Wiederbeleben(quelle, helferQuelle)`
### 🧑 Charakterauswahl (`resources/[aether]/aether-character/`) ✅ migriert
Vollständig an den Server angebunden: Die Liste kommt aus der Datenbank, Erstellen und Löschen
laufen über geprüfte NUI-Callbacks, und die Auswahl lässt den Core den Charakter spawnen.
Im Browser weiterhin ansehbar (zeigt dann Beispieldaten):
`resources/[aether]/aether-character/html/index.html`.
**Serverseitige Prüfungen:** Namen (nur Buchstaben, 2–32 Zeichen), Geburtsdatum (TT.MM.JJJJ,
Jahr 1900–2010), Slotgrenze aus `Config.MaxCharaktere` und Eigentumsprüfung bei Spielen/Löschen.
**3D-Vorschau:** `client/vorschau.lua` spawnt einen **lokalen Ped** als lebende Vorschau und
richtet eine Kamera darauf; die NUI liegt mit einem **transparenten Bereich** darüber. Jede
Änderung an Slidern oder Paletten wird sofort auf den Ped übertragen (gebündelt, damit Slider
die Verbindung nicht fluten). Enthalten:
- Ansichten **Gesicht / Körper** (Kamerafahrt statt SVG-Wechsel)
- **Drehen** des Peds per ◀ ▶ unter der Vorschau
- **Geschlechtswechsel** erzeugt den Ped mit dem passenden Modell neu (Drehung/Ansicht bleiben)
- Auf der Auswahlseite zeigt der Ped den gerade **markierten Charakter**
**Aussehen:** `client/aussehen.lua` überträgt die Werte des Erstellers auf den GTA-Ped
(Kopfform, Hautton, Frisur, Haar-/Augenfarbe, Bart, Augenbrauen, Gesichts-Slider,
Kleidungsfarben). Die Zuordnungstabellen sind bewusst als **Annäherung** angelegt und leicht
nachjustierbar.
> **Browser-Fallback:** Ohne FiveM (Datei direkt öffnen) fehlt der 3D-Ped. Die Seite erkennt das
> und schaltet auf das frühere Kasten-Layout mit der stilisierten **SVG-Vorschau** zurück — das
> Modul bleibt also weiter eigenständig ansehbar.
**Auswahl:**
- Bis zu 5 Charakter-Slots mit Portrait-Vorschau, Name, Job, Spielzeit
- Aktionen: Spielen, Löschen (kein Bearbeiten — Änderungen am Charakter erfolgen ingame)
- „+ Neuen Charakter erstellen"
**Ersteller (live aktualisierte Vektor-Vorschau, Gesicht-/Körper-Ansicht):**
- Identität (Vor-/Nachname, Geschlecht, Geburtsdatum, Herkunft)
- Gesicht (Hautton, Augenfarbe)
- Merkmale (Slider: Gesichtsbreite, Kiefer, Nase, Lippen, Körperbau)
- Haare & Bart (Frisuren, Haarfarben, Augenbrauen, Bartstile)
- Kleidung (Oberteil/Hose/Schuhe-Farben)
> Das Aussehen liegt als kompaktes `aussehen`-Objekt je Charakter (Slider 0–100),
> später auf FiveM-Ped-Komponenten (Heritage/Features/Components) mappbar. Die Vorschau
> ist eine stilisierte SVG-Darstellung, kein 3D-Ped.
### 🚗 Garage (`resources/[aether]/aether-garage/`) ✅ migriert
Vier öffentliche Garagen (Legion Square, Sandy Shores, Del Perro, Paleto) mit
Karten-Markierungen — hingehen und **`E`** drücken. Nutzt die Tabelle `aether_vehicles`.
**Regeln, die der Server durchsetzt:**
- **Standort-Regel:** Ausparken nur an der Garage, wo das Fahrzeug wirklich steht — sonst
nennt die Meldung den tatsächlichen Standort
- **Zugangs-Regel:** Es werden nur Garagen angezeigt, zu denen Zugang besteht. Haus-Garagen
fragen bei `aether-immobilien` an (noch nicht migriert → bleiben verschlossen)
- **Besitz** bei jeder Aktion, **Entfernung** beim Öffnen und Einparken
- Beschlagnahmte Fahrzeuge lassen sich nur gegen **Gebühr** auslösen
**Fahrzeuge werden serverseitig erzeugt** und überleben dadurch Serverneustarts:
- Die Position ausgeparkter Fahrzeuge wird **alle 30 Sekunden** sowie beim Stoppen der
Ressource gesichert (Spalten `pos_x/pos_y/pos_z/heading`)
- Beim Serverstart stehen sie wieder **exakt dort, wo sie zuletzt waren**
- Lässt sich ein Fahrzeug nicht setzen (z. B. Modell entfernt), wandert es zurück in seine
Garage statt verloren zu gehen
- Eigenschaften, die nur clientseitig gesetzt werden können (Kennzeichen, Farbe, Zustand),
reicht der Server über einen **State Bag** weiter; der Client wendet sie an, sobald das
Fahrzeug bei ihm auftaucht
**Zustand wird echt gespeichert:** Beim Einparken liest der Client Tank, Motor- und
Karosserieschaden vom Fahrzeug; der Server begrenzt die Werte und schreibt sie zurück.
Während der Fahrt wird der Zustand zusätzlich alle 30 Sekunden gemeldet, damit er auch bei
einem unerwarteten Serverstopp nicht verloren geht.
**Kilometerstand:** GTA führt keinen Tacho — der Client zählt die gefahrene Strecke selbst
mit (Sprünge durch Teleport/Nachladen werden ignoriert), der Server addiert sie beim
Einparken auf und lässt den Stand nie sinken.
**Neue Charaktere** bekommen ein Startfahrzeug (Blista in Legion Square, abschaltbar).
**Exports:**
```lua
exports['aether-garage']:GebeFahrzeug(charakterId, 'sultan', 'legion', { name = 'Sultan' })
exports['aether-garage']:Beschlagnahmen(kennzeichen) -- fürs Polizei-CAD
exports['aether-garage']:HoleHalter(kennzeichen) -- Kennzeichenabfrage
```
**Funktionen:**
- **Auto-Vorschau** je Fahrzeugtyp (Sport/Limousine/SUV/Pickup/Kleinwagen/Muscle/Motorrad),
parametrisches SVG in Fahrzeugfarbe
- **San-Andreas-Kennzeichen** (Stile SA/Gelb/Schwarz) — Farbe **fix**, wird nur beim Kauf festgelegt
- **Zustand** (Tank/Motor/Karosserie), Kilometerstand, Klasse
- **Standort-Regel**: Ausparken nur an der Garage, wo das Fahrzeug steht; Ansehen aller
zugänglichen Garagen erlaubt („Alle"-Übersicht), Einparken lagert ins aktuelle Standort-Garage
- **Zugangs-Regel**: sichtbar sind nur **öffentliche** Garagen und **Haus-Garagen mit Zugang**
(Besitz/berechtigt); Garagen ohne Zugang erscheinen nicht
> `garagen` (mit `typ`/`zugang`), `standort` (physischer Standort), `zugaenglich()`.
> Später `standort` an die Spielerposition und `zugang` an Haus-/Schlüsselbesitz koppeln.
> Kennzeichen passen konzeptionell zur Kennzeichenabfrage im CAD.
### 🏡 AetherImmos — Immobilien (`immobilien/`)
Web-Prototyp des **Immobilien-/Wohnungs-Systems** — reines HTML/CSS/JS.
Zum Ansehen `immobilien/index.html` im Browser öffnen.
**Bereiche:**
- **Markt (AetherImmos)** — Angebote mit stilisiertem Objekt-„Foto" je Typ (Wohnung/Haus/Villa/
Gewerbe), Filter + Suche, Detailansicht mit Ausstattung
- **Kaufmenü** (separat) — Kaufpreis + Notar-/Bearbeitungsgebühr, Zahlung per Konto/Bargeld;
beim Kauf gibt es **5 Hausschlüssel (Items)**
- **Meine Immobilien** — Betreten, **Schlüssel-Verwaltung** (Anzahl gehaltener Schlüssel,
**Nachmachen** beim Schlüsseldienst; Weitergabe erfolgt übers Inventar, da Schlüssel = Item),
**Verkauf mit anteiliger Rückzahlung** (60 % des Kaufpreises)
> `marktplatz` (Angebote), `meine` (Besitz mit `schluessel`-Anzahl + `kaufpreis`),
> `RUECKKAUF=0.6`, `START_SCHLUESSEL=5`. Schlüssel koppeln ans Inventar-System; die Zugangslogik
> passt zur Haus-Garagen-Zugangsregel in `garage/`.
### 👕 Aether Threads — Kleidungsladen (`clothing/`)
Web-Prototyp des **Bekleidungsladens** mit Live-Vorschau — reines HTML/CSS/JS.
Zum Ansehen `clothing/index.html` im Browser öffnen.
**Funktionen:**
- **Live-Charaktervorschau** (SVG-Figur), Zoom **Ganzkörper / Kopf**
- Kategorien: Kopfbedeckung (Cap/Beanie/Hut/Helm), Brille, Maske (Halbmaske/Sturmhaube),
Oberteil (T-Shirt/Hemd/Hoodie/Jacke/Anzug), Hose (Jeans/Cargo/Shorts/Anzughose),
Schuhe (Sneaker/Stiefel/Sandalen), Kette (Silber/Gold)
- **Farbwahl** je Teil; bereits getragene Teile als „im Besitz" markiert (kostenlos)
- **Warenkorb** der geänderten Teile, **Zahlung** Bargeld/Karte (Deckungsprüfung),
„Kaufen & anziehen" / „Zurücksetzen"
> `kategorien` (Stile + Preise), `farben`, `getragen` (Besitz) vs. `outfit` (Anprobe).
> Der Avatar koppelt konzeptionell an dieselben Komponenten wie der Charakter-Ersteller (`character/`).
### 🛡️ Admin-Dashboard (`admin/`)
Web-Prototyp des **zentralen Server-Admin-Dashboards** — reines HTML/CSS/JS.
Zum Ansehen `admin/index.html` im Browser öffnen.
**Bereiche:**
- **Dashboard** (Live-KPIs, 24h-Chart, Schnellaktionen, Ressourcen-Status, Admin-Log)
- **Weltkarte** mit Live-Standorten + **Quick-Menü** je Spieler (Verwalten/Kamera/TP/Freeze/Kick)
- **Spieler**: Tabelle + gruppiertes Aktions-Panel (Teleport, Zustand, Geben, Moderation:
Heal/Revive/Freeze/God/Mute, Geld, Item, Fahrzeug, Job, Warn/Kick/Ban)
- **Fahrzeuge**: Bild-Spawner (Autos/Bikes/Helis/Boote/Flugzeuge) + **Tuning** (Motor/Getriebe/
Bremsen/Panzerung/Turbo/Lack), Reparieren/Herholen/Löschen
- **Live-Kameras** (WebRTC-Spectate, Einzel- & Multi-Cam-Views), **Kill-/Deathcams**
(manuell speicherbar, sonst Auto-Löschung nach 24 h)
- **Staatsprojekte** (aktivierbar nach positiver Abstimmung aus der Handy-Staat-App)
- **Immobilien**, **Fraktionen** (Gehalt/h, erstellen/löschen), **Wirtschaft**
- **Weltsteuerung** (Zeit/Wetter/Blackout/Cleanup), **Ankündigungen**
- **Bans & Warns**, **Reports/Support**, **Ressourcen**, **Audit-Log**, **Admin-Team**, **Einstellungen**
> Die „Kamera-Feeds" sind stilisierte Simulationen (in FiveM WebRTC-Stream). Datenmodelle
> (`players`, `weltFahrzeuge`, `staatsprojekte`, `killcams`, `fraktionen` …) sind vom UI getrennt.
### 🎖️ Fraktions-/Gang-Verwaltung (`factions/`)
Web-Prototyp der **Fraktions-/Gang-Verwaltung** aus Führungssicht — reines HTML/CSS/JS.
Zum Ansehen `factions/index.html` im Browser öffnen. Oben Fraktion + „Als Rang ansehen" wählen.
**Funktionen:**
- **Mehrere Fraktionen** (Gangs & legaler Betrieb), Akzentfarbe passt sich an
- **Mitglieder**: Roster, Befördern/Degradieren/Kicken (rang-gated), Einladen
- **Ränge & Rechte**: Gehalt je Rang + Rechte-Matrix (Einladen/Kicken/Befördern/Kasse/Ränge/
Territorien/Fahrzeuge/Einstellungen), Ränge anlegen/löschen; Leitung hat alle Rechte
- **Kasse**: Einzahlen/Auszahlen, **Payday** (zieht alle Gehälter), Transaktionsliste
- **Territorien** (nur Gangs): Karte mit Kontroll-% (kontrolliert/umkämpft/feindlich),
Angreifen/Verstärken/Ausbauen (Ausbau kostet Kasse, erhöht Einnahmen/h)
- **Fraktionsfahrzeuge** (gemeinsame Garage, Freigabe ab Rang), **Logbuch**, **Einstellungen**
> Rechtesystem greift überall über `darf(recht)` (abhängig vom gewählten Rang). Datenmodell
> `fraktionen[]` (Ränge/Mitglieder/Kasse/Territorien/Fahrzeuge/Logs). Deckt die im Admin-Panel
> referenzierten Fraktionen ab.
### 💼 Aether Jobs (`resources/[aether]/aether-jobs/`) ✅ migriert
Am **Jobcenter** (Karten-Markierung) mit **`E`** öffnen; wer im Dienst ist, erreicht die
Arbeitsansicht jederzeit mit **`J`**. Acht Jobs mit Level und XP, gespeichert in `aether_jobs`.
**Gearbeitet wird in der Welt, nicht im Menü.** Jeder Arbeitsschritt hat einen echten Ort:
- Nach dem Antreten schließt sich das Menü und eine **Route auf der Karte** führt zum Arbeitsort
- Dort steht ein **Bodenmarker**; in Reichweite erscheint `[E] Paket laden`
- Die Handlung läuft mit **Animation und Fortschrittsbalken** ab (Angeln 12 s, Baum fällen 10 s,
Erz abbauen 12 s …), erst danach meldet der Client sie
- Eine **Aufgabenanzeige** am Bildschirmrand zeigt durchgehend Aufgabe, Ziel und Entfernung
- Jobs mit Fahrzeug (Lieferdienst, Müllabfuhr, Taxi, Trucker) bekommen beim Antreten ein
**Dienstfahrzeug** gestellt, das bei Feierabend wieder verschwindet
**Der Server prüft alles Wesentliche:**
- **Ortsprüfung:** Der Spieler muss tatsächlich am Arbeitsort stehen, sonst wird abgelehnt
- **Zeitprüfung:** Die Handlung muss ihre vorgesehene Dauer gebraucht haben
- **Aufträge, Orte und Löhne entstehen ausschließlich serverseitig** — der Client meldet nur
„Schritt erledigt" und kann weder Ziel noch Bezahlung beeinflussen
- Nur **ein Job gleichzeitig**; Level/XP und Verdienst werden pro Job dauerhaft gespeichert
- **Levelbonus** von 5 % je Level fließt in den Lohn ein, Höchstlevel 20
**Exports:**
```lua
exports['aether-jobs']:HoleAktivenJob(source) -- z. B. für Job-Fahrzeuge
exports['aether-jobs']:HoleLevel(source, 'trucker')
```
**Funktionen:**
- **Jobcenter** mit 8 Jobs (Lieferdienst, Müllabfuhr, Taxi, Trucker, Tankwart, Angeln,
Holzfäller, Bergbau) inkl. Level/XP je Job
- **Dienst an/aus** (nur ein Job gleichzeitig), Akzentfarbe passt sich dem Job an
- **Arbeits-Loop**: mehrschrittige Aufträge mit Fortschrittsanzeige, Lohn-Vorschau,
Fang/Ausbeute-Flavor; bei Abschluss Geld + XP, ggf. Level-Up (erhöht den Lohn)
- **Schicht-Statistik** (Verdient, Aufträge, Dienstzeit-Timer), **Feierabend**
- **Statistik** über alle Jobs (Level/XP)
> `jobs[]` (mit `schritte` + `auftrag(level)`), `fortschritt` (Level/XP je Job), `spieler`
> (Bargeld/Konto). Aktionen später an echte Ingame-Zonen/Fahrten koppelbar.
### 🎰 Aether Casino (`casino/`)
Web-Prototyp des **Casino-UIs** mit fünf spielbaren Glücksspielen — reines HTML/CSS/JS.
Zum Ansehen `casino/index.html` im Browser öffnen.
**Guthaben & Session:**
- **Guthaben** (Casino-Chips) getrennt vom **Bargeld**; **Aufladen/Auszahlen** zwischen beiden
- **Session-Bilanz** (Netto-Gewinn/-Verlust der aktuellen Sitzung, grün/rot)
- Wiederverwendbare **Einsatz-Steuerung** (Schnell-Chips 50/100/500/1000 + „Max")
**Spiele (mit bewusst kalkuliertem Hausvorteil):**
- **Spielautomat** — 3 gewichtete Walzen, Auszahlungstabelle, 7️⃣7️⃣7️⃣-Jackpot ×180 (RTP ~90 %)
- **Blackjack** — gegen den Dealer (Ziehen/Stehen/Verdoppeln), echter Blackjack ×2, Dealer zieht bis 17
- **Roulette** — einfache Chancen (×2), Dutzende (×3), Einzelzahl (×36), mehrere Einsätze gleichzeitig
- **Glücksrad** — 12 Felder, Multiplikator ×0 bis ×5 (Hausvorteil ~21 %)
- **Münzwurf** — Kopf oder Zahl, ×2 bei richtiger Wahl
> Zustand liegt getrennt: `spieler` (Bargeld), `guthaben` (Chips), `session` (Ein/Aus). Jedes Spiel
> hält sein eigenes Datenmodell (`slotReel`/`slotPay`, `bj`, `rlt.bets`, `wSegs`) — Gewichtungen/
> Multiplikatoren zentral definiert und leicht anpassbar. Der Spielwechsel erstattet offene Einsätze.
## Konventionen
- Jedes System ist eine eigenständige `index.html` (self-contained, ohne externe Abhängigkeiten).
- Daten/Zustand sind bewusst vom UI getrennt gehalten — vorbereitet für die spätere
Anbindung an FiveM (NUI-Callbacks) und Datenbank.

977
admin/index.html Normal file
View File

@ -0,0 +1,977 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
</head>
<body>
<title>Aether — Admin Dashboard</title>
<style>
:root{
--bg:#070a10;--fg:#e8ecf4;--muted:#7e879b;--surface:rgba(22,26,36,0.72);--surface2:rgba(34,39,52,0.85);
--slot:#111520;--border:rgba(255,255,255,0.08);--accent:#5b8cff;--ink:#04102b;--accent-soft:rgba(91,140,255,0.16);
--green:#34d39a;--amber:#f5b23d;--red:#f0506e;--violet:#a56bff;--cyan:#22c1d4;
}
*{box-sizing:border-box;} html,body{margin:0;height:100%;}
body{min-height:100%;background:radial-gradient(1200px 700px at 15% 0%,rgba(91,140,255,0.08),transparent 55%),radial-gradient(1000px 800px at 90% 100%,rgba(52,211,153,0.05),transparent 55%),var(--bg);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);
display:flex;align-items:center;justify-content:center;padding:20px 14px;overflow-x:hidden;}
.admin{position:relative;width:min(1360px,98vw);aspect-ratio:16/10;border-radius:20px;overflow:hidden;
background:linear-gradient(160deg,rgba(18,22,30,0.92),rgba(9,11,16,0.96));backdrop-filter:blur(20px);border:1px solid var(--border);
box-shadow:0 50px 100px -30px rgba(0,0,0,0.85);display:grid;grid-template-columns:224px 1fr;grid-template-rows:auto 1fr;grid-template-areas:"brand top" "nav main";}
.brand{grid-area:brand;padding:14px 18px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
.brand .bl{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;font-size:18px;background:linear-gradient(150deg,var(--accent),#2a52c9);}
.brand .bn{font-size:15px;font-weight:800;letter-spacing:-0.02em;line-height:1.1;} .brand .bn small{display:block;font-size:10px;color:var(--muted);font-weight:500;}
.top{grid-area:top;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;padding:0 18px;}
.srv-chip{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:700;} .srv-dot{width:9px;height:9px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);}
.top .live{display:flex;gap:16px;margin-left:8px;font-size:12px;color:var(--muted);} .top .live b{color:var(--fg);font-variant-numeric:tabular-nums;}
.top .restart{margin-left:auto;font-size:12px;color:var(--amber);font-weight:700;}
.top .me{display:flex;align-items:center;gap:9px;padding-left:14px;border-left:1px solid var(--border);} .top .me .av{width:32px;height:32px;border-radius:50%;background:linear-gradient(150deg,var(--violet),#6b3fb0);display:grid;place-items:center;font-weight:800;font-size:12px;} .top .me .mn{font-size:13px;font-weight:700;} .top .me .mr{font-size:10.5px;color:var(--amber);font-weight:700;}
.nav{grid-area:nav;border-right:1px solid var(--border);padding:10px 9px;display:flex;flex-direction:column;gap:2px;overflow-y:auto;}
.nav .grp{font-size:9px;text-transform:uppercase;letter-spacing:0.09em;color:#535c70;font-weight:800;margin:10px 12px 4px;}
.nav button{display:flex;align-items:center;gap:10px;padding:8px 11px;border-radius:9px;border:none;background:none;color:var(--muted);font-family:inherit;font-size:13px;font-weight:700;cursor:pointer;text-align:left;transition:.12s;}
.nav button .ni{font-size:15px;width:18px;text-align:center;} .nav button:hover{background:var(--surface);color:var(--fg);} .nav button.active{background:var(--accent-soft);color:var(--accent);}
.nav button .badge{margin-left:auto;background:var(--red);color:#fff;font-size:9.5px;font-weight:800;border-radius:999px;padding:1px 6px;}
.main{grid-area:main;overflow-y:auto;padding:18px 22px;} .main::-webkit-scrollbar,.nav::-webkit-scrollbar,.tblwrap::-webkit-scrollbar{width:7px;} .main::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.09);border-radius:4px;}
.phead{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;margin-bottom:14px;} .phead h2{font-size:20px;margin:0;font-weight:800;letter-spacing:-0.02em;} .phead p{margin:2px 0 0;color:var(--muted);font-size:12.5px;}
.grid{display:grid;gap:12px;} .card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:15px;}
.kpi .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;font-weight:800;display:flex;align-items:center;gap:6px;} .kpi .v{font-size:26px;font-weight:800;font-variant-numeric:tabular-nums;margin-top:3px;} .kpi .d{font-size:11.5px;font-weight:600;margin-top:1px;}
.list-h{font-size:10.5px;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);font-weight:800;margin:0 0 10px;display:flex;align-items:center;gap:8px;} .list-h .r{margin-left:auto;font-weight:600;text-transform:none;letter-spacing:0;}
.btn{border:none;border-radius:10px;padding:9px 14px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit;transition:.12s;} .btn.primary{background:var(--accent);color:#fff;} .btn.primary:hover{filter:brightness(1.08);} .btn.ghost{background:var(--surface2);color:var(--fg);border:1px solid var(--border);} .btn.red{background:var(--red);color:#2a0910;} .btn.green{background:var(--green);color:#06231a;} .btn.amber{background:var(--amber);color:#3a2600;} .btn.sm{padding:6px 10px;font-size:12px;} .btn:disabled{opacity:.4;cursor:not-allowed;} .btn:active{transform:scale(0.98);}
.input,select.input,textarea.input{width:100%;background:var(--slot);border:1px solid var(--border);border-radius:9px;padding:10px 11px;color:var(--fg);font-size:13.5px;font-family:inherit;outline:none;} .input:focus{border-color:var(--accent);} textarea.input{resize:vertical;min-height:64px;}
.flabel{font-size:10.5px;color:var(--muted);font-weight:800;text-transform:uppercase;letter-spacing:0.04em;margin:11px 2px 5px;display:block;}
.searchbar{display:flex;align-items:center;gap:9px;background:var(--slot);border:1px solid var(--border);border-radius:10px;padding:9px 12px;} .searchbar input{flex:1;background:none;border:none;color:var(--fg);font-family:inherit;font-size:13.5px;outline:none;}
.seg{display:inline-flex;background:var(--slot);border-radius:9px;padding:3px;} .seg button{border:none;background:none;color:var(--muted);font-weight:700;font-size:12px;padding:6px 12px;border-radius:7px;cursor:pointer;font-family:inherit;} .seg button.active{background:var(--accent);color:#fff;}
.pill{font-size:10px;font-weight:800;padding:3px 8px;border-radius:999px;display:inline-block;} .pill.g{background:rgba(52,211,153,0.16);color:var(--green);} .pill.a{background:rgba(245,178,61,0.16);color:var(--amber);} .pill.r{background:rgba(240,80,110,0.16);color:var(--red);} .pill.b{background:rgba(91,140,255,0.16);color:var(--accent);} .pill.v{background:rgba(165,107,255,0.16);color:var(--violet);} .pill.m{background:rgba(255,255,255,0.08);color:var(--muted);}
.tblwrap{overflow-x:auto;} table.tbl{width:100%;border-collapse:collapse;font-size:12.5px;} .tbl th{text-align:left;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;padding:8px 9px;border-bottom:1px solid var(--border);white-space:nowrap;} .tbl td{padding:9px 9px;border-bottom:1px solid var(--border);white-space:nowrap;} .tbl tr:last-child td{border:none;} .tbl tr:hover td{background:rgba(255,255,255,0.02);} .tbl .num{text-align:right;font-variant-numeric:tabular-nums;} .tbl .av{width:26px;height:26px;border-radius:50%;display:inline-grid;place-items:center;font-weight:800;color:#fff;font-size:10px;vertical-align:middle;margin-right:7px;}
.barmini{height:7px;border-radius:4px;background:var(--slot);overflow:hidden;width:90px;display:inline-block;vertical-align:middle;} .barmini>i{display:block;height:100%;border-radius:4px;}
.flags{display:inline-flex;gap:4px;}
.quick{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;} .quick button{justify-content:flex-start;text-align:left;}
.actiongrid .ag-h{grid-column:1/-1;font-size:9.5px;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted);font-weight:800;margin:10px 2px 0;} .actiongrid .ag-h:first-child{margin-top:0;}
.vehgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;max-height:320px;overflow-y:auto;padding-right:4px;}
.vehcard{background:var(--slot);border:1px solid var(--border);border-radius:11px;padding:8px;cursor:pointer;font-family:inherit;color:var(--fg);transition:.12s;text-align:center;} .vehcard:hover{border-color:var(--accent);transform:translateY(-2px);}
.vehimg{aspect-ratio:16/10;border-radius:8px;overflow:hidden;background:linear-gradient(180deg,#1b2432,#0d1119);display:grid;place-items:center;} .vehimg svg{width:92%;}
.vehname{font-size:11.5px;font-weight:700;margin-top:6px;}
.veh-thumb{display:inline-block;width:46px;height:26px;vertical-align:middle;margin-right:8px;} .veh-thumb svg{width:100%;height:100%;}
.sw2{width:30px;height:30px;border-radius:8px;cursor:pointer;}
.qmenu{position:fixed;z-index:200;min-width:180px;background:rgba(16,20,28,0.98);border:1px solid var(--border);border-radius:12px;padding:5px;display:none;box-shadow:0 20px 50px -12px rgba(0,0,0,0.8);} .qmenu.show{display:block;animation:qm .12s ease;} @keyframes qm{from{opacity:0;transform:scale(.96);}to{opacity:1;}}
.qmenu .qm-h{padding:7px 10px 6px;font-size:12px;font-weight:800;border-bottom:1px solid var(--border);margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.qmenu button{display:flex;align-items:center;gap:9px;width:100%;text-align:left;border:none;background:none;color:var(--fg);font-family:inherit;font-size:13px;font-weight:600;padding:8px 10px;border-radius:8px;cursor:pointer;} .qmenu button:hover{background:var(--surface2);} .qmenu button.danger{color:var(--red);}
.actiongrid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:6px;} .actiongrid button{display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding:10px;border-radius:10px;background:var(--slot);border:1px solid var(--border);color:var(--fg);font-family:inherit;cursor:pointer;font-weight:700;font-size:12px;transition:.12s;} .actiongrid button:hover{border-color:var(--accent);} .actiongrid button small{color:var(--muted);font-weight:500;font-size:10px;} .actiongrid button.on{border-color:var(--green);background:rgba(52,211,153,0.1);} .actiongrid button.danger:hover{border-color:var(--red);}
.chk{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:9px;background:var(--slot);border:1px solid var(--border);margin-bottom:5px;cursor:pointer;} .chk.sel{border-color:var(--accent);background:var(--accent-soft);} .chk .av{width:26px;height:26px;border-radius:50%;display:grid;place-items:center;font-weight:800;color:#fff;font-size:10px;flex-shrink:0;}
/* Weltkarte */
.map{position:relative;border-radius:14px;overflow:hidden;border:1px solid var(--border);height:100%;
background:linear-gradient(90deg,transparent 49.6%,rgba(255,255,255,0.04) 49.6% 50.4%,transparent 50.4%),linear-gradient(0deg,transparent 49.6%,rgba(255,255,255,0.04) 49.6% 50.4%,transparent 50.4%),repeating-linear-gradient(90deg,rgba(255,255,255,0.02) 0 1px,transparent 1px 60px),repeating-linear-gradient(0deg,rgba(255,255,255,0.02) 0 1px,transparent 1px 60px),linear-gradient(135deg,#101823,#0a0f16);}
.map .water{position:absolute;right:-6%;bottom:-8%;width:44%;height:52%;background:radial-gradient(circle at 70% 70%,rgba(40,90,140,0.5),transparent 70%);border-radius:50% 0 0 0;}
.map .distr{position:absolute;font-size:10px;font-weight:800;letter-spacing:0.1em;color:rgba(255,255,255,0.14);text-transform:uppercase;}
.mk{position:absolute;transform:translate(-50%,-50%);z-index:2;cursor:pointer;transition:left 2.8s linear,top 2.8s linear;}
.mk .dot{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;font-size:9px;font-weight:800;color:#fff;border:2px solid rgba(255,255,255,0.85);box-shadow:0 2px 6px rgba(0,0,0,0.6);transition:transform .2s;}
.mk .lbl{position:absolute;top:24px;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:9px;font-weight:700;background:rgba(0,0,0,0.6);padding:1px 5px;border-radius:5px;}
.mk.dead .dot{background:var(--red)!important;animation:mkp 1.2s infinite;} @keyframes mkp{0%,100%{box-shadow:0 0 0 0 rgba(240,80,110,0.5);}50%{box-shadow:0 0 0 9px rgba(240,80,110,0);}}
/* Live-Kameras */
.camgrid{display:grid;gap:10px;} .camtile{position:relative;aspect-ratio:16/9;border-radius:12px;overflow:hidden;border:1px solid var(--border);background:#05070b;}
.camfeed{position:absolute;inset:0;} .camfeed .scene{position:absolute;inset:0;} .camfeed .scan{position:absolute;inset:0;background:repeating-linear-gradient(0deg,rgba(0,0,0,0.14) 0 2px,transparent 2px 4px);pointer-events:none;}
.camfeed .noise{position:absolute;inset:0;opacity:0.05;background-image:radial-gradient(rgba(255,255,255,0.8) 1px,transparent 1px);background-size:3px 3px;animation:noise .3s steps(2) infinite;} @keyframes noise{to{transform:translate(1px,1px);}}
.camfeed .sweep{position:absolute;left:0;right:0;height:40%;background:linear-gradient(180deg,rgba(255,255,255,0.05),transparent);animation:sweep 4s linear infinite;} @keyframes sweep{0%{top:-40%;}100%{top:100%;}}
.cam-hud{position:absolute;inset:0;z-index:2;font-family:ui-monospace,monospace;pointer-events:none;}
.cam-hud .rec{position:absolute;top:8px;right:10px;display:flex;align-items:center;gap:6px;font-size:10px;font-weight:800;color:#ff5a6e;} .cam-hud .rec i{width:8px;height:8px;border-radius:50%;background:#ff5a6e;animation:blink 1s infinite;} @keyframes blink{50%{opacity:0.2;}}
.cam-hud .who{position:absolute;top:8px;left:10px;font-size:11px;font-weight:800;color:#fff;text-shadow:0 1px 2px #000;}
.cam-hud .foot{position:absolute;bottom:8px;left:10px;right:10px;display:flex;justify-content:space-between;gap:8px;font-size:9.5px;color:rgba(255,255,255,0.85);text-shadow:0 1px 2px #000;}
.cam-hud .cross{position:absolute;top:50%;left:50%;width:26px;height:26px;transform:translate(-50%,-50%);opacity:0.55;} .cam-hud .cross::before,.cam-hud .cross::after{content:"";position:absolute;background:rgba(255,255,255,0.7);} .cam-hud .cross::before{left:50%;top:0;bottom:0;width:1px;transform:translateX(-50%);} .cam-hud .cross::after{top:50%;left:0;right:0;height:1px;transform:translateY(-50%);}
.camtile .ctrls{position:absolute;bottom:8px;right:8px;z-index:3;display:flex;gap:5px;} .camtile .ctrls button{border:none;background:rgba(0,0,0,0.55);color:#fff;border-radius:7px;padding:4px 8px;font-family:inherit;font-size:11px;font-weight:700;cursor:pointer;} .camtile .ctrls button:hover{background:rgba(0,0,0,0.85);}
.viewlist>button{width:100%;text-align:left;margin-bottom:6px;}
/* Killcams */
.kc{display:flex;gap:12px;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:10px;margin-bottom:8px;}
.kc .thumb{width:120px;aspect-ratio:16/9;border-radius:8px;overflow:hidden;flex-shrink:0;position:relative;border:1px solid var(--border);} .kc .thumb .scan{position:absolute;inset:0;background:repeating-linear-gradient(0deg,rgba(0,0,0,0.18) 0 2px,transparent 2px 4px);} .kc .thumb .pl{position:absolute;inset:0;display:grid;place-items:center;color:rgba(255,255,255,0.85);font-size:20px;} .kc .grow{flex:1;min-width:0;}
.overlay{position:absolute;inset:0;z-index:40;background:rgba(4,6,10,0.72);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;padding:22px;} .overlay.show{display:flex;}
.modal{width:560px;max-width:97%;max-height:90%;overflow-y:auto;background:rgba(18,22,30,0.98);border:1px solid var(--border);border-radius:16px;padding:20px;box-shadow:0 30px 70px -18px rgba(0,0,0,0.8);} .modal.sm{width:400px;}
.modal h3{margin:0 0 3px;font-size:18px;} .modal .msub{color:var(--muted);font-size:12.5px;margin-bottom:12px;} .modal-actions{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:14px;}
.kv{display:flex;justify-content:space-between;font-size:12.5px;padding:5px 0;border-bottom:1px solid var(--border);} .kv:last-child{border:none;} .kv span:first-child{color:var(--muted);}
.toast{position:absolute;bottom:16px;left:50%;transform:translateX(-50%) translateY(16px);background:rgba(12,15,22,0.97);border:1px solid var(--border);color:var(--fg);padding:10px 18px;border-radius:11px;font-size:13px;font-weight:600;z-index:50;opacity:0;pointer-events:none;transition:.3s;} .toast.show{opacity:1;transform:translateX(-50%) translateY(0);} .toast.good{border-color:var(--green);} .toast.warn{border-color:var(--red);}
.empty{text-align:center;color:var(--muted);padding:36px 10px;} .empty .big{font-size:36px;margin-bottom:8px;}
.chart svg{width:100%;display:block;}
/* Zeilen-Layout (Moderation, Weltkarte, Einstellungen) */
.row{display:flex;align-items:center;gap:10px;padding:9px 11px;border-radius:9px;background:var(--slot);border:1px solid var(--border);}
.row .grow,.grow{flex:1;min-width:0;}
.row .t{font-weight:700;font-size:13px;} .row .m{font-size:11.5px;color:var(--muted);margin-top:1px;}
.mono{font-family:ui-monospace,"SF Mono",Menlo,monospace;font-variant-numeric:tabular-nums;}
.swatches{display:flex;flex-wrap:wrap;gap:8px;}
</style>
<div class="admin">
<div class="brand"><span class="bl">🛡️</span><div class="bn">Aether Admin<small>Server-Verwaltung</small></div></div>
<div class="top" id="top"></div>
<div class="nav" id="nav"></div>
<div class="main" id="main"></div>
<div class="overlay" id="overlay"></div>
<div class="toast" id="toast"></div>
</div>
<div class="qmenu" id="qmenu"></div>
<script>
"use strict";
const $=id=>document.getElementById(id);
function sicher(t){const d=document.createElement("div");d.textContent=t==null?"":t;return d.innerHTML;}
const euro=n=>Math.round(n).toLocaleString("de-DE")+" €";
const kompakt=n=>n>=1e6?(n/1e6).toFixed(1)+" Mio":n>=1e3?(n/1e3).toFixed(1)+"k":""+n;
let toastTimer=null; function toast(t,typ){const e=$("toast");e.textContent=t;e.className="toast show"+(typ?" "+typ:"");clearTimeout(toastTimer);toastTimer=setTimeout(()=>e.classList.remove("show"),2400);}
function modal(html,sm){$("overlay").innerHTML=`<div class="modal${sm?' sm':''}">${html}</div>`;$("overlay").classList.add("show");}
function closeModal(){$("overlay").classList.remove("show");$("overlay").innerHTML="";}
const initialen=n=>n.replace(/\s*\(.*\)/,"").split(" ").map(w=>w[0]).slice(0,2).join("");
const avColor=(n)=>{let h=0;for(const c of n)h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 45% 45%)`;};
/* =====================================================================
ZUSTAND
===================================================================== */
const admin={name:"Owner",rolle:"Owner"};
const server={name:"Aether RP",slots:64,uptimeMin:1187,tps:19.4,cpu:38,ram:58,neustart:214,version:"v2.4.1"};
const welt={stunde:14,minute:32,wetter:"Klar",blackout:false,zeitFix:false,wetterFix:false};
const economy={umlauf:184300000,staatskasse:12480000,steuer:8};
let players=[
{id:1,name:"Max Weber",job:"Spediteur",fraktion:null,cash:1240,bank:48320,ping:38,region:"Legion Square",spielzeit:142,warns:0,god:false,frozen:false,muted:false},
{id:2,name:"Lena Berger",job:"Barkeeperin",fraktion:null,cash:640,bank:12400,ping:52,region:"Vespucci",spielzeit:88,warns:1,god:false,frozen:false,muted:false},
{id:3,name:"Dana Cole",job:"Polizist",fraktion:"LSPD",cash:2100,bank:167200,ping:29,region:"Mission Row",spielzeit:410,warns:0,god:false,frozen:false,muted:false},
{id:4,name:"Sofia Klein",job:"Sanitäterin",fraktion:"EMS",cash:900,bank:74000,ping:41,region:"Central Hospital",spielzeit:220,warns:0,god:false,frozen:false,muted:false},
{id:5,name:"Vincent Cross",job:"Arbeitslos",fraktion:"Ballas",cash:8400,bank:2100,ping:88,region:"Grove Street",spielzeit:64,warns:3,god:false,frozen:false,muted:true},
{id:6,name:"Karl Mendez",job:"Mechaniker",fraktion:null,cash:1500,bank:22140,ping:63,region:"Sandy Shores",spielzeit:156,warns:0,god:false,frozen:false,muted:false},
{id:7,name:"Mia Chen",job:"Polizist",fraktion:"LSPD",cash:800,bank:54300,ping:34,region:"Vinewood",spielzeit:98,warns:0,god:false,frozen:false,muted:false},
{id:8,name:"Ben Fischer",job:"Mechaniker",fraktion:null,cash:300,bank:9800,ping:120,region:"LS Customs",spielzeit:47,warns:0,god:false,frozen:false,muted:false},
{id:9,name:"Ava Stone",job:"Anwältin",fraktion:null,cash:5200,bank:210000,ping:45,region:"Del Perro",spielzeit:302,warns:0,god:true,frozen:false,muted:false},
{id:10,name:"Rico Vargas",job:"Arbeitslos",fraktion:"Ballas",cash:15600,bank:400,ping:150,region:"Grove Street",spielzeit:33,warns:2,god:false,frozen:true,muted:false},
{id:11,name:"Tom Barker",job:"Polizist",fraktion:"LSPD",cash:1900,bank:143900,ping:31,region:"Highway 68",spielzeit:388,warns:0,god:false,frozen:false,muted:false},
{id:12,name:"Nora Vale",job:"Journalistin",fraktion:null,cash:2700,bank:33000,ping:57,region:"Downtown",spielzeit:129,warns:0,god:false,frozen:false,muted:false}
];
let nextPid=13;
const jobs=["Arbeitslos","Spediteur","Mechaniker","Barkeeperin","Anwältin","Journalistin","Polizist","Sanitäterin","Taxifahrer"];
let fraktionen=[
{name:"LSPD",mitglieder:14,gehalt:2500,farbe:"#4aa8ff"},
{name:"EMS",mitglieder:9,gehalt:2200,farbe:"#f0506e"},
{name:"Ballas",mitglieder:11,gehalt:0,farbe:"#7c5cf6"},
{name:"Vagos",mitglieder:8,gehalt:0,farbe:"#f5b23d"},
{name:"Mechaniker-Gilde",mitglieder:6,gehalt:1800,farbe:"#34d39a"}
];
const fahrzeugModelle=["Sultan RS","Adder","Bati 801","Baller","Kuruma","Bison","Police Cruiser","Ambulance","Taxi","Sanchez"];
let weltFahrzeuge=[
{model:"Sultan RS",plate:"46EEK572",owner:"Max Weber",zustand:85},
{model:"Police Cruiser",plate:"LSPD-114",owner:"Dana Cole",zustand:97},
{model:"Ambulance",plate:"EMS-41",owner:"Sofia Klein",zustand:100},
{model:"Bati 801",plate:"88KLM214",owner:"Rico Vargas",zustand:62},
{model:"Adder",plate:"09HGT456",owner:"Ava Stone",zustand:78}
];
// deterministische Farbe je Modellname
function vehColor(name){let h=0;for(const c of name)h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 55% 48%)`;}
// Fahrzeug-Katalog mit Kategorien für den Spawner
const fahrzeugKatalog=[
{kat:"Sportwagen",typ:"sport",items:["Sultan RS","Adder","Kuruma","Elegy RH8","Comet"]},
{kat:"Limousinen",typ:"sedan",items:["Tailgater","Asterope","Primo","Fugitive"]},
{kat:"SUV & Pickup",typ:"suv",items:["Baller","Cavalcade","Granger","Bison","Sadler"]},
{kat:"Motorräder",typ:"bike",items:["Bati 801","Sanchez","Akuma","Hakuchou"]},
{kat:"Einsatz",typ:"sedan",items:["Police Cruiser","FIB","Ambulance","Feuerwehr"]},
{kat:"Helikopter",typ:"heli",items:["Maverick","Buzzard","Frogger","Polmav"]},
{kat:"Boote",typ:"boat",items:["Dinghy","Speeder","Marquis","Jetmax"]},
{kat:"Flugzeuge",typ:"plane",items:["Luxor","Duster","Titan","Mammatus"]}
];
// schematisches Fahrzeug-Icon je Typ (für Spawner-Bilder & Tabelle)
function fahrzeugIcon(typ,farbe){
const wh=(cx,cy,r)=>`<circle cx="${cx}" cy="${cy}" r="${r}" fill="#14161b"/><circle cx="${cx}" cy="${cy}" r="${(r*0.42).toFixed(1)}" fill="#4a505c"/>`;
const sh=`<ellipse cx="100" cy="98" rx="80" ry="6" fill="rgba(0,0,0,0.25)"/>`;
if(typ==="bike")return `<svg viewBox="0 0 200 110">${sh}<path d="M58 82 L96 54 L124 54 L148 82" stroke="#2a2e37" stroke-width="7" fill="none" stroke-linecap="round"/><path d="M80 58 C94 48 118 48 128 60 L130 68 L74 70 Z" fill="${farbe}"/><path d="M138 46 C158 50 164 70 152 86 L140 80 C150 68 146 56 132 54 Z" fill="${farbe}"/>${wh(60,82,20)}${wh(146,82,20)}</svg>`;
if(typ==="heli")return `<svg viewBox="0 0 200 110">${sh}<line x1="18" y1="38" x2="182" y2="38" stroke="#3a3f4a" stroke-width="4" stroke-linecap="round"/><rect x="96" y="38" width="6" height="10" fill="#2a2e37"/><path d="M42 62 Q60 46 98 48 L150 54 Q170 58 150 72 L72 76 Q46 76 42 62 Z" fill="${farbe}"/><path d="M150 54 L188 52 L188 60 L154 64 Z" fill="${farbe}"/><rect x="183" y="42" width="6" height="26" rx="2" fill="#2a2e37"/><path d="M72 76 L66 90 M118 74 L124 90" stroke="#2a2e37" stroke-width="4" stroke-linecap="round"/><path d="M58 92 h74" stroke="#3a3f4a" stroke-width="4" stroke-linecap="round"/></svg>`;
if(typ==="boat")return `<svg viewBox="0 0 200 110">${sh}<path d="M28 66 L172 66 L150 90 Q150 94 144 94 L52 94 Q46 94 44 88 Z" fill="${farbe}"/><path d="M72 48 L140 48 Q148 48 148 58 L148 64 L72 64 Z" fill="${farbe}"/><rect x="80" y="52" width="58" height="10" fill="rgba(8,14,22,0.85)"/><path d="M14 100 q10 -6 20 0 t20 0 t20 0 t20 0 t20 0 t20 0 t20 0 t20 0" stroke="#2a5a7a" stroke-width="3" fill="none"/></svg>`;
if(typ==="plane")return `<svg viewBox="0 0 200 110">${sh}<path d="M24 66 Q40 58 70 58 L156 60 Q178 62 178 68 Q178 74 156 74 L70 74 Q40 74 24 66 Z" fill="${farbe}"/><path d="M62 62 L112 30 L124 32 L94 66 Z" fill="${farbe}"/><path d="M62 70 L112 100 L124 98 L94 68 Z" fill="${farbe}"/><path d="M158 60 L178 44 L184 46 L172 64 Z" fill="${farbe}"/><circle cx="150" cy="66" r="4" fill="rgba(8,14,22,0.85)"/><circle cx="130" cy="66" r="4" fill="rgba(8,14,22,0.85)"/></svg>`;
const tall=typ==="suv"||typ==="pickup",rt=tall?30:40;
return `<svg viewBox="0 0 200 110">${sh}<path d="M20 80 C18 62 34 58 54 56 L74 ${rt+8} C88 ${rt} 120 ${rt} 134 ${rt+10} L166 58 C182 62 190 70 190 82 L190 86 L20 86 Z" fill="${farbe}"/><path d="M80 ${rt+10} C92 ${rt+2} 116 ${rt+2} 128 ${rt+12} L146 58 L76 58 Z" fill="rgba(8,14,22,0.85)"/>${wh(58,84,15)}${wh(150,84,15)}</svg>`;
}
// Standardwerte (Typ/Farbe/Tuning) für vorhandene Weltfahrzeuge
weltFahrzeuge.forEach(v=>{v.typ=v.typ||({"Sultan RS":"sport","Police Cruiser":"sedan","Ambulance":"suv","Bati 801":"bike","Adder":"sport"}[v.model]||"sedan");v.farbe=v.farbe||vehColor(v.model);v.tuning=v.tuning||{motor:2,bremsen:1,getriebe:1,turbo:false,panzerung:0};});
const immobilien=[
{name:"Apartment 4B",ort:"Vespucci Blvd",besitzer:"Max Weber",preis:95000},
{name:"Villa Vinewood Hills",ort:"Vinewood Hills",besitzer:"Ava Stone",preis:1200000},
{name:"Loft Downtown",ort:"Downtown",besitzer:null,preis:145000},
{name:"Bungalow Sandy Shores",ort:"Sandy Shores",besitzer:"Karl Mendez",preis:68000}
];
let bans=[
{name:"XxToxicxX",grund:"Cheating (Aimbot)",admin:"Owner",dauer:"permanent",datum:"19.07.2026"},
{name:"RDM_King",grund:"RDM / Regelbruch",admin:"Admin_Sara",dauer:"7 Tage",datum:"20.07.2026"}
];
let warns=[
{name:"Vincent Cross",grund:"Beleidigung im Voice",admin:"Mod_Ben",datum:"20.07.2026"},
{name:"Rico Vargas",grund:"Fail RP",admin:"Owner",datum:"21.07.2026"}
];
let reports=[
{id:"r1",spieler:"Lena Berger",betreff:"Spieler rammt absichtlich",prio:2,status:"offen"},
{id:"r2",spieler:"Ben Fischer",betreff:"Bug: Fahrzeug verschwunden",prio:3,status:"offen"},
{id:"r3",spieler:"Nora Vale",betreff:"Möglicher Cheater bei Grove St.",prio:1,status:"in Bearbeitung"}
];
let ressourcen=[
{name:"aether-core",status:"running",cpu:4.2,mem:82},
{name:"aether-phone",status:"running",cpu:2.1,mem:46},
{name:"aether-inventory",status:"running",cpu:3.4,mem:64},
{name:"aether-mdt",status:"running",cpu:1.2,mem:28},
{name:"aether-housing",status:"running",cpu:0.8,mem:22},
{name:"aether-anticheat",status:"running",cpu:5.6,mem:71},
{name:"legacy-fuel",status:"stopped",cpu:0,mem:0}
];
const adminTeam=[
{name:"Owner",rolle:"Owner",online:true},
{name:"Admin_Sara",rolle:"Admin",online:true},
{name:"Mod_Ben",rolle:"Moderator",online:true},
{name:"Supporter_Jo",rolle:"Supporter",online:false}
];
const spieler24h=[18,14,12,10,9,11,16,22,28,31,35,38,40,39,42,44,41,43,46,48,45,42,40,42];
const geldVerlauf=[171,173,176,178,180,181,183,184].map(v=>v*1e6);
// Live-Positionen für die Weltkarte
players.forEach((p,i)=>{p.posX=12+((i*29+13)%76);p.posY=16+((i*41+9)%68);p.tot=false;});
// Staatsprojekte (Abstimmungsergebnisse aus der Handy-Staat-App)
const ZUSTIMMUNG_MIN=0.6;
let staatsprojekte=[
{id:"sp1",titel:"Neue Feuerwache Sandy Shores",beschreibung:"Bau einer Rettungswache im Norden.",kosten:450000,ja:128,nein:34,abgelaufen:true,aktiviert:false},
{id:"sp2",titel:"Ausbau Autobahn N1",beschreibung:"Vierspuriger Ausbau der Nord-Autobahn.",kosten:1200000,ja:87,nein:96,abgelaufen:true,aktiviert:false},
{id:"sp3",titel:"Steuersenkung für Kleingewerbe",beschreibung:"Senkung der Gewerbesteuer um 3%.",kosten:300000,ja:203,nein:41,abgelaufen:false,aktiviert:false},
{id:"sp4",titel:"Öffentlicher Nahverkehr",beschreibung:"Neue Buslinien in Los Santos.",kosten:680000,ja:156,nein:52,abgelaufen:true,aktiviert:true}
];
// Kill-/Deathcams (nicht gespeicherte werden nach 24 h automatisch gelöscht)
const waffenPool=["Pistole P99","SMG","Schrotflinte","Sturmgewehr","Kampfmesser","Scharfschützengewehr"];
let killcams=[
{id:"kc1",killer:"Rico Vargas",opfer:"Ben Fischer",waffe:"SMG",distanz:14,ort:"Grove Street",zeit:Date.now()-1800000,gespeichert:false},
{id:"kc2",killer:"Vincent Cross",opfer:"Mia Chen",waffe:"Pistole P99",distanz:6,ort:"Vinewood Blvd",zeit:Date.now()-9000000,gespeichert:true},
{id:"kc3",killer:"Dana Cole",opfer:"Rico Vargas",waffe:"Sturmgewehr",distanz:31,ort:"Grove Street",zeit:Date.now()-3600000,gespeichert:false},
{id:"kc4",killer:"Unbekannt",opfer:"Karl Mendez",waffe:"Scharfschützengewehr",distanz:184,ort:"Sandy Shores",zeit:Date.now()-90000000,gespeichert:false},
{id:"kc5",killer:"Rico Vargas",opfer:"Tom Barker",waffe:"Schrotflinte",distanz:4,ort:"Grove Street",zeit:Date.now()-100000000,gespeichert:false}
];
// Live-Kamera-Views (Multi-Cam für z. B. Gang-Fights)
let camViews=[{id:"cv1",name:"Gang Fight Grove St.",spieler:[5,10]}];
let aktiveView="cv1", kcFilter="alle";
const logs=[];
function log(aktion,detail){logs.unshift({zeit:new Date(),wer:admin.name,aktion,detail:detail||""});}
log("Server gestartet","Aether RP "+server.version);
/* Helfer */
const player=id=>players.find(p=>p.id===id);
function pingPill(p){const c=p<50?'g':p<90?'a':'r';return `<span class="pill ${c}">${p} ms</span>`;}
function playerFlags(p){let f="";if(p.god)f+='<span class="pill v">GOD</span>';if(p.frozen)f+='<span class="pill b">FROZEN</span>';if(p.muted)f+='<span class="pill a">MUTE</span>';if(p.warns>0)f+=`<span class="pill r">${p.warns}⚠</span>`;return f||'<span class="pill m">—</span>';}
/* =====================================================================
CHART (kleines Flächen-Diagramm)
===================================================================== */
function areaChart(werte,W,H,farbe,fmt){
const min=Math.min(...werte),max=Math.max(...werte),span=(max-min)||1,pL=6,pT=6,pB=6;
const x=i=>pL+(W-pL*2)*(i/(werte.length-1)), y=v=>pT+(H-pT-pB)*(1-(v-min)/span);
let d="M"+x(0).toFixed(1)+" "+y(werte[0]).toFixed(1);
for(let i=1;i<werte.length;i++)d+=" L"+x(i).toFixed(1)+" "+y(werte[i]).toFixed(1);
const area=d+` L${x(werte.length-1).toFixed(1)} ${H-pB} L${x(0).toFixed(1)} ${H-pB} Z`;
const uid="a"+Math.random().toString(36).slice(2,6);
return `<svg viewBox="0 0 ${W} ${H}" style="height:${H}px"><defs><linearGradient id="${uid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${farbe}" stop-opacity="0.35"/><stop offset="100%" stop-color="${farbe}" stop-opacity="0"/></linearGradient></defs>
<path d="${area}" fill="url(#${uid})"/><path d="${d}" fill="none" stroke="${farbe}" stroke-width="2" vector-effect="non-scaling-stroke"/>
<circle cx="${x(werte.length-1).toFixed(1)}" cy="${y(werte[werte.length-1]).toFixed(1)}" r="3.5" fill="${farbe}"/></svg>`;
}
/* =====================================================================
SHELL
===================================================================== */
let sektion="dashboard", mapTimer=null;
const nav=[
{grp:"Überblick"},{id:"dashboard",label:"Dashboard",icon:"📊"},{id:"weltkarte",label:"Weltkarte",icon:"🗺️"},
{grp:"Verwaltung"},{id:"spieler",label:"Spieler",icon:"👥"},{id:"fahrzeuge",label:"Fahrzeuge",icon:"🚗"},{id:"immobilien",label:"Immobilien",icon:"🏠"},{id:"fraktionen",label:"Fraktionen",icon:"🚔"},{id:"wirtschaft",label:"Wirtschaft",icon:"💰"},{id:"staat",label:"Staatsprojekte",icon:"🏛️"},
{grp:"Überwachung"},{id:"cams",label:"Live-Kameras",icon:"📹"},{id:"killcams",label:"Kill-/Deathcams",icon:"🎬"},
{grp:"Welt"},{id:"welt",label:"Weltsteuerung",icon:"🌍"},{id:"broadcast",label:"Ankündigungen",icon:"📢"},
{grp:"Moderation"},{id:"moderation",label:"Bans & Warns",icon:"🔨"},{id:"reports",label:"Reports",icon:"🎫"},
{grp:"System"},{id:"ressourcen",label:"Ressourcen",icon:"🧩"},{id:"logs",label:"Audit-Log",icon:"📜"},{id:"team",label:"Admin-Team",icon:"⭐"},{id:"settings",label:"Einstellungen",icon:"⚙️"}
];
function zeitStr(){return String(welt.stunde).padStart(2,"0")+":"+String(welt.minute).padStart(2,"0");}
function uptimeStr(){const h=Math.floor(server.uptimeMin/60),m=server.uptimeMin%60;return h+"h "+String(m).padStart(2,"0")+"m";}
function renderTop(){
const online=players.length;
$("top").innerHTML=`<div class="srv-chip"><span class="srv-dot"></span>${server.name} · ${server.version}</div>
<div class="live"><span>Spieler <b>${online}/${server.slots}</b></span><span>TPS <b>${server.tps.toFixed(1)}</b></span><span>CPU <b>${server.cpu}%</b></span><span>Uptime <b>${uptimeStr()}</b></span><span>🕒 <b>${zeitStr()}</b> · ${welt.wetter}</span></div>
<div class="restart">⟳ Neustart in ${Math.floor(server.neustart/60)}h ${server.neustart%60}m</div>
<div class="me"><span class="av">OW</span><div><div class="mn">${admin.name}</div><div class="mr">${admin.rolle}</div></div></div>`;
}
function renderNav(){
const offeneReports=reports.filter(r=>r.status!=="geschlossen").length;
const aktivierbar=staatsprojekte.filter(p=>!p.aktiviert&&p.abgelaufen&&(p.ja/((p.ja+p.nein)||1)>=ZUSTIMMUNG_MIN)).length;
const badge=(id)=> id==='reports'&&offeneReports?`<span class="badge">${offeneReports}</span>`: id==='staat'&&aktivierbar?`<span class="badge" style="background:var(--green);color:#06231a">${aktivierbar}</span>`:'';
$("nav").innerHTML=nav.map(it=>it.grp?`<div class="grp">${it.grp}</div>`:
`<button data-s="${it.id}" class="${it.id===sektion?'active':''}"><span class="ni">${it.icon}</span>${it.label}${badge(it.id)}</button>`).join("");
$("nav").querySelectorAll("[data-s]").forEach(b=>b.addEventListener("click",()=>{sektion=b.dataset.s;renderNav();renderSektion();}));
}
function renderSektion(){clearInterval(mapTimer);(sektionen[sektion]||sektionen.dashboard)();}
function refresh(){renderTop();renderNav();renderSektion();}
const sektionen={};
/* =====================================================================
DASHBOARD
===================================================================== */
sektionen.dashboard=()=>{
const online=players.length, hoherPing=players.filter(p=>p.ping>=90).length, offeneReports=reports.filter(r=>r.status!=="geschlossen").length;
$("main").innerHTML=`<div class="phead"><div><h2>Dashboard</h2><p>Serverüberblick in Echtzeit</p></div></div>
<div class="grid" style="grid-template-columns:repeat(4,1fr)">
<div class="card kpi"><div class="l">👥 Spieler online</div><div class="v" id="kpi-online">${online}/${server.slots}</div><div class="d" style="color:var(--green)">${Math.round(online/server.slots*100)}% Auslastung</div></div>
<div class="card kpi"><div class="l">⚡ Ø TPS</div><div class="v" id="kpi-tps" style="color:${server.tps>18?'var(--green)':'var(--amber)'}">${server.tps.toFixed(1)}</div><div class="d" style="color:var(--muted)">Ziel: 20.0</div></div>
<div class="card kpi"><div class="l">🖥️ CPU / RAM</div><div class="v"><span id="kpi-cpu">${server.cpu}</span>% / ${server.ram}%</div><div class="d" style="color:var(--muted)">Host-Auslastung</div></div>
<div class="card kpi"><div class="l">🎫 Offene Reports</div><div class="v" style="color:${offeneReports?'var(--amber)':'var(--green)'}">${offeneReports}</div><div class="d" style="color:var(--muted)">${hoherPing} Spieler mit hohem Ping</div></div>
</div>
<div class="grid" style="grid-template-columns:1.6fr 1fr;margin-top:12px">
<div class="card"><div class="list-h">Spieler online · letzte 24 h <span class="r">jetzt ${online}</span></div><div class="chart">${areaChart(spieler24h,600,150,'#5b8cff')}</div></div>
<div class="card"><div class="list-h">Schnellaktionen</div><div class="quick">
<button class="btn ghost" data-q="broadcast">📢 Ankündigung</button>
<button class="btn ghost" data-q="wetter">🌦️ Wetter setzen</button>
<button class="btn ghost" data-q="zeit">🕒 Zeit setzen</button>
<button class="btn ghost" data-q="healall">❤️ Alle heilen</button>
<button class="btn ghost" data-q="save">💾 Welt speichern</button>
<button class="btn red" data-q="restart">⟳ Server-Neustart</button>
</div></div>
</div>
<div class="grid" style="grid-template-columns:1fr 1fr;margin-top:12px">
<div class="card"><div class="list-h">Ressourcen-Status <span class="r">${ressourcen.filter(r=>r.status==='running').length}/${ressourcen.length} aktiv</span></div>
${ressourcen.slice(0,5).map(r=>`<div class="kv"><span>${sicher(r.name)}</span><span>${r.status==='running'?`<span class="pill g">läuft</span> ${r.cpu}% CPU`:'<span class="pill r">gestoppt</span>'}</span></div>`).join('')}</div>
<div class="card"><div class="list-h">Letzte Admin-Aktionen</div>${logs.slice(0,6).map(l=>`<div class="kv"><span>${sicher(l.aktion)}${l.detail?': '+sicher(l.detail):''}</span><span style="color:var(--muted)">${l.zeit.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}</span></div>`).join('')||'<div class="empty">Keine Aktionen.</div>'}</div>
</div>`;
$("main").querySelectorAll("[data-q]").forEach(b=>b.addEventListener("click",()=>schnellAktion(b.dataset.q)));
};
function schnellAktion(q){
if(q==="broadcast"){sektion="broadcast";refresh();}
else if(q==="wetter"||q==="zeit"){sektion="welt";refresh();}
else if(q==="healall"){log("Alle Spieler geheilt");toast("Alle "+players.length+" Spieler geheilt","good");}
else if(q==="save"){log("Welt gespeichert");toast("Welt & Spieler gespeichert","good");}
else if(q==="restart")restartModal();
}
function restartModal(){
modal(`<h3>Server-Neustart</h3><div class="msub">Alle Spieler werden benachrichtigt und die Welt gespeichert.</div>
<label class="flabel">Countdown</label><select class="input" id="rs-time"><option value="0">Sofort</option><option value="1">1 Minute</option><option value="5" selected>5 Minuten</option><option value="15">15 Minuten</option></select>
<label class="flabel">Grund (optional)</label><input class="input" id="rs-reason" placeholder="z. B. Update v2.4.2">
<div class="modal-actions"><button class="btn ghost" id="rs-cancel">Abbrechen</button><button class="btn red" id="rs-ok">Neustart einleiten</button></div>`,true);
$("rs-cancel").addEventListener("click",closeModal);
$("rs-ok").addEventListener("click",()=>{const t=$("rs-time").value;log("Neustart geplant",t+" min");closeModal();toast("Neustart in "+t+" min eingeleitet","warn");});
}
/* =====================================================================
SPIELER
===================================================================== */
let spielerSuche="", spielerFilter="alle";
sektionen.spieler=()=>{
$("main").innerHTML=`<div class="phead"><div><h2>Spieler</h2><p>${players.length} online · Verwaltung &amp; Aktionen</p></div>
<div class="seg" id="sp-filter"><button data-f="alle" class="${spielerFilter==='alle'?'active':''}">Alle</button><button data-f="lspd" class="${spielerFilter==='lspd'?'active':''}">LSPD</button><button data-f="ems" class="${spielerFilter==='ems'?'active':''}">EMS</button><button data-f="flagged" class="${spielerFilter==='flagged'?'active':''}">Auffällig</button></div></div>
<div class="searchbar" style="margin-bottom:12px"><span>🔎</span><input id="sp-such" placeholder="Name oder Region suchen…" value="${sicher(spielerSuche)}"></div>
<div class="card" style="padding:6px 12px"><div class="tblwrap"><table class="tbl"><thead><tr><th>ID</th><th>Spieler</th><th>Job / Fraktion</th><th>Region</th><th>Ping</th><th class="num">Geld</th><th>Status</th><th></th></tr></thead><tbody id="sp-body"></tbody></table></div></div>`;
const s=$("sp-such");s.addEventListener("input",()=>{spielerSuche=s.value.trim().toLowerCase();zeichneSpieler();});
$("sp-filter").querySelectorAll("[data-f]").forEach(b=>b.addEventListener("click",()=>{spielerFilter=b.dataset.f;renderSektion();}));
zeichneSpieler();
};
function spielerListe(){return players.filter(p=>{
const q=!spielerSuche||(p.name+" "+p.region).toLowerCase().includes(spielerSuche);
const f=spielerFilter==="alle"||(spielerFilter==="lspd"&&p.fraktion==="LSPD")||(spielerFilter==="ems"&&p.fraktion==="EMS")||(spielerFilter==="flagged"&&(p.warns>0||p.ping>=90||p.muted));
return q&&f;});}
function zeichneSpieler(){
const el=$("sp-body");if(!el)return;
el.innerHTML=spielerListe().map(p=>`<tr><td class="num" style="color:var(--muted)">#${p.id}</td>
<td><span class="av" style="background:${avColor(p.name)}">${initialen(p.name)}</span>${sicher(p.name)}</td>
<td>${sicher(p.job)}${p.fraktion?` <span class="pill b">${p.fraktion}</span>`:''}</td>
<td style="color:var(--muted)">${sicher(p.region)}</td><td>${pingPill(p.ping)}</td>
<td class="num">${euro(p.cash+p.bank)}</td><td><span class="flags">${playerFlags(p)}</span></td>
<td class="num"><button class="btn primary sm" data-p="${p.id}">Verwalten</button></td></tr>`).join('')||'<tr><td colspan="8" class="empty">Keine Spieler gefunden.</td></tr>';
el.querySelectorAll("[data-p]").forEach(b=>b.addEventListener("click",()=>spielerModal(+b.dataset.p)));
}
function spielerModal(id){
const p=player(id);if(!p)return;
modal(`<h3><span class="av" style="background:${avColor(p.name)};width:30px;height:30px">${initialen(p.name)}</span> ${sicher(p.name)} <span style="color:var(--muted);font-size:13px">#${p.id}</span></h3>
<div class="msub">${sicher(p.job)}${p.fraktion?' · '+p.fraktion:''} · ${sicher(p.region)} · ${p.spielzeit} Std</div>
<div class="grid" style="grid-template-columns:1fr 1fr;margin-bottom:10px">
<div class="card" style="padding:11px"><div class="kv"><span>Bargeld</span><span>${euro(p.cash)}</span></div><div class="kv"><span>Bank</span><span>${euro(p.bank)}</span></div><div class="kv"><span>Ping</span><span>${p.ping} ms</span></div></div>
<div class="card" style="padding:11px"><div class="kv"><span>Verwarnungen</span><span>${p.warns}</span></div><div class="kv"><span>Godmode</span><span>${p.god?'an':'aus'}</span></div><div class="kv"><span>Eingefroren</span><span>${p.frozen?'ja':'nein'}</span></div></div></div>
<div class="actiongrid">
<div class="ag-h">Teleport &amp; Ansicht</div>
<button data-a="goto">🧭 Zu Spieler<small>Teleport hin</small></button>
<button data-a="bring">📥 Herholen<small>zu mir</small></button>
<button data-a="spectate">👁️ Beobachten<small>Spectate</small></button>
<button data-a="cam">📹 Live-Kamera<small>WebRTC-View</small></button>
<div class="ag-h">Zustand</div>
<button data-a="heal">❤️ Heilen<small>volle HP</small></button>
<button data-a="revive">✨ Wiederbeleben<small>Revive</small></button>
<button data-a="armor">🛡️ Rüstung<small>+Armor</small></button>
<button data-a="freeze" class="${p.frozen?'on':''}">🧊 Einfrieren<small>${p.frozen?'aktiv':'toggle'}</small></button>
<button data-a="god" class="${p.god?'on':''}">⭐ Godmode<small>${p.god?'aktiv':'toggle'}</small></button>
<button data-a="mute" class="${p.muted?'on':''}">🔇 Stumm<small>${p.muted?'aktiv':'toggle'}</small></button>
<div class="ag-h">Geben &amp; Setzen</div>
<button data-a="money">💵 Geld<small>geben/nehmen</small></button>
<button data-a="item">🎁 Item/Waffe<small>geben</small></button>
<button data-a="veh">🚗 Fahrzeug<small>spawnen</small></button>
<button data-a="job">💼 Job/Fraktion<small>setzen</small></button>
<div class="ag-h">Moderation</div>
<button data-a="warn">⚠️ Verwarnen<small>Warn</small></button>
<button data-a="note">📝 Notiz<small>intern</small></button>
<button data-a="kick" class="danger">👢 Kicken<small>trennen</small></button>
<button data-a="ban" class="danger">🔨 Bannen<small>sperren</small></button>
</div>
<div class="modal-actions" style="grid-template-columns:1fr"><button class="btn ghost" id="pm-close">Schließen</button></div>`);
$("pm-close").addEventListener("click",closeModal);
$("overlay").querySelectorAll("[data-a]").forEach(b=>b.addEventListener("click",()=>spielerAktion(p,b.dataset.a)));
}
function spielerAktion(p,a){
const simpel={goto:"Zu "+p.name+" teleportiert",bring:p.name+" hergeholt",spectate:"Beobachte "+p.name,heal:p.name+" geheilt",revive:p.name+" wiederbelebt",armor:p.name+" Rüstung gegeben"};
if(simpel[a]){log(a+" · "+p.name);toast(simpel[a],"good");return;}
if(a==="freeze"){p.frozen=!p.frozen;log((p.frozen?"Eingefroren":"Aufgetaut"),p.name);toast(p.name+(p.frozen?" eingefroren":" aufgetaut"));spielerModal(p.id);zeichneSpieler();return;}
if(a==="god"){p.god=!p.god;log("Godmode "+(p.god?"an":"aus"),p.name);toast(p.name+" Godmode "+(p.god?"an":"aus"));spielerModal(p.id);zeichneSpieler();return;}
if(a==="mute"){p.muted=!p.muted;log("Mute "+(p.muted?"an":"aus"),p.name);toast(p.name+(p.muted?" stummgeschaltet":" entstummt"));spielerModal(p.id);zeichneSpieler();return;}
if(a==="cam")return einzelKamera(p);
if(a==="money")return geldModal(p);
if(a==="item")return itemModal(p);
if(a==="veh")return vehGebenModal(p);
if(a==="job")return jobModal(p);
if(a==="warn")return warnModal(p);
if(a==="note")return notizModal(p);
if(a==="kick")return kickModal(p);
if(a==="ban")return banModal(p);
}
function geldModal(p){
modal(`<h3>Geld · ${sicher(p.name)}</h3><div class="msub">Bargeld ${euro(p.cash)} · Bank ${euro(p.bank)}</div>
<label class="flabel">Konto</label><select class="input" id="g-konto"><option value="cash">Bargeld</option><option value="bank" selected>Bank</option></select>
<label class="flabel">Betrag (negativ = entfernen)</label><input class="input" id="g-betrag" type="number" placeholder="z. B. 5000">
<div class="modal-actions"><button class="btn ghost" id="g-cancel">Abbrechen</button><button class="btn primary" id="g-ok">Anwenden</button></div>`,true);
$("g-cancel").addEventListener("click",()=>spielerModal(p.id));
$("g-ok").addEventListener("click",()=>{const b=parseInt($("g-betrag").value,10);if(!b){toast("Betrag angeben","warn");return;}const k=$("g-konto").value;p[k]=Math.max(0,p[k]+b);log("Geld "+(b>0?"+":"")+b,p.name+" ("+k+")");toast((b>0?"+":"")+euro(Math.abs(b)).replace('-','')+" "+(b>0?"gutgeschrieben":"entfernt"),"good");spielerModal(p.id);zeichneSpieler();});
}
function itemModal(p){
const items=["Wasserflasche","Sandwich","Verband","Reparaturkit","Dietrich","Pistole P99","9mm Munition","Funkgerät","Smartphone"];
modal(`<h3>Item / Waffe geben · ${sicher(p.name)}</h3><div class="msub">Fügt das Item dem Inventar hinzu</div>
<label class="flabel">Item</label><select class="input" id="i-item">${items.map(i=>`<option>${i}</option>`).join('')}</select>
<label class="flabel">Menge</label><input class="input" id="i-menge" type="number" value="1">
<div class="modal-actions"><button class="btn ghost" id="i-cancel">Abbrechen</button><button class="btn primary" id="i-ok">Geben</button></div>`,true);
$("i-cancel").addEventListener("click",()=>spielerModal(p.id));
$("i-ok").addEventListener("click",()=>{const it=$("i-item").value,m=parseInt($("i-menge").value,10)||1;log("Item gegeben",p.name+": "+m+"× "+it);closeModal();toast(m+"× "+it+" an "+p.name,"good");});
}
function vehGebenModal(p){
modal(`<h3>Fahrzeug spawnen · ${sicher(p.name)}</h3><div class="msub">Erstellt ein Fahrzeug für den Spieler</div>
<label class="flabel">Modell</label><select class="input" id="v-model">${fahrzeugModelle.map(m=>`<option>${m}</option>`).join('')}</select>
<div class="modal-actions"><button class="btn ghost" id="v-cancel">Abbrechen</button><button class="btn primary" id="v-ok">Spawnen</button></div>`,true);
$("v-cancel").addEventListener("click",()=>spielerModal(p.id));
$("v-ok").addEventListener("click",()=>{const m=$("v-model").value;const plate="AD-"+Math.floor(1000+Math.random()*9000);weltFahrzeuge.unshift({model:m,plate,owner:p.name,zustand:100});log("Fahrzeug gespawnt",m+" für "+p.name);closeModal();toast(m+" ("+plate+") für "+p.name+" gespawnt","good");});
}
function jobModal(p){
modal(`<h3>Job / Fraktion · ${sicher(p.name)}</h3>
<label class="flabel">Job</label><select class="input" id="j-job">${jobs.map(j=>`<option ${p.job===j?'selected':''}>${j}</option>`).join('')}</select>
<label class="flabel">Fraktion</label><select class="input" id="j-frak"><option value="">— keine —</option>${fraktionen.map(f=>`<option ${p.fraktion===f.name?'selected':''}>${f.name}</option>`).join('')}</select>
<div class="modal-actions"><button class="btn ghost" id="j-cancel">Abbrechen</button><button class="btn primary" id="j-ok">Setzen</button></div>`,true);
$("j-cancel").addEventListener("click",()=>spielerModal(p.id));
$("j-ok").addEventListener("click",()=>{p.job=$("j-job").value;p.fraktion=$("j-frak").value||null;log("Job/Fraktion gesetzt",p.name+": "+p.job+(p.fraktion?" / "+p.fraktion:""));toast(p.name+" → "+p.job,"good");spielerModal(p.id);zeichneSpieler();});
}
function warnModal(p){
modal(`<h3>Verwarnung · ${sicher(p.name)}</h3><div class="msub">Bisher ${p.warns} Verwarnung(en)</div>
<label class="flabel">Grund</label><input class="input" id="wn-grund" placeholder="z. B. Fail RP">
<div class="modal-actions"><button class="btn ghost" id="wn-cancel">Abbrechen</button><button class="btn amber" id="wn-ok">Verwarnen</button></div>`,true);
$("wn-cancel").addEventListener("click",()=>spielerModal(p.id));
$("wn-ok").addEventListener("click",()=>{const g=$("wn-grund").value.trim()||"Regelverstoß";p.warns++;warns.unshift({name:p.name,grund:g,admin:admin.name,datum:new Date().toLocaleDateString('de-DE')});log("Verwarnung",p.name+": "+g);toast(p.name+" verwarnt","warn");spielerModal(p.id);zeichneSpieler();});
}
function notizModal(p){
modal(`<h3>Interne Notiz · ${sicher(p.name)}</h3><textarea class="input" id="nt-text" placeholder="Notiz zum Spieler…"></textarea>
<div class="modal-actions"><button class="btn ghost" id="nt-cancel">Abbrechen</button><button class="btn primary" id="nt-ok">Speichern</button></div>`,true);
$("nt-cancel").addEventListener("click",()=>spielerModal(p.id));
$("nt-ok").addEventListener("click",()=>{const t=$("nt-text").value.trim();if(!t){toast("Text eingeben","warn");return;}log("Notiz",p.name+": "+t);closeModal();toast("Notiz gespeichert","good");});
}
function kickModal(p){
modal(`<h3>Spieler kicken · ${sicher(p.name)}</h3><label class="flabel">Grund</label><input class="input" id="k-grund" placeholder="z. B. AFK / Regelbruch">
<div class="modal-actions"><button class="btn ghost" id="k-cancel">Abbrechen</button><button class="btn red" id="k-ok">Kicken</button></div>`,true);
$("k-cancel").addEventListener("click",()=>spielerModal(p.id));
$("k-ok").addEventListener("click",()=>{const g=$("k-grund").value.trim()||"Kein Grund";players=players.filter(x=>x.id!==p.id);log("Kick",p.name+": "+g);closeModal();toast(p.name+" gekickt","warn");refresh();});
}
function banModal(p){
modal(`<h3>Spieler bannen · ${sicher(p.name)}</h3><div class="msub">Spieler wird getrennt und gesperrt.</div>
<label class="flabel">Grund</label><input class="input" id="b-grund" placeholder="z. B. Cheating">
<label class="flabel">Dauer</label><select class="input" id="b-dauer"><option>1 Tag</option><option>3 Tage</option><option selected>7 Tage</option><option>30 Tage</option><option>permanent</option></select>
<div class="modal-actions"><button class="btn ghost" id="b-cancel">Abbrechen</button><button class="btn red" id="b-ok">Bannen</button></div>`,true);
$("b-cancel").addEventListener("click",()=>spielerModal(p.id));
$("b-ok").addEventListener("click",()=>{const g=$("b-grund").value.trim()||"Regelbruch",d=$("b-dauer").value;players=players.filter(x=>x.id!==p.id);bans.unshift({name:p.name,grund:g,admin:admin.name,dauer:d,datum:new Date().toLocaleDateString('de-DE')});log("Ban",p.name+": "+g+" ("+d+")");closeModal();toast(p.name+" gebannt ("+d+")","warn");refresh();});
}
/* =====================================================================
FAHRZEUGE
===================================================================== */
sektionen.fahrzeuge=()=>{
$("main").innerHTML=`<div class="phead"><div><h2>Fahrzeuge</h2><p>${weltFahrzeuge.length} in der Welt</p></div>
<div style="display:flex;gap:8px"><button class="btn ghost" id="fz-clean">🧹 Aufräumen</button><button class="btn primary" id="fz-spawn">+ Fahrzeug spawnen</button></div></div>
<div class="card" style="padding:6px 12px"><div class="tblwrap"><table class="tbl"><thead><tr><th>Fahrzeug</th><th>Kennzeichen</th><th>Besitzer</th><th>Zustand</th><th></th></tr></thead><tbody>
${weltFahrzeuge.map((v,i)=>`<tr><td><span class="veh-thumb">${fahrzeugIcon(v.typ,v.farbe)}</span>${sicher(v.model)}${v.tuning&&v.tuning.turbo?' <span class="pill v">Turbo</span>':''}</td><td class="mono">${sicher(v.plate)}</td><td>${sicher(v.owner||'—')}</td>
<td><span class="barmini"><i style="width:${v.zustand}%;background:${v.zustand>70?'var(--green)':v.zustand>40?'var(--amber)':'var(--red)'}"></i></span> ${v.zustand}%</td>
<td class="num"><button class="btn primary sm" data-tune="${i}">Bearbeiten</button> <button class="btn ghost sm" data-repair="${i}">Reparieren</button> <button class="btn ghost sm" data-tp="${i}">Herholen</button> <button class="btn red sm" data-del="${i}">Löschen</button></td></tr>`).join('')||'<tr><td colspan="5" class="empty">Keine Fahrzeuge.</td></tr>'}
</tbody></table></div></div>`;
$("fz-spawn").addEventListener("click",spawnVehModal);
$("fz-clean").addEventListener("click",()=>{const n=weltFahrzeuge.length;weltFahrzeuge=[];log("Fahrzeuge aufgeräumt",n+" entfernt");toast(n+" Fahrzeuge entfernt","good");renderSektion();});
$("main").querySelectorAll("[data-tune]").forEach(b=>b.addEventListener("click",()=>tuningModal(+b.dataset.tune)));
$("main").querySelectorAll("[data-repair]").forEach(b=>b.addEventListener("click",()=>{weltFahrzeuge[+b.dataset.repair].zustand=100;log("Fahrzeug repariert",weltFahrzeuge[+b.dataset.repair].plate);toast("Repariert","good");renderSektion();}));
$("main").querySelectorAll("[data-tp]").forEach(b=>b.addEventListener("click",()=>{toast(weltFahrzeuge[+b.dataset.tp].model+" hergeholt","good");log("Fahrzeug hergeholt",weltFahrzeuge[+b.dataset.tp].plate);}));
$("main").querySelectorAll("[data-del]").forEach(b=>b.addEventListener("click",()=>{const v=weltFahrzeuge.splice(+b.dataset.del,1)[0];log("Fahrzeug gelöscht",v.plate);toast(v.model+" gelöscht","warn");renderSektion();}));
};
/* Bild-Spawner: Kategorie wählen, Fahrzeug anklicken */
function spawnVehModal(){
let katIdx=0, such="";
modal(`<h3>Fahrzeug spawnen</h3><div class="msub">Kategorie wählen und auf ein Fahrzeug klicken</div>
<label class="flabel">Besitzer</label><select class="input" id="sv-owner"><option value="">— Admin —</option>${players.map(p=>`<option>${sicher(p.name)}</option>`).join('')}</select>
<div class="seg" id="sv-tabs" style="margin:12px 0 8px;flex-wrap:wrap;display:flex">${fahrzeugKatalog.map((k,i)=>`<button data-k="${i}" class="${i===0?'active':''}">${k.kat}</button>`).join('')}</div>
<div class="searchbar" style="margin-bottom:10px"><span>🔎</span><input id="sv-such" placeholder="Modell suchen (alle Kategorien)…"></div>
<div id="sv-grid" class="vehgrid"></div>
<div class="modal-actions" style="grid-template-columns:1fr"><button class="btn ghost" id="sv-close">Schließen</button></div>`);
const zeichne=()=>{
let items=[];
if(such)fahrzeugKatalog.forEach(k=>k.items.forEach(n=>{if(n.toLowerCase().includes(such))items.push({name:n,typ:k.typ});}));
else {const k=fahrzeugKatalog[katIdx];items=k.items.map(n=>({name:n,typ:k.typ}));}
$("sv-grid").innerHTML=items.map(it=>`<button class="vehcard" data-spawn="${sicher(it.name)}" data-typ="${it.typ}"><div class="vehimg">${fahrzeugIcon(it.typ,vehColor(it.name))}</div><div class="vehname">${sicher(it.name)}</div></button>`).join('')||'<div class="empty" style="grid-column:1/-1">Kein Treffer.</div>';
$("sv-grid").querySelectorAll("[data-spawn]").forEach(b=>b.addEventListener("click",()=>{
const o=$("sv-owner").value||"Admin", m=b.dataset.spawn, plate="AD-"+Math.floor(1000+Math.random()*9000);
weltFahrzeuge.unshift({model:m,plate,owner:o,zustand:100,typ:b.dataset.typ,farbe:vehColor(m),tuning:{motor:0,bremsen:0,getriebe:0,turbo:false,panzerung:0}});
log("Fahrzeug gespawnt",m+" für "+o);toast(m+" ("+plate+") für "+o+" gespawnt","good");}));
};
$("sv-tabs").querySelectorAll("[data-k]").forEach(b=>b.addEventListener("click",()=>{katIdx=+b.dataset.k;such="";$("sv-such").value="";$("sv-tabs").querySelectorAll("[data-k]").forEach(x=>x.classList.remove("active"));b.classList.add("active");zeichne();}));
$("sv-such").addEventListener("input",e=>{such=e.target.value.trim().toLowerCase();zeichne();});
$("sv-close").addEventListener("click",()=>{closeModal();renderSektion();});
zeichne();
}
/* Fahrzeug bearbeiten: Leistung, Einbauten, Lack */
function tuningModal(i){const v=weltFahrzeuge[i],t=v.tuning;let farbe=v.farbe;
const stufe=(id,label)=>`<div><label class="flabel">${label}</label><select class="input" data-tn="${id}">${[0,1,2,3,4].map(s=>`<option value="${s}" ${t[id]===s?'selected':''}>${s===0?'Serie':'Stufe '+s}</option>`).join('')}</select></div>`;
const swatches=["#c0392b","#2f7bbf","#20232a","#e6e8ee","#2f6b4a","#e0a020","#7c5cf6","#c79a5a","#f0506e","#34d39a"];
modal(`<h3>🔧 Fahrzeug bearbeiten · ${sicher(v.model)}</h3><div class="msub">Leistung, Einbauten &amp; Optik · ${sicher(v.plate)}</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 14px">${stufe('motor','Motor')}${stufe('getriebe','Getriebe')}${stufe('bremsen','Bremsen')}${stufe('panzerung','Panzerung')}</div>
<label class="chk" style="margin-top:10px;cursor:pointer"><input type="checkbox" id="tn-turbo" ${t.turbo?'checked':''} style="width:16px;height:16px;accent-color:var(--accent)"><div class="grow" style="font-weight:700;font-size:13px">Turbo-Umbau</div></label>
<label class="flabel">Lackfarbe</label><div class="swatches" id="tn-farbe" style="display:flex;flex-wrap:wrap;gap:8px">${swatches.map(c=>`<div class="sw2" data-c="${c}" style="background:${c};border:2px solid ${v.farbe===c?'#fff':'transparent'};box-shadow:${v.farbe===c?'0 0 0 2px var(--accent)':'none'}"></div>`).join('')}</div>
<div class="modal-actions"><button class="btn ghost" id="tn-cancel">Abbrechen</button><button class="btn primary" id="tn-ok">Übernehmen</button></div>`);
$("tn-farbe").querySelectorAll("[data-c]").forEach(el=>el.addEventListener("click",()=>{farbe=el.dataset.c;$("tn-farbe").querySelectorAll("[data-c]").forEach(x=>{x.style.borderColor='transparent';x.style.boxShadow='none';});el.style.borderColor='#fff';el.style.boxShadow='0 0 0 2px var(--accent)';}));
$("tn-cancel").addEventListener("click",closeModal);
$("tn-ok").addEventListener("click",()=>{$("overlay").querySelectorAll("[data-tn]").forEach(s=>{t[s.dataset.tn]=+s.value;});t.turbo=$("tn-turbo").checked;v.farbe=farbe;log("Fahrzeug bearbeitet",v.model+" ("+v.plate+")");closeModal();toast("Fahrzeug bearbeitet","good");renderSektion();});
}
/* =====================================================================
IMMOBILIEN
===================================================================== */
sektionen.immobilien=()=>{
$("main").innerHTML=`<div class="phead"><div><h2>Immobilien</h2><p>${immobilien.length} Objekte · ${immobilien.filter(i=>i.besitzer).length} vergeben</p></div></div>
<div class="card" style="padding:6px 12px"><div class="tblwrap"><table class="tbl"><thead><tr><th>Objekt</th><th>Lage</th><th>Besitzer</th><th class="num">Wert</th><th></th></tr></thead><tbody>
${immobilien.map((im,i)=>`<tr><td>🏠 ${sicher(im.name)}</td><td style="color:var(--muted)">${sicher(im.ort)}</td>
<td>${im.besitzer?sicher(im.besitzer):'<span class="pill m">frei</span>'}</td><td class="num">${euro(im.preis)}</td>
<td class="num"><button class="btn ghost sm" data-assign="${i}">Besitzer setzen</button>${im.besitzer?` <button class="btn red sm" data-evict="${i}">Räumen</button>`:''}</td></tr>`).join('')}
</tbody></table></div></div>`;
$("main").querySelectorAll("[data-assign]").forEach(b=>b.addEventListener("click",()=>immoAssign(+b.dataset.assign)));
$("main").querySelectorAll("[data-evict]").forEach(b=>b.addEventListener("click",()=>{const im=immobilien[+b.dataset.evict];log("Immobilie geräumt",im.name);im.besitzer=null;toast(im.name+" geräumt","warn");renderSektion();}));
};
function immoAssign(i){const im=immobilien[i];
modal(`<h3>Besitzer setzen · ${sicher(im.name)}</h3><label class="flabel">Neuer Besitzer</label><select class="input" id="im-owner"><option value="">— frei —</option>${players.map(p=>`<option ${im.besitzer===p.name?'selected':''}>${sicher(p.name)}</option>`).join('')}</select>
<div class="modal-actions"><button class="btn ghost" id="im-cancel">Abbrechen</button><button class="btn primary" id="im-ok">Setzen</button></div>`,true);
$("im-cancel").addEventListener("click",closeModal);
$("im-ok").addEventListener("click",()=>{im.besitzer=$("im-owner").value||null;log("Immobilie zugewiesen",im.name+" → "+(im.besitzer||"frei"));closeModal();toast("Besitzer aktualisiert","good");renderSektion();});
}
/* =====================================================================
FRAKTIONEN
===================================================================== */
sektionen.fraktionen=()=>{
$("main").innerHTML=`<div class="phead"><div><h2>Fraktionen</h2><p>${fraktionen.length} Fraktionen · ${fraktionen.reduce((s,f)=>s+f.mitglieder,0)} Mitglieder</p></div><button class="btn primary" id="fr-neu">+ Neue Fraktion</button></div>
<div class="grid" style="grid-template-columns:repeat(2,1fr)">${fraktionen.map((f,i)=>`<div class="card"><div style="display:flex;align-items:center;gap:10px"><span class="av" style="background:${f.farbe}">${initialen(f.name)}</span><div class="grow" style="flex:1"><div style="font-weight:800">${sicher(f.name)}</div><div style="font-size:12px;color:var(--muted)">${f.mitglieder} Mitglieder</div></div><span class="pill ${f.gehalt?'g':'m'}">${f.gehalt?euro(f.gehalt)+' /h':'kein Payday'}</span></div>
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap"><button class="btn ghost sm" data-gehalt="${i}">Gehalt anpassen</button><button class="btn ghost sm" data-members="${i}">Mitglieder</button><button class="btn red sm" data-delfrak="${i}">Löschen</button></div></div>`).join('')}</div>
<div class="card" style="margin-top:12px;font-size:12px;color:var(--muted)">💡 Das <b style="color:var(--fg)">Gehalt</b> ist der Betrag, der pro Stunde geleisteter Arbeit im <b style="color:var(--fg)">Payday</b> an jedes Mitglied ausgezahlt wird. Ein dediziertes Fraktions-/Gang-System (Ränge, Kassen, Territorien) folgt separat — diese Fraktionen sind dort bereits enthalten.</div>`;
$("fr-neu").addEventListener("click",fraktionModal);
$("main").querySelectorAll("[data-gehalt]").forEach(b=>b.addEventListener("click",()=>gehaltModal(+b.dataset.gehalt)));
$("main").querySelectorAll("[data-members]").forEach(b=>b.addEventListener("click",()=>{const f=fraktionen[+b.dataset.members],list=players.filter(p=>p.fraktion===f.name);
modal(`<h3>Mitglieder · ${sicher(f.name)}</h3><div class="msub">Online: ${list.length} von ${f.mitglieder}</div>${list.map(p=>`<div class="kv"><span>${sicher(p.name)}</span><span>${sicher(p.job)}</span></div>`).join('')||'<div class="empty">Niemand online.</div>'}<div class="modal-actions" style="grid-template-columns:1fr"><button class="btn ghost" id="fm-c">Schließen</button></div>`);
$("fm-c").addEventListener("click",closeModal);}));
$("main").querySelectorAll("[data-delfrak]").forEach(b=>b.addEventListener("click",()=>{const f=fraktionen[+b.dataset.delfrak];
modal(`<h3>Fraktion löschen?</h3><p style="color:var(--muted);font-size:13px">„${sicher(f.name)}“ wird entfernt. Mitglieder verlieren ihre Zugehörigkeit.</p><div class="modal-actions"><button class="btn ghost" id="df-c">Abbrechen</button><button class="btn red" id="df-ok">Löschen</button></div>`,true);
$("df-c").addEventListener("click",closeModal);$("df-ok").addEventListener("click",()=>{players.forEach(p=>{if(p.fraktion===f.name)p.fraktion=null;});fraktionen=fraktionen.filter(x=>x!==f);log("Fraktion gelöscht",f.name);closeModal();toast("Fraktion gelöscht","warn");renderSektion();});}));
};
function gehaltModal(i){const f=fraktionen[i];
modal(`<h3>Gehalt · ${sicher(f.name)}</h3><div class="msub">Stündliche Payday-Auszahlung pro Mitglied</div><label class="flabel">Gehalt (€ pro Stunde)</label><input class="input" id="fg" type="number" value="${f.gehalt}">
<div class="modal-actions"><button class="btn ghost" id="fg-c">Abbrechen</button><button class="btn primary" id="fg-ok">Speichern</button></div>`,true);
$("fg-c").addEventListener("click",closeModal);$("fg-ok").addEventListener("click",()=>{f.gehalt=Math.max(0,parseInt($("fg").value,10)||0);log("Fraktionsgehalt",f.name+" → "+euro(f.gehalt)+"/h");closeModal();toast("Gehalt: "+euro(f.gehalt)+"/h","good");renderSektion();});
}
function fraktionModal(){const swatches=["#4aa8ff","#f0506e","#7c5cf6","#f5b23d","#34d39a","#e0503f","#c96fd6","#22c1d4"];let farbe=swatches[0];
modal(`<h3>Neue Fraktion</h3><label class="flabel">Name</label><input class="input" id="nf-name" placeholder="z. B. Marabunta">
<label class="flabel">Farbe</label><div class="swatches" id="nf-farbe" style="display:flex;gap:8px;flex-wrap:wrap">${swatches.map((c,i)=>`<div class="sw2" data-c="${c}" style="background:${c};border:2px solid ${i===0?'#fff':'transparent'}"></div>`).join('')}</div>
<label class="flabel">Gehalt (€ / Stunde)</label><input class="input" id="nf-gehalt" type="number" value="0">
<div class="modal-actions"><button class="btn ghost" id="nf-c">Abbrechen</button><button class="btn primary" id="nf-ok">Erstellen</button></div>`,true);
$("nf-farbe").querySelectorAll("[data-c]").forEach(el=>el.addEventListener("click",()=>{farbe=el.dataset.c;$("nf-farbe").querySelectorAll("[data-c]").forEach(x=>x.style.borderColor='transparent');el.style.borderColor='#fff';}));
$("nf-c").addEventListener("click",closeModal);
$("nf-ok").addEventListener("click",()=>{const n=$("nf-name").value.trim();if(!n){toast("Name angeben","warn");return;}fraktionen.push({name:n,mitglieder:0,gehalt:parseInt($("nf-gehalt").value,10)||0,farbe});log("Fraktion erstellt",n);closeModal();toast("Fraktion „"+n+"“ erstellt","good");renderSektion();});
}
/* =====================================================================
WIRTSCHAFT
===================================================================== */
sektionen.wirtschaft=()=>{
const vermoegen=players.reduce((s,p)=>s+p.cash+p.bank,0), avg=Math.round(vermoegen/players.length);
$("main").innerHTML=`<div class="phead"><div><h2>Wirtschaft</h2><p>Geldmenge, Staatskasse &amp; Steuern</p></div></div>
<div class="grid" style="grid-template-columns:repeat(4,1fr)">
<div class="card kpi"><div class="l">💶 Geld im Umlauf</div><div class="v">${kompakt(economy.umlauf)}</div><div class="d" style="color:var(--muted)">gesamt</div></div>
<div class="card kpi"><div class="l">🏛️ Staatskasse</div><div class="v">${kompakt(economy.staatskasse)}</div><div class="d" style="color:var(--green)">stabil</div></div>
<div class="card kpi"><div class="l">👤 Ø Vermögen</div><div class="v">${kompakt(avg)}</div><div class="d" style="color:var(--muted)">pro Spieler online</div></div>
<div class="card kpi"><div class="l">📊 Steuersatz</div><div class="v">${economy.steuer}%</div><div class="d" style="color:var(--muted)">Einkommen</div></div></div>
<div class="grid" style="grid-template-columns:1.6fr 1fr;margin-top:12px">
<div class="card"><div class="list-h">Geldmenge · Verlauf</div><div class="chart">${areaChart(geldVerlauf,600,150,'#34d39a')}</div></div>
<div class="card"><div class="list-h">Aktionen</div><div style="display:flex;flex-direction:column;gap:8px">
<button class="btn ghost" id="e-all">💸 Geld an alle Spieler</button>
<button class="btn ghost" id="e-kasse">🏛️ Staatskasse anpassen</button>
<button class="btn ghost" id="e-steuer">📊 Steuersatz ändern</button></div></div></div>`;
$("e-all").addEventListener("click",()=>{modal(`<h3>Geld an alle</h3><label class="flabel">Betrag pro Spieler (Bank)</label><input class="input" id="ea" type="number" placeholder="z. B. 1000"><div class="modal-actions"><button class="btn ghost" id="ea-c">Abbrechen</button><button class="btn primary" id="ea-ok">Auszahlen</button></div>`,true);
$("ea-c").addEventListener("click",closeModal);$("ea-ok").addEventListener("click",()=>{const b=parseInt($("ea").value,10);if(!b){toast("Betrag angeben","warn");return;}players.forEach(p=>p.bank+=b);log("Geld an alle",euro(b)+" × "+players.length);closeModal();toast(euro(b)+" an "+players.length+" Spieler","good");renderSektion();});});
$("e-kasse").addEventListener("click",()=>{modal(`<h3>Staatskasse</h3><label class="flabel">Neuer Stand (€)</label><input class="input" id="ek" type="number" value="${economy.staatskasse}"><div class="modal-actions"><button class="btn ghost" id="ek-c">Abbrechen</button><button class="btn primary" id="ek-ok">Speichern</button></div>`,true);
$("ek-c").addEventListener("click",closeModal);$("ek-ok").addEventListener("click",()=>{economy.staatskasse=parseInt($("ek").value,10)||0;log("Staatskasse gesetzt",euro(economy.staatskasse));closeModal();toast("Staatskasse aktualisiert","good");renderSektion();});});
$("e-steuer").addEventListener("click",()=>{modal(`<h3>Steuersatz</h3><label class="flabel">Prozent</label><input class="input" id="es" type="number" value="${economy.steuer}"><div class="modal-actions"><button class="btn ghost" id="es-c">Abbrechen</button><button class="btn primary" id="es-ok">Speichern</button></div>`,true);
$("es-c").addEventListener("click",closeModal);$("es-ok").addEventListener("click",()=>{economy.steuer=Math.max(0,Math.min(100,parseInt($("es").value,10)||0));log("Steuersatz",economy.steuer+"%");closeModal();toast("Steuersatz: "+economy.steuer+"%","good");renderSektion();});});
};
/* =====================================================================
WELTSTEUERUNG
===================================================================== */
sektionen.welt=()=>{
const wetterOpt=["Klar","Bewölkt","Regen","Gewitter","Nebel","Schnee","Sturm"];
$("main").innerHTML=`<div class="phead"><div><h2>Weltsteuerung</h2><p>Zeit, Wetter &amp; Umgebung</p></div></div>
<div class="grid" style="grid-template-columns:1fr 1fr">
<div class="card"><div class="list-h">🕒 Zeit <span class="r">${zeitStr()} Uhr</span></div>
<input type="range" min="0" max="23" value="${welt.stunde}" id="w-stunde" style="width:100%;accent-color:var(--accent)">
<div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap"><button class="btn ghost sm" data-h="6">Morgen</button><button class="btn ghost sm" data-h="12">Mittag</button><button class="btn ghost sm" data-h="18">Abend</button><button class="btn ghost sm" data-h="0">Nacht</button></div>
<button class="btn ${welt.zeitFix?'green':'ghost'}" id="w-zeitfix" style="width:100%;margin-top:10px">${welt.zeitFix?'🔒 Zeit eingefroren':'Zeit einfrieren'}</button></div>
<div class="card"><div class="list-h">🌦️ Wetter <span class="r">${welt.wetter}</span></div>
<div style="display:flex;flex-wrap:wrap;gap:6px">${wetterOpt.map(w=>`<button class="btn ${welt.wetter===w?'primary':'ghost'} sm" data-w="${w}">${w}</button>`).join('')}</div>
<button class="btn ${welt.wetterFix?'green':'ghost'}" id="w-wfix" style="width:100%;margin-top:10px">${welt.wetterFix?'🔒 Wetter eingefroren':'Wetter einfrieren'}</button>
<button class="btn ${welt.blackout?'amber':'ghost'}" id="w-blackout" style="width:100%;margin-top:8px">${welt.blackout?'💡 Blackout aktiv':'🌑 Blackout umschalten'}</button></div></div>
<div class="card" style="margin-top:12px"><div class="list-h">🧹 Bereinigung</div><div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn ghost" data-clean="veh">Fahrzeuge entfernen</button><button class="btn ghost" data-clean="ped">NPCs entfernen</button><button class="btn ghost" data-clean="obj">Objekte entfernen</button><button class="btn ghost" data-clean="ground">Leichen aufräumen</button></div></div>`;
$("w-stunde").addEventListener("input",e=>{welt.stunde=+e.target.value;log("Zeit gesetzt",zeitStr());renderTop();renderSektion();});
$("main").querySelectorAll("[data-h]").forEach(b=>b.addEventListener("click",()=>{welt.stunde=+b.dataset.h;log("Zeit gesetzt",zeitStr());renderTop();renderSektion();toast("Zeit: "+zeitStr()+" Uhr");}));
$("w-zeitfix").addEventListener("click",()=>{welt.zeitFix=!welt.zeitFix;log("Zeit "+(welt.zeitFix?"eingefroren":"freigegeben"));renderSektion();toast(welt.zeitFix?"Zeit eingefroren":"Zeit läuft");});
$("main").querySelectorAll("[data-w]").forEach(b=>b.addEventListener("click",()=>{welt.wetter=b.dataset.w;log("Wetter gesetzt",welt.wetter);renderTop();renderSektion();toast("Wetter: "+welt.wetter,"good");}));
$("w-wfix").addEventListener("click",()=>{welt.wetterFix=!welt.wetterFix;log("Wetter "+(welt.wetterFix?"eingefroren":"freigegeben"));renderSektion();});
$("w-blackout").addEventListener("click",()=>{welt.blackout=!welt.blackout;log("Blackout "+(welt.blackout?"an":"aus"));renderSektion();toast(welt.blackout?"Blackout aktiviert":"Blackout deaktiviert","warn");});
$("main").querySelectorAll("[data-clean]").forEach(b=>b.addEventListener("click",()=>{const map={veh:"Fahrzeuge",ped:"NPCs",obj:"Objekte",ground:"Leichen"};log("Bereinigt",map[b.dataset.clean]);toast(map[b.dataset.clean]+" entfernt","good");}));
};
/* =====================================================================
ANKÜNDIGUNGEN
===================================================================== */
const broadcasts=[];
sektionen.broadcast=()=>{
$("main").innerHTML=`<div class="phead"><div><h2>Ankündigungen</h2><p>Nachricht an alle Spieler senden</p></div></div>
<div class="grid" style="grid-template-columns:1fr 1fr;align-items:start"><div class="card">
<label class="flabel">Typ</label><select class="input" id="bc-typ"><option>Info</option><option>Warnung</option><option>Event</option><option>Neustart</option></select>
<label class="flabel">Nachricht</label><textarea class="input" id="bc-msg" placeholder="Nachricht an den ganzen Server…"></textarea>
<button class="btn primary" id="bc-send" style="width:100%;margin-top:12px">📢 Senden</button></div>
<div class="card"><div class="list-h">Verlauf</div>${broadcasts.length?broadcasts.map(b=>`<div class="kv"><span><span class="pill ${b.typ==='Warnung'?'r':b.typ==='Event'?'v':'b'}">${b.typ}</span> ${sicher(b.msg)}</span><span style="color:var(--muted)">${b.zeit}</span></div>`).join(''):'<div class="empty">Noch keine Ankündigungen.</div>'}</div></div>`;
$("bc-send").addEventListener("click",()=>{const m=$("bc-msg").value.trim(),t=$("bc-typ").value;if(!m){toast("Nachricht eingeben","warn");return;}broadcasts.unshift({typ:t,msg:m,zeit:new Date().toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})});log("Ankündigung",t+": "+m);toast("An alle gesendet","good");renderSektion();});
};
/* =====================================================================
MODERATION
===================================================================== */
sektionen.moderation=()=>{
$("main").innerHTML=`<div class="phead"><div><h2>Bans &amp; Verwarnungen</h2><p>${bans.length} Bans · ${warns.length} Warns</p></div><button class="btn red" id="mod-ban">+ Manueller Ban</button></div>
<div class="grid" style="grid-template-columns:1fr 1fr;align-items:start">
<div class="card"><div class="list-h">🔨 Aktive Bans</div>${bans.map((b,i)=>`<div class="row" style="margin-bottom:6px;padding:9px 11px;background:var(--slot)"><div class="grow"><div style="font-weight:700">${sicher(b.name)}</div><div style="font-size:11.5px;color:var(--muted)">${sicher(b.grund)} · ${b.dauer} · ${b.admin}</div></div><button class="btn ghost sm" data-unban="${i}">Entbannen</button></div>`).join('')||'<div class="empty">Keine Bans.</div>'}</div>
<div class="card"><div class="list-h">⚠️ Verwarnungen</div>${warns.map((w,i)=>`<div class="row" style="margin-bottom:6px;padding:9px 11px;background:var(--slot)"><div class="grow"><div style="font-weight:700">${sicher(w.name)}</div><div style="font-size:11.5px;color:var(--muted)">${sicher(w.grund)} · ${w.admin} · ${w.datum}</div></div><button class="btn ghost sm" data-delwarn="${i}">Löschen</button></div>`).join('')||'<div class="empty">Keine Verwarnungen.</div>'}</div></div>`;
$("mod-ban").addEventListener("click",()=>{modal(`<h3>Manueller Ban</h3><label class="flabel">Name / Identifier</label><input class="input" id="mb-name"><label class="flabel">Grund</label><input class="input" id="mb-grund"><label class="flabel">Dauer</label><select class="input" id="mb-dauer"><option>1 Tag</option><option>7 Tage</option><option>30 Tage</option><option selected>permanent</option></select><div class="modal-actions"><button class="btn ghost" id="mb-c">Abbrechen</button><button class="btn red" id="mb-ok">Bannen</button></div>`,true);
$("mb-c").addEventListener("click",closeModal);$("mb-ok").addEventListener("click",()=>{const n=$("mb-name").value.trim();if(!n){toast("Name angeben","warn");return;}bans.unshift({name:n,grund:$("mb-grund").value.trim()||"—",admin:admin.name,dauer:$("mb-dauer").value,datum:new Date().toLocaleDateString('de-DE')});log("Manueller Ban",n);closeModal();toast(n+" gebannt","warn");renderSektion();});});
$("main").querySelectorAll("[data-unban]").forEach(b=>b.addEventListener("click",()=>{const x=bans.splice(+b.dataset.unban,1)[0];log("Entbannt",x.name);toast(x.name+" entbannt","good");renderSektion();}));
$("main").querySelectorAll("[data-delwarn]").forEach(b=>b.addEventListener("click",()=>{const x=warns.splice(+b.dataset.delwarn,1)[0];log("Verwarnung gelöscht",x.name);toast("Verwarnung gelöscht");renderSektion();}));
};
/* =====================================================================
REPORTS
===================================================================== */
sektionen.reports=()=>{
const prioP={1:['Priorität 1','r','#f0506e'],2:['Priorität 2','a','#f5b23d'],3:['Priorität 3','b','#4aa8ff']};
const statusP={offen:['offen','a'],'in Bearbeitung':['in Bearbeitung','b'],geschlossen:['geschlossen','g']};
const offen=reports.filter(r=>r.status!=='geschlossen').length;
$("main").innerHTML=`<div class="phead"><div><h2>Reports / Support</h2><p>${offen} offen · ${reports.length} gesamt</p></div></div>
<div style="display:flex;flex-direction:column;gap:10px">${reports.map((r,i)=>{const pp=prioP[r.prio],sp=statusP[r.status]||['?','m'];
return `<div class="card" style="display:flex;align-items:center;gap:16px;padding:14px 18px;border-left:3px solid ${pp[2]}">
<div class="grow" style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"><span style="font-family:ui-monospace,monospace;font-size:11.5px;color:var(--muted);font-weight:700">#T-${String(i+1).padStart(3,'0')}</span><span style="font-weight:800;font-size:14.5px">${sicher(r.betreff)}</span></div>
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;flex-wrap:wrap"><span class="pill ${pp[1]}">${pp[0]}</span><span class="pill ${sp[1]}">${sp[0]}</span><span style="font-size:12px;color:var(--muted)">Gemeldet von <b style="color:var(--fg)">${sicher(r.spieler)}</b></span></div></div>
<div style="display:flex;gap:7px;flex-shrink:0">${r.status==='geschlossen'?`<button class="btn ghost sm" data-reopen="${r.id}">Wieder öffnen</button>`:`<button class="btn ghost sm" data-claim="${r.id}">Übernehmen</button><button class="btn primary sm" data-answer="${r.id}">Antworten</button><button class="btn green sm" data-close="${r.id}">Schließen</button>`}</div>
</div>`;}).join('')||'<div class="empty"><div class="big">🎫</div>Keine Reports.</div>'}</div>`;
$("main").querySelectorAll("[data-claim]").forEach(b=>b.addEventListener("click",()=>{const r=reports.find(x=>x.id===b.dataset.claim);r.status="in Bearbeitung";log("Report übernommen",r.betreff);toast("Übernommen");renderSektion();renderNav();}));
$("main").querySelectorAll("[data-close]").forEach(b=>b.addEventListener("click",()=>{const r=reports.find(x=>x.id===b.dataset.close);r.status="geschlossen";log("Report geschlossen",r.betreff);toast("Geschlossen","good");renderSektion();renderNav();}));
$("main").querySelectorAll("[data-reopen]").forEach(b=>b.addEventListener("click",()=>{const r=reports.find(x=>x.id===b.dataset.reopen);r.status="offen";log("Report wieder geöffnet",r.betreff);toast("Wieder geöffnet");renderSektion();renderNav();}));
$("main").querySelectorAll("[data-answer]").forEach(b=>b.addEventListener("click",()=>{const r=reports.find(x=>x.id===b.dataset.answer);
modal(`<h3>Antwort · ${sicher(r.spieler)}</h3><div class="msub">${sicher(r.betreff)}</div><textarea class="input" id="ra" placeholder="Antwort an den Spieler…"></textarea><div class="modal-actions"><button class="btn ghost" id="ra-c">Abbrechen</button><button class="btn primary" id="ra-ok">Senden</button></div>`,true);
$("ra-c").addEventListener("click",closeModal);$("ra-ok").addEventListener("click",()=>{if(!$("ra").value.trim()){toast("Text eingeben","warn");return;}r.status="in Bearbeitung";log("Report beantwortet",r.betreff);closeModal();toast("Antwort gesendet","good");renderSektion();renderNav();});}));
};
/* =====================================================================
RESSOURCEN
===================================================================== */
sektionen.ressourcen=()=>{
$("main").innerHTML=`<div class="phead"><div><h2>Ressourcen</h2><p>${ressourcen.filter(r=>r.status==='running').length}/${ressourcen.length} aktiv · TPS ${server.tps.toFixed(1)}</p></div>
<div style="display:flex;gap:8px"><button class="btn ghost" id="rz-save">💾 Speichern</button><button class="btn red" id="rz-restart">⟳ Server-Neustart</button></div></div>
<div class="card" style="padding:6px 12px"><div class="tblwrap"><table class="tbl"><thead><tr><th>Ressource</th><th>Status</th><th>CPU</th><th>RAM</th><th></th></tr></thead><tbody>
${ressourcen.map((r,i)=>`<tr><td>🧩 ${sicher(r.name)}</td><td>${r.status==='running'?'<span class="pill g">läuft</span>':'<span class="pill r">gestoppt</span>'}</td>
<td class="num">${r.cpu.toFixed(1)}%</td><td class="num">${r.mem} MB</td>
<td class="num">${r.status==='running'?`<button class="btn ghost sm" data-restart="${i}">Restart</button> <button class="btn red sm" data-stop="${i}">Stop</button>`:`<button class="btn green sm" data-start="${i}">Start</button>`}</td></tr>`).join('')}
</tbody></table></div></div>`;
$("rz-save").addEventListener("click",()=>{log("Alles gespeichert");toast("Gespeichert","good");});
$("rz-restart").addEventListener("click",restartModal);
$("main").querySelectorAll("[data-restart]").forEach(b=>b.addEventListener("click",()=>{log("Ressource neugestartet",ressourcen[+b.dataset.restart].name);toast(ressourcen[+b.dataset.restart].name+" neugestartet","good");}));
$("main").querySelectorAll("[data-stop]").forEach(b=>b.addEventListener("click",()=>{const r=ressourcen[+b.dataset.stop];r.status="stopped";r.cpu=0;r.mem=0;log("Ressource gestoppt",r.name);toast(r.name+" gestoppt","warn");renderSektion();}));
$("main").querySelectorAll("[data-start]").forEach(b=>b.addEventListener("click",()=>{const r=ressourcen[+b.dataset.start];r.status="running";r.cpu=1+Math.random()*3;r.mem=20+Math.floor(Math.random()*40);log("Ressource gestartet",r.name);toast(r.name+" gestartet","good");renderSektion();}));
};
/* =====================================================================
AUDIT-LOG
===================================================================== */
let logSuche="", logWer="alle";
sektionen.logs=()=>{
const admins=["alle",...new Set(logs.map(l=>l.wer))];
if(!admins.includes(logWer))logWer="alle";
$("main").innerHTML=`<div class="phead"><div><h2>Audit-Log</h2><p>${logs.length} protokollierte Aktionen</p></div>
<button class="btn ghost" id="lg-export">⬇️ CSV exportieren</button></div>
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<div class="searchbar" style="flex:1;min-width:200px"><span>🔎</span><input id="lg-such" placeholder="Aktion oder Detail suchen…" value="${sicher(logSuche)}"></div>
<select class="input" id="lg-wer" style="width:auto;flex:none">${admins.map(a=>`<option value="${sicher(a)}" ${a===logWer?'selected':''}>${a==='alle'?'Alle Admins':sicher(a)}</option>`).join('')}</select></div>
<div class="card" style="padding:6px 12px"><div class="tblwrap"><table class="tbl"><thead><tr><th>Zeit</th><th>Admin</th><th>Aktion</th><th>Detail</th></tr></thead><tbody id="lg-body"></tbody></table></div></div>`;
const s=$("lg-such");s.addEventListener("input",()=>{logSuche=s.value.trim().toLowerCase();zeichneLogs();});
$("lg-wer").addEventListener("change",e=>{logWer=e.target.value;zeichneLogs();});
$("lg-export").addEventListener("click",exportLogs);
zeichneLogs();
};
function logListe(){return logs.filter(l=>{
const q=!logSuche||(l.aktion+" "+l.detail).toLowerCase().includes(logSuche);
return q&&(logWer==="alle"||l.wer===logWer);});}
function zeichneLogs(){
const el=$("lg-body");if(!el)return;
el.innerHTML=logListe().map(l=>`<tr><td class="mono" style="color:var(--muted)">${l.zeit.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'})}</td><td>${sicher(l.wer)}</td><td>${sicher(l.aktion)}</td><td style="color:var(--muted)">${sicher(l.detail)}</td></tr>`).join('')||'<tr><td colspan="4" class="empty">Keine Treffer.</td></tr>';
}
function exportLogs(){
const rows=[["Zeit","Admin","Aktion","Detail"],...logListe().map(l=>[l.zeit.toLocaleString('de-DE'),l.wer,l.aktion,l.detail])];
const csv=rows.map(r=>r.map(c=>`"${String(c).replace(/"/g,'""')}"`).join(";")).join("\n");
const a=document.createElement("a");a.href="data:text/csv;charset=utf-8,"+encodeURIComponent(csv);a.download="audit-log.csv";a.click();
log("Audit-Log exportiert",logListe().length+" Zeilen");toast("CSV-Export erstellt","good");
}
/* =====================================================================
ADMIN-TEAM
===================================================================== */
const adminRollen=["Owner","Admin","Moderator","Supporter"];
sektionen.team=()=>{
const rolleP={Owner:'r',Admin:'a',Moderator:'b',Supporter:'v'};
const darf=admin.rolle==="Owner";
$("main").innerHTML=`<div class="phead"><div><h2>Admin-Team</h2><p>${adminTeam.filter(a=>a.online).length}/${adminTeam.length} online${darf?'':' · nur der Owner darf das Team verwalten'}</p></div>${darf?'<button class="btn primary" id="tm-neu">+ Admin hinzufügen</button>':''}</div>
<div class="grid" style="grid-template-columns:repeat(2,1fr)">${adminTeam.map((a,i)=>`<div class="card"><div style="display:flex;align-items:center;gap:11px"><span class="av" style="width:40px;height:40px;background:${avColor(a.name)}">${initialen(a.name)}</span>
<div style="flex:1"><div style="font-weight:800">${sicher(a.name)}${a.name===admin.name?' <span class="pill m">du</span>':''}</div><div style="font-size:12px"><span class="pill ${rolleP[a.rolle]}">${a.rolle}</span></div></div>
<span class="pill ${a.online?'g':'m'}">${a.online?'online':'offline'}</span></div>
${darf?`<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap"><button class="btn ghost sm" data-trolle="${i}">Rolle ändern</button>${a.name===admin.name?'':`<button class="btn red sm" data-tdel="${i}">Entfernen</button>`}</div>`:''}</div>`).join('')}</div>
<div class="card" style="margin-top:12px;font-size:12.5px;color:var(--muted)">Rechte-Stufen: <b style="color:var(--red)">Owner</b> (alles) › <b style="color:var(--amber)">Admin</b> (Spieler/Welt/Fahrzeuge) › <b style="color:var(--accent)">Moderator</b> (Kick/Warn/Reports) › <b style="color:var(--violet)">Supporter</b> (Reports/Tickets).</div>`;
if(!darf)return;
$("tm-neu").addEventListener("click",teamAddModal);
$("main").querySelectorAll("[data-trolle]").forEach(b=>b.addEventListener("click",()=>teamRolleModal(+b.dataset.trolle)));
$("main").querySelectorAll("[data-tdel]").forEach(b=>b.addEventListener("click",()=>{const a=adminTeam[+b.dataset.tdel];
modal(`<h3>Admin entfernen?</h3><p style="color:var(--muted);font-size:13px">„${sicher(a.name)}“ (${a.rolle}) verliert alle Admin-Rechte.</p><div class="modal-actions"><button class="btn ghost" id="td-c">Abbrechen</button><button class="btn red" id="td-ok">Entfernen</button></div>`,true);
$("td-c").addEventListener("click",closeModal);$("td-ok").addEventListener("click",()=>{adminTeam.splice(adminTeam.indexOf(a),1);log("Admin entfernt",a.name+" ("+a.rolle+")");closeModal();toast(a.name+" entfernt","warn");renderSektion();});}));
};
function teamAddModal(){
modal(`<h3>Admin hinzufügen</h3><div class="msub">Vergibt Admin-Rechte an einen Spieler</div>
<label class="flabel">Name / Identifier</label><input class="input" id="ta-name" placeholder="z. B. Steam-Name oder Lizenz">
<label class="flabel">Rolle</label><select class="input" id="ta-rolle">${adminRollen.map(r=>`<option ${r==='Moderator'?'selected':''}>${r}</option>`).join('')}</select>
<div class="modal-actions"><button class="btn ghost" id="ta-c">Abbrechen</button><button class="btn primary" id="ta-ok">Hinzufügen</button></div>`,true);
$("ta-c").addEventListener("click",closeModal);
$("ta-ok").addEventListener("click",()=>{const n=$("ta-name").value.trim();if(!n){toast("Name angeben","warn");return;}
if(adminTeam.some(a=>a.name.toLowerCase()===n.toLowerCase())){toast("Ist bereits im Team","warn");return;}
const r=$("ta-rolle").value;adminTeam.push({name:n,rolle:r,online:false});log("Admin hinzugefügt",n+" ("+r+")");closeModal();toast(n+" als "+r+" hinzugefügt","good");renderSektion();});
}
function teamRolleModal(i){const a=adminTeam[i];const eigen=a.name===admin.name;
modal(`<h3>Rolle ändern · ${sicher(a.name)}</h3><div class="msub">Aktuell: ${a.rolle}</div>
<label class="flabel">Neue Rolle</label><select class="input" id="tr-rolle">${adminRollen.map(r=>`<option ${a.rolle===r?'selected':''}>${r}</option>`).join('')}</select>
${eigen?'<div class="card" style="margin-top:10px;font-size:12px;color:var(--amber)">⚠️ Du änderst deine eigene Rolle. Eine Herabstufung entzieht dir sofort Rechte.</div>':''}
<div class="modal-actions"><button class="btn ghost" id="tr-c">Abbrechen</button><button class="btn primary" id="tr-ok">Speichern</button></div>`,true);
$("tr-c").addEventListener("click",closeModal);
$("tr-ok").addEventListener("click",()=>{const r=$("tr-rolle").value;a.rolle=r;if(eigen){admin.rolle=r;renderTop();}log("Rolle geändert",a.name+" → "+r);closeModal();toast(a.name+" ist jetzt "+r,"good");renderSektion();});
}
/* =====================================================================
EINSTELLUNGEN
===================================================================== */
const settings={maxSlots:64,pvp:true,whitelist:true,voice:true,economy:true};
sektionen.settings=()=>{
const sw=(k,label)=>`<div class="row" style="margin:0 0 8px"><div class="grow"><div class="t">${label}</div></div><button class="btn ${settings[k]?'green':'ghost'} sm" data-set="${k}">${settings[k]?'AN':'AUS'}</button></div>`;
$("main").innerHTML=`<div class="phead"><div><h2>Einstellungen</h2><p>Server-Konfiguration</p></div></div>
<div class="grid" style="grid-template-columns:1fr 1fr;align-items:start"><div class="card"><div class="list-h">Allgemein</div>
<label class="flabel">Servername</label><input class="input" id="st-name" value="${sicher(server.name)}">
<label class="flabel">Maximale Slots</label><input class="input" id="st-slots" type="number" value="${settings.maxSlots}">
<label class="flabel">Neustart-Intervall (Stunden)</label><input class="input" id="st-restart" type="number" min="0" step="1" value="${Math.round(server.neustart/60)}">
<button class="btn primary sm" id="st-save" style="margin-top:12px">Speichern</button></div>
<div class="card"><div class="list-h">Regeln &amp; Systeme</div>${sw('pvp','PvP aktiviert')}${sw('whitelist','Whitelist')}${sw('voice','Voice-Chat')}${sw('economy','Wirtschaftssystem')}</div></div>`;
$("st-save").addEventListener("click",()=>{
const name=$("st-name").value.trim()||server.name;server.name=name;
settings.maxSlots=parseInt($("st-slots").value,10)||64;server.slots=settings.maxSlots;
const std=Math.max(0,parseInt($("st-restart").value,10)||0);server.neustart=std*60;
log("Einstellungen gespeichert",name+" · "+settings.maxSlots+" Slots · Neustart alle "+std+"h");toast("Einstellungen gespeichert","good");renderTop();});
$("main").querySelectorAll("[data-set]").forEach(b=>b.addEventListener("click",()=>{settings[b.dataset.set]=!settings[b.dataset.set];log("Einstellung",b.dataset.set+" "+(settings[b.dataset.set]?"an":"aus"));renderSektion();}));
};
/* =====================================================================
WELTKARTE (Live-Standorte)
===================================================================== */
const frakFarbe=f=>({LSPD:"#4aa8ff",EMS:"#f0506e",Ballas:"#7c5cf6",Vagos:"#f5b23d"}[f]||"#34d39a");
/* Quick-Menü für Spieler (Karte & Listen) */
function quickMenu(p,ev){
if(!p)return; const box=$("qmenu");
const acts=[
["⚙️ Verwalten (alle Aktionen)",()=>spielerModal(p.id)],
["📹 Live-Kamera",()=>einzelKamera(p)],
["🧭 Zu Spieler",()=>{log("goto",p.name);toast("Zu "+p.name+" teleportiert","good");}],
["📥 Spieler herholen",()=>{log("bring",p.name);toast(p.name+" hergeholt","good");}],
["❤️ Heilen",()=>{log("heal",p.name);toast(p.name+" geheilt","good");}],
[p.frozen?"🔥 Auftauen":"🧊 Einfrieren",()=>{p.frozen=!p.frozen;log("freeze",p.name);toast(p.name+(p.frozen?" eingefroren":" aufgetaut"));if(sektion==="weltkarte"||sektion==="spieler")renderSektion();}],
["👢 Kicken",()=>kickModal(p),"danger"]
];
box.innerHTML=`<div class="qm-h">${sicher(p.name)} · ${sicher(p.region)}</div>`+acts.map((a,i)=>`<button data-qa="${i}" class="${a[2]||''}">${a[0]}</button>`).join("");
box.classList.add("show");
let x=Math.min(ev.clientX,window.innerWidth-box.offsetWidth-8), y=Math.min(ev.clientY,window.innerHeight-box.offsetHeight-8);
box.style.left=x+"px"; box.style.top=y+"px";
box.querySelectorAll("[data-qa]").forEach(b=>b.addEventListener("click",()=>{schliesseQuick();acts[+b.dataset.qa][1]();}));
}
function schliesseQuick(){$("qmenu").classList.remove("show");}
document.addEventListener("click",e=>{if(!e.target.closest("#qmenu"))schliesseQuick();});
sektionen.weltkarte=()=>{
$("main").innerHTML=`<div class="phead"><div><h2>Weltkarte</h2><p>${players.length} Spieler live · Marker oder Zeile anklicken für das Quick-Menü</p></div></div>
<div style="display:grid;grid-template-columns:1fr 252px;gap:12px;height:calc(100% - 58px);min-height:340px">
<div class="map" id="map"><div class="water"></div>
<div class="distr" style="left:12%;top:14%">Vinewood</div><div class="distr" style="left:58%;top:20%">Downtown</div><div class="distr" style="left:20%;top:74%">Vespucci</div><div class="distr" style="left:64%;top:66%">Del Perro</div><div class="distr" style="left:40%;top:44%">Legion Sq.</div>
${players.map(p=>`<div class="mk${p.tot?' dead':''}" id="mk-${p.id}" data-mk="${p.id}" style="left:${p.posX}%;top:${p.posY}%"><div class="dot" style="background:${frakFarbe(p.fraktion)}">${initialen(p.name)}</div><div class="lbl">${sicher(p.name.split(' ')[0])}</div></div>`).join('')}</div>
<div style="overflow-y:auto"><div class="list-h">Spieler online <span class="r">${players.length}</span></div>
${players.map(p=>`<div class="row" style="padding:8px 10px;margin-bottom:6px;cursor:pointer" data-pq="${p.id}"><span class="av" style="width:28px;height:28px;font-size:10px;background:${frakFarbe(p.fraktion)}">${initialen(p.name)}</span><div class="grow"><div class="t" style="font-size:13px">${sicher(p.name)}</div><div class="m">${sicher(p.region)}${p.fraktion?' · '+p.fraktion:''} · ${p.ping} ms</div></div><span style="color:var(--muted);font-size:16px">›</span></div>`).join('')}</div></div>`;
$("main").querySelectorAll("[data-mk]").forEach(m=>m.addEventListener("click",e=>{e.stopPropagation();quickMenu(player(+m.dataset.mk),e);}));
$("main").querySelectorAll("[data-pq]").forEach(r=>r.addEventListener("click",e=>{e.stopPropagation();quickMenu(player(+r.dataset.pq),e);}));
clearInterval(mapTimer);
mapTimer=setInterval(()=>{players.forEach(p=>{p.posX=Math.max(5,Math.min(95,p.posX+(Math.random()-0.5)*5));p.posY=Math.max(7,Math.min(92,p.posY+(Math.random()-0.5)*5));const el=$("mk-"+p.id);if(el){el.style.left=p.posX+'%';el.style.top=p.posY+'%';}});},2800);
};
/* =====================================================================
LIVE-KAMERAS (WebRTC-Spectate / Multi-Cam-Views)
===================================================================== */
function szeneFarbe(region){let h=0;for(const c of (region||""))h=(h*31+c.charCodeAt(0))%360;return `linear-gradient(180deg,hsl(${h} 32% 22%),hsl(${(h+40)%360} 26% 9%))`;}
function camFeed(pid,voll){const p=player(pid);if(!p)return '';
return `<div class="camtile" data-tile="${pid}"><div class="camfeed"><div class="scene" style="background:${szeneFarbe(p.region)}"></div><div class="sweep"></div><div class="noise"></div><div class="scan"></div></div>
<div class="cam-hud"><div class="who">CAM · ${sicher(p.name)} #${p.id}</div><div class="rec"><i></i>LIVE</div><div class="cross"></div>
<div class="foot"><span>📍 ${sicher(p.region)}</span><span class="cam-time">${new Date().toLocaleTimeString('de-DE')}</span><span>${p.ping} ms</span></div></div>
${voll?'':`<div class="ctrls"><button data-full="${pid}">⛶</button><button data-goto2="${pid}">Goto</button><button data-rmcam="${pid}">✕</button></div>`}</div>`;
}
function einzelKamera(p){
let v=camViews.find(x=>x.id==="single"); if(!v){v={id:"single",name:"Einzelkamera",spieler:[]};camViews.unshift(v);}
v.spieler=[p.id]; v.name="Kamera: "+p.name; aktiveView="single"; sektion="cams"; closeModal(); log("Live-Kamera geöffnet",p.name); renderNav(); renderSektion();
}
sektionen.cams=()=>{
if(!camViews.find(v=>v.id===aktiveView))aktiveView=camViews[0]&&camViews[0].id;
const v=camViews.find(x=>x.id===aktiveView);
const cols=!v?1:v.spieler.length<=1?1:v.spieler.length<=4?2:3;
$("main").innerHTML=`<div class="phead"><div><h2>Live-Kameras</h2><p>WebRTC-Spectate · Einzel- &amp; Multi-Cam-Views</p></div><button class="btn primary" id="cam-new">+ View erstellen</button></div>
<div style="display:grid;grid-template-columns:230px 1fr;gap:12px;align-items:start">
<div class="viewlist">${camViews.map(x=>`<button class="btn ${x.id===aktiveView?'primary':'ghost'}" data-v="${x.id}">${sicher(x.name)} · ${x.spieler.length}📹</button>`).join('')||'<div class="empty">Keine Views.</div>'}
${v?`<button class="btn ghost sm" id="view-edit" style="width:100%;margin-top:6px">Spieler bearbeiten</button><button class="btn ghost sm" id="view-del" style="width:100%;margin-top:6px">View löschen</button>`:''}</div>
<div>${v&&v.spieler.length?`<div class="camgrid" style="grid-template-columns:repeat(${cols},1fr)">${v.spieler.map(pid=>camFeed(pid)).join('')}</div>`:'<div class="empty" style="padding:60px"><div class="big">📹</div>Keine Spieler in dieser View — füge welche hinzu.</div>'}</div></div>`;
$("cam-new").addEventListener("click",()=>viewModal(null));
$("main").querySelectorAll("[data-v]").forEach(b=>b.addEventListener("click",()=>{aktiveView=b.dataset.v;renderSektion();}));
const ve=$("view-edit");if(ve)ve.addEventListener("click",()=>viewModal(v));
const vd=$("view-del");if(vd)vd.addEventListener("click",()=>{camViews=camViews.filter(x=>x.id!==v.id);aktiveView=camViews[0]&&camViews[0].id;log("Cam-View gelöscht",v.name);toast("View gelöscht");renderSektion();});
bindCamCtrls();
};
function bindCamCtrls(){
$("main").querySelectorAll("[data-rmcam]").forEach(b=>b.addEventListener("click",()=>{const v=camViews.find(x=>x.id===aktiveView);v.spieler=v.spieler.filter(id=>id!==+b.dataset.rmcam);renderSektion();}));
$("main").querySelectorAll("[data-goto2]").forEach(b=>b.addEventListener("click",()=>{const p=player(+b.dataset.goto2);log("goto (Kamera)",p.name);toast("Zu "+p.name+" teleportiert","good");}));
$("main").querySelectorAll("[data-full]").forEach(b=>b.addEventListener("click",()=>{const p=player(+b.dataset.full);modal(`<h3>📹 ${sicher(p.name)} · Live</h3><div class="msub">📍 ${sicher(p.region)} · ${p.ping} ms</div>${camFeed(p.id,true)}<div class="modal-actions" style="grid-template-columns:1fr"><button class="btn ghost" id="fc-close">Schließen</button></div>`);$("fc-close").addEventListener("click",closeModal);}));
}
function viewModal(v){const editing=!!v;const sel=new Set(editing?v.spieler:[]);
modal(`<h3>${editing?'View bearbeiten':'Neue Live-View'}</h3><div class="msub">Mehrere Spieler wählen → Multi-Cam (z. B. Gang-Fight-Kontrolle)</div>
${editing?'':'<label class="flabel">Name der View</label><input class="input" id="vw-name" placeholder="z. B. Gang Fight Grove St.">'}
<label class="flabel">Spieler</label><div style="max-height:280px;overflow-y:auto">${players.map(p=>`<div class="chk${sel.has(p.id)?' sel':''}" data-pv="${p.id}"><span class="av" style="background:${avColor(p.name)}">${initialen(p.name)}</span><div class="grow" style="font-weight:700;font-size:13px">${sicher(p.name)}<span style="color:var(--muted);font-weight:500"> · ${sicher(p.region)}</span></div></div>`).join('')}</div>
<div class="modal-actions"><button class="btn ghost" id="vw-cancel">Abbrechen</button><button class="btn primary" id="vw-ok">${editing?'Speichern':'Erstellen'}</button></div>`);
$("overlay").querySelectorAll("[data-pv]").forEach(el=>el.addEventListener("click",()=>{const id=+el.dataset.pv;if(sel.has(id)){sel.delete(id);el.classList.remove('sel');}else{sel.add(id);el.classList.add('sel');}}));
$("vw-cancel").addEventListener("click",()=>{closeModal();renderSektion();});
$("vw-ok").addEventListener("click",()=>{
if(editing){v.spieler=[...sel];}
else{const name=($("vw-name").value||"").trim()||"Neue View";const id="cv"+Date.now();camViews.push({id,name,spieler:[...sel]});aktiveView=id;log("Cam-View erstellt",name+" ("+sel.size+" Spieler)");}
closeModal();toast("View gespeichert","good");renderSektion();});
}
/* =====================================================================
KILL-/DEATHCAMS
===================================================================== */
function alterText(t){const min=Math.floor((Date.now()-t)/60000);if(min<60)return "vor "+min+" min";const h=Math.floor(min/60);return "vor "+h+" h";}
function killcamCard(k){const alt=Date.now()-k.zeit,rest=Math.max(0,Math.ceil((86400000-alt)/3600000));
return `<div class="kc"><div class="thumb"><div style="position:absolute;inset:0;background:${szeneFarbe(k.ort)}"></div><div class="scan"></div><div class="pl">▶</div></div>
<div class="grow"><div style="font-weight:800;font-size:14px">${sicher(k.killer)} <span style="color:var(--red)">▸</span> ${sicher(k.opfer)}</div>
<div style="font-size:12px;color:var(--muted);margin-top:2px">🔫 ${sicher(k.waffe)} · ${k.distanz} m · 📍 ${sicher(k.ort)} · ${alterText(k.zeit)}</div>
<div style="margin-top:6px">${k.gespeichert?'<span class="pill g">💾 gespeichert – bleibt erhalten</span>':(rest>0?`<span class="pill a">wird in ${rest} h automatisch gelöscht</span>`:'<span class="pill r">überfällig – wird gelöscht</span>')}</div></div>
<div style="display:flex;flex-direction:column;gap:6px"><button class="btn primary sm" data-view="${k.id}">Ansehen</button><button class="btn ${k.gespeichert?'ghost':'green'} sm" data-save="${k.id}">${k.gespeichert?'Freigeben':'💾 Speichern'}</button><button class="btn ghost sm" data-del="${k.id}">Löschen</button></div></div>`;
}
sektionen.killcams=()=>{
const list=killcams.filter(k=>kcFilter==='alle'||k.gespeichert);
const alt=killcams.filter(k=>!k.gespeichert&&(Date.now()-k.zeit)>86400000).length;
$("main").innerHTML=`<div class="phead"><div><h2>Kill- / Deathcams</h2><p>${killcams.length} Aufnahmen · nicht gespeicherte werden nach 24 h gelöscht</p></div>
<div style="display:flex;gap:8px"><div class="seg" id="kc-filter"><button data-f="alle" class="${kcFilter==='alle'?'active':''}">Alle</button><button data-f="saved" class="${kcFilter==='saved'?'active':''}">Gespeichert</button></div>${alt?`<button class="btn red" id="kc-clean">${alt} alte löschen</button>`:''}</div></div>
${list.map(killcamCard).join('')||'<div class="empty"><div class="big">🎬</div>Keine Aufnahmen.</div>'}`;
$("kc-filter").querySelectorAll("[data-f]").forEach(b=>b.addEventListener("click",()=>{kcFilter=b.dataset.f;renderSektion();}));
const cl=$("kc-clean");if(cl)cl.addEventListener("click",()=>{const n=killcams.length;killcams=killcams.filter(k=>k.gespeichert||(Date.now()-k.zeit)<=86400000);log("Alte Aufnahmen gelöscht",(n-killcams.length)+" entfernt");toast((n-killcams.length)+" alte Aufnahmen gelöscht","warn");renderSektion();});
$("main").querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>killcamViewer(b.dataset.view)));
$("main").querySelectorAll("[data-save]").forEach(b=>b.addEventListener("click",()=>{const k=killcams.find(x=>x.id===b.dataset.save);k.gespeichert=!k.gespeichert;log("Aufnahme "+(k.gespeichert?"gespeichert":"freigegeben"),k.killer+" ▸ "+k.opfer);toast(k.gespeichert?"Gespeichert – bleibt erhalten":"Speicherung entfernt");renderSektion();}));
$("main").querySelectorAll("[data-del]").forEach(b=>b.addEventListener("click",()=>{killcams=killcams.filter(x=>x.id!==b.dataset.del);toast("Aufnahme gelöscht");renderSektion();}));
};
function killcamViewer(id){const k=killcams.find(x=>x.id===id);
modal(`<h3>🎬 Replay · ${sicher(k.killer)} ▸ ${sicher(k.opfer)}</h3><div class="msub">${sicher(k.waffe)} · ${k.distanz} m · ${sicher(k.ort)} · ${alterText(k.zeit)}</div>
<div class="camtile" style="aspect-ratio:16/9"><div class="camfeed"><div class="scene" style="background:${szeneFarbe(k.ort)}"></div><div class="sweep"></div><div class="noise"></div><div class="scan"></div></div>
<div class="cam-hud"><div class="who">REPLAY · ${sicher(k.opfer)} †</div><div class="rec" style="color:#fff"><i style="background:#fff"></i>REC</div><div class="cross"></div><div class="foot"><span>${sicher(k.killer)} ▸ ${sicher(k.opfer)}</span><span>${sicher(k.waffe)} · ${k.distanz} m</span></div></div></div>
<input type="range" min="0" max="100" value="60" style="width:100%;accent-color:var(--accent);margin-top:10px">
<div class="modal-actions"><button class="btn ${k.gespeichert?'ghost':'green'}" id="kv-save">${k.gespeichert?'Speicherung entfernen':'💾 Speichern'}</button><button class="btn ghost" id="kv-close">Schließen</button></div>`);
$("kv-close").addEventListener("click",closeModal);
$("kv-save").addEventListener("click",()=>{k.gespeichert=!k.gespeichert;log("Aufnahme "+(k.gespeichert?"gespeichert":"freigegeben"));closeModal();toast(k.gespeichert?"Gespeichert":"Freigegeben");renderSektion();});
}
/* =====================================================================
STAATSPROJEKTE (aktivierbar nach positiver Abstimmung)
===================================================================== */
function projektCard(p){const gesamt=p.ja+p.nein,proz=gesamt?Math.round(p.ja/gesamt*100):0,positiv=proz>=ZUSTIMMUNG_MIN*100;
let status,aktion="";
if(p.aktiviert){status='<span class="pill g">✔ Umgesetzt</span>';}
else if(!p.abgelaufen){status='<span class="pill b">Abstimmung läuft</span>';aktion=`<button class="btn ghost sm" data-end="${p.id}">Abstimmung beenden</button>`;}
else if(positiv){status='<span class="pill a">bereit zur Umsetzung</span>';aktion=`<button class="btn green sm" data-act="${p.id}">Aktivieren</button>`;}
else {status='<span class="pill r">abgelehnt · zu wenig Zustimmung</span>';aktion=`<button class="btn ghost sm" data-reject="${p.id}">Archivieren</button>`;}
return `<div class="card" style="margin-bottom:10px"><div style="display:flex;align-items:flex-start;gap:12px"><div class="grow" style="flex:1">
<div style="font-weight:800;font-size:15px">${sicher(p.titel)} ${status}</div>
<div style="font-size:12.5px;color:var(--muted);margin:3px 0 8px">${sicher(p.beschreibung)} · Kosten: ${euro(p.kosten)}</div>
<div style="height:9px;border-radius:5px;background:var(--slot);overflow:hidden;display:flex"><div style="width:${proz}%;background:var(--green)"></div><div style="width:${100-proz}%;background:var(--red)"></div></div>
<div style="font-size:11.5px;color:var(--muted);margin-top:5px">${p.ja} Dafür · ${p.nein} Dagegen · <b style="color:${positiv?'var(--green)':'var(--red)'}">${proz}% Zustimmung</b> (nötig: ${ZUSTIMMUNG_MIN*100}%)</div></div>
<div style="display:flex;flex-direction:column;gap:6px">${aktion}</div></div></div>`;
}
sektionen.staat=()=>{
const offen=staatsprojekte.filter(p=>!p.aktiviert).length, umgesetzt=staatsprojekte.filter(p=>p.aktiviert).length;
$("main").innerHTML=`<div class="phead"><div><h2>Staatsprojekte</h2><p>Abstimmung aus der Handy-Staat-App · ${offen} offen · ${umgesetzt} umgesetzt</p></div></div>
<div class="card" style="margin-bottom:12px;font-size:12px;color:var(--muted)">Ein Projekt kann <b style="color:var(--fg)">aktiviert</b> werden, sobald die <b style="color:var(--fg)">Abstimmung beendet</b> ist und mindestens <b style="color:var(--green)">${ZUSTIMMUNG_MIN*100}% Zustimmung</b> erreicht wurden. Die Kosten werden aus der Staatskasse (${euro(economy.staatskasse)}) beglichen.</div>
${staatsprojekte.map(projektCard).join('')}`;
$("main").querySelectorAll("[data-end]").forEach(b=>b.addEventListener("click",()=>{const p=staatsprojekte.find(x=>x.id===b.dataset.end);p.abgelaufen=true;log("Abstimmung beendet",p.titel);toast("Abstimmung beendet");renderSektion();renderNav();}));
$("main").querySelectorAll("[data-act]").forEach(b=>b.addEventListener("click",()=>{const p=staatsprojekte.find(x=>x.id===b.dataset.act);if(p.kosten>economy.staatskasse){toast("Staatskasse nicht gedeckt","warn");return;}economy.staatskasse-=p.kosten;p.aktiviert=true;log("Staatsprojekt aktiviert",p.titel+" ("+euro(p.kosten)+")");toast("✔ „"+p.titel+"“ umgesetzt","good");renderSektion();renderNav();}));
$("main").querySelectorAll("[data-reject]").forEach(b=>b.addEventListener("click",()=>{const p=staatsprojekte.find(x=>x.id===b.dataset.reject);staatsprojekte=staatsprojekte.filter(x=>x.id!==b.dataset.reject);log("Staatsprojekt archiviert",p.titel);toast("Archiviert");renderSektion();renderNav();}));
};
/* =====================================================================
LIVE-TICK & START
===================================================================== */
setInterval(()=>{
server.tps=Math.max(15,Math.min(20,server.tps+(Math.random()-0.5)*0.6));
server.cpu=Math.max(25,Math.min(70,server.cpu+Math.floor((Math.random()-0.5)*4)));
if(server.neustart>0&&Math.random()<0.5)server.neustart--;
server.uptimeMin++;
players.forEach(p=>{p.ping=Math.max(15,Math.min(200,p.ping+Math.floor((Math.random()-0.5)*8)));});
document.querySelectorAll(".cam-time").forEach(e=>e.textContent=new Date().toLocaleTimeString("de-DE"));
renderTop();
if(sektion==="dashboard"){const k=$("kpi-tps");if(k)k.textContent=server.tps.toFixed(1);const c=$("kpi-cpu");if(c)c.textContent=server.cpu;}
},3000);
$("overlay").addEventListener("click",e=>{if(e.target===$("overlay"))closeModal();});
refresh();
</script>
</body>
</html>

366
casino/index.html Normal file
View File

@ -0,0 +1,366 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
</head>
<body>
<title>Aether — Casino</title>
<style>
:root{
--bg:#070a08;--fg:#f0ecdf;--muted:#9a9683;--surface:rgba(24,30,24,0.72);--surface2:rgba(36,44,36,0.85);
--slot:#101610;--border:rgba(255,255,255,0.09);--accent:#e0b64a;--ink:#231a04;--accent-soft:rgba(224,182,74,0.16);
--green:#34d39a;--red:#f0506e;--felt:#12321f;--gold:#e0b64a;
}
*{box-sizing:border-box;} html,body{margin:0;height:100%;}
body{min-height:100%;background:radial-gradient(1100px 700px at 50% -10%,rgba(224,182,74,0.1),transparent 55%),var(--bg);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);
display:flex;align-items:center;justify-content:center;padding:22px 14px;overflow-x:hidden;}
.app{position:relative;width:min(1200px,98vw);aspect-ratio:16/10;border-radius:20px;overflow:hidden;
background:linear-gradient(160deg,rgba(18,24,18,0.94),rgba(8,10,8,0.97));border:1px solid rgba(224,182,74,0.25);
box-shadow:0 50px 100px -30px rgba(0,0,0,0.85);display:grid;grid-template-columns:206px 1fr;grid-template-rows:auto 1fr;grid-template-areas:"brand top" "nav main";}
.brand{grid-area:brand;padding:14px 16px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
.brand .bl{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;font-size:19px;background:linear-gradient(150deg,var(--gold),#a8801f);color:#231a04;}
.brand .bn{font-size:16px;font-weight:800;letter-spacing:-0.02em;} .brand .bn span{color:var(--gold);} .brand .bn small{display:block;font-size:10px;color:var(--muted);font-weight:500;}
.top{grid-area:top;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:14px;padding:0 18px;}
.top .chip{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:7px 13px;text-align:right;} .top .chip .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;font-weight:800;} .top .chip .v{font-size:15px;font-weight:800;font-variant-numeric:tabular-nums;}
.top .guthaben .v{color:var(--gold);} .top .netto.plus .v{color:var(--green);} .top .netto.minus .v{color:var(--red);}
.top .acts{margin-left:auto;display:flex;gap:8px;}
.nav{grid-area:nav;border-right:1px solid var(--border);padding:12px 9px;display:flex;flex-direction:column;gap:2px;overflow-y:auto;}
.nav button{display:flex;align-items:center;gap:10px;padding:9px 11px;border-radius:9px;border:none;background:none;color:var(--muted);font-family:inherit;font-size:13.5px;font-weight:700;cursor:pointer;text-align:left;transition:.12s;}
.nav button .ni{font-size:15px;width:18px;text-align:center;} .nav button:hover{background:var(--surface);color:var(--fg);} .nav button.active{background:var(--accent-soft);color:var(--gold);}
.main{grid-area:main;overflow-y:auto;padding:20px 24px;} .main::-webkit-scrollbar,.nav::-webkit-scrollbar{width:7px;} .main::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.09);border-radius:4px;}
.phead{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;margin-bottom:16px;} .phead h2{font-size:21px;margin:0;font-weight:800;letter-spacing:-0.02em;} .phead p{margin:2px 0 0;color:var(--muted);font-size:12.5px;}
.btn{border:none;border-radius:11px;padding:11px 16px;font-size:14px;font-weight:800;cursor:pointer;font-family:inherit;transition:.12s;} .btn.primary{background:var(--gold);color:var(--ink);} .btn.primary:hover{filter:brightness(1.08);} .btn.ghost{background:var(--surface2);color:var(--fg);border:1px solid var(--border);} .btn.green{background:var(--green);color:#06231a;} .btn.red{background:var(--red);color:#2a0910;} .btn.sm{padding:7px 12px;font-size:12.5px;} .btn:disabled{opacity:.4;cursor:not-allowed;} .btn:active{transform:scale(0.98);}
.card{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:18px;}
.felt{background:radial-gradient(120% 90% at 50% 0%,#184026,var(--felt));border:1px solid rgba(224,182,74,0.25);border-radius:18px;padding:22px;}
.list-h{font-size:10.5px;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);font-weight:800;margin:0 0 10px;}
.input{width:120px;background:var(--slot);border:1px solid var(--border);border-radius:9px;padding:10px 11px;color:var(--fg);font-size:15px;font-family:inherit;outline:none;font-weight:700;text-align:center;font-variant-numeric:tabular-nums;} .input:focus{border-color:var(--gold);}
.flabel{font-size:10.5px;color:var(--muted);font-weight:800;text-transform:uppercase;letter-spacing:0.04em;margin:0 2px 6px;display:block;}
.quickchips{display:flex;gap:6px;} .quickchips button{border:none;background:var(--slot);border:1px solid var(--border);color:var(--fg);border-radius:8px;padding:8px 11px;font-family:inherit;font-weight:700;font-size:12.5px;cursor:pointer;} .quickchips button:hover{border-color:var(--gold);}
.pill{font-size:10px;font-weight:800;padding:3px 9px;border-radius:999px;display:inline-block;} .pill.g{background:rgba(52,211,153,0.16);color:var(--green);} .pill.r{background:rgba(240,80,110,0.16);color:var(--red);} .pill.gold{background:var(--accent-soft);color:var(--gold);}
.result{text-align:center;font-size:18px;font-weight:800;margin-top:12px;min-height:24px;} .result.win{color:var(--green);} .result.lose{color:var(--red);} .result.jackpot{color:var(--gold);}
/* Lobby */
.lobbygrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px;}
.gamecard{border-radius:16px;padding:18px;cursor:pointer;border:1px solid var(--border);background:var(--surface);transition:.12s;position:relative;overflow:hidden;}
.gamecard:hover{transform:translateY(-3px);border-color:var(--gold);} .gamecard .gi{font-size:36px;} .gamecard .gn{font-size:17px;font-weight:800;margin-top:8px;} .gamecard .gd{font-size:12px;color:var(--muted);margin-top:3px;} .gamecard .go{position:absolute;top:12px;right:14px;color:var(--gold);font-size:11px;font-weight:800;}
/* Slots */
.reels{display:flex;gap:12px;justify-content:center;margin:10px 0 6px;}
.reel{width:92px;height:110px;border-radius:14px;background:linear-gradient(180deg,#0c120c,#0a0e0a);border:2px solid rgba(224,182,74,0.4);display:grid;place-items:center;font-size:52px;overflow:hidden;box-shadow:0 0 0 4px rgba(0,0,0,0.3) inset;}
.reel.spin{animation:shake .1s infinite;} @keyframes shake{50%{transform:translateY(-2px);}}
.paytable{display:grid;grid-template-columns:1fr 1fr;gap:4px 18px;font-size:12px;color:var(--muted);} .paytable b{color:var(--fg);}
/* Karten */
.hand{display:flex;gap:8px;flex-wrap:wrap;min-height:96px;align-items:center;}
.pkarte{width:64px;height:90px;border-radius:9px;background:#f6f3ea;color:#1a1a1a;display:flex;flex-direction:column;justify-content:space-between;padding:6px 8px;font-weight:800;box-shadow:0 4px 10px rgba(0,0,0,0.4);animation:deal .3s ease;} @keyframes deal{from{transform:translateY(-14px) scale(.9);opacity:0;}to{transform:none;opacity:1;}}
.pkarte.rot{color:#c0392b;} .pkarte .r{font-size:16px;} .pkarte .s{font-size:22px;text-align:center;} .pkarte .r.bot{align-self:flex-end;transform:rotate(180deg);}
.pkarte.back{background:linear-gradient(135deg,#7c2f8f,#3a1450);color:transparent;} .pkarte.back::after{content:"♠";color:rgba(255,255,255,0.3);font-size:30px;margin:auto;}
.handval{font-size:13px;color:var(--muted);font-weight:700;margin-top:6px;}
/* Roulette */
.rnum{width:110px;height:110px;border-radius:50%;display:grid;place-items:center;font-size:44px;font-weight:800;margin:0 auto;border:3px solid rgba(224,182,74,0.5);}
.rnum.rot{background:#8a1f1f;} .rnum.schwarz{background:#1a1a1a;} .rnum.gruen{background:#14713f;}
.betbtns{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;} .betbtns button{border:none;background:var(--slot);border:1px solid var(--border);color:var(--fg);border-radius:9px;padding:10px;font-family:inherit;font-weight:700;font-size:12.5px;cursor:pointer;} .betbtns button:hover{border-color:var(--gold);}
.betlist{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;} .betlist .b{background:var(--surface2);border:1px solid var(--border);border-radius:999px;padding:4px 10px;font-size:11.5px;font-weight:700;}
/* Glücksrad */
.wheelwrap{position:relative;width:230px;height:230px;margin:0 auto;}
.wheel{width:100%;height:100%;border-radius:50%;border:6px solid var(--gold);transition:transform 4.4s cubic-bezier(.15,.9,.25,1);box-shadow:0 0 0 4px rgba(0,0,0,0.4);}
.wheel .lbl{position:absolute;top:50%;left:50%;font-size:12px;font-weight:800;color:#fff;text-shadow:0 1px 2px #000;white-space:nowrap;}
.wpointer{position:absolute;top:-6px;left:50%;transform:translateX(-50%);border-left:11px solid transparent;border-right:11px solid transparent;border-top:20px solid var(--gold);z-index:2;}
.whub{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:40px;height:40px;border-radius:50%;background:var(--gold);border:3px solid #231a04;z-index:2;display:grid;place-items:center;font-size:16px;}
/* Coin */
.coin{width:120px;height:120px;border-radius:50%;margin:6px auto 0;display:grid;place-items:center;font-size:50px;font-weight:800;color:#231a04;background:radial-gradient(circle at 35% 30%,#f4d97a,#c69a2e);border:4px solid #a8801f;transition:transform .1s;}
.coin.flip{animation:coinflip 1.2s ease;} @keyframes coinflip{0%{transform:rotateY(0);}100%{transform:rotateY(1800deg);}}
.toast{position:absolute;bottom:16px;left:50%;transform:translateX(-50%) translateY(16px);background:rgba(10,14,10,0.97);border:1px solid var(--border);color:var(--fg);padding:11px 20px;border-radius:12px;font-size:13.5px;font-weight:600;z-index:50;opacity:0;pointer-events:none;transition:.3s;} .toast.show{opacity:1;transform:translateX(-50%) translateY(0);} .toast.good{border-color:var(--green);} .toast.warn{border-color:var(--red);} .toast.gold{border-color:var(--gold);}
</style>
<div class="app">
<div class="brand"><span class="bl">🎰</span><div class="bn">Aether <span>Casino</span><small>Los Santos</small></div></div>
<div class="top" id="top"></div>
<div class="nav" id="nav"></div>
<div class="main" id="main"></div>
<div class="toast" id="toast"></div>
</div>
<script>
"use strict";
const $=id=>document.getElementById(id);
function sicher(t){const d=document.createElement("div");d.textContent=t==null?"":t;return d.innerHTML;}
const euro=n=>Math.round(n).toLocaleString("de-DE")+" €";
const rnd=(a,b)=>a+Math.floor(Math.random()*(b-a+1));
const pick=a=>a[Math.floor(Math.random()*a.length)];
let toastTimer=null; function toast(t,typ){const e=$("toast");e.textContent=t;e.className="toast show"+(typ?" "+typ:"");clearTimeout(toastTimer);toastTimer=setTimeout(()=>e.classList.remove("show"),2600);}
/* =====================================================================
GUTHABEN / SESSION
===================================================================== */
const spieler={bargeld:20000};
let guthaben=5000;
const session={ein:0,aus:0};
let sektion="lobby", busy=false;
function setzen(betrag){ if(!(betrag>0)){toast("Einsatz wählen","warn");return false;} if(betrag>guthaben){toast("Nicht genug Guthaben","warn");return false;} guthaben-=betrag; session.ein+=betrag; renderTop(); return true; }
function gewinne(total){ if(total>0){ guthaben+=total; session.aus+=total; } renderTop(); }
function renderTop(){
const netto=session.aus-session.ein;
$("top").innerHTML=`<div class="chip guthaben"><div class="l">Guthaben</div><div class="v">${euro(guthaben)}</div></div>
<div class="chip"><div class="l">Bargeld</div><div class="v">${euro(spieler.bargeld)}</div></div>
<div class="chip netto ${netto>=0?'plus':'minus'}"><div class="l">Session</div><div class="v">${netto>=0?'+':''}${euro(netto)}</div></div>
<div class="acts"><button class="btn ghost sm" id="t-load">+ Aufladen</button><button class="btn ghost sm" id="t-cash">Auszahlen</button></div>`;
$("t-load").addEventListener("click",()=>geldModal('load'));
$("t-cash").addEventListener("click",()=>geldModal('cash'));
}
function geldModal(dir){ const rein=dir==='load';
const b=prompt(rein?"Guthaben aufladen (Bargeld: "+euro(spieler.bargeld)+")\nBetrag in €:":"Guthaben auszahlen (Guthaben: "+euro(guthaben)+")\nBetrag in €:");
const n=parseInt(b,10); if(!n||n<=0)return;
if(rein){ if(n>spieler.bargeld){toast("Nicht genug Bargeld","warn");return;} spieler.bargeld-=n; guthaben+=n; toast(euro(n)+" aufgeladen","good"); }
else { if(n>guthaben){toast("Nicht genug Guthaben","warn");return;} guthaben-=n; spieler.bargeld+=n; toast(euro(n)+" ausgezahlt","good"); }
renderTop();
}
/* =====================================================================
SHELL
===================================================================== */
const nav=[{id:"lobby",label:"Lobby",icon:"🏛️"},{id:"slots",label:"Spielautomat",icon:"🎰"},{id:"blackjack",label:"Blackjack",icon:"🃏"},{id:"roulette",label:"Roulette",icon:"🎡"},{id:"wheel",label:"Glücksrad",icon:"🎯"},{id:"coin",label:"Münzwurf",icon:"🪙"}];
function renderNav(){
$("nav").innerHTML=nav.map(n=>`<button data-s="${n.id}" class="${n.id===sektion?'active':''}"><span class="ni">${n.icon}</span>${n.label}</button>`).join('');
$("nav").querySelectorAll("[data-s]").forEach(b=>b.addEventListener("click",()=>{if(busy)return;sektion=b.dataset.s;renderNav();renderSektion();}));
}
function renderSektion(){(sektionen[sektion]||sektionen.lobby)();}
const sektionen={};
/* Einsatz-Steuerung (wiederverwendbar) */
function einsatzHtml(id,val){return `<div><label class="flabel">Einsatz</label><div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><input class="input" id="${id}" type="number" value="${val}" min="1"><div class="quickchips">${[50,100,500,1000].map(c=>`<button data-chip="${c}">${c}</button>`).join('')}<button data-chip="max">Max</button></div></div></div>`;}
function bindEinsatz(id){document.querySelectorAll("[data-chip]").forEach(b=>b.addEventListener("click",()=>{const el=$(id);el.value=b.dataset.chip==='max'?guthaben:b.dataset.chip;}));}
const leseEinsatz=id=>parseInt($(id).value,10)||0;
/* =====================================================================
LOBBY
===================================================================== */
sektionen.lobby=()=>{
const netto=session.aus-session.ein;
$("main").innerHTML=`<div class="phead"><div><h2>Willkommen im Aether Casino</h2><p>Wähle ein Spiel · viel Glück!</p></div></div>
<div class="felt" style="margin-bottom:16px;display:flex;align-items:center;justify-content:space-between"><div><div style="font-size:12px;color:#bfe0c8;text-transform:uppercase;letter-spacing:0.05em;font-weight:800">Dein Guthaben</div><div style="font-size:38px;font-weight:800;color:var(--gold)">${euro(guthaben)}</div></div>
<div style="text-align:right"><div style="font-size:12px;color:#bfe0c8">Session-Bilanz</div><div style="font-size:22px;font-weight:800;color:${netto>=0?'var(--green)':'var(--red)'}">${netto>=0?'+':''}${euro(netto)}</div></div></div>
<div class="lobbygrid">${nav.slice(1).map(g=>{const info={slots:"3 Walzen · Jackpot bis ×180",blackjack:"Schlage den Dealer auf 21",roulette:"Farben, Zahlen & Dutzende",wheel:"Dreh am Rad · bis ×5",coin:"Kopf oder Zahl · ×2"};
return `<div class="gamecard" data-g="${g.id}"><div class="gi">${g.icon}</div><div class="gn">${g.label}</div><div class="gd">${info[g.id]}</div><div class="go">Spielen ›</div></div>`;}).join('')}</div>
<div style="text-align:center;color:var(--muted);font-size:11.5px;margin-top:14px">🎲 Spiel mit Verantwortung — auf lange Sicht gewinnt die Bank.</div>`;
$("main").querySelectorAll("[data-g]").forEach(c=>c.addEventListener("click",()=>{sektion=c.dataset.g;renderNav();renderSektion();}));
};
/* =====================================================================
SLOTS
===================================================================== */
/* Gewichtete Walzen: seltene Symbole = hohe Auszahlung. RTP ~90% (Hausvorteil ~10%). */
const slotReel=[{s:"🍒",w:8},{s:"🍋",w:7},{s:"🔔",w:6},{s:"⭐",w:4},{s:"💎",w:3},{s:"7️⃣",w:2}];
const slotPay={"🍒":10,"🍋":12,"🔔":18,"⭐":35,"💎":70,"7️⃣":180};
const slotSyms=slotReel.map(x=>x.s), slotGewicht=slotReel.reduce((s,x)=>s+x.w,0);
function slotPick(){let r=Math.random()*slotGewicht;for(const x of slotReel){if((r-=x.w)<0)return x.s;}return slotReel[0].s;}
sektionen.slots=()=>{
setSpiel("slots");
$("main").innerHTML=`<div class="phead"><div><h2>Spielautomat</h2><p>3× gleiches Symbol gewinnt · seltene Symbole zahlen mehr · 7️⃣7️⃣7️⃣ = Jackpot ×180</p></div></div>
<div class="felt" style="text-align:center"><div class="reels"><div class="reel" id="r0">🍒</div><div class="reel" id="r1">🔔</div><div class="reel" id="r2">7️⃣</div></div>
<div class="result" id="slot-res">Setze deinen Einsatz und drehe!</div></div>
<div class="card" style="margin-top:14px;display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap">${einsatzHtml('slot-ein',100)}<button class="btn primary" id="slot-spin" style="min-width:160px">🎰 Drehen</button></div>
<div class="card" style="margin-top:14px"><div class="list-h">Auszahlungstabelle (×Einsatz)</div><div class="paytable">${slotReel.map(x=>`<div>${x.s} ${x.s} ${x.s} <b>×${slotPay[x.s]}</b></div>`).join('')}<div>🍒 🍒 <span style="color:var(--muted)">(zwei)</span> <b>×1.5</b></div></div></div>`;
bindEinsatz('slot-ein');
$("slot-spin").addEventListener("click",slotSpin);
};
function slotSpin(){
if(busy)return; const ein=leseEinsatz('slot-ein'); if(!setzen(ein))return;
busy=true; setSpiel("slots"); $("slot-spin").disabled=true; $("slot-res").textContent="…"; $("slot-res").className="result";
const finals=[slotPick(),slotPick(),slotPick()];
const ivs=[]; [0,1,2].forEach(i=>{$("r"+i).classList.add("spin");ivs[i]=setInterval(()=>{$("r"+i).textContent=pick(slotSyms);},70);});
[0,1,2].forEach(i=>setTimeout(()=>{clearInterval(ivs[i]);$("r"+i).classList.remove("spin");$("r"+i).textContent=finals[i];if(i===2)slotErgebnis(finals,ein);},700+i*450));
}
function slotErgebnis(f,ein){
let gewinn=0,txt="",cls="lose";
if(f[0]===f[1]&&f[1]===f[2]){ gewinn=ein*slotPay[f[0]]; cls=f[0]==="7️⃣"?"jackpot":"win"; txt=(f[0]==="7️⃣"?"🎉 JACKPOT! ":"Drei Gleiche! ")+f.join("")+" · +"+euro(gewinn); }
else if(f.filter(x=>x==="🍒").length===2){ gewinn=Math.round(ein*1.5); cls="win"; txt="Zwei 🍒 · +"+euro(gewinn); }
else { txt="Kein Gewinn · "+euro(ein)+" verloren"; }
if(gewinn>0)gewinne(gewinn);
const r=$("slot-res"); r.textContent=txt; r.className="result "+cls;
if(gewinn>0)toast((cls==="jackpot"?"🎉 JACKPOT ":"Gewonnen ")+euro(gewinn),cls==="jackpot"?"gold":"good");
busy=false; $("slot-spin").disabled=false;
}
/* =====================================================================
BLACKJACK
===================================================================== */
const suits=[["♠",0],["♥",1],["♦",1],["♣",0]], ranks=["A","2","3","4","5","6","7","8","9","10","J","Q","K"];
function neuesDeck(){const d=[];for(const [s,rot] of suits)for(const r of ranks)d.push({r,s,rot});for(let i=d.length-1;i>0;i--){const j=rnd(0,i);[d[i],d[j]]=[d[j],d[i]];}return d;}
function kWert(c){return c.r==="A"?11:["J","Q","K","10"].includes(c.r)?10:+c.r;}
function handWert(h){let sum=h.reduce((s,c)=>s+kWert(c),0),as=h.filter(c=>c.r==="A").length;while(sum>21&&as>0){sum-=10;as--;}return sum;}
function karteHtml(c,back){if(back)return `<div class="pkarte back"></div>`;return `<div class="pkarte ${c.rot?'rot':''}"><div class="r">${c.r}<span style="font-size:13px"> ${c.s}</span></div><div class="s">${c.s}</div><div class="r bot">${c.r}<span style="font-size:13px"> ${c.s}</span></div></div>`;}
const bj={phase:"bet"};
sektionen.blackjack=()=>{
setSpiel("blackjack");
const spielt=bj.phase==="spielen", ende=bj.phase==="ende";
const dVal=bj.dealer?(spielt?kWert(bj.dealer[0]):handWert(bj.dealer)):0;
$("main").innerHTML=`<div class="phead"><div><h2>Blackjack</h2><p>Näher an 21 als der Dealer · überkaufst du zuerst, verlierst du</p></div></div>
<div class="felt">
<div class="list-h" style="color:#bfe0c8">Dealer ${bj.dealer?'· '+(spielt?dVal+' +?':handWert(bj.dealer)):''}</div>
<div class="hand" id="bj-dealer">${bj.dealer?bj.dealer.map((c,i)=>karteHtml(c,spielt&&i===1)).join(''):'<span style="color:var(--muted)">—</span>'}</div>
<div class="list-h" style="color:#bfe0c8;margin-top:14px">Deine Hand ${bj.player?'· '+handWert(bj.player):''}</div>
<div class="hand" id="bj-player">${bj.player?bj.player.map(c=>karteHtml(c)).join(''):'<span style="color:var(--muted)">—</span>'}</div>
<div class="result ${bj.resCls||''}" id="bj-res">${bj.resTxt||''}</div>
</div>
<div class="card" style="margin-top:14px;display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap">
${bj.phase==="bet"||ende?einsatzHtml('bj-ein',bj.ein||100):`<div><div class="flabel">Einsatz</div><div style="font-size:18px;font-weight:800">${euro(bj.ein)}</div></div>`}
${bj.phase==="bet"||ende?`<button class="btn primary" id="bj-deal" style="min-width:150px">🃏 Austeilen</button>`:
`<button class="btn ghost" id="bj-hit">Ziehen</button><button class="btn green" id="bj-stand">Stehen</button><button class="btn ghost" id="bj-double" ${bj.player.length===2&&guthaben>=bj.ein?'':'disabled'}>Verdoppeln</button>`}
</div>`;
if(bj.phase==="bet"||ende){bindEinsatz('bj-ein');$("bj-deal").addEventListener("click",bjDeal);}
else{$("bj-hit").addEventListener("click",bjHit);$("bj-stand").addEventListener("click",bjStand);$("bj-double").addEventListener("click",bjDouble);}
};
function bjDeal(){const ein=leseEinsatz('bj-ein');if(!setzen(ein))return;bj.ein=ein;bj.deck=neuesDeck();bj.player=[bj.deck.pop(),bj.deck.pop()];bj.dealer=[bj.deck.pop(),bj.deck.pop()];bj.phase="spielen";bj.resTxt="";bj.resCls="";
if(handWert(bj.player)===21){bjStand(true);}else sektionen.blackjack();}
function bjHit(){bj.player.push(bj.deck.pop());if(handWert(bj.player)>21){bjEnde("bust");}else sektionen.blackjack();}
function bjDouble(){if(!setzen(bj.ein))return;bj.ein*=2;bj.player.push(bj.deck.pop());if(handWert(bj.player)>21)bjEnde("bust");else bjStand();}
function bjStand(auto){ while(handWert(bj.dealer)<17)bj.dealer.push(bj.deck.pop()); bjEnde(auto===true?"bjcheck":"stand"); }
function bjEnde(){const pv=handWert(bj.player),dv=handWert(bj.dealer);let total=0,txt,cls;
const blackjack=pv===21&&bj.player.length===2;
if(pv>21){txt="Überkauft! −"+euro(bj.ein);cls="lose";}
else if(dv>21){total=bj.ein*2;txt="Dealer überkauft · +"+euro(bj.ein);cls="win";}
else if(blackjack&&!(dv===21&&bj.dealer.length===2)){total=bj.ein*2;txt="🎉 Blackjack! +"+euro(bj.ein);cls="jackpot";}
else if(pv>dv){total=bj.ein*2;txt="Du gewinnst · +"+euro(bj.ein);cls="win";}
else if(pv<dv){txt="Dealer gewinnt · −"+euro(bj.ein);cls="lose";}
else {total=bj.ein;txt="Unentschieden · Einsatz zurück";cls="";}
gewinne(total); bj.phase="ende"; bj.resTxt=txt; bj.resCls=cls;
if(total>bj.ein)toast(txt,cls==="jackpot"?"gold":"good");
sektionen.blackjack();
}
/* =====================================================================
ROULETTE
===================================================================== */
const rotZahlen=[1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36];
const rFarbe=n=>n===0?"gruen":rotZahlen.includes(n)?"rot":"schwarz";
const rlt={bets:[]};
sektionen.roulette=()=>{
setSpiel("roulette");
const gesamt=rlt.bets.reduce((s,b)=>s+b.betrag,0);
$("main").innerHTML=`<div class="phead"><div><h2>Roulette</h2><p>Setze auf Farben, Zahlen oder Dutzende</p></div></div>
<div class="felt" style="text-align:center"><div class="rnum ${rlt.last!=null?rFarbe(rlt.last):'schwarz'}" id="rnum">${rlt.last!=null?rlt.last:'—'}</div><div class="result ${rlt.resCls||''}" id="rlt-res" style="margin-top:14px">${rlt.resTxt||'Platziere Einsätze und drehe'}</div></div>
<div class="card" style="margin-top:14px"><div style="display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:12px">${einsatzHtml('rlt-ein',100)}<div style="flex:1"></div><button class="btn primary" id="rlt-spin" ${gesamt>0?'':'disabled'} style="min-width:150px">🎡 Drehen (${euro(gesamt)})</button><button class="btn ghost" id="rlt-clear" ${gesamt>0?'':'disabled'}>Löschen</button></div>
<div class="list-h">Einfache Chancen (×2)</div><div class="betbtns" style="grid-template-columns:repeat(6,1fr);margin-bottom:8px">
<button data-bet="rot|Rot">🔴 Rot</button><button data-bet="schwarz|Schwarz">⚫ Schwarz</button><button data-bet="gerade|Gerade">Gerade</button><button data-bet="ungerade|Ungerade">Ungerade</button><button data-bet="tief|1–18">1–18</button><button data-bet="hoch|19–36">19–36</button></div>
<div class="list-h">Dutzend (×3)</div><div class="betbtns" style="margin-bottom:8px"><button data-bet="d1|1. Dutzend">1–12</button><button data-bet="d2|2. Dutzend">13–24</button><button data-bet="d3|3. Dutzend">25–36</button></div>
<div class="list-h">Einzelne Zahl (×36)</div><div style="display:flex;gap:8px;align-items:center"><input class="input" id="rlt-zahl" type="number" min="0" max="36" placeholder="0–36"><button class="btn ghost sm" id="rlt-setzahl">Zahl setzen</button></div>
${rlt.bets.length?`<div class="betlist">${rlt.bets.map((b,i)=>`<span class="b">${sicher(b.label)}: ${euro(b.betrag)} <span style="cursor:pointer;color:var(--red)" data-delbet="${i}">✕</span></span>`).join('')}</div>`:''}</div>`;
bindEinsatz('rlt-ein');
$("main").querySelectorAll("[data-bet]").forEach(b=>b.addEventListener("click",()=>rltAddBet(b.dataset.bet)));
$("rlt-setzahl").addEventListener("click",()=>{const z=parseInt($("rlt-zahl").value,10);if(isNaN(z)||z<0||z>36){toast("Zahl 0–36","warn");return;}rltAddBet("z"+z+"|Zahl "+z);});
$("rlt-spin").addEventListener("click",rltSpin);
$("rlt-clear").addEventListener("click",()=>{rlt.bets.forEach(b=>{guthaben+=b.betrag;session.ein-=b.betrag;});rlt.bets=[];renderTop();sektionen.roulette();});
$("main").querySelectorAll("[data-delbet]").forEach(b=>b.addEventListener("click",()=>{const i=+b.dataset.delbet,x=rlt.bets.splice(i,1)[0];guthaben+=x.betrag;session.ein-=x.betrag;renderTop();sektionen.roulette();}));
};
function rltAddBet(spec){const ein=leseEinsatz('rlt-ein');if(!setzen(ein))return;const [typ,label]=spec.split('|');rlt.bets.push({typ,label,betrag:ein});sektionen.roulette();}
function rltSpin(){
if(busy||!rlt.bets.length)return; busy=true; setSpiel("roulette");
const num=rnd(0,36); const el=$("rnum"); let t=0;
const iv=setInterval(()=>{const r=rnd(0,36);el.textContent=r;el.className="rnum "+rFarbe(r);t++;if(t>18){clearInterval(iv);el.textContent=num;el.className="rnum "+rFarbe(num);rltErgebnis(num);}},90);
$("rlt-res").textContent="Die Kugel rollt…"; $("rlt-res").className="result";
}
function rltErgebnis(num){
let gewinn=0; const gesamt=rlt.bets.reduce((s,b)=>s+b.betrag,0);
for(const b of rlt.bets){ let g=0;
if(b.typ==="rot"&&rFarbe(num)==="rot")g=b.betrag*2;
else if(b.typ==="schwarz"&&rFarbe(num)==="schwarz")g=b.betrag*2;
else if(b.typ==="gerade"&&num!==0&&num%2===0)g=b.betrag*2;
else if(b.typ==="ungerade"&&num%2===1)g=b.betrag*2;
else if(b.typ==="tief"&&num>=1&&num<=18)g=b.betrag*2;
else if(b.typ==="hoch"&&num>=19&&num<=36)g=b.betrag*2;
else if(b.typ==="d1"&&num>=1&&num<=12)g=b.betrag*3;
else if(b.typ==="d2"&&num>=13&&num<=24)g=b.betrag*3;
else if(b.typ==="d3"&&num>=25&&num<=36)g=b.betrag*3;
else if(b.typ.startsWith("z")&&+b.typ.slice(1)===num)g=b.betrag*36;
gewinn+=g;
}
gewinne(gewinn);
rlt.last=num; rlt.bets=[];
if(gewinn>0){rlt.resTxt="Zahl "+num+" ("+({rot:"Rot",schwarz:"Schwarz",gruen:"Grün"}[rFarbe(num)])+") · +"+euro(gewinn);rlt.resCls="win";toast("Gewonnen "+euro(gewinn),"good");}
else {rlt.resTxt="Zahl "+num+" · "+euro(gesamt)+" verloren";rlt.resCls="lose";}
busy=false; sektionen.roulette();
}
/* =====================================================================
GLÜCKSRAD
===================================================================== */
/* 12 Felder, die meisten verlieren (×0/×0.5). EV ≈ 0.79 → Hausvorteil ~21%. */
const wSegs=[{m:0,c:"#4a2530"},{m:0.5,c:"#3d4a2a"},{m:0,c:"#2a3340"},{m:1.5,c:"#2f6b4a"},{m:0,c:"#4a2530"},{m:0,c:"#2a3340"},{m:2,c:"#3563a8"},{m:0,c:"#4a2530"},{m:0.5,c:"#3d4a2a"},{m:0,c:"#2a3340"},{m:5,c:"#e0b64a"},{m:0,c:"#4a2530"}];
let wRot=0;
sektionen.wheel=()=>{
setSpiel("wheel");
const seg=360/wSegs.length;
const grad=wSegs.map((s,i)=>`${s.c} ${i*seg}deg ${(i+1)*seg}deg`).join(",");
$("main").innerHTML=`<div class="phead"><div><h2>Glücksrad</h2><p>Setze und dreh am Rad · Multiplikator bis ×10</p></div></div>
<div class="felt" style="text-align:center"><div class="wheelwrap"><div class="wpointer"></div><div class="wheel" id="wheel" style="background:conic-gradient(${grad});transform:rotate(${wRot}deg)">${wSegs.map((s,i)=>`<div class="lbl" style="transform:translate(-50%,-50%) rotate(${i*seg+seg/2}deg) translateY(-84px)">×${s.m}</div>`).join('')}</div><div class="whub">🎯</div></div>
<div class="result ${wheelResCls||''}" id="wheel-res" style="margin-top:14px">${wheelResTxt||'Setze deinen Einsatz und drehe'}</div></div>
<div class="card" style="margin-top:14px;display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap">${einsatzHtml('wheel-ein',100)}<button class="btn primary" id="wheel-spin" style="min-width:160px">🎯 Drehen</button></div>`;
bindEinsatz('wheel-ein');
$("wheel-spin").addEventListener("click",wheelSpin);
};
let wheelResTxt="",wheelResCls="";
function wheelSpin(){
if(busy)return; const ein=leseEinsatz('wheel-ein'); if(!setzen(ein))return;
busy=true; const seg=360/wSegs.length; const idx=rnd(0,wSegs.length-1);
// Feld idx mittig unter den Zeiger (oben) bringen: Zielwinkel = -Feldmitte
const zielMod=((360-(idx*seg+seg/2))%360+360)%360;
const aktuellMod=((wRot%360)+360)%360;
const delta=((zielMod-aktuellMod)%360+360)%360;
wRot+=360*5+delta; // mind. 5 volle Umdrehungen, endet exakt auf idx
const w=$("wheel"); w.style.transform="rotate("+wRot+"deg)";
$("wheel-spin").disabled=true; $("wheel-res").textContent="Das Rad dreht…"; $("wheel-res").className="result";
setTimeout(()=>{const m=wSegs[idx].m,gewinn=Math.round(ein*m);gewinne(gewinn);
if(m>1){wheelResTxt="×"+m+" · +"+euro(gewinn-ein);wheelResCls=m>=5?"jackpot":"win";toast("×"+m+" · "+euro(gewinn),m>=5?"gold":"good");}
else if(m===1){wheelResTxt="×1 · Einsatz zurück";wheelResCls="";}
else if(m>0){wheelResTxt="×"+m+" · nur "+euro(gewinn)+" zurück ("+euro(ein-gewinn)+" Verlust)";wheelResCls="lose";}
else {wheelResTxt="Leider verloren · −"+euro(ein);wheelResCls="lose";}
busy=false; sektionen.wheel();},4500);
}
/* =====================================================================
MÜNZWURF
===================================================================== */
let coinRes="🪙",coinResTxt="",coinResCls="";
sektionen.coin=()=>{
setSpiel("coin");
$("main").innerHTML=`<div class="phead"><div><h2>Münzwurf</h2><p>Kopf oder Zahl · richtig = ×2</p></div></div>
<div class="felt" style="text-align:center"><div class="coin" id="coin">${coinRes}</div><div class="result ${coinResCls}" id="coin-res" style="margin-top:14px">${coinResTxt||'Wähle Kopf oder Zahl'}</div></div>
<div class="card" style="margin-top:14px;display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap">${einsatzHtml('coin-ein',100)}<div style="display:flex;gap:8px"><button class="btn ghost" id="coin-kopf" style="min-width:110px">👑 Kopf</button><button class="btn ghost" id="coin-zahl" style="min-width:110px">🔢 Zahl</button></div></div>`;
bindEinsatz('coin-ein');
$("coin-kopf").addEventListener("click",()=>coinFlip("kopf"));
$("coin-zahl").addEventListener("click",()=>coinFlip("zahl"));
};
function coinFlip(wahl){
if(busy)return; const ein=leseEinsatz('coin-ein'); if(!setzen(ein))return;
busy=true; const c=$("coin"); c.classList.remove("flip"); void c.offsetWidth; c.classList.add("flip");
$("coin-res").textContent="…"; $("coin-res").className="result";
const gewonnen=Math.random()<0.475; const erg=gewonnen?wahl:(wahl==="kopf"?"zahl":"kopf");
setTimeout(()=>{coinRes=erg==="kopf"?"👑":"🔢";c.textContent=coinRes;
if(gewonnen){const g=ein*2;gewinne(g);coinResTxt=(erg==="kopf"?"Kopf":"Zahl")+" · +"+euro(ein);coinResCls="win";toast("Gewonnen "+euro(g),"good");}
else {coinResTxt=(erg==="kopf"?"Kopf":"Zahl")+" · −"+euro(ein);coinResCls="lose";}
busy=false; sektionen.coin();},1200);
}
/* Spielwechsel-Reset (offene Einsätze erstatten) */
function setSpiel(id){
if(id!=="blackjack"){bj.phase="bet";bj.player=null;bj.dealer=null;bj.resTxt="";bj.resCls="";}
if(id!=="roulette"&&rlt.bets&&rlt.bets.length){rlt.bets.forEach(b=>{guthaben+=b.betrag;session.ein-=b.betrag;});rlt.bets=[];renderTop();}
}
/* START */
renderTop(); renderNav(); renderSektion();
</script>
</body>
</html>

227
clothing/index.html Normal file
View File

@ -0,0 +1,227 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
</head>
<body>
<title>Aether Threads — Bekleidung</title>
<style>
:root{
--bg:#08090d;--fg:#eef0f6;--muted:#868ca0;--surface:rgba(22,24,33,0.72);--surface2:rgba(36,39,52,0.85);
--slot:#12141b;--border:rgba(255,255,255,0.1);--accent:#c96fd6;--ink:#2a0a30;--accent-soft:rgba(201,111,214,0.16);
--red:#f0506e;--green:#34d39a;--amber:#f5b23d;
}
*{box-sizing:border-box;} html,body{margin:0;height:100%;}
body{min-height:100%;background:radial-gradient(1100px 700px at 20% 0%,rgba(201,111,214,0.1),transparent 55%),radial-gradient(900px 700px at 90% 100%,rgba(74,168,255,0.06),transparent 55%),var(--bg);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);
display:flex;align-items:center;justify-content:center;padding:24px 16px;overflow-x:hidden;}
.app{position:relative;width:min(1200px,97vw);aspect-ratio:16/10;border-radius:22px;overflow:hidden;
background:radial-gradient(120% 90% at 50% 0%,#17141f,#0a0a0f 70%);border:1px solid var(--border);box-shadow:0 50px 100px -30px rgba(0,0,0,0.85);display:grid;grid-template-columns:1fr 400px;}
/* Vorschau */
.preview{position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:radial-gradient(60% 55% at 50% 46%,rgba(201,111,214,0.09),transparent 70%);}
.preview .floor{position:absolute;bottom:9%;left:50%;transform:translateX(-50%);width:44%;height:4%;border-radius:50%;background:radial-gradient(ellipse,rgba(201,111,214,0.28),transparent 70%);}
.preview svg{position:relative;z-index:1;height:90%;max-width:88%;filter:drop-shadow(0 24px 30px rgba(0,0,0,0.5));}
.zoomtoggle{position:absolute;top:16px;left:50%;transform:translateX(-50%);z-index:3;display:flex;background:rgba(0,0,0,0.4);border:1px solid var(--border);border-radius:11px;padding:4px;gap:2px;}
.zoomtoggle button{border:none;background:none;color:var(--muted);font-family:inherit;font-weight:700;font-size:12px;padding:7px 14px;border-radius:8px;cursor:pointer;} .zoomtoggle button.active{background:var(--accent);color:#fff;}
.brand{position:absolute;top:18px;left:22px;z-index:3;} .brand h1{font-size:18px;margin:0;font-weight:800;letter-spacing:-0.01em;} .brand h1 span{color:var(--accent);} .brand small{color:var(--muted);font-size:11px;}
/* Panel */
.panel{border-left:1px solid var(--border);display:grid;grid-template-rows:auto auto 1fr auto;min-height:0;}
.p-head{padding:16px 20px 10px;display:flex;align-items:center;gap:12px;} .p-head .money{margin-left:auto;text-align:right;} .p-head .money .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;font-weight:800;} .p-head .money .v{font-size:15px;font-weight:800;font-variant-numeric:tabular-nums;}
.cats{display:flex;gap:4px;padding:0 16px 10px;flex-wrap:wrap;overflow-x:auto;}
.cats button{border:none;background:var(--slot);color:var(--muted);font-family:inherit;font-weight:700;font-size:12px;padding:9px 11px;border-radius:10px;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:3px;min-width:52px;}
.cats button .ci{font-size:16px;} .cats button.active{background:var(--accent);color:#fff;} .cats button .dot{width:6px;height:6px;border-radius:50%;background:var(--amber);}
.p-body{overflow-y:auto;padding:8px 20px;} .p-body::-webkit-scrollbar{width:7px;} .p-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:4px;}
.flabel{font-size:10.5px;color:var(--muted);font-weight:800;text-transform:uppercase;letter-spacing:0.05em;margin:14px 2px 8px;display:block;} .flabel:first-child{margin-top:4px;}
.styles{display:flex;flex-direction:column;gap:7px;}
.styleopt{display:flex;align-items:center;gap:11px;padding:11px 13px;border-radius:12px;background:var(--slot);border:1px solid var(--border);cursor:pointer;transition:.12s;}
.styleopt:hover{border-color:var(--accent);} .styleopt.sel{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent) inset;}
.styleopt .sn{font-weight:700;font-size:13.5px;} .styleopt .sp{margin-left:auto;font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;} .styleopt .sp.free{color:var(--muted);}
.styleopt .owned{font-size:9.5px;font-weight:800;color:var(--green);background:rgba(52,211,153,0.15);padding:2px 7px;border-radius:999px;margin-left:auto;}
.swatches{display:flex;flex-wrap:wrap;gap:8px;} .sw{width:32px;height:32px;border-radius:9px;cursor:pointer;border:2px solid transparent;} .sw.sel{border-color:#fff;box-shadow:0 0 0 2px var(--accent);}
.p-foot{border-top:1px solid var(--border);padding:14px 20px;}
.cartline{display:flex;justify-content:space-between;font-size:12px;color:var(--muted);padding:2px 0;} .cartline b{color:var(--fg);}
.sumrow{display:flex;justify-content:space-between;align-items:baseline;margin:6px 0 10px;} .sumrow .tl{font-weight:800;font-size:15px;} .sumrow .tv{font-weight:800;font-size:20px;font-variant-numeric:tabular-nums;}
.pay{display:flex;background:var(--slot);border-radius:10px;padding:4px;margin-bottom:10px;} .pay button{flex:1;border:none;background:none;color:var(--muted);font-family:inherit;font-weight:700;font-size:12.5px;padding:8px;border-radius:8px;cursor:pointer;} .pay button.active{background:var(--accent);color:#fff;}
.foot-actions{display:grid;grid-template-columns:auto 1fr;gap:9px;} .btn{border:none;border-radius:11px;padding:12px 16px;font-size:14px;font-weight:800;cursor:pointer;font-family:inherit;transition:.12s;} .btn.primary{background:var(--accent);color:#fff;} .btn.primary:hover{filter:brightness(1.08);} .btn.ghost{background:var(--surface2);color:var(--fg);border:1px solid var(--border);} .btn:disabled{opacity:.4;cursor:not-allowed;} .btn:active{transform:scale(0.98);}
.toast{position:absolute;bottom:18px;left:calc((100% - 400px)/2);transform:translateX(-50%) translateY(16px);background:rgba(12,14,20,0.97);border:1px solid var(--border);color:var(--fg);padding:11px 20px;border-radius:12px;font-size:13.5px;font-weight:600;z-index:50;opacity:0;pointer-events:none;transition:.3s;white-space:nowrap;} .toast.show{opacity:1;transform:translateX(-50%) translateY(0);} .toast.warn{border-color:var(--red);} .toast.good{border-color:var(--green);}
</style>
<div class="app">
<div class="preview">
<div class="brand"><h1>Aether<span>Threads</span></h1><small>Bekleidung · Los Santos</small></div>
<div class="zoomtoggle" id="zoomtoggle"><button data-z="ganz" class="active">Ganzkörper</button><button data-z="kopf">Kopf</button></div>
<div class="floor"></div>
<div id="avatar"></div>
</div>
<div class="panel">
<div class="p-head"><div><div style="font-weight:800;font-size:15px">Umkleide</div><div style="font-size:11.5px;color:var(--muted)">Anprobieren &amp; kaufen</div></div>
<div class="money"><div class="l">Bargeld</div><div class="v" id="m-cash"></div></div>
<div class="money"><div class="l">Konto</div><div class="v" id="m-bank"></div></div></div>
<div class="cats" id="cats"></div>
<div class="p-body" id="p-body"></div>
<div class="p-foot" id="p-foot"></div>
</div>
<div class="toast" id="toast"></div>
</div>
<script>
"use strict";
const $=id=>document.getElementById(id);
const euro=n=>n.toLocaleString("de-DE")+" €";
let toastTimer=null; function toast(t,typ){const e=$("toast");e.textContent=t;e.className="toast show"+(typ?" "+typ:"");clearTimeout(toastTimer);toastTimer=setTimeout(()=>e.classList.remove("show"),2400);}
function schattiere(hex,f){const n=parseInt(hex.slice(1),16);let r=(n>>16)&255,g=(n>>8)&255,b=n&255;r=Math.max(0,Math.min(255,Math.round(r*f)));g=Math.max(0,Math.min(255,Math.round(g*f)));b=Math.max(0,Math.min(255,Math.round(b*f)));return "#"+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1);}
/* =====================================================================
SORTIMENT
===================================================================== */
const farben=[{n:"Schwarz",c:"#22242b"},{n:"Weiß",c:"#e8eaef"},{n:"Grau",c:"#6b7180"},{n:"Navy",c:"#27324f"},{n:"Rot",c:"#b23a3a"},{n:"Grün",c:"#2f6b4a"},{n:"Blau",c:"#3563a8"},{n:"Beige",c:"#c7b18a"},{n:"Gold",c:"#c9a24a"},{n:"Bordeaux",c:"#6b2740"}];
const kategorien=[
{id:"kopf", name:"Kopf", icon:"🧢", farbe:true, styles:[{n:"Keine",p:0},{n:"Cap",p:120},{n:"Beanie",p:90},{n:"Hut",p:180},{n:"Helm",p:400}]},
{id:"brille", name:"Brille", icon:"🕶️", farbe:false, styles:[{n:"Keine",p:0},{n:"Sonnenbrille",p:150},{n:"Brille",p:200}]},
{id:"maske", name:"Maske", icon:"😷", farbe:true, styles:[{n:"Keine",p:0},{n:"Halbmaske",p:80},{n:"Sturmhaube",p:140}]},
{id:"oberteil", name:"Oberteil",icon:"👕", farbe:true, styles:[{n:"T-Shirt",p:90},{n:"Hemd",p:160},{n:"Hoodie",p:220},{n:"Jacke",p:380},{n:"Anzug",p:650}]},
{id:"hose", name:"Hose", icon:"👖", farbe:true, styles:[{n:"Jeans",p:120},{n:"Cargo",p:160},{n:"Shorts",p:80},{n:"Anzughose",p:240}]},
{id:"schuhe", name:"Schuhe", icon:"👟", farbe:true, styles:[{n:"Sneaker",p:180},{n:"Stiefel",p:260},{n:"Sandalen",p:70}]},
{id:"kette", name:"Kette", icon:"📿", farbe:false, styles:[{n:"Keine",p:0},{n:"Silberkette",p:300},{n:"Goldkette",p:900}]}
];
const spieler={bargeld:6800,konto:52000};
// getragen = aktuell besessenes Outfit (Basis); outfit = aktuelle Auswahl (Anprobe)
const getragen={kopf:{s:0,c:0},brille:{s:0,c:0},maske:{s:0,c:0},oberteil:{s:0,c:3},hose:{s:0,c:6},schuhe:{s:0,c:1},kette:{s:0,c:8}};
let outfit=JSON.parse(JSON.stringify(getragen));
let aktiveKat="oberteil", zoom="ganz", zahlung="bargeld";
const kat=id=>kategorien.find(k=>k.id===id);
const styleName=id=>kat(id).styles[outfit[id].s].n;
const styleObj=(id,s)=>kat(id).styles[s];
const farbe=id=>farben[outfit[id].c].c;
/* =====================================================================
AVATAR-VORSCHAU (bekleidete Figur, front)
===================================================================== */
function buildAvatar(){
const haut="#e8b48f",hautD=schattiere(haut,0.86),haar="#3a2417";
const topC=farbe('oberteil'),topD=schattiere(topC,0.78),hoseC=farbe('hose'),schuhC=farbe('schuhe');
const topS=styleName('oberteil'),hoseS=styleName('hose'),schuhS=styleName('schuhe');
const kopfS=styleName('kopf'),brilleS=styleName('brille'),maskeS=styleName('maske'),ketteS=styleName('kette');
const langeArm=topS!=="T-Shirt", shorts=hoseS==="Shorts";
const sturmhaube=maskeS==="Sturmhaube";
let L="";
// Beine (Haut-Basis, damit bei Shorts Unterschenkel sichtbar)
L+=`<path d="M96 250 L118 252 L116 388 L98 388 Z" fill="${haut}"/><path d="M144 250 L122 252 L124 388 L142 388 Z" fill="${haut}"/>`;
// Hose
const hoseBot=shorts?312:386;
L+=`<path d="M92 248 L120 251 L117 ${hoseBot} L96 ${hoseBot} Z" fill="${hoseC}"/><path d="M148 248 L120 251 L123 ${hoseBot} L144 ${hoseBot} Z" fill="${hoseC}"/>`;
if(hoseS==="Cargo")L+=`<rect x="97" y="300" width="10" height="16" rx="2" fill="${schattiere(hoseC,0.82)}"/><rect x="133" y="300" width="10" height="16" rx="2" fill="${schattiere(hoseC,0.82)}"/>`;
if(hoseS==="Anzughose")L+=`<line x1="107" y1="256" x2="107" y2="380" stroke="${schattiere(hoseC,0.82)}" stroke-width="1.5"/><line x1="133" y1="256" x2="133" y2="380" stroke="${schattiere(hoseC,0.82)}" stroke-width="1.5"/>`;
// Schuhe
if(schuhS==="Stiefel"){L+=`<path d="M92 356 h30 v40 q0 6 -6 6 h-24 Z" fill="${schuhC}"/><path d="M148 356 h-30 v40 q0 6 6 6 h24 Z" fill="${schuhC}"/>`;}
else if(schuhS==="Sandalen"){L+=`<rect x="90" y="394" width="34" height="8" rx="3" fill="${schuhC}"/><rect x="116" y="394" width="34" height="8" rx="3" fill="${schuhC}"/><path d="M98 388 l12 6 M112 384 l4 10" stroke="${schuhC}" stroke-width="3"/>`;}
else {L+=`<path d="M88 386 h32 q10 0 10 10 v4 q0 3 -4 3 h-42 q-4 0 -4 -5 Z" fill="${schuhC}"/><path d="M152 386 h-32 q-10 0 -10 10 v4 q0 3 4 3 h42 q4 0 4 -5 Z" fill="${schuhC}"/>`;}
// Arme (Ärmel = Oberteilfarbe)
L+=`<rect x="50" y="116" width="18" height="100" rx="9" fill="${topC}"/><rect x="172" y="116" width="18" height="100" rx="9" fill="${topC}"/>`;
if(!langeArm){ // T-Shirt: Unterarm Haut
L+=`<rect x="50" y="164" width="18" height="52" rx="9" fill="${haut}"/><rect x="172" y="164" width="18" height="52" rx="9" fill="${haut}"/>`;
}
L+=`<circle cx="59" cy="218" r="9" fill="${haut}"/><circle cx="181" cy="218" r="9" fill="${haut}"/>`;
// Hoodie-Kapuze hinter Hals
if(topS==="Hoodie")L+=`<path d="M84 108 Q120 96 156 108 L150 130 Q120 118 90 130 Z" fill="${topD}"/>`;
// Hals + Kopf
L+=`<rect x="108" y="92" width="24" height="22" fill="${hautD}"/>`;
// Torso (Oberteil)
L+=`<path d="M70 112 Q120 98 170 112 L166 252 Q120 262 74 252 Z" fill="${topC}"/>`;
// Oberteil-Details
if(topS==="Hemd")L+=`<path d="M120 112 L108 128 L120 140 L132 128 Z" fill="${schattiere(topC,1.12)}"/><line x1="120" y1="140" x2="120" y2="248" stroke="${topD}" stroke-width="2"/>`;
if(topS==="Hoodie")L+=`<path d="M92 200 h56 v34 q-28 8 -56 0 Z" fill="${topD}"/><line x1="112" y1="118" x2="112" y2="150" stroke="#d8d8d8" stroke-width="2"/><line x1="128" y1="118" x2="128" y2="150" stroke="#d8d8d8" stroke-width="2"/>`;
if(topS==="Jacke")L+=`<line x1="120" y1="114" x2="120" y2="250" stroke="#3a3a3a" stroke-width="3"/><path d="M104 112 L120 120 L136 112" fill="none" stroke="${topD}" stroke-width="6"/>`;
if(topS==="Anzug")L+=`<path d="M120 112 L104 118 L118 170 L120 150 L122 170 L136 118 Z" fill="#e8eaef"/><path d="M104 118 L98 200 L118 150 Z" fill="${topD}"/><path d="M136 118 L142 200 L122 150 Z" fill="${topD}"/><path d="M116 150 L120 176 L124 150 Z" fill="#8a1f2f"/>`;
// Kette
if(ketteS!=="Keine"){const kc=ketteS==="Goldkette"?"#e6c34d":"#cdd2da";L+=`<path d="M108 112 Q120 150 132 112" fill="none" stroke="${kc}" stroke-width="${ketteS==="Goldkette"?4:2.5}"/><circle cx="120" cy="150" r="${ketteS==="Goldkette"?5:3.5}" fill="${kc}"/>`;}
// Kopf (Haut)
if(!sturmhaube)L+=`<circle cx="120" cy="62" r="32" fill="${haut}"/><ellipse cx="89" cy="66" rx="6" ry="9" fill="${hautD}"/><ellipse cx="151" cy="66" rx="6" ry="9" fill="${hautD}"/>`;
// Haare (falls nicht durch Helm/Sturmhaube/Beanie verdeckt)
const haarVerdeckt=sturmhaube||kopfS==="Helm";
if(!haarVerdeckt)L+=`<path d="M89 60 Q120 22 151 60 Q150 40 120 38 Q90 40 89 60 Z" fill="${haar}"/>`;
// Gesicht
if(!sturmhaube){
L+=`<path d="M104 52 q6 -4 12 0" stroke="${schattiere(haar,0.8)}" stroke-width="2.4" fill="none" stroke-linecap="round"/><path d="M124 52 q6 -4 12 0" stroke="${schattiere(haar,0.8)}" stroke-width="2.4" fill="none" stroke-linecap="round"/>`;
L+=`<ellipse cx="110" cy="60" rx="3.4" ry="2.4" fill="#2a2320"/><ellipse cx="130" cy="60" rx="3.4" ry="2.4" fill="#2a2320"/>`;
L+=`<path d="M120 64 v7 h-4" stroke="${hautD}" stroke-width="2" fill="none"/>`;
if(maskeS==="Keine")L+=`<path d="M112 80 q8 5 16 0" stroke="${schattiere(haut,0.6)}" stroke-width="2" fill="none" stroke-linecap="round"/>`;
} else {
// Sturmhaube: Kopf in Maskenfarbe, Augenschlitz
const mc=farbe('maske');L+=`<circle cx="120" cy="62" r="33" fill="${mc}"/><rect x="100" y="55" width="40" height="11" rx="5" fill="#12100e"/><ellipse cx="110" cy="60" rx="3" ry="2.2" fill="#fff"/><ellipse cx="130" cy="60" rx="3" ry="2.2" fill="#fff"/>`;
}
// Halbmaske
if(maskeS==="Halbmaske"){const mc=farbe('maske');L+=`<path d="M100 66 Q120 60 140 66 L138 84 Q120 94 102 84 Z" fill="${mc}"/><path d="M100 66 Q120 72 140 66" fill="none" stroke="${schattiere(mc,0.8)}" stroke-width="1.5"/>`;}
// Brille
if(brilleS!=="Keine"){const dark=brilleS==="Sonnenbrille";const lens=dark?"#1a1a1f":"rgba(180,210,230,0.35)";const frame=dark?"#0d0d10":"#2a2a2a";
L+=`<rect x="101" y="55" width="17" height="11" rx="4" fill="${lens}" stroke="${frame}" stroke-width="2"/><rect x="122" y="55" width="17" height="11" rx="4" fill="${lens}" stroke="${frame}" stroke-width="2"/><line x1="118" y1="59" x2="122" y2="59" stroke="${frame}" stroke-width="2"/>`;}
// Kopfbedeckung
const kc=farbe('kopf');
if(kopfS==="Cap")L+=`<path d="M90 50 Q120 26 150 50 L150 55 Q120 46 90 55 Z" fill="${kc}"/><path d="M120 53 h46 q6 0 6 6 l-52 -2 Z" fill="${schattiere(kc,0.85)}"/>`;
else if(kopfS==="Beanie")L+=`<path d="M88 58 Q120 22 152 58 Q120 50 88 58 Z" fill="${kc}"/><rect x="88" y="52" width="64" height="9" rx="4" fill="${schattiere(kc,0.85)}"/>`;
else if(kopfS==="Hut")L+=`<ellipse cx="120" cy="52" rx="52" ry="10" fill="${schattiere(kc,0.85)}"/><path d="M96 52 Q98 26 120 26 Q142 26 144 52 Z" fill="${kc}"/><rect x="96" y="46" width="48" height="7" fill="${schattiere(kc,0.7)}"/>`;
else if(kopfS==="Helm")L+=`<path d="M86 66 Q86 22 120 22 Q154 22 154 66 Z" fill="${kc}"/><rect x="90" y="54" width="60" height="12" rx="3" fill="#12100e"/><path d="M86 66 h68 v5 h-68 Z" fill="${schattiere(kc,0.75)}"/>`;
const vb=zoom==="kopf"?"64 18 112 116":"0 0 240 420";
return `<svg viewBox="${vb}">${L}</svg>`;
}
function updateAvatar(){$("avatar").innerHTML=buildAvatar();}
/* =====================================================================
PANEL
===================================================================== */
function geld(){$("m-cash").textContent=euro(spieler.bargeld);$("m-bank").textContent=euro(spieler.konto);}
function veraendert(id){return outfit[id].s!==getragen[id].s||outfit[id].c!==getragen[id].c;}
function renderCats(){
$("cats").innerHTML=kategorien.map(k=>`<button data-k="${k.id}" class="${k.id===aktiveKat?'active':''}"><span class="ci">${k.icon}</span>${k.name}${veraendert(k.id)?'<span class="dot"></span>':''}</button>`).join('');
$("cats").querySelectorAll("[data-k]").forEach(b=>b.addEventListener("click",()=>{aktiveKat=b.dataset.k;renderCats();renderBody();}));
}
function renderBody(){
const k=kat(aktiveKat), o=outfit[aktiveKat];
let html=`<label class="flabel">${k.name} · Modell</label><div class="styles">`;
html+=k.styles.map((s,i)=>{const besitzt=getragen[aktiveKat].s===i;
return `<div class="styleopt ${i===o.s?'sel':''}" data-s="${i}"><span style="font-size:18px">${k.icon}</span><span class="sn">${s.n}</span>${besitzt?'<span class="owned">im Besitz</span>':`<span class="sp ${s.p===0?'free':''}">${s.p===0?'—':euro(s.p)}</span>`}</div>`;}).join('');
html+=`</div>`;
if(k.farbe&&k.styles[o.s].n!=="Keine"){
html+=`<label class="flabel">Farbe</label><div class="swatches">${farben.map((f,i)=>`<div class="sw ${i===o.c?'sel':''}" data-c="${i}" style="background:${f.c}" title="${f.n}"></div>`).join('')}</div>`;
}
$("p-body").innerHTML=html;
$("p-body").querySelectorAll("[data-s]").forEach(el=>el.addEventListener("click",()=>{o.s=+el.dataset.s;renderCats();renderBody();updateAvatar();renderFoot();}));
$("p-body").querySelectorAll("[data-c]").forEach(el=>el.addEventListener("click",()=>{o.c=+el.dataset.c;renderBody();updateAvatar();renderFoot();}));
}
function warenkorb(){
// geänderte Teile mit Preis > 0
return kategorien.filter(k=>veraendert(k.id)).map(k=>({id:k.id,name:k.name,teil:styleObj(k.id,outfit[k.id].s),preis:styleObj(k.id,outfit[k.id].s).p})).filter(x=>x.preis>0);
}
function renderFoot(){
const wk=warenkorb(), total=wk.reduce((s,x)=>s+x.preis,0), diff=kategorien.some(k=>veraendert(k.id));
$("p-foot").innerHTML=`
${wk.length?wk.map(x=>`<div class="cartline"><b>${x.name}: ${x.teil.n}</b><span>${euro(x.preis)}</span></div>`).join(''):'<div class="cartline" style="justify-content:center;padding:6px 0">Keine Änderungen ausgewählt.</div>'}
<div class="sumrow"><span class="tl">Gesamt</span><span class="tv">${euro(total)}</span></div>
<div class="pay" id="pay"><button data-p="bargeld" class="${zahlung==='bargeld'?'active':''}">💵 Bargeld</button><button data-p="karte" class="${zahlung==='karte'?'active':''}">💳 Karte</button></div>
<div class="foot-actions"><button class="btn ghost" id="reset" ${diff?'':'disabled'}>Zurücksetzen</button><button class="btn primary" id="buy" ${total>0?'':'disabled'}>Kaufen &amp; anziehen · ${euro(total)}</button></div>`;
$("pay").querySelectorAll("[data-p]").forEach(b=>b.addEventListener("click",()=>{zahlung=b.dataset.p;renderFoot();}));
$("reset").addEventListener("click",()=>{outfit=JSON.parse(JSON.stringify(getragen));renderCats();renderBody();updateAvatar();renderFoot();toast("Auswahl zurückgesetzt");});
$("buy").addEventListener("click",kaufen);
}
function kaufen(){
const total=warenkorb().reduce((s,x)=>s+x.preis,0); if(total<=0)return;
const guthaben=zahlung==="bargeld"?spieler.bargeld:spieler.konto;
if(total>guthaben){toast(zahlung==="bargeld"?"Nicht genug Bargeld":"Konto nicht gedeckt","warn");return;}
if(zahlung==="bargeld")spieler.bargeld-=total;else spieler.konto-=total;
Object.keys(getragen).forEach(id=>{getragen[id]={s:outfit[id].s,c:outfit[id].c};}); // Outfit übernehmen
geld();renderCats();renderBody();renderFoot();toast("Gekauft & angezogen · "+euro(total),"good");
}
/* Events */
$("zoomtoggle").querySelectorAll("[data-z]").forEach(b=>b.addEventListener("click",()=>{zoom=b.dataset.z;$("zoomtoggle").querySelectorAll("[data-z]").forEach(x=>x.classList.toggle("active",x.dataset.z===zoom));updateAvatar();}));
geld();renderCats();renderBody();renderFoot();updateAvatar();
</script>
</body>
</html>

68
docker-compose.yml Normal file
View File

@ -0,0 +1,68 @@
# =====================================================================
# Aether Framework — Datenbank + Auto-Migration
#
# Startet eine MariaDB und spielt beim Hochfahren alle Schemas ein.
# Bereits angewendete Migrationen werden über `schema_migrations`
# erkannt und übersprungen — mehrfaches `up` ist gefahrlos.
#
# cp .env.example .env # Zugangsdaten anpassen
# docker compose up -d # DB starten + migrieren
# docker compose logs -f migrate # Migration mitlesen
#
# Adminer (Web-Oberfläche für die DB) optional dazu:
# docker compose --profile tools up -d
# =====================================================================
services:
db:
image: mariadb:11
container_name: aether-db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-aether_root}
MARIADB_DATABASE: ${DB_NAME:-aether}
MARIADB_USER: ${DB_USER:-aether}
MARIADB_PASSWORD: ${DB_PASSWORD:-aether}
ports:
- "${DB_PORT:-3306}:3306"
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 30
# Läuft einmal pro `up`, wartet auf die DB und spielt die Migrationen ein.
migrate:
image: mariadb:11
container_name: aether-migrate
depends_on:
db:
condition: service_healthy
environment:
DB_HOST: db
DB_NAME: ${DB_NAME:-aether}
DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-aether_root}
volumes:
- ./sql:/sql:ro
- ./docker/migrate.sh:/migrate.sh:ro
entrypoint: ["sh", "/migrate.sh"]
restart: "no"
# Optional: schlanke Web-Oberfläche auf http://localhost:8080
adminer:
image: adminer:4
container_name: aether-adminer
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "${ADMINER_PORT:-8080}:8080"
environment:
ADMINER_DEFAULT_SERVER: db
profiles: ["tools"]
volumes:
db_data:

48
docker/README.md Normal file
View File

@ -0,0 +1,48 @@
# Datenbank per Docker
Startet eine MariaDB und spielt alle Schemas automatisch ein. Der FiveM-
Server läuft dabei **wie gewohnt auf dem Host** und verbindet sich über
`127.0.0.1:3306`.
## Start
```bash
cp .env.example .env # Zugangsdaten anpassen (mind. Passwörter ändern)
docker compose up -d # DB starten + migrieren
docker compose logs -f migrate
```
Beim ersten `up` wird die Datenbank angelegt und alle Schemas eingespielt.
Bei jedem weiteren `up` prüft der Migrator die Tabelle `schema_migrations`
und überspringt, was schon drin ist — mehrfaches Starten ist gefahrlos.
## Verbindung im FiveM-Server
In `server.cfg` (Benutzer/Passwort/Port wie in der `.env`):
```cfg
set mysql_connection_string "mysql://aether:aether@127.0.0.1:3306/aether?charset=utf8mb4"
```
## Neue Migrationen hinzufügen
- **Neues Modul-Schema:** einfach eine `.sql` nach `sql/` legen — der
Migrator nimmt sie beim nächsten `up` automatisch mit (idempotent, also
am besten mit `CREATE TABLE IF NOT EXISTS`).
- **Nachtrag/Änderung an bestehenden Tabellen:** als `sql/updates/00X_….sql`
ablegen. Diese laufen fehlertolerant (ein „Duplicate column" auf einer
frischen DB wird nur vermerkt, nicht als Fehler gewertet).
Reihenfolge: `aether.sql` zuerst, dann die übrigen Basis-Schemas, dann
`updates/*.sql`.
## Nützliche Befehle
```bash
docker compose --profile tools up -d # zusätzlich Adminer auf :8080
docker compose exec db mariadb -uroot -p aether # DB-Shell
docker compose down # stoppen (Daten bleiben)
docker compose down -v # stoppen + Daten löschen (frisch)
```
Nach `down -v` ist die DB leer — das nächste `up` migriert komplett neu.

88
docker/migrate.sh Normal file
View File

@ -0,0 +1,88 @@
#!/bin/sh
# =====================================================================
# Aether — Datenbank-Migrator
#
# Spielt die Schemas aus /sql in fester Reihenfolge ein und merkt sich
# jede angewendete Datei in der Tabelle `schema_migrations`. Ein
# erneuter Lauf überspringt, was schon drin ist.
#
# Basis-Schemas : idempotent (CREATE TABLE IF NOT EXISTS) — Abbruch bei Fehler
# updates/*.sql : Nachträge — fehlertolerant (z. B. "Duplicate column"
# auf frischer DB ist unkritisch und wird nur vermerkt)
# =====================================================================
set -eu
HOST="${DB_HOST:-db}"
DB="${DB_NAME:-aether}"
SQLDIR="${SQL_DIR:-/sql}"
export MYSQL_PWD="${DB_ROOT_PASSWORD:-}"
# Alle Client-Aufrufe laufen als root über diesen Wrapper
run() { mariadb -h "$HOST" -u root "$@"; }
# --- Auf die Datenbank warten (depends_on sollte reichen, sicher ist sicher) ---
echo "[migrate] warte auf ${HOST} ..."
i=0
until run -e "SELECT 1" >/dev/null 2>&1; do
i=$((i + 1))
if [ "$i" -gt 60 ]; then echo "[migrate] DB nicht erreichbar — Abbruch."; exit 1; fi
sleep 2
done
# --- Datenbank + Migrationstabelle sicherstellen ---
run -e "CREATE DATABASE IF NOT EXISTS \`${DB}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
run "$DB" -e "CREATE TABLE IF NOT EXISTS schema_migrations (
name VARCHAR(255) PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);"
schon_angewendet() {
[ "$(run -N -s "$DB" -e "SELECT COUNT(*) FROM schema_migrations WHERE name='$1'")" != "0" ]
}
merken() {
run "$DB" -e "INSERT IGNORE INTO schema_migrations (name) VALUES ('$1')"
}
# anwenden <datei> <schluessel> <tolerant:0|1>
anwenden() {
datei="$1"; schluessel="$2"; tolerant="${3:-0}"
if schon_angewendet "$schluessel"; then
echo "[migrate] ✓ ${schluessel} (bereits angewendet)"
return 0
fi
echo "[migrate] → ${schluessel}"
if run "$DB" < "$datei"; then
merken "$schluessel"
echo "[migrate] ok"
elif [ "$tolerant" = "1" ]; then
echo "[migrate] ! Fehler ignoriert (Nachtrag evtl. schon enthalten) — als angewendet vermerkt"
merken "$schluessel"
else
echo "[migrate] ✗ FEHLER beim Einspielen von ${schluessel} — Abbruch."
exit 1
fi
}
# --- Basis-Schemas in fester Reihenfolge (aether zuerst) ---
for f in aether.sql aether_medical.sql aether_police.sql aether_admin.sql; do
[ -f "${SQLDIR}/${f}" ] && anwenden "${SQLDIR}/${f}" "$f" 0
done
# --- Weitere Schemas, die evtl. später hinzukommen (alphabetisch) ---
for f in "${SQLDIR}"/*.sql; do
[ -f "$f" ] || continue
base="$(basename "$f")"
case "$base" in
aether.sql|aether_medical.sql|aether_police.sql|aether_admin.sql) ;; # schon oben
*) anwenden "$f" "$base" 0 ;;
esac
done
# --- Nachträge (fehlertolerant) ---
if [ -d "${SQLDIR}/updates" ]; then
for f in "${SQLDIR}"/updates/*.sql; do
[ -f "$f" ] && anwenden "$f" "updates/$(basename "$f")" 1
done
fi
echo "[migrate] fertig."

381
factions/index.html Normal file
View File

@ -0,0 +1,381 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
</head>
<body>
<title>Aether — Fraktionsverwaltung</title>
<style>
:root{
--bg:#080a0f;--fg:#e9ecf4;--muted:#828a9e;--surface:rgba(22,26,36,0.72);--surface2:rgba(34,39,52,0.85);
--slot:#111520;--border:rgba(255,255,255,0.09);--accent:#7c5cf6;--ink:#f4f0ff;--accent-soft:rgba(124,92,246,0.16);
--green:#34d39a;--amber:#f5b23d;--red:#f0506e;--blue:#4aa8ff;
}
*{box-sizing:border-box;} html,body{margin:0;height:100%;}
body{min-height:100%;background:radial-gradient(1100px 700px at 15% 0%,var(--accent-soft),transparent 55%),radial-gradient(900px 700px at 90% 100%,rgba(52,211,153,0.05),transparent 55%),var(--bg);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);
display:flex;align-items:center;justify-content:center;padding:22px 14px;overflow-x:hidden;transition:background .4s;}
.app{position:relative;width:min(1280px,98vw);aspect-ratio:16/10;border-radius:20px;overflow:hidden;
background:linear-gradient(160deg,rgba(18,22,30,0.92),rgba(9,11,16,0.96));backdrop-filter:blur(20px);border:1px solid var(--border);
box-shadow:0 50px 100px -30px rgba(0,0,0,0.85);display:grid;grid-template-columns:222px 1fr;grid-template-rows:auto 1fr;grid-template-areas:"brand top" "nav main";}
.brand{grid-area:brand;padding:13px 16px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
.brand .bl{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;font-weight:800;color:#fff;font-size:15px;}
.brand .bn{font-size:15px;font-weight:800;letter-spacing:-0.02em;line-height:1.1;} .brand .bn small{display:block;font-size:10px;color:var(--muted);font-weight:500;}
.top{grid-area:top;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;padding:0 18px;}
.top select{background:var(--slot);border:1px solid var(--border);color:var(--fg);border-radius:9px;padding:8px 11px;font-family:inherit;font-size:12.5px;font-weight:600;}
.top .kasse{margin-left:auto;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:7px 13px;text-align:right;} .top .kasse .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;font-weight:800;} .top .kasse .v{font-size:15px;font-weight:800;font-variant-numeric:tabular-nums;color:var(--green);}
.top .me{display:flex;align-items:center;gap:9px;padding-left:14px;border-left:1px solid var(--border);} .top .me .av{width:32px;height:32px;border-radius:50%;display:grid;place-items:center;font-weight:800;font-size:12px;color:#fff;} .top .me .mn{font-size:13px;font-weight:700;} .top .me .mr{font-size:10.5px;color:var(--accent);font-weight:700;}
.nav{grid-area:nav;border-right:1px solid var(--border);padding:11px 9px;display:flex;flex-direction:column;gap:2px;overflow-y:auto;}
.nav button{display:flex;align-items:center;gap:10px;padding:9px 11px;border-radius:9px;border:none;background:none;color:var(--muted);font-family:inherit;font-size:13.5px;font-weight:700;cursor:pointer;text-align:left;transition:.12s;}
.nav button .ni{font-size:15px;width:18px;text-align:center;} .nav button:hover{background:var(--surface);color:var(--fg);} .nav button.active{background:var(--accent-soft);color:var(--accent);}
.nav button .badge{margin-left:auto;background:var(--slot);color:var(--muted);font-size:10px;font-weight:800;border-radius:999px;padding:1px 7px;}
.main{grid-area:main;overflow-y:auto;padding:18px 22px;} .main::-webkit-scrollbar,.nav::-webkit-scrollbar,.tblwrap::-webkit-scrollbar{width:7px;} .main::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.09);border-radius:4px;}
.phead{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;margin-bottom:14px;} .phead h2{font-size:20px;margin:0;font-weight:800;letter-spacing:-0.02em;} .phead p{margin:2px 0 0;color:var(--muted);font-size:12.5px;}
.grid{display:grid;gap:12px;} .card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:15px;}
.kpi .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;font-weight:800;} .kpi .v{font-size:24px;font-weight:800;font-variant-numeric:tabular-nums;margin-top:3px;} .kpi .d{font-size:11.5px;font-weight:600;margin-top:1px;}
.list-h{font-size:10.5px;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);font-weight:800;margin:0 0 10px;display:flex;align-items:center;gap:8px;} .list-h .r{margin-left:auto;font-weight:600;text-transform:none;letter-spacing:0;}
.btn{border:none;border-radius:10px;padding:10px 15px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit;transition:.12s;} .btn.primary{background:var(--accent);color:#fff;} .btn.primary:hover{filter:brightness(1.1);} .btn.ghost{background:var(--surface2);color:var(--fg);border:1px solid var(--border);} .btn.red{background:var(--red);color:#2a0910;} .btn.green{background:var(--green);color:#06231a;} .btn.sm{padding:6px 11px;font-size:12px;} .btn:disabled{opacity:.4;cursor:not-allowed;} .btn:active{transform:scale(0.98);}
.input,select.input{width:100%;background:var(--slot);border:1px solid var(--border);border-radius:9px;padding:10px 11px;color:var(--fg);font-size:13.5px;font-family:inherit;outline:none;} .input:focus{border-color:var(--accent);}
.flabel{font-size:10.5px;color:var(--muted);font-weight:800;text-transform:uppercase;letter-spacing:0.04em;margin:11px 2px 5px;display:block;}
.row{display:flex;align-items:center;gap:11px;padding:10px 12px;background:var(--surface);border:1px solid var(--border);border-radius:12px;margin-bottom:7px;} .row .grow{flex:1;min-width:0;} .row .t{font-weight:700;font-size:13.5px;} .row .m{font-size:11.5px;color:var(--muted);}
.av{border-radius:50%;display:grid;place-items:center;font-weight:800;color:#fff;flex-shrink:0;width:36px;height:36px;font-size:12px;}
.pill{font-size:10px;font-weight:800;padding:3px 8px;border-radius:999px;display:inline-block;} .pill.on{background:rgba(52,211,153,0.16);color:var(--green);} .pill.off{background:rgba(255,255,255,0.08);color:var(--muted);} .pill.a{background:rgba(245,178,61,0.16);color:var(--amber);} .pill.r{background:rgba(240,80,110,0.16);color:var(--red);} .pill.ac{background:var(--accent-soft);color:var(--accent);}
.tblwrap{overflow-x:auto;} table.tbl{width:100%;border-collapse:collapse;font-size:12.5px;} .tbl th{text-align:left;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;padding:8px 9px;border-bottom:1px solid var(--border);white-space:nowrap;} .tbl td{padding:9px 9px;border-bottom:1px solid var(--border);} .tbl tr:last-child td{border:none;} .tbl .num{text-align:right;font-variant-numeric:tabular-nums;} .tbl .c{text-align:center;}
.toggle{width:34px;height:20px;border-radius:999px;background:var(--slot);position:relative;cursor:pointer;border:1px solid var(--border);display:inline-block;vertical-align:middle;transition:.15s;} .toggle::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:.15s;} .toggle.on{background:var(--accent);border-color:transparent;} .toggle.on::after{transform:translateX(14px);} .toggle.dis{opacity:.4;cursor:not-allowed;}
.empty{text-align:center;color:var(--muted);padding:36px 10px;} .empty .big{font-size:36px;margin-bottom:8px;}
.bartrack{height:8px;border-radius:5px;background:var(--slot);overflow:hidden;} .bartrack>i{display:block;height:100%;border-radius:5px;}
/* Territorien-Karte */
.tmap{position:relative;border-radius:14px;overflow:hidden;border:1px solid var(--border);height:340px;
background:repeating-linear-gradient(90deg,rgba(255,255,255,0.02) 0 1px,transparent 1px 56px),repeating-linear-gradient(0deg,rgba(255,255,255,0.02) 0 1px,transparent 1px 56px),linear-gradient(135deg,#101823,#0a0f16);}
.zone{position:absolute;border-radius:10px;border:1.5px solid;cursor:pointer;display:flex;align-items:flex-end;padding:7px;transition:.12s;} .zone:hover{filter:brightness(1.15);}
.zone .zn{font-size:11px;font-weight:800;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,0.7);} .zone .zc{position:absolute;top:6px;right:8px;font-size:10px;font-weight:800;color:#fff;text-shadow:0 1px 2px #000;}
.overlay{position:absolute;inset:0;z-index:40;background:rgba(4,6,10,0.72);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;padding:22px;} .overlay.show{display:flex;}
.modal{width:460px;max-width:97%;max-height:90%;overflow-y:auto;background:rgba(18,22,30,0.98);border:1px solid var(--border);border-radius:16px;padding:20px;box-shadow:0 30px 70px -18px rgba(0,0,0,0.8);} .modal.sm{width:380px;}
.modal h3{margin:0 0 3px;font-size:18px;} .modal .msub{color:var(--muted);font-size:12.5px;margin-bottom:12px;} .modal-actions{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:14px;}
.kv{display:flex;justify-content:space-between;font-size:12.5px;padding:5px 0;border-bottom:1px solid var(--border);} .kv:last-child{border:none;} .kv span:first-child{color:var(--muted);}
.toast{position:absolute;bottom:16px;left:50%;transform:translateX(-50%) translateY(16px);background:rgba(12,15,22,0.97);border:1px solid var(--border);color:var(--fg);padding:10px 18px;border-radius:11px;font-size:13px;font-weight:600;z-index:50;opacity:0;pointer-events:none;transition:.3s;} .toast.show{opacity:1;transform:translateX(-50%) translateY(0);} .toast.good{border-color:var(--green);} .toast.warn{border-color:var(--red);}
</style>
<div class="app">
<div class="brand"><span class="bl" id="brand-logo">B</span><div class="bn" id="brand-name">Fraktion<small>Verwaltung</small></div></div>
<div class="top" id="top"></div>
<div class="nav" id="nav"></div>
<div class="main" id="main"></div>
<div class="overlay" id="overlay"></div>
<div class="toast" id="toast"></div>
</div>
<script>
"use strict";
const $=id=>document.getElementById(id);
function sicher(t){const d=document.createElement("div");d.textContent=t==null?"":t;return d.innerHTML;}
const euro=n=>Math.round(n).toLocaleString("de-DE")+" €";
const initialen=n=>n.split(" ").map(w=>w[0]).slice(0,2).join("");
const avColor=n=>{let h=0;for(const c of n)h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 45% 46%)`;};
let toastTimer=null; function toast(t,typ){const e=$("toast");e.textContent=t;e.className="toast show"+(typ?" "+typ:"");clearTimeout(toastTimer);toastTimer=setTimeout(()=>e.classList.remove("show"),2400);}
function modal(html,sm){$("overlay").innerHTML=`<div class="modal${sm?' sm':''}">${html}</div>`;$("overlay").classList.add("show");}
function closeModal(){$("overlay").classList.remove("show");$("overlay").innerHTML="";}
/* =====================================================================
RECHTE-DEFINITIONEN
===================================================================== */
const RECHTE=[["einladen","Einladen"],["kicken","Kicken"],["befoerdern","Befördern"],["kasse","Kasse"],["raenge","Ränge"],["territorien","Territorien"],["fahrzeuge","Fahrzeuge"],["einstellungen","Einstell."]];
const alleRechte=(v)=>Object.fromEntries(RECHTE.map(r=>[r[0],v]));
function rang(name,gehalt,rechte){return {name,gehalt,rechte:{...alleRechte(false),...rechte}};}
const spieler={bargeld:12500}; // eigenes Bargeld (für Kasse-Einzahlung)
/* =====================================================================
FRAKTIONEN
===================================================================== */
const fraktionen=[
{id:"ballas",name:"Ballas",kurz:"Straßengang · Grove/​Davis",typ:"gang",farbe:"#7c5cf6",ink:"#f4f0ff",kasse:184000,
raenge:[rang("Boss",0,alleRechte(true)),rang("Underboss",4000,{einladen:1,kicken:1,befoerdern:1,kasse:1,territorien:1,fahrzeuge:1}),rang("Enforcer",2600,{einladen:1,kicken:1,territorien:1,fahrzeuge:1}),rang("Soldier",1500,{fahrzeuge:1}),rang("Prospect",600,{})],
mitglieder:[{name:"Deebo King",rankIdx:0,seit:"12.01.2026",letzte:"online",online:true},{name:"Vince Malloy",rankIdx:1,seit:"02.02.2026",letzte:"online",online:true},{name:"Rico Vargas",rankIdx:2,seit:"14.03.2026",letzte:"online",online:true},{name:"Tyrone Banks",rankIdx:3,seit:"20.04.2026",letzte:"vor 2 h",online:false},{name:"Marcus Hale",rankIdx:3,seit:"01.05.2026",letzte:"online",online:true},{name:"Lil J",rankIdx:4,seit:"18.06.2026",letzte:"vor 1 Tag",online:false}],
territorien:[
{name:"Grove Street",x:14,y:60,w:24,h:26,kontrolle:88,einnahmen:320,rivale:"Families"},
{name:"Davis",x:42,y:64,w:22,h:24,kontrolle:64,einnahmen:210,rivale:"Vagos"},
{name:"Chamberlain Hills",x:20,y:26,w:26,h:24,kontrolle:41,einnahmen:150,rivale:"Vagos"},
{name:"Strawberry",x:56,y:30,w:24,h:22,kontrolle:12,einnahmen:0,rivale:"Vagos"}],
fahrzeuge:[{model:"Manana",plate:"BAL-01",zugewiesen:"Enforcer+"},{model:"Baller",plate:"BAL-02",zugewiesen:"Boss"}],
logs:[], transaktionen:[{typ:"in",text:"Territorium Grove Street",betrag:320,zeit:"heute"},{typ:"out",text:"Waffenkauf",betrag:-8000,zeit:"gestern"}]},
{id:"vagos",name:"Vagos",kurz:"Straßengang · East LS",typ:"gang",farbe:"#f5b23d",ink:"#3a2600",kasse:97000,
raenge:[rang("Boss",0,alleRechte(true)),rang("Underboss",3800,{einladen:1,kicken:1,befoerdern:1,kasse:1,territorien:1,fahrzeuge:1}),rang("Veterano",2400,{einladen:1,kicken:1,territorien:1}),rang("Soldado",1400,{fahrzeuge:1}),rang("Novato",500,{})],
mitglieder:[{name:"Miguel Cruz",rankIdx:0,seit:"08.01.2026",letzte:"online",online:true},{name:"Ana Reyes",rankIdx:1,seit:"11.02.2026",letzte:"vor 30 min",online:false},{name:"Diego Sol",rankIdx:2,seit:"22.03.2026",letzte:"online",online:true},{name:"Pablo Nieto",rankIdx:3,seit:"14.05.2026",letzte:"online",online:true}],
territorien:[
{name:"El Rancho",x:60,y:56,w:26,h:26,kontrolle:82,einnahmen:280,rivale:"Ballas"},
{name:"Cypress Flats",x:60,y:24,w:24,h:24,kontrolle:58,einnahmen:190,rivale:"—"},
{name:"Davis",x:36,y:62,w:22,h:22,kontrolle:36,einnahmen:110,rivale:"Ballas"}],
fahrzeuge:[{model:"Buccaneer",plate:"VAG-01",zugewiesen:"alle"}],
logs:[], transaktionen:[{typ:"in",text:"Schutzgeld East LS",betrag:1500,zeit:"heute"}]},
{id:"lsc",name:"Los Santos Customs",kurz:"Legaler Betrieb · Werkstatt",typ:"legal",farbe:"#34d39a",ink:"#06231a",kasse:342000,
raenge:[rang("Inhaber",0,alleRechte(true)),rang("Werkstattmeister",4200,{einladen:1,kicken:1,befoerdern:1,kasse:1,fahrzeuge:1}),rang("Geselle",2400,{fahrzeuge:1}),rang("Lehrling",1200,{})],
mitglieder:[{name:"Karl Mendez",rankIdx:0,seit:"03.11.2025",letzte:"online",online:true},{name:"Ben Fischer",rankIdx:1,seit:"20.12.2025",letzte:"online",online:true},{name:"Sara Lind",rankIdx:2,seit:"14.02.2026",letzte:"vor 5 h",online:false},{name:"Tom Kessler",rankIdx:3,seit:"30.04.2026",letzte:"online",online:true}],
territorien:[],
fahrzeuge:[{model:"Flatbed",plate:"LSC-01",zugewiesen:"Geselle+"},{model:"Towtruck",plate:"LSC-02",zugewiesen:"alle"},{model:"Sultan RS",plate:"LSC-03",zugewiesen:"Meister+"}],
logs:[], transaktionen:[{typ:"in",text:"Reparaturaufträge",betrag:12400,zeit:"heute"},{typ:"out",text:"Ersatzteile",betrag:-3200,zeit:"gestern"}]}
];
let aktiv=0; // aktive Fraktion
let ansichtRang=0; // "angemeldet als" Rang (zum Testen der Rechte); 0 = Boss/Inhaber
let sektion="uebersicht";
const F=()=>fraktionen[aktiv];
const R=()=>F().raenge[ansichtRang];
const darf=recht=>!!R().rechte[recht];
const rangName=idx=>F().raenge[idx].name;
function log(aktion,detail){F().logs.unshift({zeit:new Date(),wer:F().mitglieder.find(m=>m.rankIdx===ansichtRang)?.name||rangName(ansichtRang),aktion,detail:detail||""});}
/* =====================================================================
SHELL
===================================================================== */
const nav=[
{id:"uebersicht",label:"Übersicht",icon:"🏠"},
{id:"mitglieder",label:"Mitglieder",icon:"👥"},
{id:"raenge",label:"Ränge & Rechte",icon:"🎖️"},
{id:"kasse",label:"Kasse",icon:"💰"},
{id:"territorien",label:"Territorien",icon:"🗺️",gang:true},
{id:"fahrzeuge",label:"Fahrzeuge",icon:"🚗"},
{id:"logbuch",label:"Logbuch",icon:"📜"},
{id:"einstellungen",label:"Einstellungen",icon:"⚙️"}
];
function starte(){
document.documentElement.style.setProperty("--accent",F().farbe);
document.documentElement.style.setProperty("--ink",F().ink);
document.documentElement.style.setProperty("--accent-soft","color-mix(in srgb, "+F().farbe+" 16%, transparent)");
$("brand-logo").style.background=F().farbe; $("brand-logo").textContent=initialen(F().name);
$("brand-name").innerHTML=`${sicher(F().name)}<small>${F().typ==="gang"?"Gang-Verwaltung":"Fraktions-Verwaltung"}</small>`;
renderTop(); renderNav(); renderSektion();
}
function renderTop(){
$("top").innerHTML=`<select id="frak-sel">${fraktionen.map((f,i)=>`<option value="${i}" ${i===aktiv?'selected':''}>${sicher(f.name)}</option>`).join('')}</select>
<select id="rang-sel" title="Ansicht als Rang (Rechte testen)">${F().raenge.map((r,i)=>`<option value="${i}" ${i===ansichtRang?'selected':''}>Als: ${sicher(r.name)}</option>`).join('')}</select>
<div class="kasse"><div class="l">Fraktionskasse</div><div class="v">${euro(F().kasse)}</div></div>
<div class="me"><span class="av" style="background:${F().farbe}">${initialen(rangName(ansichtRang))}</span><div><div class="mn">${sicher(rangName(ansichtRang))}</div><div class="mr">${F().mitglieder.filter(m=>m.online).length} online</div></div></div>`;
$("frak-sel").addEventListener("change",e=>{aktiv=+e.target.value;ansichtRang=0;sektion="uebersicht";starte();});
$("rang-sel").addEventListener("change",e=>{ansichtRang=+e.target.value;starte();});
}
function renderNav(){
$("nav").innerHTML=nav.filter(n=>!n.gang||F().typ==="gang").map(n=>`<button data-s="${n.id}" class="${n.id===sektion?'active':''}"><span class="ni">${n.icon}</span>${n.label}${n.id==='mitglieder'?`<span class="badge">${F().mitglieder.length}</span>`:''}</button>`).join('');
$("nav").querySelectorAll("[data-s]").forEach(b=>b.addEventListener("click",()=>{sektion=b.dataset.s;renderNav();renderSektion();}));
}
function renderSektion(){(sektionen[sektion]||sektionen.uebersicht)();}
const sektionen={};
/* =====================================================================
ÜBERSICHT
===================================================================== */
sektionen.uebersicht=()=>{
const online=F().mitglieder.filter(m=>m.online).length, payday=F().mitglieder.reduce((s,m)=>s+F().raenge[m.rankIdx].gehalt,0);
const terrIncome=F().territorien.reduce((s,t)=>s+(t.kontrolle>=50?t.einnahmen:0),0);
$("main").innerHTML=`<div class="phead"><div><h2>${sicher(F().name)}</h2><p>${sicher(F().kurz)}</p></div></div>
<div class="grid" style="grid-template-columns:repeat(4,1fr)">
<div class="card kpi"><div class="l">👥 Mitglieder</div><div class="v">${F().mitglieder.length}</div><div class="d" style="color:var(--green)">${online} online</div></div>
<div class="card kpi"><div class="l">💰 Kasse</div><div class="v">${euro(F().kasse)}</div><div class="d" style="color:var(--muted)">Fraktionsvermögen</div></div>
<div class="card kpi"><div class="l">🧾 Payday /h</div><div class="v">${euro(payday)}</div><div class="d" style="color:var(--muted)">Gehälter gesamt</div></div>
<div class="card kpi"><div class="l">${F().typ==='gang'?'🗺️ Gebiets-Einnahmen':'🔧 Aufträge /h'}</div><div class="v" style="color:var(--green)">${F().typ==='gang'?euro(terrIncome)+' /h':euro(8400)+' /h'}</div><div class="d" style="color:var(--muted)">${F().typ==='gang'?F().territorien.filter(t=>t.kontrolle>=50).length+' kontrollierte Gebiete':'geschätzt'}</div></div></div>
<div class="grid" style="grid-template-columns:1fr 1fr;margin-top:12px">
<div class="card"><div class="list-h">Online-Mitglieder</div>${F().mitglieder.filter(m=>m.online).map(m=>`<div class="row" style="margin:0 0 7px"><span class="av" style="background:${avColor(m.name)}">${initialen(m.name)}</span><div class="grow"><div class="t">${sicher(m.name)}</div><div class="m">${rangName(m.rankIdx)}</div></div><span class="pill on">online</span></div>`).join('')||'<div class="empty">Niemand online.</div>'}</div>
<div class="card"><div class="list-h">Schnellaktionen</div><div style="display:flex;flex-direction:column;gap:8px">
<button class="btn ghost" id="q-invite" ${darf('einladen')?'':'disabled'}>➕ Mitglied einladen</button>
<button class="btn ghost" id="q-payday" ${darf('kasse')?'':'disabled'}>🧾 Payday auszahlen (${euro(payday)})</button>
<button class="btn ghost" id="q-deposit" ${darf('kasse')?'':'disabled'}>💵 In Kasse einzahlen</button>
${F().typ==='gang'?'<button class="btn ghost" id="q-terr">🗺️ Territorien verwalten</button>':''}</div>
${darf('einladen')?'':'<div style="font-size:11px;color:var(--muted);margin-top:8px">🔒 Als '+sicher(rangName(ansichtRang))+' hast du eingeschränkte Rechte.</div>'}</div></div>`;
const qi=$("q-invite");if(qi)qi.addEventListener("click",()=>darf('einladen')&&einladenModal());
const qp=$("q-payday");if(qp)qp.addEventListener("click",()=>darf('kasse')&&payday());
const qd=$("q-deposit");if(qd)qd.addEventListener("click",()=>darf('kasse')&&kasseModal('in'));
const qt=$("q-terr");if(qt)qt.addEventListener("click",()=>{sektion="territorien";renderNav();renderSektion();});
};
/* =====================================================================
MITGLIEDER
===================================================================== */
sektionen.mitglieder=()=>{
$("main").innerHTML=`<div class="phead"><div><h2>Mitglieder</h2><p>${F().mitglieder.length} Mitglieder · ${F().mitglieder.filter(m=>m.online).length} online</p></div>
<button class="btn primary" id="m-invite" ${darf('einladen')?'':'disabled'}>➕ Einladen</button></div>
<div class="card" style="padding:6px 12px"><div class="tblwrap"><table class="tbl"><thead><tr><th>Mitglied</th><th>Rang</th><th class="num">Gehalt</th><th>Seit</th><th>Status</th><th></th></tr></thead><tbody>
${F().mitglieder.map((m,i)=>`<tr><td><span class="av" style="width:26px;height:26px;font-size:10px;background:${avColor(m.name)}">${initialen(m.name)}</span> ${sicher(m.name)}</td>
<td><span class="pill ac">${rangName(m.rankIdx)}</span></td><td class="num">${euro(F().raenge[m.rankIdx].gehalt)}</td><td style="color:var(--muted)">${m.seit}</td>
<td>${m.online?'<span class="pill on">online</span>':`<span class="pill off">${sicher(m.letzte)}</span>`}</td>
<td class="num"><button class="btn ghost sm" data-up="${i}" ${kannBefoerdern(m,1)?'':'disabled'}>▲</button> <button class="btn ghost sm" data-down="${i}" ${kannBefoerdern(m,-1)?'':'disabled'}>▼</button> <button class="btn red sm" data-kick="${i}" ${kannKicken(m)?'':'disabled'}>Kick</button></td></tr>`).join('')}
</tbody></table></div></div>
${darf('befoerdern')||darf('kicken')?'':'<div class="card" style="margin-top:10px;color:var(--muted);font-size:12px">🔒 Als '+sicher(rangName(ansichtRang))+' kannst du keine Ränge ändern oder Mitglieder entfernen.</div>'}`;
const mi=$("m-invite");if(mi)mi.addEventListener("click",()=>darf('einladen')&&einladenModal());
$("main").querySelectorAll("[data-up]").forEach(b=>b.addEventListener("click",()=>befoerdern(+b.dataset.up,1)));
$("main").querySelectorAll("[data-down]").forEach(b=>b.addEventListener("click",()=>befoerdern(+b.dataset.down,-1)));
$("main").querySelectorAll("[data-kick]").forEach(b=>b.addEventListener("click",()=>kickMitglied(+b.dataset.kick)));
};
function kannBefoerdern(m,dir){ if(!darf('befoerdern'))return false; const neu=m.rankIdx-dir; // dir +1 = hoch (kleinerer Index)
if(neu<0||neu>=F().raenge.length)return false; if(neu<=ansichtRang&&ansichtRang!==0)return false; // nicht auf/über eigenen Rang befördern (außer Boss)
if(m.rankIdx<=ansichtRang&&ansichtRang!==0)return false; return true; }
function kannKicken(m){ if(!darf('kicken'))return false; return m.rankIdx>ansichtRang||ansichtRang===0&&m.rankIdx!==0; }
function befoerdern(i,dir){const m=F().mitglieder[i];if(!kannBefoerdern(m,dir))return;m.rankIdx-=dir;log(dir>0?"Befördert":"Degradiert",m.name+" → "+rangName(m.rankIdx));toast(m.name+" "+(dir>0?"befördert":"degradiert")+" zu "+rangName(m.rankIdx),"good");renderSektion();renderTop();}
function kickMitglied(i){const m=F().mitglieder[i];if(!kannKicken(m))return;
modal(`<h3>Mitglied entfernen?</h3><p style="color:var(--muted);font-size:13px">„${sicher(m.name)}“ (${rangName(m.rankIdx)}) aus ${sicher(F().name)} entfernen?</p><div class="modal-actions"><button class="btn ghost" id="k-c">Abbrechen</button><button class="btn red" id="k-ok">Entfernen</button></div>`,true);
$("k-c").addEventListener("click",closeModal);$("k-ok").addEventListener("click",()=>{F().mitglieder.splice(i,1);log("Mitglied entfernt",m.name);closeModal();toast(m.name+" entfernt","warn");renderSektion();renderTop();renderNav();});}
function einladenModal(){
modal(`<h3>Mitglied einladen</h3><div class="msub">Neues Mitglied zu ${sicher(F().name)} hinzufügen</div>
<label class="flabel">Name / ID</label><input class="input" id="ei-name" placeholder="z. B. Max Weber">
<label class="flabel">Startrang</label><select class="input" id="ei-rang">${F().raenge.map((r,i)=>i>0?`<option value="${i}" ${i===F().raenge.length-1?'selected':''}>${sicher(r.name)}</option>`:'').join('')}</select>
<div class="modal-actions"><button class="btn ghost" id="ei-c">Abbrechen</button><button class="btn primary" id="ei-ok">Einladen</button></div>`,true);
$("ei-c").addEventListener("click",closeModal);
$("ei-ok").addEventListener("click",()=>{const n=$("ei-name").value.trim();if(!n){toast("Name angeben","warn");return;}F().mitglieder.push({name:n,rankIdx:+$("ei-rang").value,seit:new Date().toLocaleDateString('de-DE'),letzte:"gerade beigetreten",online:true});log("Mitglied eingeladen",n);closeModal();toast(n+" eingeladen","good");renderSektion();renderTop();renderNav();});
}
/* =====================================================================
RÄNGE & RECHTE
===================================================================== */
sektionen.raenge=()=>{
const editier=darf('raenge');
$("main").innerHTML=`<div class="phead"><div><h2>Ränge &amp; Rechte</h2><p>${F().raenge.length} Ränge · Gehälter &amp; Berechtigungen</p></div>${editier?'<button class="btn primary" id="r-neu">➕ Rang</button>':''}</div>
<div class="card" style="padding:6px 12px"><div class="tblwrap"><table class="tbl"><thead><tr><th>Rang</th><th class="num">Gehalt /h</th>${RECHTE.map(r=>`<th class="c">${r[1]}</th>`).join('')}<th></th></tr></thead><tbody>
${F().raenge.map((r,i)=>`<tr><td><span class="pill ac">${sicher(r.name)}</span>${i===0?' <span class="pill a">Leitung</span>':''}</td>
<td class="num">${i===0?'—':euro(r.gehalt)}</td>
${RECHTE.map(rc=>`<td class="c"><span class="toggle ${r.rechte[rc[0]]?'on':''} ${editier&&i!==0?'':'dis'}" data-rt="${i}|${rc[0]}"></span></td>`).join('')}
<td class="num">${editier&&i!==0?`<button class="btn ghost sm" data-gehalt="${i}">Gehalt</button> <button class="btn red sm" data-delr="${i}" ${F().mitglieder.some(m=>m.rankIdx===i)?'disabled title="Rang belegt"':''}>✕</button>`:''}</td></tr>`).join('')}
</tbody></table></div></div>
<div class="card" style="margin-top:10px;font-size:12px;color:var(--muted)">Der oberste Rang (Leitung) besitzt automatisch alle Rechte. ${editier?'Klicke die Schalter, um Rechte je Rang zu ändern.':'🔒 Nur mit dem Recht „Ränge" bearbeitbar.'}</div>`;
if(editier){
const rn=$("r-neu");if(rn)rn.addEventListener("click",neuerRangModal);
$("main").querySelectorAll("[data-rt]").forEach(t=>t.addEventListener("click",()=>{const [i,rc]=t.dataset.rt.split('|');if(+i===0)return;const r=F().raenge[+i];r.rechte[rc]=!r.rechte[rc];log("Recht geändert",F().raenge[+i].name+": "+rc+" "+(r.rechte[rc]?'an':'aus'));renderSektion();}));
$("main").querySelectorAll("[data-gehalt]").forEach(b=>b.addEventListener("click",()=>{const r=F().raenge[+b.dataset.gehalt];
modal(`<h3>Gehalt · ${sicher(r.name)}</h3><label class="flabel">Gehalt (€ / Stunde Payday)</label><input class="input" id="rg" type="number" value="${r.gehalt}"><div class="modal-actions"><button class="btn ghost" id="rg-c">Abbrechen</button><button class="btn primary" id="rg-ok">Speichern</button></div>`,true);
$("rg-c").addEventListener("click",closeModal);$("rg-ok").addEventListener("click",()=>{r.gehalt=Math.max(0,parseInt($("rg").value,10)||0);log("Gehalt geändert",r.name+" → "+euro(r.gehalt));closeModal();toast("Gehalt aktualisiert","good");renderSektion();});}));
$("main").querySelectorAll("[data-delr]").forEach(b=>b.addEventListener("click",()=>{const i=+b.dataset.delr;if(F().mitglieder.some(m=>m.rankIdx===i)){toast("Rang ist belegt","warn");return;}const r=F().raenge.splice(i,1)[0];F().mitglieder.forEach(m=>{if(m.rankIdx>i)m.rankIdx--;});log("Rang gelöscht",r.name);toast("Rang gelöscht","warn");renderSektion();}));
}
};
function neuerRangModal(){
modal(`<h3>Neuer Rang</h3><label class="flabel">Name</label><input class="input" id="nr-name" placeholder="z. B. Veteran"><label class="flabel">Gehalt (€ / h)</label><input class="input" id="nr-gehalt" type="number" value="1000">
<div class="modal-actions"><button class="btn ghost" id="nr-c">Abbrechen</button><button class="btn primary" id="nr-ok">Erstellen</button></div>`,true);
$("nr-c").addEventListener("click",closeModal);
$("nr-ok").addEventListener("click",()=>{const n=$("nr-name").value.trim();if(!n){toast("Name angeben","warn");return;}F().raenge.push(rang(n,parseInt($("nr-gehalt").value,10)||0,{}));log("Rang erstellt",n);closeModal();toast("Rang „"+n+"“ erstellt","good");renderSektion();});
}
/* =====================================================================
KASSE
===================================================================== */
sektionen.kasse=()=>{
const kann=darf('kasse'), payday=F().mitglieder.reduce((s,m)=>s+F().raenge[m.rankIdx].gehalt,0);
$("main").innerHTML=`<div class="phead"><div><h2>Fraktionskasse</h2><p>Ein- und Auszahlungen · Payday</p></div></div>
<div class="grid" style="grid-template-columns:repeat(3,1fr);margin-bottom:12px">
<div class="card kpi"><div class="l">Kassenstand</div><div class="v" style="color:var(--green)">${euro(F().kasse)}</div></div>
<div class="card kpi"><div class="l">Payday /h</div><div class="v">${euro(payday)}</div><div class="d" style="color:var(--muted)">alle Gehälter</div></div>
<div class="card kpi"><div class="l">Dein Bargeld</div><div class="v">${euro(spieler.bargeld)}</div></div></div>
<div class="grid" style="grid-template-columns:1fr 1.4fr;align-items:start">
<div class="card"><div class="list-h">Aktionen</div><div style="display:flex;flex-direction:column;gap:8px">
<button class="btn ghost" id="k-in" ${kann?'':'disabled'}>💵 Einzahlen</button>
<button class="btn ghost" id="k-out" ${kann?'':'disabled'}>🏧 Auszahlen</button>
<button class="btn primary" id="k-pay" ${kann?'':'disabled'}>🧾 Payday auszahlen (${euro(payday)})</button></div>
${kann?'':'<div style="font-size:11px;color:var(--muted);margin-top:8px">🔒 Kein Recht „Kasse".</div>'}</div>
<div class="card"><div class="list-h">Transaktionen</div>${F().transaktionen.map(t=>`<div class="kv"><span>${t.typ==='in'?'⬆️':'⬇️'} ${sicher(t.text)}<span style="color:var(--muted)"> · ${t.zeit}</span></span><span style="color:${t.betrag>0?'var(--green)':'var(--red)'};font-weight:700">${t.betrag>0?'+':''}${euro(t.betrag)}</span></div>`).join('')||'<div class="empty">Keine Transaktionen.</div>'}</div></div>`;
const ki=$("k-in");if(ki)ki.addEventListener("click",()=>kann&&kasseModal('in'));
const ko=$("k-out");if(ko)ko.addEventListener("click",()=>kann&&kasseModal('out'));
const kp=$("k-pay");if(kp)kp.addEventListener("click",()=>kann&&payday());
};
function kasseModal(dir){
const rein=dir==='in';
modal(`<h3>${rein?'In Kasse einzahlen':'Aus Kasse auszahlen'}</h3><div class="msub">${rein?'Von deinem Bargeld ('+euro(spieler.bargeld)+')':'Auf dein Bargeld · Kasse: '+euro(F().kasse)}</div>
<label class="flabel">Betrag (€)</label><input class="input" id="ka-betrag" type="number" placeholder="0">
<label class="flabel">Verwendungszweck</label><input class="input" id="ka-zweck" placeholder="optional">
<div class="modal-actions"><button class="btn ghost" id="ka-c">Abbrechen</button><button class="btn primary" id="ka-ok">${rein?'Einzahlen':'Auszahlen'}</button></div>`,true);
$("ka-c").addEventListener("click",closeModal);
$("ka-ok").addEventListener("click",()=>{const b=parseInt($("ka-betrag").value,10);if(!b||b<=0){toast("Betrag angeben","warn");return;}
if(rein){if(b>spieler.bargeld){toast("Nicht genug Bargeld","warn");return;}spieler.bargeld-=b;F().kasse+=b;F().transaktionen.unshift({typ:'in',text:$("ka-zweck").value.trim()||"Einzahlung ("+rangName(ansichtRang)+")",betrag:b,zeit:"gerade eben"});log("Kasse eingezahlt",euro(b));toast(euro(b)+" eingezahlt","good");}
else{if(b>F().kasse){toast("Kasse nicht gedeckt","warn");return;}F().kasse-=b;spieler.bargeld+=b;F().transaktionen.unshift({typ:'out',text:$("ka-zweck").value.trim()||"Auszahlung ("+rangName(ansichtRang)+")",betrag:-b,zeit:"gerade eben"});log("Kasse ausgezahlt",euro(b));toast(euro(b)+" ausgezahlt","good");}
closeModal();renderSektion();renderTop();});
}
function payday(){const gesamt=F().mitglieder.reduce((s,m)=>s+F().raenge[m.rankIdx].gehalt,0);
if(gesamt>F().kasse){toast("Kasse deckt den Payday nicht ("+euro(gesamt)+")","warn");return;}
F().kasse-=gesamt;F().transaktionen.unshift({typ:'out',text:"Payday ("+F().mitglieder.length+" Mitglieder)",betrag:-gesamt,zeit:"gerade eben"});log("Payday ausgezahlt",euro(gesamt));toast("Payday "+euro(gesamt)+" ausgezahlt","good");renderSektion();renderTop();}
/* =====================================================================
TERRITORIEN (nur Gangs)
===================================================================== */
function terrStatus(k){return k>=66?["kontrolliert","on",F().farbe]:k>=33?["umkämpft","a","#f5b23d"]:["feindlich","r","#f0506e"];}
sektionen.territorien=()=>{
if(F().typ!=="gang"){$("main").innerHTML='<div class="empty" style="padding:60px"><div class="big">🏢</div>Territorien gibt es nur für Gangs.</div>';return;}
const kontrolliert=F().territorien.filter(t=>t.kontrolle>=50).length, income=F().territorien.reduce((s,t)=>s+(t.kontrolle>=50?t.einnahmen:0),0);
$("main").innerHTML=`<div class="phead"><div><h2>Territorien</h2><p>${kontrolliert}/${F().territorien.length} kontrolliert · ${euro(income)} /h Einnahmen</p></div></div>
<div style="display:grid;grid-template-columns:1fr 300px;gap:12px;align-items:start">
<div class="tmap" id="tmap">${F().territorien.map((t,i)=>{const st=terrStatus(t.kontrolle);return `<div class="zone" data-z="${i}" style="left:${t.x}%;top:${t.y}%;width:${t.w}%;height:${t.h}%;background:${st[2]}22;border-color:${st[2]}"><span class="zc">${t.kontrolle}%</span><span class="zn">${sicher(t.name)}</span></div>`;}).join('')}</div>
<div><div class="list-h">Gebiete</div>${F().territorien.map((t,i)=>{const st=terrStatus(t.kontrolle);return `<div class="card" style="padding:12px;margin-bottom:8px;cursor:pointer" data-z="${i}"><div style="display:flex;align-items:center;justify-content:space-between"><b>${sicher(t.name)}</b><span class="pill ${st[1]}">${st[0]}</span></div>
<div class="bartrack" style="margin:8px 0 5px"><i style="width:${t.kontrolle}%;background:${st[2]}"></i></div>
<div style="font-size:11.5px;color:var(--muted)">Kontrolle ${t.kontrolle}% · ${t.kontrolle>=50?euro(t.einnahmen)+' /h':'keine Einnahmen'} · Rivale: ${sicher(t.rivale)}</div></div>`;}).join('')}</div></div>`;
$("main").querySelectorAll("[data-z]").forEach(el=>el.addEventListener("click",()=>terrModal(+el.dataset.z)));
};
function terrModal(i){const t=F().territorien[i],st=terrStatus(t.kontrolle),kann=darf('territorien');
modal(`<h3>${sicher(t.name)}</h3><div class="msub">Rivale: ${sicher(t.rivale)}</div>
<div class="kv"><span>Kontrolle</span><span><span class="pill ${st[1]}">${st[0]}</span> ${t.kontrolle}%</span></div>
<div class="kv"><span>Einnahmen</span><span>${t.kontrolle>=50?euro(t.einnahmen)+' /h':'—'}</span></div>
<div class="bartrack" style="margin:10px 0"><i style="width:${t.kontrolle}%;background:${st[2]}"></i></div>
${kann?`<div class="modal-actions" style="grid-template-columns:1fr 1fr"><button class="btn primary" id="t-attack">⚔️ Angreifen</button><button class="btn ghost" id="t-defend">🛡️ Verstärken</button></div>
<div class="modal-actions" style="grid-template-columns:1fr 1fr;margin-top:8px"><button class="btn green" id="t-upgrade" ${t.kontrolle>=50?'':'disabled'}>⬆️ Ausbauen · ${euro(6000)}</button><button class="btn ghost" id="t-close">Schließen</button></div>`
:`<div style="font-size:12px;color:var(--muted);margin-top:8px">🔒 Kein Recht „Territorien".</div><div class="modal-actions" style="grid-template-columns:1fr;margin-top:8px"><button class="btn ghost" id="t-close">Schließen</button></div>`}`);
$("t-close").addEventListener("click",closeModal);
if(kann){
$("t-attack").addEventListener("click",()=>{t.kontrolle=Math.min(100,t.kontrolle+10+Math.floor(Math.random()*15));log("Angriff",t.name+" → "+t.kontrolle+"%");toast("Angriff auf "+t.name+" · Kontrolle "+t.kontrolle+"%","good");renderSektion();closeModal();});
$("t-defend").addEventListener("click",()=>{t.kontrolle=Math.min(100,t.kontrolle+5);log("Verstärkt",t.name);toast(t.name+" verstärkt · "+t.kontrolle+"%");renderSektion();closeModal();});
const up=$("t-upgrade");if(up)up.addEventListener("click",()=>{if(F().kasse<6000){toast("Kasse nicht gedeckt","warn");return;}F().kasse-=6000;t.einnahmen+=80;F().transaktionen.unshift({typ:'out',text:"Ausbau "+t.name,betrag:-6000,zeit:"gerade eben"});log("Gebiet ausgebaut",t.name+" +80/h");toast(t.name+" ausgebaut · +80 €/h","good");renderSektion();renderTop();closeModal();});
}
}
/* =====================================================================
FAHRZEUGE
===================================================================== */
sektionen.fahrzeuge=()=>{
const kann=darf('fahrzeuge');
$("main").innerHTML=`<div class="phead"><div><h2>Fraktionsfahrzeuge</h2><p>${F().fahrzeuge.length} Fahrzeuge · gemeinsame Garage</p></div>${kann?'<button class="btn primary" id="fz-add">➕ Hinzufügen</button>':''}</div>
<div class="card" style="padding:6px 12px"><div class="tblwrap"><table class="tbl"><thead><tr><th>Modell</th><th>Kennzeichen</th><th>Freigabe ab</th><th></th></tr></thead><tbody>
${F().fahrzeuge.map((v,i)=>`<tr><td>🚗 ${sicher(v.model)}</td><td class="mono">${sicher(v.plate)}</td><td>${sicher(v.zugewiesen)}</td><td class="num">${kann?`<button class="btn red sm" data-delv="${i}">Entfernen</button>`:''}</td></tr>`).join('')||'<tr><td colspan="4" class="empty">Keine Fahrzeuge.</td></tr>'}
</tbody></table></div></div>`;
const fa=$("fz-add");if(fa)fa.addEventListener("click",()=>{
modal(`<h3>Fahrzeug hinzufügen</h3><label class="flabel">Modell</label><input class="input" id="fv-model" placeholder="z. B. Sultan"><label class="flabel">Kennzeichen</label><input class="input" id="fv-plate" placeholder="z. B. GNG-04"><label class="flabel">Freigabe ab Rang</label><select class="input" id="fv-rang"><option value="alle">alle Mitglieder</option>${F().raenge.map(r=>`<option>${sicher(r.name)}+</option>`).join('')}</select><div class="modal-actions"><button class="btn ghost" id="fv-c">Abbrechen</button><button class="btn primary" id="fv-ok">Hinzufügen</button></div>`,true);
$("fv-c").addEventListener("click",closeModal);$("fv-ok").addEventListener("click",()=>{const m=$("fv-model").value.trim();if(!m){toast("Modell angeben","warn");return;}F().fahrzeuge.push({model:m,plate:$("fv-plate").value.trim()||"GNG-"+Math.floor(10+Math.random()*90),zugewiesen:$("fv-rang").value});log("Fahrzeug hinzugefügt",m);closeModal();toast(m+" hinzugefügt","good");renderSektion();});});
$("main").querySelectorAll("[data-delv]").forEach(b=>b.addEventListener("click",()=>{const v=F().fahrzeuge.splice(+b.dataset.delv,1)[0];log("Fahrzeug entfernt",v.plate);toast(v.model+" entfernt","warn");renderSektion();}));
};
/* =====================================================================
LOGBUCH
===================================================================== */
sektionen.logbuch=()=>{
$("main").innerHTML=`<div class="phead"><div><h2>Logbuch</h2><p>${F().logs.length} protokollierte Aktionen</p></div></div>
<div class="card" style="padding:6px 12px"><div class="tblwrap"><table class="tbl"><thead><tr><th>Zeit</th><th>Von</th><th>Aktion</th><th>Detail</th></tr></thead><tbody>
${F().logs.map(l=>`<tr><td class="mono" style="color:var(--muted)">${l.zeit.toLocaleTimeString('de-DE')}</td><td>${sicher(l.wer)}</td><td>${sicher(l.aktion)}</td><td style="color:var(--muted)">${sicher(l.detail)}</td></tr>`).join('')||'<tr><td colspan="4" class="empty">Noch keine Einträge.</td></tr>'}
</tbody></table></div></div>`;
};
/* =====================================================================
EINSTELLUNGEN
===================================================================== */
sektionen.einstellungen=()=>{
const kann=darf('einstellungen');
$("main").innerHTML=`<div class="phead"><div><h2>Einstellungen</h2><p>Fraktionsdaten &amp; Rekrutierung</p></div></div>
<div class="grid" style="grid-template-columns:1fr 1fr;align-items:start">
<div class="card"><div class="list-h">Fraktion</div>
<label class="flabel">Name</label><input class="input" id="s-name" value="${sicher(F().name)}" ${kann?'':'disabled'}>
<label class="flabel">Beschreibung</label><input class="input" id="s-desc" value="${sicher(F().kurz)}" ${kann?'':'disabled'}>
<label class="flabel">Farbe</label><div style="display:flex;gap:8px;flex-wrap:wrap" id="s-farbe">${["#7c5cf6","#f5b23d","#34d39a","#4aa8ff","#f0506e","#e0503f","#22c1d4","#c96fd6"].map(c=>`<div data-c="${c}" style="width:30px;height:30px;border-radius:8px;background:${c};cursor:${kann?'pointer':'default'};border:2px solid ${F().farbe===c?'#fff':'transparent'}"></div>`).join('')}</div>
${kann?'<button class="btn primary" id="s-save" style="margin-top:12px">Speichern</button>':'<div style="font-size:11px;color:var(--muted);margin-top:10px">🔒 Kein Recht „Einstellungen".</div>'}</div>
<div class="card"><div class="list-h">Typ &amp; Status</div>
<div class="kv"><span>Typ</span><span>${F().typ==='gang'?'Gang (illegal)':'Legale Fraktion'}</span></div>
<div class="kv"><span>Mitglieder</span><span>${F().mitglieder.length}</span></div>
<div class="kv"><span>Ränge</span><span>${F().raenge.length}</span></div>
<div class="kv"><span>Kasse</span><span>${euro(F().kasse)}</span></div>
${F().typ==='gang'?`<div class="kv"><span>Territorien</span><span>${F().territorien.length}</span></div>`:''}</div></div>`;
if(kann){
let farbe=F().farbe;
$("s-farbe").querySelectorAll("[data-c]").forEach(el=>el.addEventListener("click",()=>{farbe=el.dataset.c;$("s-farbe").querySelectorAll("[data-c]").forEach(x=>x.style.borderColor='transparent');el.style.borderColor='#fff';}));
$("s-save").addEventListener("click",()=>{F().name=$("s-name").value.trim()||F().name;F().kurz=$("s-desc").value.trim();F().farbe=farbe;log("Einstellungen geändert",F().name);toast("Gespeichert","good");starte();});
}
};
/* START */
starte();
</script>
</body>
</html>

424
fuel/index.html Normal file
View File

@ -0,0 +1,424 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Aether — Tankstelle</title>
<style>
:root{
--bg:#0a0806;--fg:#f3efe4;--muted:#9c9585; /* Grundfarben */
--surface:rgba(26,22,16,0.72);--surface2:rgba(38,32,22,0.9); /* Panel-Flächen */
--slot:#120f0a;--border:rgba(255,255,255,0.09); /* Eingaben/Rahmen */
--accent:#f5a742;--accent2:#f0d24a;--ink:#241603;/* Tank-Orange als Leitfarbe */
--accent-soft:rgba(245,167,66,0.15);
--violet:#7c5cf6; /* Aether-Akzent (sekundär) */
--green:#34d39a;--red:#f0506e; /* Erfolg / Fehler */
}
*{box-sizing:border-box;} html,body{margin:0;height:100%;}
body{min-height:100%;background:radial-gradient(1000px 620px at 50% -8%,rgba(245,167,66,0.1),transparent 55%),var(--bg);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);
display:flex;align-items:center;justify-content:center;padding:22px 14px;}
.app{position:relative;width:min(940px,98vw);border-radius:22px;overflow:hidden;
background:linear-gradient(160deg,rgba(24,20,14,0.96),rgba(8,7,5,0.98));border:1px solid rgba(245,167,66,0.22);
box-shadow:0 50px 100px -30px rgba(0,0,0,0.85);}
/* Kopfzeile */
.head{display:flex;align-items:center;gap:12px;padding:16px 22px;border-bottom:1px solid var(--border);}
.head .bl{width:40px;height:40px;border-radius:11px;display:grid;place-items:center;font-size:21px;
background:linear-gradient(150deg,var(--accent),#c47f1e);color:var(--ink);}
.head .bn{font-size:17px;font-weight:800;letter-spacing:-0.02em;}
.head .bn span{color:var(--accent);} .head .bn small{display:block;font-size:10.5px;color:var(--muted);font-weight:500;letter-spacing:0.04em;}
.head .money{margin-left:auto;display:flex;gap:8px;}
.head .chip{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:7px 13px;text-align:right;}
.head .chip .l{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;font-weight:800;}
.head .chip .v{font-size:15px;font-weight:800;font-variant-numeric:tabular-nums;}
/* Zweispaltiger Körper: Zapfsäulen-Display + Steuerung */
.body{display:grid;grid-template-columns:1fr 1fr;gap:0;}
.display{padding:24px;border-right:1px solid var(--border);}
.controls{padding:24px;display:flex;flex-direction:column;gap:16px;}
/* Digitale Zähler (Liter + Preis) */
.counters{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:18px;}
.counter{background:#0c0a06;border:1px solid rgba(245,167,66,0.3);border-radius:14px;padding:14px 16px;
box-shadow:inset 0 0 24px rgba(0,0,0,0.6);}
.counter .cl{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;font-weight:800;}
.counter .cv{font-size:34px;font-weight:800;font-variant-numeric:tabular-nums;letter-spacing:0.02em;
color:var(--accent2);text-shadow:0 0 14px rgba(240,210,74,0.35);line-height:1.15;}
.counter .cu{font-size:13px;color:var(--muted);font-weight:700;}
.counter.preis .cv{color:var(--accent);text-shadow:0 0 14px rgba(245,167,66,0.4);}
.ppl{text-align:center;font-size:11.5px;color:var(--muted);margin:-6px 0 16px;font-weight:600;}
/* Tankanzeige (Balken E → F) + Pumpe */
.tankwrap{display:flex;align-items:center;gap:16px;}
.nozzle{font-size:40px;transition:.15s;filter:drop-shadow(0 4px 8px rgba(0,0,0,0.5));}
.nozzle.on{animation:wackel .5s ease-in-out infinite;color:var(--accent);}
@keyframes wackel{50%{transform:translateY(-3px) rotate(-4deg);}}
.tank{flex:1;}
.tank .tl{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);font-weight:800;margin-bottom:5px;}
.tank .bar{height:26px;border-radius:9px;background:var(--slot);border:1px solid var(--border);overflow:hidden;position:relative;}
.tank .bar .fill{height:100%;width:0;border-radius:8px 0 0 8px;transition:width .12s linear;
background:linear-gradient(90deg,var(--accent),var(--accent2));}
.tank .bar .pct{position:absolute;inset:0;display:grid;place-items:center;font-size:12.5px;font-weight:800;
color:#fff;text-shadow:0 1px 3px rgba(0,0,0,0.7);font-variant-numeric:tabular-nums;}
.tank .bar .fill.warn{background:linear-gradient(90deg,var(--red),#f0806e);}
/* Steuerungs-Bausteine */
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:14px;}
.lh{font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted);font-weight:800;margin:0 0 10px;}
.veh{display:flex;align-items:center;gap:12px;}
.veh .vi{font-size:26px;}
.veh .vn{font-weight:800;font-size:15px;} .veh .vs{font-size:12px;color:var(--muted);}
.veh .plate{margin-left:auto;background:#f3efe4;color:#1a1a1a;font-weight:800;border-radius:6px;padding:5px 10px;
font-size:13px;letter-spacing:0.06em;border:2px solid #c9c4b4;}
.seg{display:flex;gap:6px;} .seg button{flex:1;border:1px solid var(--border);background:var(--slot);color:var(--fg);
border-radius:10px;padding:10px 8px;font-family:inherit;font-weight:700;font-size:12.5px;cursor:pointer;transition:.12s;}
.seg button:hover{border-color:var(--accent);} .seg button.active{background:var(--accent-soft);border-color:var(--accent);color:var(--accent);}
.seg.sorte button{flex-direction:column;gap:3px;display:flex;align-items:center;}
.seg.sorte button .p{font-size:10.5px;color:var(--muted);font-weight:600;} .seg.sorte button.active .p{color:var(--accent);}
.inrow{display:flex;align-items:center;gap:10px;margin-top:10px;}
.input{flex:1;background:var(--slot);border:1px solid var(--border);border-radius:10px;padding:11px 13px;color:var(--fg);
font-size:16px;font-family:inherit;font-weight:700;outline:none;font-variant-numeric:tabular-nums;}
.input:focus{border-color:var(--accent);} .inrow .unit{font-size:14px;color:var(--muted);font-weight:800;}
.zahlung{display:flex;gap:8px;} .zahlung button{flex:1;display:flex;align-items:center;justify-content:space-between;
border:1px solid var(--border);background:var(--slot);color:var(--fg);border-radius:10px;padding:10px 12px;
font-family:inherit;font-weight:700;font-size:13px;cursor:pointer;transition:.12s;}
.zahlung button:hover{border-color:var(--accent);} .zahlung button.active{background:var(--accent-soft);border-color:var(--accent);}
.zahlung button .zb{font-size:11px;color:var(--muted);font-weight:700;}
.btn{border:none;border-radius:12px;padding:15px;font-size:15px;font-weight:800;cursor:pointer;font-family:inherit;transition:.12s;width:100%;}
.btn.go{background:linear-gradient(150deg,var(--accent),#d88a1e);color:var(--ink);} .btn.go:hover{filter:brightness(1.06);}
.btn.stop{background:var(--red);color:#2a0910;} .btn:active{transform:scale(0.99);}
.btn:disabled{opacity:.45;cursor:not-allowed;}
.hint{font-size:12px;color:var(--muted);text-align:center;min-height:16px;}
.hint.err{color:var(--red);} .hint.ok{color:var(--green);}
/* Beleg-Overlay */
.overlay{position:absolute;inset:0;background:rgba(6,5,3,0.72);backdrop-filter:blur(4px);display:none;
align-items:center;justify-content:center;padding:20px;z-index:10;}
.overlay.on{display:flex;}
.beleg{width:320px;background:#f6f2e8;color:#2a2418;border-radius:12px;padding:22px;font-family:"SF Mono",ui-monospace,Menlo,monospace;
box-shadow:0 30px 60px -20px rgba(0,0,0,0.8);animation:auf .3s ease;}
@keyframes auf{from{opacity:0;transform:translateY(12px);}to{opacity:1;transform:none;}}
.beleg h3{margin:0 0 3px;text-align:center;font-size:15px;letter-spacing:0.1em;}
.beleg .sub{text-align:center;font-size:11px;color:#8a8270;margin-bottom:14px;}
.beleg .bz{display:flex;justify-content:space-between;font-size:12.5px;padding:4px 0;border-bottom:1px dashed #cfc7b2;}
.beleg .bz.sum{border-bottom:none;border-top:2px solid #2a2418;margin-top:6px;padding-top:8px;font-size:15px;font-weight:800;}
.beleg .dank{text-align:center;font-size:11px;color:#8a8270;margin-top:14px;}
.beleg .b-btn{margin-top:16px;width:100%;border:none;background:#2a2418;color:#f6f2e8;border-radius:9px;padding:12px;
font-family:inherit;font-weight:700;cursor:pointer;font-size:13px;}
/* Demo-Leiste */
.demo{display:flex;align-items:center;gap:8px;padding:11px 22px;border-top:1px solid var(--border);}
.demo span{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;font-weight:800;}
.demo button{border:1px solid var(--border);background:rgba(255,255,255,0.04);color:var(--fg);border-radius:8px;
padding:6px 11px;font-family:inherit;font-size:11.5px;font-weight:700;cursor:pointer;}
.demo button:hover{border-color:var(--violet);color:#fff;}
@media (max-width:820px){.body{grid-template-columns:1fr;} .display{border-right:none;border-bottom:1px solid var(--border);}}
</style>
</head>
<body>
<div class="app">
<!-- Kopfzeile mit Geld-Anzeige -->
<div class="head">
<span class="bl">⛽</span>
<div class="bn">Aether <span>Fuel</span><small>Tankstelle · Los Santos</small></div>
<div class="money">
<div class="chip"><div class="l">Bargeld</div><div class="v" id="mBar">–</div></div>
<div class="chip"><div class="l">Konto</div><div class="v" id="mKonto">–</div></div>
</div>
</div>
<div class="body">
<!-- Zapfsäulen-Display -->
<div class="display">
<div class="counters">
<div class="counter"><div class="cl">Menge</div><div class="cv" id="cLiter">0,00</div><div class="cu">Liter</div></div>
<div class="counter preis"><div class="cl">Zu zahlen</div><div class="cv" id="cPreis">0,00</div><div class="cu">Euro</div></div>
</div>
<div class="ppl" id="ppl">Grundpreis –</div>
<div class="tankwrap">
<div class="nozzle" id="nozzle">⛽</div>
<div class="tank">
<div class="tl"><span>E — leer</span><span>voll — F</span></div>
<div class="bar"><div class="fill" id="tankFill"></div><div class="pct" id="tankPct">0 %</div></div>
</div>
</div>
</div>
<!-- Steuerung -->
<div class="controls">
<div class="card">
<div class="lh">Fahrzeug</div>
<div class="veh">
<span class="vi">🚗</span>
<div><div class="vn" id="vName">–</div><div class="vs" id="vTank">–</div></div>
<span class="plate" id="vPlate">–</span>
</div>
</div>
<div>
<div class="lh">Spritsorte</div>
<div class="seg sorte" id="sorte"></div>
</div>
<div>
<div class="lh">Menge wählen</div>
<div class="seg" id="modus">
<button data-m="voll" class="active">Volltanken</button>
<button data-m="liter">Liter</button>
<button data-m="betrag">Betrag €</button>
</div>
<div class="inrow" id="inrow" style="display:none">
<input class="input" id="wert" type="number" min="1" placeholder="0">
<span class="unit" id="unit">L</span>
</div>
</div>
<div>
<div class="lh">Zahlung</div>
<div class="zahlung" id="zahlung">
<button data-z="bargeld" class="active">💵 Bargeld <span class="zb" id="zBar"></span></button>
<button data-z="konto">💳 Karte <span class="zb" id="zKonto"></span></button>
</div>
</div>
<button class="btn go" id="actionBtn">⛽ Tanken</button>
<div class="hint" id="hint">Wähle Sorte und Menge, dann tanken.</div>
</div>
</div>
<!-- Demo-Leiste -->
<div class="demo">
<span>Demo</span>
<button id="dLeer">🚗 Tank leeren (Probefahrt)</button>
<button id="dReich">💰 Geld auffüllen</button>
</div>
<!-- Beleg-Overlay -->
<div class="overlay" id="overlay"><div class="beleg" id="beleg"></div></div>
</div>
<script>
"use strict";
/* Kurzer Zugriffshelfer auf Elemente per ID */
const $=id=>document.getElementById(id);
/* Betrag als Euro (2 Nachkommastellen) formatieren */
const euro=n=>n.toLocaleString("de-DE",{minimumFractionDigits:2,maximumFractionDigits:2})+" €";
/* Litermenge formatieren */
const liter=n=>n.toLocaleString("de-DE",{minimumFractionDigits:2,maximumFractionDigits:2});
/* Ganze Euro (für Geld-Chips) */
const euroKurz=n=>Math.round(n).toLocaleString("de-DE")+" €";
/* =====================================================================
DATEN (im echten Betrieb per NUI vom Client gesetzt) — vom UI getrennt.
===================================================================== */
const spieler={bargeld:1200, konto:8600}; // Geld des Spielers
const fahrzeug={modell:"Übermacht Sentinel", kennzeichen:"LS-4821",
tankGroesse:65, fuellstand:24}; // Tank in Litern (aktuell / max)
const spritSorten=[ // Verfügbare Sorten mit Literpreis
{id:"e10", name:"Benzin E10", preis:1.72},
{id:"super", name:"Super Plus", preis:1.94},
{id:"diesel", name:"Diesel", preis:1.66},
];
const FLUSS_L_PRO_S=9; // Durchfluss der Zapfsäule (Liter pro Sekunde)
const TICK_MS=45; // Aktualisierungsintervall der Zapf-Animation
/* =====================================================================
ZUSTAND
===================================================================== */
let sorteIndex=0; // gewählte Spritsorte
let modus="voll"; // Tankmodus: voll | liter | betrag
let zahlung="bargeld"; // Zahlungsart: bargeld | konto
let phase="idle"; // idle | tanken | fertig
let pumpTimer=null; // Timer der laufenden Zapf-Animation
let aktLiter=0; // bereits gezapfte Liter dieser Sitzung
let zielLiter=0; // Zielmenge der aktuellen Zapfung
let geklemmtDurchGeld=false; // wurde die Zielmenge durchs Guthaben begrenzt?
/* Aktuell gewählte Sorte / verfügbares Guthaben je Zahlungsart */
const sorte=()=>spritSorten[sorteIndex];
const guthaben=()=>zahlung==="bargeld"?spieler.bargeld:spieler.konto;
/* =====================================================================
RENDERING
===================================================================== */
/* Geld-Chips oben und in der Zahlungsauswahl aktualisieren */
function renderGeld(){
$("mBar").textContent=euroKurz(spieler.bargeld);
$("mKonto").textContent=euroKurz(spieler.konto);
$("zBar").textContent=euroKurz(spieler.bargeld);
$("zKonto").textContent=euroKurz(spieler.konto);
}
/* Fahrzeug-Karte füllen */
function renderFahrzeug(){
$("vName").textContent=fahrzeug.modell;
$("vTank").textContent="Tank "+fahrzeug.tankGroesse+" L";
$("vPlate").textContent=fahrzeug.kennzeichen;
}
/* Spritsorten-Buttons aufbauen */
function renderSorten(){
$("sorte").innerHTML=spritSorten.map((s,i)=>
`<button data-s="${i}" class="${i===sorteIndex?'active':''}">${s.name}<span class="p">${euro(s.preis)}/L</span></button>`).join('');
$("sorte").querySelectorAll("[data-s]").forEach(b=>b.addEventListener("click",()=>{if(phase==="tanken")return;sorteIndex=+b.dataset.s;renderSorten();renderPreisInfo();aktualisiereHint();}));
}
/* Grundpreis-Zeile unter den Zählern */
function renderPreisInfo(){$("ppl").textContent="Grundpreis "+euro(sorte().preis)+" / Liter";}
/* Digitale Zähler + Tankbalken aktualisieren */
function renderDisplay(){
$("cLiter").textContent=liter(aktLiter);
$("cPreis").textContent=liter(aktLiter*sorte().preis);
const gesamt=Math.min(fahrzeug.tankGroesse,fahrzeug.fuellstand+aktLiter);
const pct=gesamt/fahrzeug.tankGroesse*100;
$("tankFill").style.width=pct+"%";
$("tankFill").classList.toggle("warn",pct<15);
$("tankPct").textContent=Math.round(pct)+" %";
}
/* =====================================================================
ZIELMENGE BERECHNEN
===================================================================== */
/* Ermittelt die Zielmenge in Litern (begrenzt durch Tank und Guthaben) */
function berechneZiel(){
const platz=fahrzeug.tankGroesse-fahrzeug.fuellstand; // freier Platz im Tank
const proLiter=sorte().preis;
let ziel;
if(modus==="voll") ziel=platz;
else if(modus==="liter") ziel=Math.min(platz,parseFloat($("wert").value)||0);
else /* betrag */ ziel=Math.min(platz,(parseFloat($("wert").value)||0)/proLiter);
// Durch verfügbares Guthaben begrenzen
const maxDurchGeld=guthaben()/proLiter;
geklemmtDurchGeld=ziel>maxDurchGeld+1e-6;
ziel=Math.min(ziel,maxDurchGeld);
return Math.max(0,ziel);
}
/* Hinweistext passend zur aktuellen Auswahl setzen */
function aktualisiereHint(){
if(phase==="tanken")return;
const platz=fahrzeug.tankGroesse-fahrzeug.fuellstand;
if(platz<0.1){setHint("Tank ist bereits voll.","err");$("actionBtn").disabled=true;return;}
const ziel=berechneZiel();
$("actionBtn").disabled=ziel<0.1;
if(ziel<0.1){setHint(modus==="voll"?"Tank ist voll.":"Bitte gültige Menge / genug Guthaben.","err");return;}
const kosten=ziel*sorte().preis;
setHint((geklemmtDurchGeld?"Durch Guthaben begrenzt: ":"")+
liter(ziel)+" L ≈ "+euro(kosten)+" ("+(zahlung==="bargeld"?"Bargeld":"Karte")+")",geklemmtDurchGeld?"":"");
}
/* Hinweiszeile setzen */
function setHint(text,typ){$("hint").textContent=text;$("hint").className="hint"+(typ?" "+typ:"");}
/* =====================================================================
ZAPFVORGANG
===================================================================== */
/* Startet die Zapfung bis zur berechneten Zielmenge */
function starteTanken(){
zielLiter=berechneZiel();
if(zielLiter<0.1){aktualisiereHint();return;}
phase="tanken"; aktLiter=0;
$("nozzle").classList.add("on");
$("actionBtn").className="btn stop"; $("actionBtn").textContent="■ Stopp";
sperreEingaben(true);
const schritt=FLUSS_L_PRO_S*TICK_MS/1000; // Liter pro Tick
pumpTimer=setInterval(()=>{
aktLiter=Math.min(zielLiter,aktLiter+schritt);
renderDisplay();
setHint("Zapfe … "+liter(aktLiter)+" L · "+euro(aktLiter*sorte().preis),"");
if(aktLiter>=zielLiter-1e-6)stoppeTanken();
},TICK_MS);
}
/* Beendet die Zapfung (regulär oder per Stopp), bucht ab und zeigt den Beleg */
function stoppeTanken(){
clearInterval(pumpTimer); pumpTimer=null;
$("nozzle").classList.remove("on");
const kosten=aktLiter*sorte().preis;
// Betrag von der gewählten Zahlungsart abbuchen
if(zahlung==="bargeld")spieler.bargeld-=kosten; else spieler.konto-=kosten;
fahrzeug.fuellstand=Math.min(fahrzeug.tankGroesse,fahrzeug.fuellstand+aktLiter);
phase="fertig";
renderGeld(); renderDisplay();
zeigeBeleg(aktLiter,kosten);
}
/* Ein-/Ausgaben während der Zapfung sperren */
function sperreEingaben(sperr){
$("sorte").querySelectorAll("button").forEach(b=>b.disabled=sperr);
$("modus").querySelectorAll("button").forEach(b=>b.disabled=sperr);
$("zahlung").querySelectorAll("button").forEach(b=>b.disabled=sperr);
$("wert").disabled=sperr;
}
/* =====================================================================
BELEG
===================================================================== */
/* Zeigt den Kassenbeleg nach der Zapfung */
function zeigeBeleg(l,kosten){
const kontoRest=zahlung==="bargeld"?spieler.bargeld:spieler.konto;
$("beleg").innerHTML=`
<h3>AETHER FUEL</h3><div class="sub">Tankstelle Los Santos · ${new Date().toLocaleTimeString("de-DE",{hour:"2-digit",minute:"2-digit"})}</div>
<div class="bz"><span>Fahrzeug</span><span>${fahrzeug.kennzeichen}</span></div>
<div class="bz"><span>Sorte</span><span>${sorte().name}</span></div>
<div class="bz"><span>Menge</span><span>${liter(l)} L</span></div>
<div class="bz"><span>Literpreis</span><span>${euro(sorte().preis)}</span></div>
<div class="bz"><span>Zahlung</span><span>${zahlung==="bargeld"?"Bargeld":"Karte"}</span></div>
<div class="bz"><span>Neuer Füllstand</span><span>${Math.round(fahrzeug.fuellstand/fahrzeug.tankGroesse*100)} %</span></div>
<div class="bz sum"><span>Gesamt</span><span>${euro(kosten)}</span></div>
<div class="bz" style="border:none;padding-top:6px"><span>Restguthaben</span><span>${euroKurz(kontoRest)}</span></div>
<div class="dank">Gute Fahrt — danke fürs Tanken!</div>
<button class="b-btn" id="belegOk">Fertig</button>`;
$("overlay").classList.add("on");
$("belegOk").addEventListener("click",belegSchliessen);
}
/* Schließt den Beleg und stellt den Ausgangszustand wieder her */
function belegSchliessen(){
$("overlay").classList.remove("on");
phase="idle"; aktLiter=0;
$("actionBtn").className="btn go"; $("actionBtn").textContent="⛽ Tanken";
sperreEingaben(false);
renderDisplay(); aktualisiereHint();
}
/* =====================================================================
STEUERUNG (Buttons / Eingaben)
===================================================================== */
/* Tankmodus umschalten (blendet das Eingabefeld passend ein) */
$("modus").querySelectorAll("[data-m]").forEach(b=>b.addEventListener("click",()=>{
if(phase==="tanken")return;
modus=b.dataset.m;
$("modus").querySelectorAll("button").forEach(x=>x.classList.toggle("active",x===b));
$("inrow").style.display=modus==="voll"?"none":"flex";
$("unit").textContent=modus==="liter"?"L":"€";
$("wert").value="";
aktualisiereHint();
}));
$("wert").addEventListener("input",aktualisiereHint);
/* Zahlungsart umschalten */
$("zahlung").querySelectorAll("[data-z]").forEach(b=>b.addEventListener("click",()=>{
if(phase==="tanken")return;
zahlung=b.dataset.z;
$("zahlung").querySelectorAll("button").forEach(x=>x.classList.toggle("active",x===b));
aktualisiereHint();
}));
/* Haupt-Button: startet die Zapfung oder stoppt sie */
$("actionBtn").addEventListener("click",()=>{ if(phase==="tanken")stoppeTanken(); else starteTanken(); });
/* Demo-Buttons */
$("dLeer").addEventListener("click",()=>{ if(phase==="tanken")return;
fahrzeug.fuellstand=Math.round(Math.random()*18+4); renderFahrzeug(); renderDisplay(); aktualisiereHint(); });
$("dReich").addEventListener("click",()=>{ spieler.bargeld+=2000; spieler.konto+=5000; renderGeld(); aktualisiereHint(); });
/* =====================================================================
START
===================================================================== */
renderGeld(); renderFahrzeug(); renderSorten(); renderPreisInfo();
renderDisplay(); aktualisiereHint();
</script>
</body>
</html>

296
immobilien/index.html Normal file
View File

@ -0,0 +1,296 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
</head>
<body>
<title>AetherImmos — Immobilien</title>
<style>
:root{
--bg:#080b0d;--fg:#e9f0ee;--muted:#7f8c8a;--surface:rgba(22,28,29,0.72);--surface2:rgba(34,42,43,0.85);
--slot:#101615;--border:rgba(255,255,255,0.09);--accent:#2fbf7a;--ink:#042113;--accent-soft:rgba(47,191,122,0.15);
--red:#f0506e;--amber:#f5b23d;--blue:#4aa8ff;--money:#34d39a;
}
*{box-sizing:border-box;} html,body{margin:0;height:100%;}
body{min-height:100%;background:radial-gradient(1100px 700px at 18% 0%,rgba(47,191,122,0.09),transparent 55%),radial-gradient(900px 700px at 90% 100%,rgba(74,168,255,0.06),transparent 55%),var(--bg);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);
display:flex;align-items:center;justify-content:center;padding:24px 16px;overflow-x:hidden;}
.app{position:relative;width:min(1220px,97vw);aspect-ratio:16/10;border-radius:22px;overflow:hidden;
background:linear-gradient(160deg,rgba(20,25,26,0.92),rgba(10,12,13,0.96));backdrop-filter:blur(20px);
border:1px solid var(--border);box-shadow:0 50px 100px -30px rgba(0,0,0,0.85);display:flex;flex-direction:column;}
.head{display:flex;align-items:center;gap:14px;padding:15px 24px;border-bottom:1px solid var(--border);}
.head .logo{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;font-size:20px;background:linear-gradient(150deg,var(--accent),#177a4a);box-shadow:0 8px 20px var(--accent-soft);}
.head h1{font-size:19px;margin:0;font-weight:800;letter-spacing:-0.02em;} .head h1 span{color:var(--accent);} .head h1 small{display:block;font-size:11px;color:var(--muted);font-weight:500;}
.switch{display:flex;gap:5px;margin-left:18px;background:var(--slot);border-radius:12px;padding:4px;}
.switch button{border:none;background:none;color:var(--muted);font-family:inherit;font-weight:700;font-size:13px;padding:8px 15px;border-radius:9px;cursor:pointer;}
.switch button.active{background:var(--accent);color:var(--ink);}
.wallet{margin-left:auto;display:flex;gap:10px;} .money-chip{background:var(--surface);border:1px solid var(--border);border-radius:11px;padding:7px 13px;text-align:right;} .money-chip .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;font-weight:800;} .money-chip .v{font-size:14px;font-weight:800;font-variant-numeric:tabular-nums;}
.body{flex:1;overflow-y:auto;padding:18px 24px;} .body::-webkit-scrollbar{width:8px;} .body::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.09);border-radius:4px;}
.phead{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;margin-bottom:14px;}
.phead h2{font-size:21px;margin:0;font-weight:800;letter-spacing:-0.02em;} .phead p{margin:2px 0 0;color:var(--muted);font-size:12.5px;}
.filters{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap;align-items:center;}
.seg{display:inline-flex;background:var(--slot);border-radius:10px;padding:3px;} .seg button{border:none;background:none;color:var(--muted);font-weight:700;font-size:12.5px;padding:7px 13px;border-radius:8px;cursor:pointer;font-family:inherit;} .seg button.active{background:var(--accent);color:var(--ink);}
.searchbar{display:flex;align-items:center;gap:9px;background:var(--slot);border:1px solid var(--border);border-radius:10px;padding:9px 13px;flex:1;min-width:180px;} .searchbar input{flex:1;background:none;border:none;color:var(--fg);font-family:inherit;font-size:13.5px;outline:none;}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;}
.card{background:var(--surface);border:1px solid var(--border);border-radius:16px;overflow:hidden;cursor:pointer;transition:transform .12s,border-color .12s;}
.card:hover{transform:translateY(-3px);border-color:var(--accent);}
.card .photo{aspect-ratio:16/10;position:relative;overflow:hidden;} .card .photo svg{width:100%;height:100%;display:block;}
.card .ptag{position:absolute;top:10px;left:10px;background:rgba(0,0,0,0.55);color:#fff;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;}
.card .price{position:absolute;bottom:10px;right:10px;background:var(--accent);color:var(--ink);font-size:15px;font-weight:800;padding:5px 11px;border-radius:9px;font-variant-numeric:tabular-nums;}
.card .cinfo{padding:13px 15px;} .card .ct{font-size:15px;font-weight:800;} .card .cl{font-size:12.5px;color:var(--muted);margin-top:1px;}
.card .cstats{display:flex;gap:14px;margin-top:9px;font-size:12px;color:var(--muted);font-weight:600;} .card .cstats b{color:var(--fg);}
/* Detail */
.detail-top{display:grid;grid-template-columns:1.4fr 1fr;gap:20px;align-items:start;}
.detail-photo{border-radius:16px;overflow:hidden;border:1px solid var(--border);} .detail-photo svg{width:100%;display:block;}
.detail-side .price-big{font-size:30px;font-weight:800;font-variant-numeric:tabular-nums;} .detail-side .price-big small{font-size:13px;color:var(--muted);font-weight:600;}
.kv{display:flex;justify-content:space-between;font-size:13px;padding:7px 0;border-bottom:1px solid var(--border);} .kv:last-child{border:none;} .kv span:first-child{color:var(--muted);}
.chips{display:flex;flex-wrap:wrap;gap:7px;margin-top:6px;} .chip{background:var(--slot);border:1px solid var(--border);border-radius:999px;padding:5px 11px;font-size:11.5px;font-weight:600;display:inline-flex;gap:6px;align-items:center;}
.chip::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--accent);}
.desc{color:#c2cdca;font-size:13.5px;line-height:1.6;margin-top:12px;}
.card2{background:var(--surface);border:1px solid var(--border);border-radius:15px;padding:16px;}
.list-h{font-size:10.5px;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);font-weight:800;margin:0 0 10px;display:flex;align-items:center;gap:8px;} .list-h .r{margin-left:auto;font-weight:600;text-transform:none;letter-spacing:0;}
.row{display:flex;align-items:center;gap:11px;padding:10px 12px;background:var(--surface);border:1px solid var(--border);border-radius:12px;margin-bottom:7px;}
.row .av{width:34px;height:34px;border-radius:50%;display:grid;place-items:center;font-weight:800;color:#fff;flex-shrink:0;} .row .grow{flex:1;min-width:0;} .row .t{font-weight:700;font-size:13.5px;} .row .m{font-size:11.5px;color:var(--muted);}
.pill{font-size:10px;font-weight:800;padding:3px 8px;border-radius:999px;} .pill.own{background:rgba(47,191,122,0.16);color:var(--accent);} .pill.key{background:rgba(245,178,61,0.16);color:var(--amber);}
.btn{border:none;border-radius:11px;padding:12px 18px;font-size:14px;font-weight:800;cursor:pointer;font-family:inherit;transition:.12s;} .btn.primary{background:var(--accent);color:var(--ink);} .btn.primary:hover{filter:brightness(1.08);} .btn.ghost{background:var(--surface2);color:var(--fg);border:1px solid var(--border);} .btn.red{background:var(--red);color:#2a0910;} .btn.sm{padding:8px 13px;font-size:12.5px;} .btn:active{transform:scale(0.98);} .btn:disabled{opacity:.4;cursor:not-allowed;}
.back{background:var(--surface2);border:1px solid var(--border);color:var(--fg);border-radius:10px;padding:8px 13px;font-family:inherit;font-weight:700;cursor:pointer;}
.input,select.input{width:100%;background:var(--slot);border:1px solid var(--border);border-radius:10px;padding:11px 12px;color:var(--fg);font-size:14px;font-family:inherit;outline:none;} .input:focus{border-color:var(--accent);}
.flabel{font-size:10.5px;color:var(--muted);font-weight:800;text-transform:uppercase;letter-spacing:0.04em;margin:12px 2px 6px;display:block;}
.overlay{position:absolute;inset:0;z-index:40;background:rgba(4,6,8,0.72);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;padding:22px;} .overlay.show{display:flex;}
.modal{width:460px;max-width:96%;max-height:90%;overflow-y:auto;background:rgba(18,22,23,0.98);border:1px solid var(--border);border-radius:18px;padding:22px;box-shadow:0 30px 70px -18px rgba(0,0,0,0.8);}
.modal h3{margin:0 0 3px;font-size:19px;} .modal .msub{color:var(--muted);font-size:12.5px;margin-bottom:14px;} .modal-actions{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:16px;}
.pay{display:flex;background:var(--slot);border-radius:10px;padding:4px;margin-top:6px;} .pay button{flex:1;border:none;background:none;color:var(--muted);font-family:inherit;font-weight:700;font-size:13px;padding:9px;border-radius:8px;cursor:pointer;} .pay button.active{background:var(--accent);color:var(--ink);}
.chk{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:9px;background:var(--slot);border:1px solid var(--border);margin-bottom:5px;cursor:pointer;} .chk .av{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;font-weight:800;color:#fff;font-size:11px;}
.toast{position:absolute;bottom:18px;left:50%;transform:translateX(-50%) translateY(16px);background:rgba(12,16,17,0.97);border:1px solid var(--border);color:var(--fg);padding:11px 20px;border-radius:12px;font-size:13.5px;font-weight:600;z-index:50;opacity:0;pointer-events:none;transition:.3s;} .toast.show{opacity:1;transform:translateX(-50%) translateY(0);} .toast.warn{border-color:var(--red);} .toast.good{border-color:var(--accent);}
.empty{text-align:center;color:var(--muted);padding:44px 10px;} .empty .big{font-size:44px;margin-bottom:8px;}
</style>
<div class="app">
<div class="head"><span class="logo">🏡</span><h1>Aether<span>Immos</span><small>Immobilien in Los Santos</small></h1>
<div class="switch" id="switch"></div>
<div class="wallet"><div class="money-chip"><div class="l">Bargeld</div><div class="v" id="w-cash"></div></div><div class="money-chip"><div class="l">Konto</div><div class="v" id="w-bank"></div></div></div>
</div>
<div class="body" id="body"></div>
<div class="overlay" id="overlay"></div><div class="toast" id="toast"></div>
</div>
<script>
"use strict";
const $=id=>document.getElementById(id);
function sicher(t){const d=document.createElement("div");d.textContent=t==null?"":t;return d.innerHTML;}
const euro=n=>n.toLocaleString("de-DE")+" €";
const initialen=n=>n.replace(/\s*\(.*\)/,"").split(" ").map(w=>w[0]).slice(0,2).join("");
let toastTimer=null; function toast(t,typ){const e=$("toast");e.textContent=t;e.className="toast show"+(typ?" "+typ:"");clearTimeout(toastTimer);toastTimer=setTimeout(()=>e.classList.remove("show"),2400);}
function modal(html){$("overlay").innerHTML=`<div class="modal">${html}</div>`;$("overlay").classList.add("show");}
function closeModal(){$("overlay").classList.remove("show");$("overlay").innerHTML="";}
/* =====================================================================
DATEN
===================================================================== */
const OWNER="Max Weber";
const RUECKKAUF=0.6; // Verkauf gibt 60% des Kaufpreises zurück
const START_SCHLUESSEL=5; // Anzahl Schlüssel beim Kauf
const NACHMACH_GEBUEHR=250; // Kosten fürs Nachmachen (Schlüsseldienst)
const spieler={bargeld:45000,konto:380000};
// Bekannte Personen (Schlüssel-Vergabe)
const kontakte=["Lena Berger","Ben Fischer","Karl Mendez","Sofia Klein"];
// Marktangebote (AetherImmos)
const marktplatz=[
{id:"m1",titel:"Apartment Vinewood",typ:"Wohnung",ort:"Vinewood Blvd",preis:85000,zimmer:2,flaeche:65,garage:1,farbe:"#2f7bbf",features:["Balkon","Möbliert","Aufzug"],beschreibung:"Helle 2-Zimmer-Wohnung mit Balkon im Herzen von Vinewood. Ideal für Einsteiger."},
{id:"m2",titel:"Stadthaus Mirror Park",typ:"Haus",ort:"Mirror Park",preis:210000,zimmer:4,flaeche:140,garage:2,farbe:"#bf6a2f",features:["Garten","Garage","Kamin"],beschreibung:"Charmantes Stadthaus mit Garten und Doppelgarage in ruhiger Lage."},
{id:"m3",titel:"Villa Vinewood Hills",typ:"Villa",ort:"Vinewood Hills",preis:1200000,zimmer:7,flaeche:420,garage:4,farbe:"#8a6a34",features:["Pool","Meerblick","Sicherheit","Garage"],beschreibung:"Exklusive Luxusvilla mit Infinity-Pool, Panoramablick und Sicherheitsanlage."},
{id:"m4",titel:"Loft Downtown",typ:"Wohnung",ort:"Downtown",preis:145000,zimmer:1,flaeche:90,garage:0,farbe:"#5a5a66",features:["Dachterrasse","Möbliert"],beschreibung:"Modernes Industrie-Loft mit offener Raumaufteilung und Dachterrasse."},
{id:"m5",titel:"Gewerbe Legion Square",typ:"Gewerbe",ort:"Legion Square",preis:320000,zimmer:0,flaeche:200,garage:1,farbe:"#2f9e73",features:["Ladenfläche","Lager","Vitrine"],beschreibung:"Attraktive Gewerbefläche in Top-Lauflage – perfekt für Einzelhandel."},
{id:"m6",titel:"Bungalow Sandy Shores",typ:"Haus",ort:"Sandy Shores",preis:68000,zimmer:3,flaeche:110,garage:1,farbe:"#c79a5a",features:["Garten","Werkstatt"],beschreibung:"Gemütlicher Bungalow mit Werkstatt – viel Platz fürs Geld."},
{id:"m7",titel:"Penthouse Del Perro",typ:"Wohnung",ort:"Del Perro",preis:890000,zimmer:3,flaeche:210,garage:2,farbe:"#7c5cf6",features:["Pool","Meerblick","Aufzug","Sicherheit"],beschreibung:"Spektakuläres Penthouse mit privatem Pool und atemberaubendem Meerblick."}
];
// Eigene Immobilien (mit Schlüssel-Zugang; Schlüssel = Item im Inventar)
const meine=[
{id:"o1",titel:"Apartment 4B",typ:"Wohnung",ort:"Vespucci Blvd",kaufpreis:95000,zimmer:2,flaeche:70,garage:1,farbe:"#2f7bbf",features:["Balkon","Garage","Möbliert"],beschreibung:"Deine erste eigene Wohnung in Vespucci.",schluessel:5,gekauft:"20.05.2026"}
];
let ansicht="markt"; // "markt" | "meine"
let filterTyp="Alle";
let suche="";
let marktDetail=null; // geöffnetes Angebot
let meineDetail=null; // geöffnete eigene Immobilie
/* =====================================================================
IMMOBILIEN-ILLUSTRATION (stilisiertes "Foto" je Typ)
===================================================================== */
function immoSvg(typ,farbe){
const dach=schattiere(farbe,0.62), wand=farbe, wandD=schattiere(farbe,0.82), glas="#bfe0f0", glasD="#8fbcd6";
const himmel=`<rect width="300" height="188" fill="url(#sky)"/><circle cx="252" cy="40" r="20" fill="#ffe3a0" opacity="0.8"/><rect y="150" width="300" height="38" fill="#20302a"/>`;
const fenster=(x,y,w,h)=>`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="2" fill="${glas}"/><rect x="${x}" y="${y}" width="${w}" height="${h}" rx="2" fill="none" stroke="${glasD}" stroke-width="1"/><line x1="${x+w/2}" y1="${y}" x2="${x+w/2}" y2="${y+h}" stroke="${glasD}" stroke-width="1"/>`;
let g="";
if(typ==="Villa"){
g=`<rect x="34" y="88" width="232" height="64" fill="${wand}"/><rect x="34" y="82" width="232" height="8" fill="${dach}"/>
<rect x="44" y="100" width="46" height="44" fill="${wandD}"/>${fenster(100,100,40,30)}${fenster(150,100,40,30)}${fenster(200,100,40,30)}
<rect x="120" y="120" width="24" height="32" fill="${dach}"/><ellipse cx="70" cy="150" rx="34" ry="7" fill="#2a7bbf" opacity="0.55"/>`;
} else if(typ==="Haus"){
g=`<path d="M60 98 L150 56 L240 98 Z" fill="${dach}"/><rect x="78" y="98" width="144" height="54" fill="${wand}"/>
<rect x="132" y="118" width="26" height="34" fill="${dach}"/>${fenster(92,110,30,24)}${fenster(178,110,30,24)}<rect x="196" y="60" width="12" height="24" fill="${dach}"/>`;
} else if(typ==="Gewerbe"){
g=`<rect x="40" y="86" width="220" height="66" fill="${wand}"/><rect x="40" y="80" width="220" height="10" fill="${dach}"/>
<rect x="50" y="120" width="200" height="32" fill="${glasD}"/>${fenster(56,124,40,24)}${fenster(102,124,40,24)}${fenster(148,124,40,24)}${fenster(194,124,40,24)}
<path d="M46 108 h208 l-10 12 h-188 Z" fill="${schattiere(farbe,1.1)}"/><rect x="120" y="90" width="60" height="14" rx="3" fill="${dach}"/>`;
} else { // Wohnung / Penthouse
g=`<rect x="70" y="46" width="160" height="106" fill="${wand}"/><rect x="70" y="42" width="160" height="8" fill="${dach}"/>`;
for(let r=0;r<4;r++)for(let c=0;c<4;c++)g+=fenster(84+c*38,56+r*24,26,16);
g+=`<rect x="132" y="130" width="30" height="22" fill="${dach}"/>`;
}
return `<svg viewBox="0 0 300 188"><defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#25506b"/><stop offset="100%" stop-color="#3a7a8a"/></linearGradient></defs>${himmel}${g}</svg>`;
}
function schattiere(hex,f){const n=parseInt(hex.slice(1),16);let r=(n>>16)&255,g=(n>>8)&255,b=n&255;r=Math.max(0,Math.min(255,Math.round(r*f)));g=Math.max(0,Math.min(255,Math.round(g*f)));b=Math.max(0,Math.min(255,Math.round(b*f)));return "#"+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1);}
/* =====================================================================
SHELL
===================================================================== */
function renderSwitch(){
$("switch").innerHTML=`<button data-a="markt" class="${ansicht==='markt'?'active':''}">Markt</button><button data-a="meine" class="${ansicht==='meine'?'active':''}">Meine Immobilien${meine.length?' ('+meine.length+')':''}</button>`;
$("switch").querySelectorAll("[data-a]").forEach(b=>b.addEventListener("click",()=>{ansicht=b.dataset.a;marktDetail=null;meineDetail=null;render();}));
}
function aktualisiereGeld(){$("w-cash").textContent=euro(spieler.bargeld);$("w-bank").textContent=euro(spieler.konto);}
function render(){renderSwitch();aktualisiereGeld();(ansicht==="markt"?renderMarkt:renderMeine)();}
/* =====================================================================
MARKT (AetherImmos)
===================================================================== */
function renderMarkt(){
if(marktDetail){renderMarktDetail();return;}
const typen=["Alle","Wohnung","Haus","Villa","Gewerbe"];
const liste=marktplatz.filter(m=>(filterTyp==="Alle"||m.typ===filterTyp)&&(!suche||(m.titel+" "+m.ort).toLowerCase().includes(suche)));
$("body").innerHTML=`<div class="phead"><div><h2>Immobilienmarkt</h2><p>${marktplatz.length} Angebote in Los Santos</p></div></div>
<div class="filters"><div class="seg" id="f-typ">${typen.map(t=>`<button data-t="${t}" class="${t===filterTyp?'active':''}">${t}</button>`).join('')}</div>
<div class="searchbar"><span>🔎</span><input id="f-such" placeholder="Ort oder Titel suchen…" value="${sicher(suche)}"></div></div>
<div class="grid" id="markt-grid">${liste.map(marktCard).join('')||'<div class="empty" style="grid-column:1/-1"><div class="big">🏙️</div>Keine passenden Angebote.</div>'}</div>`;
$("f-typ").querySelectorAll("[data-t]").forEach(b=>b.addEventListener("click",()=>{filterTyp=b.dataset.t;renderMarkt();}));
const s=$("f-such");s.addEventListener("input",()=>{suche=s.value.trim().toLowerCase();const g=$("markt-grid");const l=marktplatz.filter(m=>(filterTyp==="Alle"||m.typ===filterTyp)&&(!suche||(m.titel+" "+m.ort).toLowerCase().includes(suche)));g.innerHTML=l.map(marktCard).join('')||'<div class="empty" style="grid-column:1/-1">Keine Treffer.</div>';bindMarktCards();});
bindMarktCards();
}
function marktCard(m){return `<div class="card" data-m="${m.id}"><div class="photo">${immoSvg(m.typ,m.farbe)}<span class="ptag">${m.typ}</span><span class="price">${euro(m.preis)}</span></div>
<div class="cinfo"><div class="ct">${sicher(m.titel)}</div><div class="cl">📍 ${sicher(m.ort)}</div>
<div class="cstats">${m.zimmer?`<span><b>${m.zimmer}</b> Zi.</span>`:''}<span><b>${m.flaeche}</b> m²</span><span><b>${m.garage}</b> 🚗</span></div></div></div>`;}
function bindMarktCards(){$("body").querySelectorAll("[data-m]").forEach(c=>c.addEventListener("click",()=>{marktDetail=c.dataset.m;renderMarktDetail();}));}
function renderMarktDetail(){
const m=marktplatz.find(x=>x.id===marktDetail);
$("body").innerHTML=`<div class="phead"><div style="display:flex;align-items:center;gap:12px"><button class="back" id="md-back">‹ Markt</button><h2 style="margin:0">${sicher(m.titel)}</h2></div></div>
<div class="detail-top"><div class="detail-photo">${immoSvg(m.typ,m.farbe)}</div>
<div class="detail-side"><div class="price-big">${euro(m.preis)} <small>Kaufpreis</small></div>
<div style="margin:14px 0">
<div class="kv"><span>Typ</span><span>${m.typ}</span></div><div class="kv"><span>Lage</span><span>${sicher(m.ort)}</span></div>
<div class="kv"><span>Zimmer</span><span>${m.zimmer||'—'}</span></div><div class="kv"><span>Wohnfläche</span><span>${m.flaeche} m²</span></div>
<div class="kv"><span>Garagenplätze</span><span>${m.garage}</span></div></div>
<button class="btn primary" id="md-buy" style="width:100%">Kaufen · ${euro(m.preis)}</button></div></div>
<div class="card2" style="margin-top:16px"><div class="list-h">Ausstattung</div><div class="chips">${m.features.map(f=>`<span class="chip">${sicher(f)}</span>`).join('')}</div>
<div class="desc">${sicher(m.beschreibung)}</div></div>`;
$("md-back").addEventListener("click",()=>{marktDetail=null;renderMarkt();});
$("md-buy").addEventListener("click",()=>kaufMenu(m));
}
/* =====================================================================
KAUFMENÜ (separat)
===================================================================== */
let kaufZahlung="konto";
function kaufMenu(m){
kaufZahlung="konto";
const notar=Math.round(m.preis*0.02);
modal(`<h3>Kaufmenü</h3><div class="msub">${sicher(m.titel)} · ${sicher(m.ort)}</div>
<div style="border-radius:12px;overflow:hidden;border:1px solid var(--border);margin-bottom:12px">${immoSvg(m.typ,m.farbe)}</div>
<div class="kv"><span>Kaufpreis</span><span>${euro(m.preis)}</span></div>
<div class="kv"><span>Notar- &amp; Bearbeitungsgebühr (2%)</span><span>${euro(notar)}</span></div>
<div class="kv" style="font-weight:800"><span>Gesamt</span><span>${euro(m.preis+notar)}</span></div>
<div style="font-size:11.5px;color:var(--muted);margin-top:8px">Beim Kauf erhältst du <b>${START_SCHLUESSEL} Hausschlüssel (Items)</b>, die du nachmachen und weitergeben kannst.</div>
<label class="flabel">Zahlung</label><div class="pay" id="k-pay"><button data-p="konto" class="active">💳 Konto (${euro(spieler.konto)})</button><button data-p="bargeld">💵 Bargeld (${euro(spieler.bargeld)})</button></div>
<div class="modal-actions"><button class="btn ghost" id="k-cancel">Abbrechen</button><button class="btn primary" id="k-ok">Kauf bestätigen</button></div>`);
const gesamt=m.preis+notar;
$("k-pay").querySelectorAll("[data-p]").forEach(b=>b.addEventListener("click",()=>{kaufZahlung=b.dataset.p;$("k-pay").querySelectorAll("[data-p]").forEach(x=>x.classList.remove("active"));b.classList.add("active");}));
$("k-cancel").addEventListener("click",closeModal);
$("k-ok").addEventListener("click",()=>{
const guthaben=kaufZahlung==="konto"?spieler.konto:spieler.bargeld;
if(gesamt>guthaben){toast(kaufZahlung==="konto"?"Konto nicht gedeckt":"Nicht genug Bargeld","warn");return;}
if(kaufZahlung==="konto")spieler.konto-=gesamt;else spieler.bargeld-=gesamt;
// Angebot vom Markt entfernen, in Besitz überführen, Schlüssel-Item vergeben
marktplatz.splice(marktplatz.findIndex(x=>x.id===m.id),1);
meine.push({id:"o"+Date.now(),titel:m.titel,typ:m.typ,ort:m.ort,kaufpreis:m.preis,zimmer:m.zimmer,flaeche:m.flaeche,garage:m.garage,farbe:m.farbe,features:m.features,beschreibung:m.beschreibung,schluessel:START_SCHLUESSEL,gekauft:new Date().toLocaleDateString('de-DE')});
closeModal();toast("🏡 "+m.titel+" gekauft! "+START_SCHLUESSEL+" Schlüssel erhalten.","good");
ansicht="meine";marktDetail=null;render();
});
}
/* =====================================================================
MEINE IMMOBILIEN (Verwaltung, Zugang/Schlüssel, Verkauf)
===================================================================== */
function renderMeine(){
if(meineDetail){renderMeineDetail();return;}
$("body").innerHTML=`<div class="phead"><div><h2>Meine Immobilien</h2><p>${meine.length} in deinem Besitz</p></div></div>
${meine.length?`<div class="grid">${meine.map(o=>`<div class="card" data-o="${o.id}"><div class="photo">${immoSvg(o.typ,o.farbe)}<span class="ptag">${o.typ}</span></div>
<div class="cinfo"><div class="ct">${sicher(o.titel)}</div><div class="cl">📍 ${sicher(o.ort)} · ${o.schluessel} Schlüssel</div>
<div class="cstats">${o.zimmer?`<span><b>${o.zimmer}</b> Zi.</span>`:''}<span><b>${o.flaeche}</b> m²</span><span><b>${o.garage}</b> 🚗</span></div></div></div>`).join('')}</div>`
:'<div class="empty"><div class="big">🏠</div>Du besitzt noch keine Immobilie.<div style="margin-top:12px"><button class="btn primary" id="zum-markt">Zum Markt</button></div></div>'}`;
$("body").querySelectorAll("[data-o]").forEach(c=>c.addEventListener("click",()=>{meineDetail=c.dataset.o;renderMeineDetail();}));
const zm=$("zum-markt");if(zm)zm.addEventListener("click",()=>{ansicht="markt";render();});
}
function renderMeineDetail(){
const o=meine.find(x=>x.id===meineDetail);
const verkaufswert=Math.round(o.kaufpreis*RUECKKAUF);
$("body").innerHTML=`<div class="phead"><div style="display:flex;align-items:center;gap:12px"><button class="back" id="o-back">‹ Meine Immobilien</button><h2 style="margin:0">${sicher(o.titel)}</h2><span class="pill own">Eigentum</span></div>
<button class="btn primary" id="o-enter">Betreten</button></div>
<div class="detail-top"><div class="detail-photo">${immoSvg(o.typ,o.farbe)}</div>
<div class="detail-side">
<div class="kv"><span>Typ</span><span>${o.typ} · ${sicher(o.ort)}</span></div>
<div class="kv"><span>Wohnfläche</span><span>${o.flaeche} m² · ${o.zimmer||'—'} Zi.</span></div>
<div class="kv"><span>Gekauft am</span><span>${o.gekauft} für ${euro(o.kaufpreis)}</span></div>
<div class="kv"><span>Verkaufswert (${Math.round(RUECKKAUF*100)}%)</span><span style="color:var(--money);font-weight:800">${euro(verkaufswert)}</span></div>
<div class="chips" style="margin-top:12px">${o.features.map(f=>`<span class="chip">${sicher(f)}</span>`).join('')}</div>
<button class="btn red" id="o-sell" style="width:100%;margin-top:16px">Verkaufen · ${euro(verkaufswert)} zurück</button></div></div>
<div class="card2" style="margin-top:16px"><div class="list-h">Schlüssel (Items) <span class="r">Du hältst: <b style="color:var(--amber)">${o.schluessel}</b></span></div>
<div class="row" style="margin:0"><span class="av" style="background:#f5b23d;color:#3a2600">🔑</span>
<div class="grow"><div class="t">Hausschlüssel „${sicher(o.titel)}“</div><div class="m">Kann nachgemacht und als Item weitergegeben werden</div></div></div>
<div style="display:flex;gap:9px;margin-top:10px"><button class="btn primary sm" id="o-copy">+ Nachmachen · ${euro(NACHMACH_GEBUEHR)}</button></div>
<div style="font-size:11.5px;color:var(--muted);margin-top:8px">Beim Kauf erhältst du ${START_SCHLUESSEL} Schlüssel. „Nachmachen" erstellt beim Schlüsseldienst eine Kopie. Weitergeben an andere geschieht über das Inventar (Schlüssel = Item).</div></div>`;
$("o-back").addEventListener("click",()=>{meineDetail=null;renderMeine();});
$("o-enter").addEventListener("click",()=>toast("🚪 "+o.titel+" betreten"));
$("o-sell").addEventListener("click",()=>verkaufFrage(o));
$("o-copy").addEventListener("click",()=>{
if(spieler.konto>=NACHMACH_GEBUEHR)spieler.konto-=NACHMACH_GEBUEHR;
else if(spieler.bargeld>=NACHMACH_GEBUEHR)spieler.bargeld-=NACHMACH_GEBUEHR;
else {toast("Nicht genug Geld für den Schlüsseldienst","warn");return;}
o.schluessel++;toast("🔑 Schlüssel nachgemacht","good");aktualisiereGeld();renderMeineDetail();});
}
function verkaufFrage(o){
const wert=Math.round(o.kaufpreis*RUECKKAUF);
modal(`<h3>Immobilie verkaufen</h3><div class="msub">${sicher(o.titel)}</div>
<div class="kv"><span>Ursprünglicher Kaufpreis</span><span>${euro(o.kaufpreis)}</span></div>
<div class="kv"><span>Rückkaufquote</span><span>${Math.round(RUECKKAUF*100)} %</span></div>
<div class="kv" style="font-weight:800"><span>Du erhältst</span><span style="color:var(--money)">${euro(wert)}</span></div>
<div style="font-size:11.5px;color:var(--muted);margin-top:8px">Der Verkauf erstattet nur einen Teil des Kaufpreises. Alle Schlüssel werden ungültig.</div>
<label class="flabel">Auszahlung auf</label><div class="pay" id="v-pay"><button data-p="konto" class="active">💳 Konto</button><button data-p="bargeld">💵 Bargeld</button></div>
<div class="modal-actions"><button class="btn ghost" id="v-cancel">Abbrechen</button><button class="btn red" id="v-ok">Verkaufen</button></div>`);
let ziel="konto";
$("v-pay").querySelectorAll("[data-p]").forEach(b=>b.addEventListener("click",()=>{ziel=b.dataset.p;$("v-pay").querySelectorAll("[data-p]").forEach(x=>x.classList.remove("active"));b.classList.add("active");}));
$("v-cancel").addEventListener("click",closeModal);
$("v-ok").addEventListener("click",()=>{
if(ziel==="konto")spieler.konto+=wert;else spieler.bargeld+=wert;
meine.splice(meine.findIndex(x=>x.id===o.id),1);
closeModal();toast("Verkauft für "+euro(wert)+" · Schlüssel ungültig","good");
meineDetail=null;render();
});
}
$("overlay").addEventListener("click",e=>{if(e.target===$("overlay"))closeModal();});
render();
</script>
</body>
</html>

404
minigames/index.html Normal file
View File

@ -0,0 +1,404 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Aether — Minispiele</title>
<style>
:root{
--bg:#08070d;--fg:#f0eef7;--muted:#918bad; /* Grundfarben */
--surface:rgba(22,19,36,0.72);--surface2:rgba(34,29,54,0.9); /* Panel-Flächen */
--slot:#100e1a;--border:rgba(255,255,255,0.09); /* Eingaben/Rahmen */
--accent:#7c5cf6;--accent2:#5b8cff;--ink:#160c2e;/* Aether-Akzent */
--accent-soft:rgba(124,92,246,0.16);
--green:#34d39a;--red:#f0506e;--amber:#f5a742; /* Zustände */
}
*{box-sizing:border-box;} html,body{margin:0;height:100%;}
body{min-height:100%;background:radial-gradient(1000px 640px at 50% -8%,rgba(124,92,246,0.12),transparent 55%),var(--bg);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);
display:flex;align-items:center;justify-content:center;padding:22px 14px;}
.app{position:relative;width:min(1000px,98vw);aspect-ratio:16/10;border-radius:20px;overflow:hidden;
background:linear-gradient(160deg,rgba(20,17,32,0.95),rgba(8,7,12,0.98));border:1px solid rgba(124,92,246,0.22);
box-shadow:0 50px 100px -30px rgba(0,0,0,0.85);display:grid;grid-template-columns:210px 1fr;grid-template-rows:auto 1fr;
grid-template-areas:"brand top" "nav main";}
.brand{grid-area:brand;padding:14px 16px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
.brand .bl{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;font-size:19px;background:linear-gradient(150deg,var(--accent),var(--accent2));color:#fff;}
.brand .bn{font-size:16px;font-weight:800;letter-spacing:-0.02em;} .brand .bn span{color:var(--accent);} .brand .bn small{display:block;font-size:10px;color:var(--muted);font-weight:500;}
.top{grid-area:top;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;padding:0 20px;font-size:12.5px;color:var(--muted);}
.top b{color:var(--fg);}
.nav{grid-area:nav;border-right:1px solid var(--border);padding:12px 9px;display:flex;flex-direction:column;gap:2px;}
.nav button{display:flex;align-items:center;gap:10px;padding:10px 11px;border-radius:9px;border:none;background:none;color:var(--muted);font-family:inherit;font-size:13.5px;font-weight:700;cursor:pointer;text-align:left;transition:.12s;}
.nav button .ni{font-size:15px;width:18px;text-align:center;} .nav button:hover{background:var(--surface);color:var(--fg);} .nav button.active{background:var(--accent-soft);color:var(--accent);}
.main{grid-area:main;overflow-y:auto;padding:22px 26px;display:flex;flex-direction:column;}
.phead{margin-bottom:16px;} .phead h2{font-size:22px;margin:0;font-weight:800;letter-spacing:-0.02em;} .phead p{margin:3px 0 0;color:var(--muted);font-size:13px;}
.stage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;text-align:center;}
.btn{border:none;border-radius:11px;padding:12px 20px;font-size:14px;font-weight:800;cursor:pointer;font-family:inherit;transition:.12s;}
.btn.primary{background:var(--accent);color:#fff;} .btn.primary:hover{filter:brightness(1.1);}
.btn.ghost{background:var(--surface2);color:var(--fg);border:1px solid var(--border);}
.btn.green{background:var(--green);color:#052018;} .btn:active{transform:scale(0.98);}
.kbd{display:inline-block;background:rgba(255,255,255,0.1);border:1px solid var(--border);border-bottom-width:2px;border-radius:6px;padding:1px 8px;font-weight:800;font-size:12px;}
/* Übersicht */
.lobbygrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;width:100%;}
.gamecard{border-radius:16px;padding:18px;cursor:pointer;border:1px solid var(--border);background:var(--surface);transition:.12s;text-align:left;}
.gamecard:hover{transform:translateY(-3px);border-color:var(--accent);}
.gamecard .gi{font-size:32px;} .gamecard .gn{font-size:16px;font-weight:800;margin-top:8px;} .gamecard .gd{font-size:12px;color:var(--muted);margin-top:3px;line-height:1.4;}
.roundinfo{font-size:13px;color:var(--muted);font-weight:700;} .roundinfo b{color:var(--accent);}
/* Skill-Check: rotierender Zeiger */
.scwrap{position:relative;width:230px;height:230px;}
.scwrap svg{width:100%;height:100%;}
.sc-track{stroke:rgba(255,255,255,0.1);}
.sc-zone{stroke:var(--green);transition:stroke .2s;}
.sc-pointer{stroke:var(--accent);stroke-width:5;stroke-linecap:round;}
.sc-hub{position:absolute;inset:0;display:grid;place-items:center;font-size:15px;font-weight:800;pointer-events:none;}
/* Lockpick: Timing-Bar + Pins */
.pins{display:flex;gap:8px;} .pins i{width:26px;height:8px;border-radius:99px;background:var(--slot);border:1px solid var(--border);}
.pins i.set{background:var(--green);border-color:transparent;} .pins i.fail{background:var(--red);}
.lives{font-size:13px;color:var(--muted);font-weight:700;} .lives b{color:var(--red);}
.lpbar{position:relative;width:min(420px,80vw);height:52px;border-radius:12px;background:var(--slot);border:1px solid var(--border);overflow:hidden;}
.lp-zone{position:absolute;top:0;bottom:0;background:linear-gradient(180deg,rgba(52,211,153,0.4),rgba(52,211,153,0.18));border-left:2px solid var(--green);border-right:2px solid var(--green);}
.lp-marker{position:absolute;top:-3px;bottom:-3px;width:5px;background:var(--accent);border-radius:3px;box-shadow:0 0 12px var(--accent);}
/* Memory: 4 Pads */
.pads{display:grid;grid-template-columns:1fr 1fr;gap:12px;width:270px;height:270px;}
.pad{border-radius:16px;border:2px solid rgba(255,255,255,0.08);cursor:pointer;transition:.1s;opacity:0.5;}
.pad.lit{opacity:1;transform:scale(0.96);box-shadow:0 0 26px currentColor;}
.pad[data-p="0"]{background:#7c5cf6;color:#7c5cf6;} .pad[data-p="1"]{background:#5b8cff;color:#5b8cff;}
.pad[data-p="2"]{background:#34d39a;color:#34d39a;} .pad[data-p="3"]{background:#f5a742;color:#f5a742;}
.pads.locked .pad{cursor:not-allowed;}
/* Keypad-Crack */
.kpwrap{display:flex;gap:26px;align-items:flex-start;flex-wrap:wrap;justify-content:center;}
.kpdisplay{display:flex;gap:8px;} .kpdisplay .d{width:46px;height:58px;border-radius:10px;background:var(--slot);border:1px solid var(--border);
display:grid;place-items:center;font-size:26px;font-weight:800;font-variant-numeric:tabular-nums;}
.kpdisplay .d.fill{border-color:var(--accent);}
.keypad{display:grid;grid-template-columns:repeat(3,58px);gap:8px;}
.keypad button{height:52px;border-radius:10px;border:1px solid var(--border);background:var(--surface2);color:var(--fg);font-family:inherit;font-size:19px;font-weight:800;cursor:pointer;transition:.1s;}
.keypad button:hover{border-color:var(--accent);} .keypad button.wide{grid-column:span 1;font-size:15px;}
.kphist{min-width:180px;max-height:260px;overflow-y:auto;display:flex;flex-direction:column;gap:6px;}
.kphist .row{background:var(--surface);border:1px solid var(--border);border-radius:9px;padding:7px 10px;}
/* Jede Ziffer bekommt ihren Marker direkt darunter (positionsbezogen) */
.kphist .zeile{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;text-align:center;}
.kphist .zeile .z{font-size:17px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.1;}
.kphist .zeile .m{font-size:12px;margin-top:3px;line-height:1;}
.kpmeta{display:flex;gap:16px;font-size:13px;color:var(--muted);font-weight:700;} .kpmeta b{color:var(--fg);}
.kpmeta .t.low b{color:var(--red);}
/* Ergebnis-Banner */
.result{display:flex;flex-direction:column;align-items:center;gap:14px;animation:auf .3s ease;}
@keyframes auf{from{opacity:0;transform:translateY(12px);}to{opacity:1;transform:none;}}
.result .ri{font-size:60px;} .result h3{margin:0;font-size:26px;font-weight:900;}
.result.win h3{color:var(--green);} .result.lose h3{color:var(--red);}
.result p{margin:0;color:var(--muted);font-size:13.5px;} .result .row{display:flex;gap:10px;}
</style>
</head>
<body>
<div class="app">
<div class="brand"><span class="bl">🕹️</span><div class="bn">Aether <span>Minigames</span><small>Skill-Checks</small></div></div>
<div class="top" id="top">Sammlung von Minispielen · in FiveM per NUI ausgelöst, geben <b>Erfolg / Fehlschlag</b> zurück.</div>
<div class="nav" id="nav"></div>
<div class="main" id="main"></div>
</div>
<script>
"use strict";
/* Kurzer Zugriffshelfer auf Elemente per ID */
const $=id=>document.getElementById(id);
/* Ganzzahl-Zufall a..b inklusive */
const rnd=(a,b)=>a+Math.floor(Math.random()*(b-a+1));
/* =====================================================================
TIMER-VERWALTUNG (damit beim Wechsel nichts weiterläuft)
===================================================================== */
let _intervals=[], _timeouts=[];
let letzteAktion=0; // Zeitstempel der letzten Aktion (verhindert Doppelauslösung)
/* Intervall registrieren */
function I(fn,ms){const id=setInterval(fn,ms);_intervals.push(id);return id;}
/* Timeout registrieren */
function T(fn,ms){const id=setTimeout(fn,ms);_timeouts.push(id);return id;}
/* Alle laufenden Timer stoppen */
function stopAlle(){_intervals.forEach(clearInterval);_timeouts.forEach(clearTimeout);_intervals=[];_timeouts=[];}
/* =====================================================================
SHELL / NAVIGATION
===================================================================== */
const spiele=[
{id:"skillcheck", icon:"🎯", label:"Skill-Check", desc:"Stoppe den Zeiger in der grünen Zone — mehrere Runden, wird schneller."},
{id:"lockpick", icon:"🔓", label:"Lockpick", desc:"Setze jeden Pin, indem du den Marker in der Zone triffst. 3 Versuche."},
{id:"memory", icon:"🧠", label:"Memory", desc:"Merke dir die Sequenz der Felder und wiederhole sie fehlerfrei."},
{id:"keypad", icon:"🔢", label:"Code knacken", desc:"Errate den 4-stelligen Code aus den Hinweisen — gegen die Zeit."},
];
const nav=[{id:"lobby",icon:"🏠",label:"Übersicht"},...spiele.map(s=>({id:s.id,icon:s.icon,label:s.label}))];
let sektion="lobby";
/* Navigationsleiste rendern */
function renderNav(){
$("nav").innerHTML=nav.map(n=>`<button data-s="${n.id}" class="${n.id===sektion?'active':''}"><span class="ni">${n.icon}</span>${n.label}</button>`).join('');
$("nav").querySelectorAll("[data-s]").forEach(b=>b.addEventListener("click",()=>wechsle(b.dataset.s)));
}
/* Zu einer Sektion wechseln (stoppt vorher alle Timer) */
function wechsle(id){stopAlle();sektion=id;renderNav();(sektionen[id]||sektionen.lobby)();}
const sektionen={};
/* Gemeinsames Ergebnis-Banner (Erfolg/Fehlschlag) mit Aktionen */
function spielEnde(gewonnen,text){
stopAlle();
const spiel=spiele.find(s=>s.id===sektion);
$("main").innerHTML=`<div class="phead"><h2>${spiel.label}</h2></div>
<div class="stage"><div class="result ${gewonnen?'win':'lose'}">
<div class="ri">${gewonnen?'✅':'❌'}</div>
<h3>${gewonnen?'Erfolg!':'Fehlgeschlagen'}</h3>
<p>${text}</p>
<div class="row"><button class="btn primary" id="reNochmal">↻ Nochmal</button>
<button class="btn ghost" id="reBack">Zur Übersicht</button></div>
</div></div>`;
$("reNochmal").addEventListener("click",()=>wechsle(sektion));
$("reBack").addEventListener("click",()=>wechsle("lobby"));
}
/* =====================================================================
ÜBERSICHT
===================================================================== */
sektionen.lobby=()=>{
$("main").innerHTML=`<div class="phead"><h2>Minispiele</h2><p>Wähle einen Skill-Check zum Ausprobieren.</p></div>
<div class="lobbygrid">${spiele.map(s=>
`<div class="gamecard" data-g="${s.id}"><div class="gi">${s.icon}</div><div class="gn">${s.label}</div><div class="gd">${s.desc}</div></div>`).join('')}</div>`;
$("main").querySelectorAll("[data-g]").forEach(c=>c.addEventListener("click",()=>wechsle(c.dataset.g)));
};
/* =====================================================================
1) SKILL-CHECK (rotierender Zeiger in die grüne Zone stoppen)
===================================================================== */
const SC_RUNDEN=3; // Anzahl zu bestehender Runden
const SC_UMFANG=2*Math.PI*92; // Umfang des Rings (Radius 92)
const sc={runde:1, winkel:0, spd:3, zoneStart:0, zoneWinkel:60, aktiv:false};
sektionen.skillcheck=()=>{
Object.assign(sc,{runde:1,winkel:0,spd:3,zoneWinkel:60,aktiv:true});
$("main").innerHTML=`<div class="phead"><h2>Skill-Check</h2><p>Drücke <span class="kbd">Leertaste</span> oder klicke, wenn der Zeiger in der grünen Zone steht.</p></div>
<div class="stage">
<div class="roundinfo">Runde <b id="sc-runde">1</b> / ${SC_RUNDEN}</div>
<div class="scwrap" id="sc-click">
<svg viewBox="0 0 230 230">
<circle class="sc-track" cx="115" cy="115" r="92" fill="none" stroke-width="14"></circle>
<circle class="sc-zone" id="sc-zone" cx="115" cy="115" r="92" fill="none" stroke-width="14" stroke-linecap="butt"></circle>
<line class="sc-pointer" id="sc-pointer" x1="115" y1="115" x2="115" y2="18"></line>
</svg>
<div class="sc-hub">STOP</div>
</div>
<button class="btn primary" id="sc-btn">Stoppen</button>
</div>`;
scNeueRunde();
I(()=>{ // Zeiger drehen
sc.winkel=(sc.winkel+sc.spd)%360;
$("sc-pointer").setAttribute("transform",`rotate(${sc.winkel} 115 115)`);
},16);
$("sc-btn").addEventListener("click",scAktion);
$("sc-click").addEventListener("click",scAktion);
};
/* Neue Runde vorbereiten: Zone zufällig setzen, Tempo/Größe je Runde */
function scNeueRunde(){
sc.zoneWinkel=[60,46,36][sc.runde-1]||34; // Zone schrumpft je Runde
sc.spd=[3,3.9,4.8][sc.runde-1]||5; // Tempo steigt je Runde
sc.zoneStart=rnd(0,359);
const arc=sc.zoneWinkel/360*SC_UMFANG;
const z=$("sc-zone");
z.setAttribute("stroke-dasharray",`${arc} ${SC_UMFANG}`);
z.setAttribute("transform",`rotate(${sc.zoneStart-90} 115 115)`); // -90: Start oben
$("sc-runde").textContent=sc.runde;
}
/* Aktion: prüfen, ob der Zeiger in der Zone steht */
function scAktion(){
if(!sc.aktiv)return;
// Doppelauslösung (Leertaste + fokussierter Button) verhindern
const jetzt=performance.now(); if(jetzt-letzteAktion<140)return; letzteAktion=jetzt;
const rel=((sc.winkel-sc.zoneStart)%360+360)%360; // Abstand zum Zonenstart
if(rel<=sc.zoneWinkel){ // Treffer
if(sc.runde>=SC_RUNDEN){sc.aktiv=false;spielEnde(true,"Alle "+SC_RUNDEN+" Runden getroffen — sauber!");}
else {sc.runde++;scNeueRunde();}
} else { // Verfehlt
sc.aktiv=false;spielEnde(false,"Zeiger verfehlt die Zone. Timing üben!");
}
}
/* =====================================================================
2) LOCKPICK (Marker in der Zone stoppen, mehrere Pins)
===================================================================== */
const LP_PINS=4;
const lp={pin:0, leben:3, pos:0, dir:1, spd:1.6, zoneStart:0, zoneBreite:26, aktiv:false};
sektionen.lockpick=()=>{
Object.assign(lp,{pin:0,leben:3,pos:5,dir:1,aktiv:true});
$("main").innerHTML=`<div class="phead"><h2>Lockpick</h2><p>Drücke <span class="kbd">Leertaste</span> oder klicke, wenn der Marker in der grünen Zone ist.</p></div>
<div class="stage">
<div class="roundinfo">Pins <span class="pins" id="lp-pins" style="display:inline-flex;vertical-align:middle;margin:0 6px"></span> · <span class="lives" id="lp-leben"></span></div>
<div class="lpbar" id="lp-bar"><div class="lp-zone" id="lp-zone"></div><div class="lp-marker" id="lp-marker"></div></div>
<button class="btn primary" id="lp-btn">Pin setzen</button>
</div>`;
lpRenderPins(); lpRenderLeben(); lpNeuerPin();
I(()=>{ // Marker hin und her bewegen
lp.pos+=lp.dir*lp.spd;
if(lp.pos>=100){lp.pos=100;lp.dir=-1;} if(lp.pos<=0){lp.pos=0;lp.dir=1;}
$("lp-marker").style.left="calc("+lp.pos+"% - 2.5px)";
},16);
$("lp-btn").addEventListener("click",lpAktion);
$("lp-bar").addEventListener("click",lpAktion);
};
/* Pin-Anzeige (gesetzt/offen) rendern */
function lpRenderPins(){$("lp-pins").innerHTML=Array.from({length:LP_PINS},(_,i)=>`<i class="${i<lp.pin?'set':''}" id="lp-pin-${i}"></i>`).join('');}
/* Verbleibende Versuche rendern */
function lpRenderLeben(){$("lp-leben").innerHTML="Versuche <b>"+lp.leben+"</b>";}
/* Neuen Pin vorbereiten: Zone zufällig, wird enger/schneller */
function lpNeuerPin(){
lp.zoneBreite=Math.max(12,26-lp.pin*3);
lp.spd=1.6+lp.pin*0.35;
lp.zoneStart=rnd(6,94-lp.zoneBreite);
$("lp-zone").style.left=lp.zoneStart+"%";
$("lp-zone").style.width=lp.zoneBreite+"%";
}
/* Aktion: liegt der Marker in der Zone? */
function lpAktion(){
if(!lp.aktiv)return;
// Doppelauslösung (Leertaste + fokussierter Button) verhindern
const jetzt=performance.now(); if(jetzt-letzteAktion<140)return; letzteAktion=jetzt;
if(lp.pos>=lp.zoneStart&&lp.pos<=lp.zoneStart+lp.zoneBreite){ // Pin gesetzt
lp.pin++;
if(lp.pin>=LP_PINS){lp.aktiv=false;lpRenderPins();spielEnde(true,"Alle Pins gesetzt — das Schloss ist offen!");return;}
lpRenderPins(); lpNeuerPin();
} else { // Fehlversuch
lp.leben--; lpRenderLeben();
if(lp.leben<=0){lp.aktiv=false;spielEnde(false,"Dietrich abgebrochen — kein Versuch mehr übrig.");return;}
lpNeuerPin(); // neuer Zonenort als „Ruck"
}
}
/* =====================================================================
3) MEMORY (wachsende Sequenz merken und wiederholen)
===================================================================== */
const MEM_ZIEL=5; // benötigte Sequenzlänge zum Gewinnen
const mem={seq:[], eingabe:0, sperre:true};
sektionen.memory=()=>{
Object.assign(mem,{seq:[],eingabe:0,sperre:true});
$("main").innerHTML=`<div class="phead"><h2>Memory</h2><p>Merke dir die aufleuchtende Reihenfolge und tippe sie nach.</p></div>
<div class="stage">
<div class="roundinfo" id="mem-info">Achtung …</div>
<div class="pads locked" id="mem-pads">${[0,1,2,3].map(p=>`<div class="pad" data-p="${p}"></div>`).join('')}</div>
</div>`;
$("mem-pads").querySelectorAll(".pad").forEach(p=>p.addEventListener("click",()=>memEingabe(+p.dataset.p)));
T(memNaechsteRunde,700);
};
/* Ein Feld kurz aufleuchten lassen */
function memBlink(p,dauer){const el=$("mem-pads").querySelector(`[data-p="${p}"]`);if(!el)return;el.classList.add("lit");T(()=>el.classList.remove("lit"),dauer);}
/* Sequenz verlängern und dem Spieler vorspielen */
function memNaechsteRunde(){
mem.seq.push(rnd(0,3)); mem.eingabe=0; mem.sperre=true;
$("mem-pads").classList.add("locked");
$("mem-info").textContent="Merken … ("+mem.seq.length+"/"+MEM_ZIEL+")";
mem.seq.forEach((p,i)=>{T(()=>memBlink(p,380),700+i*620);});
// Nach dem Vorspielen Eingabe freigeben
T(()=>{mem.sperre=false;$("mem-pads").classList.remove("locked");$("mem-info").textContent="Jetzt wiederholen …";},700+mem.seq.length*620+150);
}
/* Spieler-Eingabe prüfen */
function memEingabe(p){
if(mem.sperre)return;
memBlink(p,220);
if(p!==mem.seq[mem.eingabe]){mem.sperre=true;spielEnde(false,"Falsche Reihenfolge bei Schritt "+(mem.eingabe+1)+".");return;}
mem.eingabe++;
if(mem.eingabe===mem.seq.length){ // Sequenz korrekt wiederholt
if(mem.seq.length>=MEM_ZIEL){mem.sperre=true;spielEnde(true,"Sequenz aus "+MEM_ZIEL+" Feldern gemeistert!");return;}
mem.sperre=true; $("mem-pads").classList.add("locked"); $("mem-info").textContent="Richtig! Weiter …";
T(memNaechsteRunde,700);
}
}
/* =====================================================================
4) KEYPAD / CODE KNACKEN (Deduktion gegen die Zeit)
===================================================================== */
const KP_STELLEN=4, KP_VERSUCHE=6, KP_ZEIT=60;
const kp={code:[], eingabe:"", versuche:KP_VERSUCHE, zeit:KP_ZEIT, aktiv:false};
sektionen.keypad=()=>{
// Geheimen Code aus vier verschiedenen Ziffern erzeugen
const ziffern=[0,1,2,3,4,5,6,7,8,9]; kp.code=[];
for(let i=0;i<KP_STELLEN;i++)kp.code.push(ziffern.splice(rnd(0,ziffern.length-1),1)[0]);
Object.assign(kp,{eingabe:"",versuche:KP_VERSUCHE,zeit:KP_ZEIT,aktiv:true});
$("main").innerHTML=`<div class="phead"><h2>Code knacken</h2><p>Vier verschiedene Ziffern. Der Marker steht unter seiner Ziffer: 🟩 hier richtig · 🟨 kommt vor, aber woanders · ⬛ kommt nicht vor.</p></div>
<div class="stage"><div class="kpmeta"><div>Versuche <b id="kp-v">${KP_VERSUCHE}</b></div><div class="t" id="kp-tw">Zeit <b id="kp-t">${KP_ZEIT}s</b></div></div>
<div class="kpwrap">
<div>
<div class="kpdisplay" id="kp-disp"></div>
<div class="keypad" id="kp-pad" style="margin-top:14px">
${[1,2,3,4,5,6,7,8,9].map(n=>`<button data-k="${n}">${n}</button>`).join('')}
<button class="wide" data-k="del">⌫</button><button data-k="0">0</button><button class="wide" data-k="ok">✓</button>
</div>
</div>
<div class="kphist" id="kp-hist"></div>
</div>
</div>`;
kpRenderDisplay();
$("kp-pad").querySelectorAll("[data-k]").forEach(b=>b.addEventListener("click",()=>kpTaste(b.dataset.k)));
// Countdown starten
I(()=>{ if(!kp.aktiv)return; kp.zeit--; $("kp-t").textContent=kp.zeit+"s";
$("kp-tw").classList.toggle("low",kp.zeit<=10);
if(kp.zeit<=0){kp.aktiv=false;spielEnde(false,"Zeit abgelaufen — Code nicht geknackt. (Er war "+kp.code.join("")+")");}
},1000);
};
/* Aktuelle Eingabe in den vier Feldern anzeigen */
function kpRenderDisplay(){
$("kp-disp").innerHTML=Array.from({length:KP_STELLEN},(_,i)=>
`<div class="d ${i<kp.eingabe.length?'fill':''}">${kp.eingabe[i]??''}</div>`).join('');
}
/* Tastendruck auf dem Keypad verarbeiten */
function kpTaste(k){
if(!kp.aktiv)return;
if(k==="del")kp.eingabe=kp.eingabe.slice(0,-1);
else if(k==="ok"){ if(kp.eingabe.length===KP_STELLEN)kpPruefe(); return; }
else if(kp.eingabe.length<KP_STELLEN)kp.eingabe+=k;
kpRenderDisplay();
}
/* Eingabe mit dem Code vergleichen und Rückmeldung geben */
function kpPruefe(){
const g=kp.eingabe.split("").map(Number);
// Rückmeldung je Stelle: 🟩 richtige Ziffer hier · 🟨 Ziffer kommt vor, aber woanders · ⬛ kommt nicht vor
const marker=g.map((z,i)=> z===kp.code[i] ? "🟩" : (kp.code.includes(z) ? "🟨" : "⬛"));
const exakt=marker.filter(m=>m==="🟩").length;
// Ergebniszeile in die Historie schreiben (Marker steht unter seiner Ziffer)
const zeile=document.createElement("div"); zeile.className="row";
zeile.innerHTML=`<div class="zeile">${g.map((z,i)=>
`<div><div class="z">${z}</div><div class="m">${marker[i]}</div></div>`).join('')}</div>`;
$("kp-hist").prepend(zeile);
if(exakt===KP_STELLEN){kp.aktiv=false;spielEnde(true,"Code "+kp.code.join("")+" geknackt!");return;}
kp.versuche--; $("kp-v").textContent=kp.versuche;
if(kp.versuche<=0){kp.aktiv=false;spielEnde(false,"Keine Versuche mehr — der Code war "+kp.code.join("")+".");return;}
kp.eingabe=""; kpRenderDisplay();
}
/* =====================================================================
GLOBALE TASTATUR (Leertaste für Skill-Check/Lockpick, Ziffern fürs Keypad)
===================================================================== */
addEventListener("keydown",e=>{
if(e.repeat)return;
if(e.code==="Space"){ // Leertaste als Aktions-Taste
// Fokus vom Button nehmen, damit die Leertaste ihn nicht zusätzlich auslöst
if(document.activeElement&&document.activeElement.blur)document.activeElement.blur();
if(sektion==="skillcheck"){e.preventDefault();scAktion();}
else if(sektion==="lockpick"){e.preventDefault();lpAktion();}
} else if(sektion==="keypad"){
if(/^[0-9]$/.test(e.key))kpTaste(e.key);
else if(e.key==="Enter")kpTaste("ok");
else if(e.key==="Backspace")kpTaste("del");
}
});
/* =====================================================================
START
===================================================================== */
renderNav(); sektionen.lobby();
</script>
</body>
</html>

294
radial/index.html Normal file
View File

@ -0,0 +1,294 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Aether — Radial-Menü</title>
<style>
:root{
--fg:#f2f0fa; /* Standard-Textfarbe */
--muted:#a09bbd; /* gedämpfter Text */
--slice:rgba(18,15,30,0.82); /* Grundfarbe der Segmente */
--hub:rgba(16,13,26,0.92); /* Naben-Hintergrund */
--border:rgba(255,255,255,0.12);/* dezente Rahmen */
--accent:#7c5cf6; /* Aether-Akzent (Violett) */
--accent2:#5b8cff; /* zweiter Akzent (Blau) */
}
*{box-sizing:border-box;}
html,body{margin:0;height:100%;overflow:hidden;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);}
/* ============================================================
STILISIERTE SPIELSZENE (Kulisse hinter dem Menü)
============================================================ */
.scene{position:fixed;inset:0;z-index:0;overflow:hidden;
background:linear-gradient(180deg,#141232 0%,#2a2350 34%,#5a3a66 54%,#b07a52 66%,#2a2236 66%);}
.scene .city{position:absolute;left:0;right:0;bottom:28%;height:30%;
background:linear-gradient(90deg,#201a38 0 8%,transparent 8% 13%,#2a2148 13% 21%,transparent 21% 26%,
#1c1730 26% 34%,transparent 34% 40%,#2a2148 40% 47%,transparent 47% 54%,#241d40 54% 62%,transparent 62% 68%,
#201a38 68% 77%,transparent 77% 83%,#2a2148 83% 91%,transparent 91%);}
.scene .ground{position:absolute;left:0;right:0;bottom:0;height:28%;background:linear-gradient(180deg,#161226,#0a0814);}
/* Abdunkelnde Ebene, wenn das Menü offen ist */
.dim{position:fixed;inset:0;z-index:1;background:rgba(6,5,12,0.55);backdrop-filter:blur(3px);
opacity:0;transition:.25s;pointer-events:none;}
.dim.on{opacity:1;}
/* ============================================================
RADIAL-RAD
============================================================ */
.stage{position:fixed;inset:0;z-index:2;display:grid;place-items:center;}
.wheel{position:relative;width:min(440px,88vw);aspect-ratio:1;opacity:0;transform:scale(0.9);
transition:.2s cubic-bezier(.2,.9,.3,1);pointer-events:none;}
.wheel.open{opacity:1;transform:scale(1);pointer-events:auto;}
.wheel svg{width:100%;height:100%;display:block;filter:drop-shadow(0 24px 50px rgba(0,0,0,0.6));}
/* Einzelnes Kuchen-Segment */
.slice{fill:var(--slice);stroke:rgba(0,0,0,0.35);stroke-width:1.2;cursor:pointer;transition:fill .12s;}
.slice:hover{fill:var(--c,var(--accent));} /* --c = Kategorie-Farbe */
.slice.parent:hover{fill:var(--c,var(--accent));}
.lbl-i{font-size:27px;pointer-events:none;dominant-baseline:central;text-anchor:middle;}
.lbl-t{font-size:12.5px;font-weight:700;fill:var(--fg);pointer-events:none;text-anchor:middle;
paint-order:stroke;stroke:rgba(0,0,0,0.45);stroke-width:2.4px;}
.chev{font-size:11px;fill:rgba(255,255,255,0.55);pointer-events:none;text-anchor:middle;} /* ›-Marker bei Untermenüs */
/* Nabe in der Mitte */
.hub{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:38%;aspect-ratio:1;border-radius:50%;
background:var(--hub);border:1px solid var(--border);display:grid;place-items:center;text-align:center;
backdrop-filter:blur(8px);box-shadow:inset 0 0 30px rgba(0,0,0,0.5);}
.hub.back{cursor:pointer;}
.hub .hi{font-size:32px;line-height:1;}
.hub .ht{font-size:14px;font-weight:800;margin-top:5px;padding:0 8px;}
.hub .hs{font-size:10.5px;color:var(--muted);margin-top:3px;font-weight:600;padding:0 10px;}
.hub.back .hs{color:var(--accent);}
/* Hinweis, wenn das Menü geschlossen ist */
.closed{position:fixed;inset:0;z-index:2;display:grid;place-items:center;}
.closed .card{background:var(--hub);border:1px solid var(--border);border-radius:18px;padding:26px 30px;text-align:center;
backdrop-filter:blur(10px);box-shadow:0 24px 50px -20px rgba(0,0,0,0.7);}
.closed .card .ci{font-size:40px;}
.closed .card h2{margin:10px 0 4px;font-size:20px;font-weight:800;}
.closed .card p{margin:0 0 16px;color:var(--muted);font-size:13.5px;}
.kbd{display:inline-block;background:rgba(255,255,255,0.1);border:1px solid var(--border);border-bottom-width:2px;
border-radius:7px;padding:2px 9px;font-weight:800;font-size:13px;font-family:inherit;}
.btn{border:none;border-radius:11px;padding:11px 20px;font-family:inherit;font-size:14px;font-weight:800;cursor:pointer;
background:var(--accent);color:#160c2e;transition:.12s;}
.btn:hover{filter:brightness(1.08);}
.wheel.open ~ .closed{display:none;}
/* Bestätigungs-Toast nach einer Aktion */
.toast{position:fixed;left:50%;bottom:34px;transform:translateX(-50%) translateY(16px);z-index:5;
background:var(--hub);border:1px solid var(--border);border-radius:12px;padding:12px 22px;font-size:14px;font-weight:700;
backdrop-filter:blur(10px);opacity:0;pointer-events:none;transition:.28s;box-shadow:0 16px 40px -16px rgba(0,0,0,0.7);}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
/* Fußzeilen-Hinweis (nur Prototyp) */
.foot{position:fixed;left:0;right:0;bottom:12px;z-index:4;text-align:center;color:var(--muted);font-size:11.5px;pointer-events:none;}
.foot .kbd{pointer-events:none;}
</style>
</head>
<body>
<!-- Kulisse -->
<div class="scene"><div class="city"></div><div class="ground"></div></div>
<div class="dim" id="dim"></div>
<!-- Rad + geschlossener Hinweis -->
<div class="stage">
<div class="wheel" id="wheel">
<svg id="svg" viewBox="0 0 400 400"></svg>
<div class="hub" id="hub"></div>
</div>
<div class="closed" id="closed">
<div class="card">
<div class="ci">🎯</div>
<h2>Interaktionsmenü</h2>
<p>Halte <span class="kbd">G</span> gedrückt, um das Rad zu öffnen.</p>
<button class="btn" id="openBtn">Menü öffnen</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="foot"><span class="kbd">G</span> Menü öffnen/schließen · Klick auf ein Segment wählt · Mitte = zurück</div>
<script>
"use strict";
/* Kurzer Zugriffshelfer auf Elemente per ID */
const $=id=>document.getElementById(id);
/* =====================================================================
MENÜ-DATEN (Kategorien mit optionalen Untermenüs)
Jede Aktion ist bewusst kosmetisch — in FiveM lösen sie NUI-Callbacks aus.
===================================================================== */
const menue=[
{icon:"🙌", label:"Interaktion", farbe:"#7c5cf6", kinder:[
{icon:"🙌", label:"Hände hoch"},
{icon:"👉", label:"Zeigen"},
{icon:"🧎", label:"Knien"},
{icon:"👋", label:"Winken"},
{icon:"🪪", label:"Ausweis zeigen"}, // greift auf documents/ zurück
]},
{icon:"🚗", label:"Fahrzeug", farbe:"#5b8cff", kinder:[
{icon:"🔒", label:"Ab-/Aufschließen"},
{icon:"⚙️", label:"Motor an/aus"},
{icon:"🧳", label:"Kofferraum"},
{icon:"🚪", label:"Türen"},
{icon:"💺", label:"Sitzplatz"},
]},
{icon:"🎭", label:"Emotes", farbe:"#e06fae", kinder:[
{icon:"🪑", label:"Sitzen"},
{icon:"💃", label:"Tanzen"},
{icon:"🚬", label:"Rauchen"},
{icon:"🥤", label:"Trinken"},
{icon:"🧍", label:"Lehnen"},
{icon:"🛑", label:"Stopp / Reset"},
]},
{icon:"💼", label:"Job", farbe:"#34d39a", kinder:[
{icon:"🟢", label:"Dienst an/aus"}, // greift auf jobs/ zurück
{icon:"🧾", label:"Rechnung stellen"},
{icon:"📦", label:"Auftrag annehmen"},
{icon:"📋", label:"Job-Aktion"},
]},
{icon:"🆘", label:"Notdienst", farbe:"#f0506e", kinder:[
{icon:"🚓", label:"Polizei rufen"}, // greift auf faction/ Dispatch zurück
{icon:"🚑", label:"Rettung rufen"},
{icon:"🚨", label:"Panik / 10-13"},
]},
{icon:"🎒", label:"Inventar", farbe:"#f5a742", aktion:"Inventar geöffnet"}, // greift auf inventory/ zurück
{icon:"📱", label:"Handy", farbe:"#3fc7e0", aktion:"Handy geöffnet"}, // greift auf phone/ zurück
];
/* =====================================================================
ZUSTAND
===================================================================== */
let offen=false; // ist das Rad geöffnet?
let aktuelleKinder=null; // aktives Untermenü (null = Wurzelebene)
let aktuelleFarbe=null; // Kategorie-Farbe des aktiven Untermenüs
let elternLabel=""; // Beschriftung der übergeordneten Kategorie
/* Geometrie des Rads (im 400×400-Koordinatensystem) */
const CX=200, CY=200, R_AUSSEN=188, R_INNEN=84, GAP=2.4;
const R_ICON=(R_AUSSEN+R_INNEN)/2; // Radius für Icon/Beschriftung
/* Punkt auf einem Kreis: Winkel in Grad, von oben im Uhrzeigersinn gemessen */
function polar(r,grad){const t=grad*Math.PI/180;return [CX+r*Math.sin(t), CY-r*Math.cos(t)];}
/* Baut den SVG-Pfad eines Donut-Segments zwischen zwei Winkeln */
function segmentPfad(t0,t1){
const [x1,y1]=polar(R_AUSSEN,t0), [x2,y2]=polar(R_AUSSEN,t1);
const [x3,y3]=polar(R_INNEN,t1), [x4,y4]=polar(R_INNEN,t0);
const gross=(t1-t0)>180?1:0; // große-Bogen-Flagge
return `M${x1.toFixed(2)} ${y1.toFixed(2)} A${R_AUSSEN} ${R_AUSSEN} 0 ${gross} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`
+` L${x3.toFixed(2)} ${y3.toFixed(2)} A${R_INNEN} ${R_INNEN} 0 ${gross} 0 ${x4.toFixed(2)} ${y4.toFixed(2)} Z`;
}
/* =====================================================================
RENDERING
===================================================================== */
/* Liefert die aktuell anzuzeigende Knotenliste (Wurzel oder Untermenü) */
function aktuelleListe(){return aktuelleKinder||menue;}
/* Zeichnet das komplette Rad (Segmente + Nabe) für die aktuelle Ebene */
function zeichneRad(){
const liste=aktuelleListe();
const n=liste.length, seg=360/n;
let svg="";
liste.forEach((node,i)=>{
// Segment 0 mittig oben; Lücke (GAP) zwischen den Segmenten
const basis=i*seg-seg/2;
const t0=basis+GAP/2, t1=basis+seg-GAP/2;
const farbe=aktuelleKinder?aktuelleFarbe:node.farbe;
const [ix,iy]=polar(R_ICON,i*seg); // Mittelwinkel = i*seg
svg+=`<path class="slice ${node.kinder?'parent':''}" style="--c:${farbe}" data-i="${i}" d="${segmentPfad(t0,t1)}"></path>`;
svg+=`<text class="lbl-i" x="${ix.toFixed(1)}" y="${(iy-9).toFixed(1)}">${node.icon}</text>`;
svg+=`<text class="lbl-t" x="${ix.toFixed(1)}" y="${(iy+16).toFixed(1)}">${node.label}</text>`;
// Kleiner ›-Hinweis, dass sich ein Untermenü öffnet
if(node.kinder){const [cx,cy]=polar(R_ICON,i*seg);svg+=`<text class="chev" x="${cx.toFixed(1)}" y="${(cy+29).toFixed(1)}">▸</text>`;}
});
$("svg").innerHTML=svg;
// Segment-Ereignisse verknüpfen (Hover aktualisiert die Nabe, Klick wählt)
$("svg").querySelectorAll(".slice").forEach(p=>{
const node=liste[+p.dataset.i];
p.addEventListener("mouseenter",()=>zeigeNabe(node));
p.addEventListener("mouseleave",standardNabe);
p.addEventListener("click",()=>waehle(node));
});
standardNabe();
}
/* Zeigt in der Nabe das gerade überfahrene Segment */
function zeigeNabe(node){
$("hub").className="hub"+(aktuelleKinder?" back":"");
$("hub").innerHTML=`<div><div class="hi">${node.icon}</div><div class="ht">${node.label}</div>
<div class="hs">${node.kinder?"Untermenü öffnen ▸":(aktuelleKinder?"↩ Klick Mitte = zurück":"Auswählen")}</div></div>`;
}
/* Setzt die Nabe auf ihren Standardtext der aktuellen Ebene zurück */
function standardNabe(){
if(aktuelleKinder){
$("hub").className="hub back";
$("hub").innerHTML=`<div><div class="hi">↩</div><div class="ht">${elternLabel}</div><div class="hs">Zurück</div></div>`;
} else {
$("hub").className="hub";
$("hub").innerHTML=`<div><div class="hi">🎯</div><div class="ht">Aktionen</div><div class="hs">Kategorie wählen</div></div>`;
}
}
/* =====================================================================
NAVIGATION & AKTIONEN
===================================================================== */
/* Reaktion auf die Auswahl eines Segments */
function waehle(node){
if(node.kinder){
// In das Untermenü wechseln
aktuelleKinder=node.kinder; aktuelleFarbe=node.farbe; elternLabel=node.label;
zeichneRad();
} else {
// Blatt-Aktion ausführen, Rückmeldung geben und Rad schließen
fuehreAus(node);
}
}
/* Führt eine Blatt-Aktion aus (hier kosmetisch: Toast + schließen) */
function fuehreAus(node){
const text=node.aktion||node.label;
toast(node.icon+" "+text);
setOffen(false);
}
/* Klick auf die Nabe: aus dem Untermenü zurück zur Wurzel */
$("hub").addEventListener("click",()=>{
if(aktuelleKinder){aktuelleKinder=null;aktuelleFarbe=null;zeichneRad();}
});
/* Öffnet/schließt das Rad; beim Schließen zurück auf die Wurzelebene */
function setOffen(b){
offen=b;
$("wheel").classList.toggle("open",b);
$("dim").classList.toggle("on",b);
$("closed").style.display=b?"none":"grid";
if(b){aktuelleKinder=null;aktuelleFarbe=null;zeichneRad();}
}
/* Kurze Bestätigungs-Einblendung */
let toastTimer=null;
function toast(text){const e=$("toast");e.textContent=text;e.classList.add("show");
clearTimeout(toastTimer);toastTimer=setTimeout(()=>e.classList.remove("show"),2200);}
/* =====================================================================
STEUERUNG (Maus + Tastatur)
===================================================================== */
$("openBtn").addEventListener("click",()=>setOffen(true));
// Taste G schaltet das Rad um (verhindert Dauerfeuer beim Halten)
addEventListener("keydown",e=>{
if(e.repeat)return;
if(e.key==="g"||e.key==="G")setOffen(!offen);
if(e.key==="Escape")setOffen(false);
});
/* =====================================================================
START (geschlossen)
===================================================================== */
setOffen(false);
</script>
</body>
</html>

View File

@ -0,0 +1,87 @@
# aether-admin
Zentrales Server-Admin-Panel für das Aether-Framework — als echtes
FiveM-Resource, mit Anbindung an `aether-core` und **echten WebRTC-Live-
Kameras** über einen separaten Media-Server.
Im Spiel mit **F9** öffnen (Command `/adminpanel`). Zugriff hat nur, wessen
`aether_users.admin_group` mindestens `support` ist.
## Rechte (Kopplung an aether-core)
Die Gruppe eines Spielers steht in **`aether_users.admin_group`** — genau
dort, wo der Core sie ohnehin führt (`user` < `support` < `admin` < `owner`).
Dieses Modul liest sie und prüft **jede** Aktion serverseitig gegen die
Rechte-Matrix in `shared/config.lua`. Die Oberfläche blendet nur aus, was
ohnehin abgelehnt würde — sie ist nie der Torwächter.
Eigene Gruppe setzen:
```sql
UPDATE aether_users SET admin_group='owner' WHERE license='DEINE_LIZENZ';
```
Oder direkt im Panel unter **Admin-Team** (ab `owner`).
## Datenbank
```
mysql < sql/aether_admin.sql
```
Setzt auf `aether.sql` auf (aus aether-core). **Bans nutzen `aether_users`
mit** (`is_banned`/`ban_reason`/`ban_until`) — der Core sperrt darüber schon
beim Connect. Eigene Tabellen: Warns, Bann-Verlauf, Reports, Notizen,
Prüfprotokoll, Ankündigungen, Staatsprojekte, Immobilien, Fraktions-Meta,
Einstellungen.
## Bereiche
Dashboard · Weltkarte · Spieler (alle Eingriffe) · Fahrzeuge · Immobilien ·
Fraktionen (inkl. Payday) · Wirtschaft · Staatsprojekte · **Live-Kameras
(WebRTC)** · Weltsteuerung · Ankündigungen · Bans & Warns · Reports ·
Ressourcen · Audit-Log · Admin-Team · Einstellungen.
Alles läuft über ein einziges Netz-Event (`aether-admin:aufruf`), das im
Kern rechtegeprüft an die Fachmodule verteilt. Jede eingreifende Aktion
landet im Prüfprotokoll (`aether_admin_log`).
Spieler melden aus dem Spiel mit **`/report <Text>`** — Tickets erscheinen
im Bereich *Reports*.
## Live-Kameras (WebRTC)
Echtes WebRTC über einen **eigenen Media-Server (SFU)**. Der FiveM-Server
routet kein Video: er prüft Rechte, lässt sich vom Media-Server kurzlebige
Token ausstellen und weist die Ziel-Clients an, in einen Raum zu senden.
Viewer (das Panel) und Publisher (der Ziel-Client, im Hintergrund) sprechen
per WebSocket direkt mit dem Media-Server.
Einrichtung und die **einzige ehrliche Grenze** — die NUI kann das Spielbild
nicht selbst abgreifen, die echte Videoquelle wird extern angeklemmt
(`window.__aetherCapture` / OBS-Virtual-Cam) — stehen in
[`media-server/README.md`](media-server/README.md).
## Aufbau
```
shared/config.lua Rechtegruppen, Rechte-Matrix, Sektionen, Media-Config
server/
db.lua auth.lua log.lua kern.lua Basis: DB, Rechte, Protokoll, Dispatcher
kamera.lua WebRTC-Orchestrierung (Token, Publisher-Aufträge)
module/*.lua je Sektion ein Fachmodul
main.lua Netz-Schnittstelle
client/
main.lua Öffnen, Spielaktionen (Teleport/Heal/Spectate/…)
kamera.lua Brücke Server → NUI (Publisher steuern)
html/ Oberfläche + echtes WebRTC (js/webrtc.js)
media-server/ eigenständiger Node-SFU (nicht Teil des Resources)
```
## Status / Tests
Server- und Client-Lua sowie die NUI-Skripte sind syntaktisch geprüft. Der
Media-Server (`media-server/server.js`) ist syntaktisch geprüft, aber ein
WebRTC-SFU lässt sich nur **im laufenden Betrieb** wirklich verifizieren —
siehe die Hinweise in `media-server/README.md`. Ein erster Lauf gegen einen
echten FiveM-Server mit eingespieltem Schema steht noch aus.

View File

@ -0,0 +1,50 @@
-- =====================================================================
-- aether-admin — Kamera-Bridge (Client)
--
-- Die NUI (webrtc.js) macht das eigentliche WebRTC. Dieser Client-Code
-- ist nur die Brücke: Wenn der Server anordnet „publiziere in Raum X",
-- reicht er das an die immer geladene NUI weiter. Signaling und Medien
-- laufen direkt zwischen NUI und Media-Server — nicht über FiveM.
--
-- Wichtig: Das Publizieren läuft im Hintergrund, auch ohne geöffnetes
-- Panel. Die NUI-Seite ist die ehrliche Stelle, an der die echte
-- Videoquelle angeklemmt wird (siehe html/js/webrtc.js).
-- =====================================================================
local function SendeAnNui(nachricht)
SendNuiMessage(json.encode(nachricht))
end
--- Der Server fordert diesen Client auf, als Publisher zu senden
RegisterNetEvent('aether-admin:kamera:publiziere', function(daten)
if type(daten) ~= 'table' or not daten.token then return end
SendeAnNui({
aktion = 'webrtcPublish',
raum = daten.raum,
url = daten.url,
token = daten.token,
})
end)
--- Der Server beendet die Übertragung dieses Raums
RegisterNetEvent('aether-admin:kamera:stopp', function(raum)
SendeAnNui({ aktion = 'webrtcStop', raum = raum })
end)
--- Statusmeldung der NUI (Bildschirmfreigabe geklappt oder blockiert)
RegisterNUICallback('kameraStatus', function(daten, cb)
TriggerServerEvent('aether-admin:kamera:status',
tostring(daten.raum or ''), tostring(daten.typ or ''), tostring(daten.fehler or ''))
cb({ ok = true })
end)
--- Der Admin bekommt die Statusmeldung eines Ziel-Clients
RegisterNetEvent('aether-admin:kamera:status', function(daten)
SendeAnNui({ aktion = 'kameraStatus', quelle = daten.quelle, typ = daten.typ, fehler = daten.fehler })
end)
-- Beim Stoppen der Ressource alle Übertragungen kappen
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
SendeAnNui({ aktion = 'webrtcStopAll' })
end)

View File

@ -0,0 +1,282 @@
-- =====================================================================
-- aether-admin — Clientlogik
--
-- Öffnet das Panel und führt die Eingriffe aus, die nur am Client
-- möglich sind (Teleport, Heilen, Spectate, Fahrzeug spawnen …). Der
-- Client entscheidet nichts selbst — er tut, was der (rechteprüfende)
-- Server ihm aufträgt.
-- =====================================================================
local istOffen = false
local darfPanel = false -- vom Server bestätigt
-- ---------------------------------------------------------------------
-- Hilfen
-- ---------------------------------------------------------------------
local function SendeAnNui(nachricht)
SendNuiMessage(json.encode(nachricht))
end
local function Meldung(text)
if not text or text == '' then return end
BeginTextCommandThefeedPost('STRING')
AddTextComponentSubstringPlayerName(text)
EndTextCommandThefeedPostTicker(false, true)
end
local function Schliessen()
if not istOffen then return end
istOffen = false
SetNuiFocus(false, false)
SendeAnNui({ aktion = 'schliessen' })
end
-- ---------------------------------------------------------------------
-- Panel öffnen / schließen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether-admin:zeige', function(daten)
darfPanel = true
istOffen = true
SetNuiFocus(true, true)
daten.aktion = 'oeffnen'
SendeAnNui(daten)
end)
RegisterNetEvent('aether-admin:verweigert', function(text)
Meldung('~r~' .. (text or 'Kein Zugriff.'))
end)
RegisterNetEvent('aether-admin:antwort', function(ruf, ergebnis, fehler)
SendeAnNui({ aktion = 'antwort', ruf = ruf, ergebnis = ergebnis, fehler = fehler })
end)
RegisterCommand('adminpanel', function()
TriggerServerEvent('aether-admin:oeffnen')
end, false)
RegisterKeyMapping('adminpanel', 'Admin-Panel öffnen', 'keyboard', 'F9')
-- Melder aus dem Spiel: /report <Text>
RegisterCommand('report', function(source, args)
local text = table.concat(args, ' ')
if #text < 2 then
Meldung('~y~Nutzung: /report <dein Anliegen>')
return
end
TriggerServerEvent('aether-admin:report', text:sub(1, 120), text)
end, false)
-- ---------------------------------------------------------------------
-- NUI-Callbacks
-- ---------------------------------------------------------------------
RegisterNUICallback('schliessen', function(_, cb)
Schliessen()
cb({ ok = true })
end)
--- Zentraler Aufruf: Oberfläche → Server → Antwort
RegisterNUICallback('aufruf', function(daten, cb)
TriggerServerEvent('aether-admin:aufruf',
tostring(daten.modul or ''), tostring(daten.aktion or ''),
daten.daten or {}, daten.ruf)
cb({ ok = true })
end)
--- Wegpunkt setzen (aus Weltkarte / Spieleraktionen)
RegisterNUICallback('wegpunkt', function(daten, cb)
if daten.x and daten.y then SetNewWaypoint(daten.x + 0.0, daten.y + 0.0) end
cb({ ok = true })
end)
-- ---------------------------------------------------------------------
-- Zustands-Eingriffe am eigenen Ped (vom Server ausgelöst)
-- ---------------------------------------------------------------------
RegisterNetEvent('aether-admin:client:heal', function()
local ped = PlayerPedId()
SetEntityHealth(ped, GetEntityMaxHealth(ped))
ClearPedBloodDamage(ped)
ClearPedWetness(ped)
Meldung('~g~Du wurdest geheilt.')
end)
RegisterNetEvent('aether-admin:client:revive', function()
local ped = PlayerPedId()
local erledigt = pcall(function() exports['aether-death']:Wiederbeleben() end)
if not erledigt then
local pos = GetEntityCoords(ped)
NetworkResurrectLocalPlayer(pos.x, pos.y, pos.z, GetEntityHeading(ped), true, false)
SetEntityHealth(ped, GetEntityMaxHealth(ped))
end
Meldung('~g~Du wurdest wiederbelebt.')
end)
RegisterNetEvent('aether-admin:client:armor', function()
SetPedArmour(PlayerPedId(), 100)
Meldung('~g~Rüstung aufgefüllt.')
end)
RegisterNetEvent('aether-admin:client:freeze', function(an)
local ped = PlayerPedId()
FreezeEntityPosition(ped, an and true or false)
Meldung(an and '~b~Du wurdest eingefroren.' or '~g~Du wurdest aufgetaut.')
end)
RegisterNetEvent('aether-admin:client:god', function(an)
local ped = PlayerPedId()
SetEntityInvincible(ped, an and true or false)
SetPlayerInvincible(PlayerId(), an and true or false)
end)
RegisterNetEvent('aether-admin:client:teleport', function(pos)
if not pos or not pos.x then return end
local ped = PlayerPedId()
SetEntityCoords(ped, pos.x + 0.0, pos.y + 0.0, pos.z + 0.0, false, false, false, false)
end)
RegisterNetEvent('aether-admin:client:hinweis', function(text)
Meldung('~y~' .. tostring(text))
end)
RegisterNetEvent('aether-admin:client:broadcast', function(daten)
local farbe = daten.typ == 'Warnung' and '~r~' or daten.typ == 'Neustart' and '~o~' or '~b~'
Meldung(('%s[%s] ~s~%s'):format(farbe, daten.typ or 'Info', daten.text or ''))
PlaySoundFrontend(-1, 'Menu_Accept', 'Phone_SoundSet_Default', true)
end)
-- ---------------------------------------------------------------------
-- Fahrzeug spawnen (am eigenen Ped)
-- ---------------------------------------------------------------------
RegisterNetEvent('aether-admin:client:fahrzeug', function(modell)
local hash = GetHashKey(modell)
if not IsModelInCdimage(hash) or not IsModelAVehicle(hash) then
Meldung('~r~Unbekanntes Fahrzeugmodell: ' .. tostring(modell))
return
end
RequestModel(hash)
local zeit = GetGameTimer()
while not HasModelLoaded(hash) and GetGameTimer() - zeit < 5000 do Wait(10) end
if not HasModelLoaded(hash) then return end
local ped = PlayerPedId()
local pos = GetEntityCoords(ped)
local heading = GetEntityHeading(ped)
local veh = CreateVehicle(hash, pos.x + 2.5, pos.y, pos.z, heading, true, false)
SetVehicleNumberPlateText(veh, 'ADMIN')
SetPedIntoVehicle(ped, veh, -1)
SetModelAsNoLongerNeeded(hash)
Meldung('~g~Fahrzeug gespawnt: ' .. modell)
end)
RegisterNetEvent('aether-admin:client:fahrzeugFix', function(netId)
local veh = NetworkGetEntityFromNetworkId(netId)
if veh and veh ~= 0 and DoesEntityExist(veh) then
SetVehicleFixed(veh); SetVehicleDeformationFixed(veh)
SetVehicleEngineHealth(veh, 1000.0); SetVehicleBodyHealth(veh, 1000.0)
SetVehicleDirtLevel(veh, 0.0)
Meldung('~g~Fahrzeug repariert.')
else
Meldung('~y~Fahrzeug nicht in Reichweite — bitte näher heranfahren.')
end
end)
RegisterNetEvent('aether-admin:client:fahrzeugHer', function(netId)
local veh = NetworkGetEntityFromNetworkId(netId)
if veh and veh ~= 0 and DoesEntityExist(veh) then
local pos = GetEntityCoords(PlayerPedId())
local heading = GetEntityHeading(PlayerPedId())
SetEntityCoords(veh, pos.x + 3.0, pos.y, pos.z, false, false, false, false)
SetEntityHeading(veh, heading)
Meldung('~g~Fahrzeug hergeholt.')
else
Meldung('~y~Fahrzeug nicht in Reichweite.')
end
end)
-- ---------------------------------------------------------------------
-- Beobachten (native Spectate) — zusätzlich zur WebRTC-Kamera
-- ---------------------------------------------------------------------
local beobachtet = false
RegisterNetEvent('aether-admin:client:spectate', function(daten)
if beobachtet then StoppeSpectate(); return end
local zielSpieler = GetPlayerFromServerId(daten.ziel)
-- Ohne Ziel in Reichweite zuerst zur letzten bekannten Position
if daten.pos then
SetEntityCoords(PlayerPedId(), daten.pos.x + 0.0, daten.pos.y + 0.0,
daten.pos.z + 30.0, false, false, false, false)
Wait(600)
end
local zielPed = GetPlayerFromServerId(daten.ziel) ~= -1
and GetPlayerPed(GetPlayerFromServerId(daten.ziel)) or 0
if zielPed == 0 or not DoesEntityExist(zielPed) then
Meldung('~y~Ziel nicht erreichbar (zu weit weg?).')
return
end
NetworkSetInSpectatorMode(true, zielPed)
beobachtet = true
Meldung('~b~Beobachtungsmodus — [Rücktaste] beendet ihn.')
CreateThread(function()
while beobachtet do
Wait(0)
if IsControlJustReleased(0, 194) then StoppeSpectate() end -- Backspace
end
end)
end)
function StoppeSpectate()
if not beobachtet then return end
NetworkSetInSpectatorMode(false, PlayerPedId())
beobachtet = false
Meldung('~g~Beobachtung beendet.')
end
-- ---------------------------------------------------------------------
-- Weltzustand anwenden (Zeit / Wetter / Blackout)
-- ---------------------------------------------------------------------
local welt = { stunde = 12, minute = 0, wetter = 'CLEAR',
zeitFix = false, wetterFix = false, blackout = false }
RegisterNetEvent('aether-admin:client:welt', function(neu)
if type(neu) ~= 'table' then return end
welt = neu
SetWeatherTypeOverTime(welt.wetter, 15.0)
SetArtificialLightsState(welt.blackout and true or false)
end)
CreateThread(function()
while true do
Wait(welt.zeitFix and 0 or 2000)
if welt.zeitFix then
NetworkOverrideClockTime(welt.stunde, welt.minute, 0)
end
if welt.wetterFix then
SetWeatherTypeNow(welt.wetter)
end
if welt.blackout then
SetArtificialLightsState(true)
end
end
end)
-- ---------------------------------------------------------------------
-- Positions-Cache füttern (Weltkarte, Teleport, Spectate)
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
Wait(3000)
local pos = GetEntityCoords(PlayerPedId())
TriggerServerEvent('aether-admin:pos', { x = pos.x, y = pos.y, z = pos.z })
end
end)
-- ---------------------------------------------------------------------
-- Aufräumen
-- ---------------------------------------------------------------------
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false)
if beobachtet then NetworkSetInSpectatorMode(false, PlayerPedId()) end
end)

View File

@ -0,0 +1,62 @@
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
name 'aether-admin'
author 'Jerrit Fritzsche'
description 'Zentrales Server-Admin-Panel mit WebRTC-Live-Kameras'
version '0.1.0'
ui_page 'html/index.html'
files {
'html/index.html',
'html/css/app.css',
'html/js/app.js',
'html/js/webrtc.js',
'html/js/sections.js',
}
shared_scripts {
'@aether-core/shared/utils.lua',
'shared/config.lua',
}
client_scripts {
'client/main.lua',
'client/kamera.lua',
}
-- Reihenfolge: Kern und Basisdienste vor den Fachmodulen.
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/db.lua',
'server/auth.lua',
'server/log.lua',
'server/kern.lua',
'server/kamera.lua',
-- --- Fachmodule (hier neue Sektionen ergänzen) ---
'server/module/dashboard.lua',
'server/module/spieler.lua',
'server/module/weltkarte.lua',
'server/module/fahrzeuge.lua',
'server/module/immobilien.lua',
'server/module/fraktionen.lua',
'server/module/wirtschaft.lua',
'server/module/staat.lua',
'server/module/welt.lua',
'server/module/broadcast.lua',
'server/module/moderation.lua',
'server/module/reports.lua',
'server/module/logs.lua',
'server/module/ressourcen.lua',
'server/module/team.lua',
'server/module/settings.lua',
-- --- Schnittstelle zuletzt ---
'server/main.lua',
}
dependencies {
'aether-core',
'oxmysql',
}

View File

@ -0,0 +1,99 @@
:root{
--bg:#070a10;--fg:#e8ecf4;--muted:#7e879b;--surface:rgba(22,26,36,0.72);--surface2:rgba(34,39,52,0.85);
--slot:#111520;--border:rgba(255,255,255,0.08);--accent:#5b8cff;--ink:#04102b;--accent-soft:rgba(91,140,255,0.16);
--green:#34d39a;--amber:#f5b23d;--red:#f0506e;--violet:#a56bff;--cyan:#22c1d4;
}
*{box-sizing:border-box;} html,body{margin:0;height:100%;}
body{min-height:100%;background:radial-gradient(1200px 700px at 15% 0%,rgba(91,140,255,0.08),transparent 55%),radial-gradient(1000px 800px at 90% 100%,rgba(52,211,153,0.05),transparent 55%),var(--bg);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);
display:flex;align-items:center;justify-content:center;padding:20px 14px;overflow-x:hidden;}
.admin{position:relative;width:min(1360px,98vw);aspect-ratio:16/10;border-radius:20px;overflow:hidden;
background:linear-gradient(160deg,rgba(18,22,30,0.92),rgba(9,11,16,0.96));backdrop-filter:blur(20px);border:1px solid var(--border);
box-shadow:0 50px 100px -30px rgba(0,0,0,0.85);display:grid;grid-template-columns:224px 1fr;grid-template-rows:auto 1fr;grid-template-areas:"brand top" "nav main";}
.brand{grid-area:brand;padding:14px 18px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
.brand .bl{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;font-size:18px;background:linear-gradient(150deg,var(--accent),#2a52c9);}
.brand .bn{font-size:15px;font-weight:800;letter-spacing:-0.02em;line-height:1.1;} .brand .bn small{display:block;font-size:10px;color:var(--muted);font-weight:500;}
.top{grid-area:top;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;padding:0 18px;}
.srv-chip{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:700;} .srv-dot{width:9px;height:9px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);}
.top .live{display:flex;gap:16px;margin-left:8px;font-size:12px;color:var(--muted);} .top .live b{color:var(--fg);font-variant-numeric:tabular-nums;}
.top .restart{margin-left:auto;font-size:12px;color:var(--amber);font-weight:700;}
.top .me{display:flex;align-items:center;gap:9px;padding-left:14px;border-left:1px solid var(--border);} .top .me .av{width:32px;height:32px;border-radius:50%;background:linear-gradient(150deg,var(--violet),#6b3fb0);display:grid;place-items:center;font-weight:800;font-size:12px;} .top .me .mn{font-size:13px;font-weight:700;} .top .me .mr{font-size:10.5px;color:var(--amber);font-weight:700;}
.nav{grid-area:nav;border-right:1px solid var(--border);padding:10px 9px;display:flex;flex-direction:column;gap:2px;overflow-y:auto;}
.nav .grp{font-size:9px;text-transform:uppercase;letter-spacing:0.09em;color:#535c70;font-weight:800;margin:10px 12px 4px;}
.nav button{display:flex;align-items:center;gap:10px;padding:8px 11px;border-radius:9px;border:none;background:none;color:var(--muted);font-family:inherit;font-size:13px;font-weight:700;cursor:pointer;text-align:left;transition:.12s;}
.nav button .ni{font-size:15px;width:18px;text-align:center;} .nav button:hover{background:var(--surface);color:var(--fg);} .nav button.active{background:var(--accent-soft);color:var(--accent);}
.nav button .badge{margin-left:auto;background:var(--red);color:#fff;font-size:9.5px;font-weight:800;border-radius:999px;padding:1px 6px;}
.main{grid-area:main;overflow-y:auto;padding:18px 22px;} .main::-webkit-scrollbar,.nav::-webkit-scrollbar,.tblwrap::-webkit-scrollbar{width:7px;} .main::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.09);border-radius:4px;}
.phead{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;margin-bottom:14px;} .phead h2{font-size:20px;margin:0;font-weight:800;letter-spacing:-0.02em;} .phead p{margin:2px 0 0;color:var(--muted);font-size:12.5px;}
.grid{display:grid;gap:12px;} .card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:15px;}
.kpi .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;font-weight:800;display:flex;align-items:center;gap:6px;} .kpi .v{font-size:26px;font-weight:800;font-variant-numeric:tabular-nums;margin-top:3px;} .kpi .d{font-size:11.5px;font-weight:600;margin-top:1px;}
.list-h{font-size:10.5px;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);font-weight:800;margin:0 0 10px;display:flex;align-items:center;gap:8px;} .list-h .r{margin-left:auto;font-weight:600;text-transform:none;letter-spacing:0;}
.btn{border:none;border-radius:10px;padding:9px 14px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit;transition:.12s;} .btn.primary{background:var(--accent);color:#fff;} .btn.primary:hover{filter:brightness(1.08);} .btn.ghost{background:var(--surface2);color:var(--fg);border:1px solid var(--border);} .btn.red{background:var(--red);color:#2a0910;} .btn.green{background:var(--green);color:#06231a;} .btn.amber{background:var(--amber);color:#3a2600;} .btn.sm{padding:6px 10px;font-size:12px;} .btn:disabled{opacity:.4;cursor:not-allowed;} .btn:active{transform:scale(0.98);}
.input,select.input,textarea.input{width:100%;background:var(--slot);border:1px solid var(--border);border-radius:9px;padding:10px 11px;color:var(--fg);font-size:13.5px;font-family:inherit;outline:none;} .input:focus{border-color:var(--accent);} textarea.input{resize:vertical;min-height:64px;}
.flabel{font-size:10.5px;color:var(--muted);font-weight:800;text-transform:uppercase;letter-spacing:0.04em;margin:11px 2px 5px;display:block;}
.searchbar{display:flex;align-items:center;gap:9px;background:var(--slot);border:1px solid var(--border);border-radius:10px;padding:9px 12px;} .searchbar input{flex:1;background:none;border:none;color:var(--fg);font-family:inherit;font-size:13.5px;outline:none;}
.seg{display:inline-flex;background:var(--slot);border-radius:9px;padding:3px;} .seg button{border:none;background:none;color:var(--muted);font-weight:700;font-size:12px;padding:6px 12px;border-radius:7px;cursor:pointer;font-family:inherit;} .seg button.active{background:var(--accent);color:#fff;}
.pill{font-size:10px;font-weight:800;padding:3px 8px;border-radius:999px;display:inline-block;} .pill.g{background:rgba(52,211,153,0.16);color:var(--green);} .pill.a{background:rgba(245,178,61,0.16);color:var(--amber);} .pill.r{background:rgba(240,80,110,0.16);color:var(--red);} .pill.b{background:rgba(91,140,255,0.16);color:var(--accent);} .pill.v{background:rgba(165,107,255,0.16);color:var(--violet);} .pill.m{background:rgba(255,255,255,0.08);color:var(--muted);}
.tblwrap{overflow-x:auto;} table.tbl{width:100%;border-collapse:collapse;font-size:12.5px;} .tbl th{text-align:left;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;padding:8px 9px;border-bottom:1px solid var(--border);white-space:nowrap;} .tbl td{padding:9px 9px;border-bottom:1px solid var(--border);white-space:nowrap;} .tbl tr:last-child td{border:none;} .tbl tr:hover td{background:rgba(255,255,255,0.02);} .tbl .num{text-align:right;font-variant-numeric:tabular-nums;} .tbl .av{width:26px;height:26px;border-radius:50%;display:inline-grid;place-items:center;font-weight:800;color:#fff;font-size:10px;vertical-align:middle;margin-right:7px;}
.barmini{height:7px;border-radius:4px;background:var(--slot);overflow:hidden;width:90px;display:inline-block;vertical-align:middle;} .barmini>i{display:block;height:100%;border-radius:4px;}
.flags{display:inline-flex;gap:4px;}
.quick{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;} .quick button{justify-content:flex-start;text-align:left;}
.actiongrid .ag-h{grid-column:1/-1;font-size:9.5px;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted);font-weight:800;margin:10px 2px 0;} .actiongrid .ag-h:first-child{margin-top:0;}
.vehgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;max-height:320px;overflow-y:auto;padding-right:4px;}
.vehcard{background:var(--slot);border:1px solid var(--border);border-radius:11px;padding:8px;cursor:pointer;font-family:inherit;color:var(--fg);transition:.12s;text-align:center;} .vehcard:hover{border-color:var(--accent);transform:translateY(-2px);}
.vehimg{aspect-ratio:16/10;border-radius:8px;overflow:hidden;background:linear-gradient(180deg,#1b2432,#0d1119);display:grid;place-items:center;} .vehimg svg{width:92%;}
.vehname{font-size:11.5px;font-weight:700;margin-top:6px;}
.veh-thumb{display:inline-block;width:46px;height:26px;vertical-align:middle;margin-right:8px;} .veh-thumb svg{width:100%;height:100%;}
.sw2{width:30px;height:30px;border-radius:8px;cursor:pointer;}
.qmenu{position:fixed;z-index:200;min-width:180px;background:rgba(16,20,28,0.98);border:1px solid var(--border);border-radius:12px;padding:5px;display:none;box-shadow:0 20px 50px -12px rgba(0,0,0,0.8);} .qmenu.show{display:block;animation:qm .12s ease;} @keyframes qm{from{opacity:0;transform:scale(.96);}to{opacity:1;}}
.qmenu .qm-h{padding:7px 10px 6px;font-size:12px;font-weight:800;border-bottom:1px solid var(--border);margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.qmenu button{display:flex;align-items:center;gap:9px;width:100%;text-align:left;border:none;background:none;color:var(--fg);font-family:inherit;font-size:13px;font-weight:600;padding:8px 10px;border-radius:8px;cursor:pointer;} .qmenu button:hover{background:var(--surface2);} .qmenu button.danger{color:var(--red);}
.actiongrid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:6px;} .actiongrid button{display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding:10px;border-radius:10px;background:var(--slot);border:1px solid var(--border);color:var(--fg);font-family:inherit;cursor:pointer;font-weight:700;font-size:12px;transition:.12s;} .actiongrid button:hover{border-color:var(--accent);} .actiongrid button small{color:var(--muted);font-weight:500;font-size:10px;} .actiongrid button.on{border-color:var(--green);background:rgba(52,211,153,0.1);} .actiongrid button.danger:hover{border-color:var(--red);}
.chk{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:9px;background:var(--slot);border:1px solid var(--border);margin-bottom:5px;cursor:pointer;} .chk.sel{border-color:var(--accent);background:var(--accent-soft);} .chk .av{width:26px;height:26px;border-radius:50%;display:grid;place-items:center;font-weight:800;color:#fff;font-size:10px;flex-shrink:0;}
/* Weltkarte */
.map{position:relative;border-radius:14px;overflow:hidden;border:1px solid var(--border);height:100%;
background:linear-gradient(90deg,transparent 49.6%,rgba(255,255,255,0.04) 49.6% 50.4%,transparent 50.4%),linear-gradient(0deg,transparent 49.6%,rgba(255,255,255,0.04) 49.6% 50.4%,transparent 50.4%),repeating-linear-gradient(90deg,rgba(255,255,255,0.02) 0 1px,transparent 1px 60px),repeating-linear-gradient(0deg,rgba(255,255,255,0.02) 0 1px,transparent 1px 60px),linear-gradient(135deg,#101823,#0a0f16);}
.map .water{position:absolute;right:-6%;bottom:-8%;width:44%;height:52%;background:radial-gradient(circle at 70% 70%,rgba(40,90,140,0.5),transparent 70%);border-radius:50% 0 0 0;}
.map .distr{position:absolute;font-size:10px;font-weight:800;letter-spacing:0.1em;color:rgba(255,255,255,0.14);text-transform:uppercase;}
.mk{position:absolute;transform:translate(-50%,-50%);z-index:2;cursor:pointer;transition:left 2.8s linear,top 2.8s linear;}
.mk .dot{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;font-size:9px;font-weight:800;color:#fff;border:2px solid rgba(255,255,255,0.85);box-shadow:0 2px 6px rgba(0,0,0,0.6);transition:transform .2s;}
.mk .lbl{position:absolute;top:24px;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:9px;font-weight:700;background:rgba(0,0,0,0.6);padding:1px 5px;border-radius:5px;}
.mk.dead .dot{background:var(--red)!important;animation:mkp 1.2s infinite;} @keyframes mkp{0%,100%{box-shadow:0 0 0 0 rgba(240,80,110,0.5);}50%{box-shadow:0 0 0 9px rgba(240,80,110,0);}}
/* Live-Kameras */
.camgrid{display:grid;gap:10px;} .camtile{position:relative;aspect-ratio:16/9;border-radius:12px;overflow:hidden;border:1px solid var(--border);background:#05070b;}
.camfeed{position:absolute;inset:0;} .camfeed .scene{position:absolute;inset:0;} .camfeed .scan{position:absolute;inset:0;background:repeating-linear-gradient(0deg,rgba(0,0,0,0.14) 0 2px,transparent 2px 4px);pointer-events:none;}
.camfeed .noise{position:absolute;inset:0;opacity:0.05;background-image:radial-gradient(rgba(255,255,255,0.8) 1px,transparent 1px);background-size:3px 3px;animation:noise .3s steps(2) infinite;} @keyframes noise{to{transform:translate(1px,1px);}}
.camfeed .sweep{position:absolute;left:0;right:0;height:40%;background:linear-gradient(180deg,rgba(255,255,255,0.05),transparent);animation:sweep 4s linear infinite;} @keyframes sweep{0%{top:-40%;}100%{top:100%;}}
.cam-hud{position:absolute;inset:0;z-index:2;font-family:ui-monospace,monospace;pointer-events:none;}
.cam-hud .rec{position:absolute;top:8px;right:10px;display:flex;align-items:center;gap:6px;font-size:10px;font-weight:800;color:#ff5a6e;} .cam-hud .rec i{width:8px;height:8px;border-radius:50%;background:#ff5a6e;animation:blink 1s infinite;} @keyframes blink{50%{opacity:0.2;}}
.cam-hud .who{position:absolute;top:8px;left:10px;font-size:11px;font-weight:800;color:#fff;text-shadow:0 1px 2px #000;}
.cam-hud .foot{position:absolute;bottom:8px;left:10px;right:10px;display:flex;justify-content:space-between;gap:8px;font-size:9.5px;color:rgba(255,255,255,0.85);text-shadow:0 1px 2px #000;}
.cam-hud .cross{position:absolute;top:50%;left:50%;width:26px;height:26px;transform:translate(-50%,-50%);opacity:0.55;} .cam-hud .cross::before,.cam-hud .cross::after{content:"";position:absolute;background:rgba(255,255,255,0.7);} .cam-hud .cross::before{left:50%;top:0;bottom:0;width:1px;transform:translateX(-50%);} .cam-hud .cross::after{top:50%;left:0;right:0;height:1px;transform:translateY(-50%);}
.camtile .ctrls{position:absolute;bottom:8px;right:8px;z-index:3;display:flex;gap:5px;} .camtile .ctrls button{border:none;background:rgba(0,0,0,0.55);color:#fff;border-radius:7px;padding:4px 8px;font-family:inherit;font-size:11px;font-weight:700;cursor:pointer;} .camtile .ctrls button:hover{background:rgba(0,0,0,0.85);}
.viewlist>button{width:100%;text-align:left;margin-bottom:6px;}
/* Killcams */
.kc{display:flex;gap:12px;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:10px;margin-bottom:8px;}
.kc .thumb{width:120px;aspect-ratio:16/9;border-radius:8px;overflow:hidden;flex-shrink:0;position:relative;border:1px solid var(--border);} .kc .thumb .scan{position:absolute;inset:0;background:repeating-linear-gradient(0deg,rgba(0,0,0,0.18) 0 2px,transparent 2px 4px);} .kc .thumb .pl{position:absolute;inset:0;display:grid;place-items:center;color:rgba(255,255,255,0.85);font-size:20px;} .kc .grow{flex:1;min-width:0;}
.overlay{position:fixed;inset:0;z-index:40;background:rgba(4,6,10,0.72);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;padding:22px;} .overlay.show{display:flex;}
.modal{width:560px;max-width:97%;max-height:90%;overflow-y:auto;background:rgba(18,22,30,0.98);border:1px solid var(--border);border-radius:16px;padding:20px;box-shadow:0 30px 70px -18px rgba(0,0,0,0.8);} .modal.sm{width:400px;}
.modal h3{margin:0 0 3px;font-size:18px;} .modal .msub{color:var(--muted);font-size:12.5px;margin-bottom:12px;} .modal-actions{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:14px;}
.kv{display:flex;justify-content:space-between;font-size:12.5px;padding:5px 0;border-bottom:1px solid var(--border);} .kv:last-child{border:none;} .kv span:first-child{color:var(--muted);}
.toast{position:fixed;bottom:16px;left:50%;transform:translateX(-50%) translateY(16px);background:rgba(12,15,22,0.97);border:1px solid var(--border);color:var(--fg);padding:10px 18px;border-radius:11px;font-size:13px;font-weight:600;z-index:50;opacity:0;pointer-events:none;transition:.3s;} .toast.show{opacity:1;transform:translateX(-50%) translateY(0);} .toast.good{border-color:var(--green);} .toast.warn{border-color:var(--red);}
.empty{text-align:center;color:var(--muted);padding:36px 10px;} .empty .big{font-size:36px;margin-bottom:8px;}
.chart svg{width:100%;display:block;}
/* Zeilen-Layout (Moderation, Weltkarte, Einstellungen) */
.row{display:flex;align-items:center;gap:10px;padding:9px 11px;border-radius:9px;background:var(--slot);border:1px solid var(--border);}
.row .grow,.grow{flex:1;min-width:0;}
.row .t{font-weight:700;font-size:13px;} .row .m{font-size:11.5px;color:var(--muted);margin-top:1px;}
.mono{font-family:ui-monospace,"SF Mono",Menlo,monospace;font-variant-numeric:tabular-nums;}
.swatches{display:flex;flex-wrap:wrap;gap:8px;}
/* WebRTC-Video-Kacheln */
.camtile video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#05070b;}
.camtile .cam-hud .who{position:absolute;bottom:8px;left:10px;top:auto;}
.camgrid .empty{grid-column:1/-1;}

View File

@ -0,0 +1,28 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Aether Admin</title>
<link rel="stylesheet" href="css/app.css">
</head>
<body>
<!-- Panel (per F9 im Spiel geöffnet) -->
<div class="admin" id="panel" hidden>
<div class="brand"><span class="bl">🛡️</span><div class="bn">Aether Admin<small>Server-Verwaltung</small></div></div>
<div class="top" id="top"></div>
<div class="nav" id="nav"></div>
<div class="main" id="main"></div>
</div>
<!-- Dialog-Ebene und Kurzmeldungen (über allem) -->
<div class="overlay" id="overlay"></div>
<div class="toast" id="toast"></div>
<script src="js/app.js"></script>
<script src="js/sections.js"></script>
<script src="js/webrtc.js"></script>
</body>
</html>

View File

@ -0,0 +1,202 @@
/* =====================================================================
aether-admin — Oberflächenkern
Die Shell (Seitenleiste, Kopfleiste, Dialoge, Meldungen) und die
einzige Brücke zum Server: `App.aufruf(modul, aktion, daten)`. Jede
Sektion rendert aus echten Serverdaten — die Oberfläche hält keinen
eigenen Spielzustand und entscheidet nichts. Rechte werden serverseitig
geprüft; die UI blendet nur aus, was ohnehin abgelehnt würde.
===================================================================== */
"use strict";
const IST_FIVEM = (typeof GetParentResourceName === "function");
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-admin";
const App = (() => {
const zustand = {
admin: { name: "—", gruppe: "user" },
sektionen: [],
rechte: {},
media: { aktiv: false },
serverName: "Aether RP",
sektion: "dashboard",
};
const sektionen = {}; // [id] = renderfunktion
const offeneRufe = {}; // [ruf] = { ok, fail }
let rufZaehler = 0;
let mapTimer = null;
const $ = (id) => document.getElementById(id);
/* ---- Helfer -------------------------------------------------------- */
function sicher(t) {
if (t === null || t === undefined) return "";
return String(t).replace(/[&<>"']/g,
(z) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[z]));
}
const euro = (n) => Math.round(n || 0).toLocaleString("de-DE") + " €";
const kompakt = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + " Mio"
: n >= 1e3 ? (n / 1e3).toFixed(1) + "k" : "" + Math.round(n || 0);
const initialen = (n) => String(n || "?").replace(/\s*\(.*\)/, "").split(" ")
.map((w) => w[0]).slice(0, 2).join("");
function avColor(n) {
let h = 0; for (const c of String(n || "")) h = (h * 31 + c.charCodeAt(0)) % 360;
return `hsl(${h} 45% 45%)`;
}
function zeit(w) {
if (!w) return "—";
const d = new Date(String(w).replace(" ", "T"));
if (isNaN(d)) return sicher(w);
return d.toLocaleString("de-DE",
{ day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function darf(schluessel) { return zustand.rechte[schluessel] === true; }
/* ---- Server-Brücke ------------------------------------------------- */
function anClient(pfad, daten) {
if (!IST_FIVEM) return Promise.resolve({});
return fetch(`https://${RESSOURCE}/${pfad}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(daten || {}),
}).then((a) => a.json()).catch(() => ({}));
}
/**
* Ruft eine Serveraktion auf und wartet auf die Antwort.
* @returns {Promise<object>}
*/
function aufruf(modul, aktion, daten) {
return new Promise((ok, fail) => {
if (!IST_FIVEM) { fail(new Error("Vorschaumodus — keine Serververbindung.")); return; }
const ruf = ++rufZaehler;
offeneRufe[ruf] = { ok, fail };
anClient("aufruf", { modul, aktion, daten: daten || {}, ruf });
setTimeout(() => {
if (offeneRufe[ruf]) {
delete offeneRufe[ruf];
fail(new Error("Zeitüberschreitung — der Server hat nicht geantwortet."));
}
}, 15000);
});
}
/* ---- Meldungen & Dialoge ------------------------------------------- */
let toastTimer = null;
function toast(text, typ) {
const e = $("toast");
e.textContent = text;
e.className = "toast show" + (typ ? " " + typ : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => e.classList.remove("show"), 2600);
}
function modal(html, klein) {
$("overlay").innerHTML = `<div class="modal${klein ? " sm" : ""}">${html}</div>`;
$("overlay").classList.add("show");
}
function closeModal() { $("overlay").classList.remove("show"); $("overlay").innerHTML = ""; }
/* ---- Kopf & Navigation --------------------------------------------- */
function renderTop() {
$("top").innerHTML =
`<div class="srv-chip"><span class="srv-dot"></span>${sicher(zustand.serverName)}</div>
<div class="live" id="top-live"></div>
<div class="me"><span class="av" style="background:${avColor(zustand.admin.name)}">${sicher(initialen(zustand.admin.name))}</span>
<div><div class="mn">${sicher(zustand.admin.name)}</div><div class="mr">${sicher(zustand.admin.gruppe)}</div></div></div>`;
}
function renderNav() {
$("nav").innerHTML = zustand.sektionen.map((it) => it.grp
? `<div class="grp">${sicher(it.grp)}</div>`
: `<button data-s="${sicher(it.id)}" class="${it.id === zustand.sektion ? "active" : ""}">
<span class="ni">${sicher(it.icon)}</span>${sicher(it.label)}
<span class="badge" id="badge-${sicher(it.id)}" hidden></span></button>`).join("");
$("nav").querySelectorAll("[data-s]").forEach((b) =>
b.addEventListener("click", () => oeffneSektion(b.dataset.s)));
}
function oeffneSektion(id) {
clearInterval(mapTimer); mapTimer = null;
zustand.sektion = id;
renderNav();
const rendern = sektionen[id] || sektionen.dashboard;
$("main").innerHTML = `<div class="empty"><div class="big">⏳</div>Lädt …</div>`;
Promise.resolve()
.then(() => rendern($("main")))
.catch((f) => {
$("main").innerHTML = `<div class="phead"><div><h2>Fehler</h2>
<p>${sicher(f.message)}</p></div></div>`;
});
}
/* ---- Öffnen / Nachrichten vom Client ------------------------------- */
function zeigePanel(daten) {
zustand.admin = daten.admin || zustand.admin;
zustand.sektionen = daten.sektionen || [];
zustand.rechte = daten.rechte || {};
zustand.media = daten.media || { aktiv: false };
zustand.serverName = daten.serverName || "Aether RP";
$("panel").hidden = false;
document.body.style.display = "";
renderTop();
renderNav();
// Erste sichtbare Sektion öffnen
const erste = zustand.sektionen.find((s) => s.id);
oeffneSektion(erste ? erste.id : "dashboard");
}
function schliessePanel() {
$("panel").hidden = true;
document.body.style.display = "none";
closeModal();
clearInterval(mapTimer); mapTimer = null;
}
window.addEventListener("message", (e) => {
const d = e.data || {};
switch (d.aktion) {
case "oeffnen": zeigePanel(d); break;
case "schliessen": schliessePanel(); break;
case "antwort": {
const offen = offeneRufe[d.ruf];
if (!offen) break;
delete offeneRufe[d.ruf];
if (d.fehler) offen.fail(new Error(d.fehler));
else offen.ok(d.ergebnis || {});
break;
}
}
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if ($("overlay").classList.contains("show")) { closeModal(); return; }
schliessen();
});
function schliessen() { anClient("schliessen", {}); schliessePanel(); }
/* ---- Öffentliche Schnittstelle ------------------------------------- */
return {
registriere(id, fn) { sektionen[id] = fn; },
zustand, aufruf, anClient, toast, modal, closeModal, schliessen,
sicher, euro, kompakt, initialen, avColor, zeit, darf,
renderNav, oeffneSektion, $,
setMapTimer(t) { clearInterval(mapTimer); mapTimer = t; },
start() {
if (IST_FIVEM) document.body.style.display = "none";
else {
// Eigenständige Browser-Vorschau: nur der Rahmen, keine Daten
$("panel").hidden = false;
zustand.sektionen = [{ grp: "Vorschau" }, { id: "dashboard", label: "Dashboard", icon: "📊" }];
renderTop(); renderNav();
$("main").innerHTML = `<div class="empty"><div class="big">🖥️</div>
Vorschaumodus — im Spiel mit <b>F9</b> öffnen. Ohne Server keine Live-Daten.</div>`;
}
},
};
})();
App.start();

View File

@ -0,0 +1,488 @@
/* =====================================================================
aether-admin — Sektionen
Jede Sektion lädt ihre Daten frisch vom Server und ruft für Eingriffe
`App.aufruf(...)`. Kein lokaler Spielzustand — was hier steht, kommt
vom Server, und was hier gedrückt wird, prüft der Server erneut.
===================================================================== */
"use strict";
(() => {
const { sicher, euro, kompakt, initialen, avColor, zeit, darf } = App;
const A = (m, a, d) => App.aufruf(m, a, d);
const neu = () => App.oeffneSektion(App.zustand.sektion);
const toast = App.toast, modal = App.modal, closeModal = App.closeModal;
const $ = App.$;
const pingPill = (p) => `<span class="pill ${p < 60 ? "g" : p < 120 ? "a" : "r"}">${p} ms</span>`;
function flags(p) {
let f = "";
if (p.god) f += '<span class="pill v">GOD</span>';
if (p.frozen) f += '<span class="pill b">FROZEN</span>';
if (p.muted) f += '<span class="pill a">MUTE</span>';
if (p.warns > 0) f += `<span class="pill r">${p.warns}⚠</span>`;
if (p.istTot) f += '<span class="pill r">†</span>';
return f || '<span class="pill m">—</span>';
}
const kopf = (titel, unter, rechts) =>
`<div class="phead"><div><h2>${sicher(titel)}</h2><p>${unter || ""}</p></div>${rechts || ""}</div>`;
/* ===== Flächen-Diagramm ============================================= */
function areaChart(werte, W, H, farbe) {
if (!werte || werte.length < 2) werte = [0, 0];
const min = Math.min(...werte), max = Math.max(...werte), span = (max - min) || 1;
const x = (i) => 6 + (W - 12) * (i / (werte.length - 1));
const y = (v) => 6 + (H - 12) * (1 - (v - min) / span);
let d = "M" + x(0).toFixed(1) + " " + y(werte[0]).toFixed(1);
for (let i = 1; i < werte.length; i++) d += " L" + x(i).toFixed(1) + " " + y(werte[i]).toFixed(1);
const uid = "g" + Math.random().toString(36).slice(2, 6);
return `<svg viewBox="0 0 ${W} ${H}" style="height:${H}px"><defs><linearGradient id="${uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${farbe}" stop-opacity="0.35"/><stop offset="100%" stop-color="${farbe}" stop-opacity="0"/></linearGradient></defs>
<path d="${d} L${x(werte.length - 1).toFixed(1)} ${H - 6} L${x(0).toFixed(1)} ${H - 6} Z" fill="url(#${uid})"/>
<path d="${d}" fill="none" stroke="${farbe}" stroke-width="2" vector-effect="non-scaling-stroke"/></svg>`;
}
/* ===== DASHBOARD ==================================================== */
App.registriere("dashboard", async (main) => {
const d = await A("dashboard", "laden");
if ($("top-live")) $("top-live").innerHTML =
`<span>Spieler <b>${d.online}/${d.slots}</b></span>
<span>Reports <b>${d.offeneReports}</b></span>
<span>Ressourcen <b>${d.ressourcen.laufend}/${d.ressourcen.gesamt}</b></span>`;
main.innerHTML = kopf("Dashboard", "Serverüberblick in Echtzeit") +
`<div class="grid" style="grid-template-columns:repeat(4,1fr)">
<div class="card kpi"><div class="l">👥 Spieler online</div><div class="v">${d.online}/${d.slots}</div><div class="d" style="color:var(--green)">${d.auslastung}% Auslastung</div></div>
<div class="card kpi"><div class="l">🎫 Offene Reports</div><div class="v" style="color:${d.offeneReports ? "var(--amber)" : "var(--green)"}">${d.offeneReports}</div><div class="d" style="color:var(--muted)">${d.hoherPing} mit hohem Ping</div></div>
<div class="card kpi"><div class="l">🔨 Aktive Bans</div><div class="v">${d.aktiveBans}</div><div class="d" style="color:var(--muted)">durchgesetzt</div></div>
<div class="card kpi"><div class="l">🧩 Ressourcen</div><div class="v">${d.ressourcen.laufend}<span style="font-size:16px;color:var(--muted)">/${d.ressourcen.gesamt}</span></div><div class="d" style="color:var(--muted)">laufen</div></div>
</div>
<div class="grid" style="grid-template-columns:1.6fr 1fr;margin-top:12px">
<div class="card"><div class="list-h">Spieler online · Verlauf <span class="r">jetzt ${d.online}</span></div><div class="chart">${areaChart(d.verlauf, 600, 150, "#5b8cff")}</div></div>
<div class="card"><div class="list-h">Schnellaktionen</div><div class="quick">
${darf("broadcast.senden") ? '<button class="btn ghost" data-q="broadcast">📢 Ankündigung</button>' : ""}
${darf("welt.wetter") ? '<button class="btn ghost" data-q="welt">🌦️ Weltsteuerung</button>' : ""}
${darf("ressourcen.neustart") ? '<button class="btn red" data-q="restart">⟳ Neustart ankündigen</button>' : ""}
</div></div>
</div>
<div class="card" style="margin-top:12px"><div class="list-h">Letzte Admin-Aktionen</div>
${(d.letzteLogs || []).map((l) => `<div class="kv"><span>${sicher(l.admin_name)}: ${sicher(l.aktion)}${l.ziel ? " · " + sicher(l.ziel) : ""}</span><span style="color:var(--muted)">${zeit(l.erstellt_am)}</span></div>`).join("") || '<div class="empty">Noch nichts protokolliert.</div>'}
</div>`;
main.querySelectorAll("[data-q]").forEach((b) => b.addEventListener("click", () => {
if (b.dataset.q === "restart") return neustartModal();
App.oeffneSektion(b.dataset.q);
}));
});
function neustartModal() {
modal(`<h3>Server-Neustart ankündigen</h3><div class="msub">Alle Spieler werden benachrichtigt.</div>
<label class="flabel">Countdown (Minuten)</label><input class="input" id="rs-min" type="number" value="5">
<label class="flabel">Grund (optional)</label><input class="input" id="rs-grund" placeholder="z. B. Update">
<div class="modal-actions"><button class="btn ghost" id="rs-c">Abbrechen</button><button class="btn red" id="rs-ok">Ankündigen</button></div>`, true);
$("rs-c").addEventListener("click", closeModal);
$("rs-ok").addEventListener("click", async () => {
try {
const r = await A("ressourcen", "neustart", { minuten: +$("rs-min").value, grund: $("rs-grund").value.trim() });
closeModal(); toast(r.hinweis || "Angekündigt", "warn");
} catch (f) { toast(f.message, "warn"); }
});
}
/* ===== SPIELER ===================================================== */
let spielerSuche = "", spielerFilter = "alle";
App.registriere("spieler", async (main) => {
const d = await A("spieler", "liste");
main._spieler = d.spieler; main._jobs = d.jobs;
main.innerHTML = kopf("Spieler", `${d.spieler.length} online · Verwaltung & Aktionen`,
`<div class="seg" id="sp-filter">
<button data-f="alle" class="${spielerFilter === "alle" ? "active" : ""}">Alle</button>
<button data-f="flagged" class="${spielerFilter === "flagged" ? "active" : ""}">Auffällig</button></div>`) +
`<div class="searchbar" style="margin-bottom:12px"><span>🔎</span><input id="sp-such" placeholder="Name oder Region…" value="${sicher(spielerSuche)}"></div>
<div class="card" style="padding:6px 12px"><div class="tblwrap"><table class="tbl"><thead><tr><th>ID</th><th>Spieler</th><th>Job / Fraktion</th><th>Region</th><th>Ping</th><th class="num">Geld</th><th>Status</th><th></th></tr></thead><tbody id="sp-body"></tbody></table></div></div>`;
const s = $("sp-such");
s.addEventListener("input", () => { spielerSuche = s.value.trim().toLowerCase(); zeichne(); });
main.querySelectorAll("#sp-filter [data-f]").forEach((b) =>
b.addEventListener("click", () => { spielerFilter = b.dataset.f; neu(); }));
zeichne();
function liste() {
return main._spieler.filter((p) => {
const q = !spielerSuche || (p.name + " " + p.region).toLowerCase().includes(spielerSuche);
const f = spielerFilter === "alle" || (p.warns > 0 || p.ping >= 120 || p.muted || p.istTot);
return q && f;
});
}
function zeichne() {
const el = $("sp-body"); if (!el) return;
el.innerHTML = liste().map((p) => `<tr>
<td class="num" style="color:var(--muted)">#${p.id}</td>
<td><span class="av" style="background:${avColor(p.name)}">${sicher(initialen(p.name))}</span>${sicher(p.name)}</td>
<td>${sicher(p.job)}${p.fraktion ? ` <span class="pill b">${sicher(p.fraktion)}</span>` : ""}</td>
<td style="color:var(--muted)">${sicher(p.region)}</td><td>${pingPill(p.ping)}</td>
<td class="num">${euro(p.cash + p.bank)}</td><td><span class="flags">${flags(p)}</span></td>
<td class="num"><button class="btn primary sm" data-p="${p.id}">Verwalten</button></td></tr>`).join("")
|| '<tr><td colspan="8" class="empty">Keine Spieler.</td></tr>';
el.querySelectorAll("[data-p]").forEach((b) => b.addEventListener("click", () => spielerModal(+b.dataset.p)));
}
});
async function spielerModal(id) {
let d;
try { d = await A("spieler", "details", { id }); } catch (f) { return toast(f.message, "warn"); }
const p = d.spieler;
const knopf = (a, icon, label, sub, extra) => darf("spieler." + a)
? `<button data-a="${a}" class="${extra || ""}">${icon} ${label}<small>${sub}</small></button>` : "";
modal(`<h3><span class="av" style="background:${avColor(p.name)};width:30px;height:30px">${sicher(initialen(p.name))}</span> ${sicher(p.name)} <span style="color:var(--muted);font-size:13px">#${p.id}</span></h3>
<div class="msub">${sicher(p.job)}${p.fraktion ? " · " + sicher(p.fraktion) : ""} · ${sicher(p.region)} · ${d.spielzeit || 0} Min · Gruppe ${sicher(d.gruppe)}</div>
<div class="grid" style="grid-template-columns:1fr 1fr;margin-bottom:10px">
<div class="card" style="padding:11px"><div class="kv"><span>Bargeld</span><span>${euro(p.cash)}</span></div><div class="kv"><span>Bank</span><span>${euro(p.bank)}</span></div><div class="kv"><span>Ping</span><span>${p.ping} ms</span></div></div>
<div class="card" style="padding:11px"><div class="kv"><span>Leben</span><span>${p.leben}</span></div><div class="kv"><span>Verwarnungen</span><span>${p.warns}</span></div><div class="kv"><span>Godmode</span><span>${p.god ? "an" : "aus"}</span></div></div></div>
<div class="actiongrid">
<div class="ag-h">Teleport & Ansicht</div>
${knopf("goto", "🧭", "Zu Spieler", "Teleport hin")}${knopf("bring", "📥", "Herholen", "zu mir")}
${knopf("spectate", "👁️", "Beobachten", "native")}${darf("kamera.oeffnen") ? '<button data-a="kamera">📹 Live-Kamera<small>WebRTC</small></button>' : ""}
<div class="ag-h">Zustand</div>
${knopf("heal", "❤️", "Heilen", "volle HP")}${knopf("revive", "✨", "Wiederbeleben", "revive")}${knopf("armor", "🛡️", "Rüstung", "+Armor")}
${knopf("freeze", "🧊", "Einfrieren", p.frozen ? "aktiv" : "toggle", p.frozen ? "on" : "")}
${knopf("god", "⭐", "Godmode", p.god ? "aktiv" : "toggle", p.god ? "on" : "")}
${knopf("mute", "🔇", "Stumm", p.muted ? "aktiv" : "toggle", p.muted ? "on" : "")}
<div class="ag-h">Geben & Setzen</div>
${knopf("geld", "💵", "Geld", "geben/nehmen")}${knopf("item", "🎁", "Item", "geben")}${knopf("fahrzeug", "🚗", "Fahrzeug", "spawnen")}${knopf("job", "💼", "Job/Fraktion", "setzen")}
<div class="ag-h">Moderation</div>
${knopf("warn", "⚠️", "Verwarnen", "Warn")}${knopf("notiz", "📝", "Notiz", "intern")}
${knopf("kick", "👢", "Kicken", "trennen", "danger")}${knopf("ban", "🔨", "Bannen", "sperren", "danger")}
</div>
${d.warns.length ? `<div class="list-h" style="margin-top:12px">Verwarnungen</div>${d.warns.map((w) => `<div class="kv"><span>${sicher(w.grund)}</span><span style="color:var(--muted)">${sicher(w.admin_name)} · ${zeit(w.erstellt_am)}</span></div>`).join("")}` : ""}
<div class="modal-actions" style="grid-template-columns:1fr"><button class="btn ghost" id="pm-close">Schließen</button></div>`);
$("pm-close").addEventListener("click", closeModal);
$("overlay").querySelectorAll("[data-a]").forEach((b) => b.addEventListener("click", () => spielerAktion(p, b.dataset.a, d)));
}
async function spielerAktion(p, a, d) {
const einfach = { goto: 1, bring: 1, spectate: 1, heal: 1, revive: 1, armor: 1, freeze: 1, god: 1, mute: 1 };
if (einfach[a]) {
try { await A("spieler", a, { id: p.id }); toast("Ausgeführt", "good"); spielerModal(p.id); neu(); }
catch (f) { toast(f.message, "warn"); }
return;
}
if (a === "kamera") { closeModal(); App.oeffneSektion("cams"); if (window.Kameras) window.Kameras.starte([p.id], p.name); return; }
if (a === "geld") return eingabeModal("Geld · " + p.name,
`<label class="flabel">Konto</label><select class="input" id="x-konto"><option value="bank" selected>Bank</option><option value="cash">Bargeld</option></select>
<label class="flabel">Betrag (negativ = entfernen)</label><input class="input" id="x-betrag" type="number">`,
() => A("spieler", "geld", { id: p.id, konto: $("x-konto").value, betrag: +$("x-betrag").value }), p);
if (a === "item") return eingabeModal("Item geben · " + p.name,
`<label class="flabel">Item</label><input class="input" id="x-item" placeholder="z. B. water">
<label class="flabel">Menge</label><input class="input" id="x-menge" type="number" value="1">`,
() => A("spieler", "item", { id: p.id, item: $("x-item").value.trim(), menge: +$("x-menge").value }), p);
if (a === "fahrzeug") return eingabeModal("Fahrzeug spawnen · " + p.name,
`<label class="flabel">Modell (Spawnname)</label><input class="input" id="x-modell" placeholder="z. B. adder">`,
() => A("spieler", "fahrzeug", { id: p.id, modell: $("x-modell").value.trim() }), p);
if (a === "job") return eingabeModal("Job / Fraktion · " + p.name,
`<label class="flabel">Job</label><select class="input" id="x-job">${(d.jobs || []).map((j) => `<option ${p.job === j ? "selected" : ""}>${sicher(j)}</option>`).join("")}</select>
<label class="flabel">Fraktion (leer = keine)</label><input class="input" id="x-frak" value="${sicher(p.fraktion || "")}">`,
() => A("spieler", "job", { id: p.id, charakterId: p.charakterId, job: $("x-job").value, fraktion: $("x-frak").value.trim() }), p);
if (a === "warn") return eingabeModal("Verwarnen · " + p.name,
`<label class="flabel">Grund</label><input class="input" id="x-grund" placeholder="z. B. Fail RP">`,
() => A("spieler", "warn", { id: p.id, grund: $("x-grund").value.trim() }), p);
if (a === "notiz") return eingabeModal("Interne Notiz · " + p.name,
`<textarea class="input" id="x-text" placeholder="Notiz…"></textarea>`,
() => A("spieler", "notiz", { id: p.id, text: $("x-text").value.trim() }), p);
if (a === "kick") return eingabeModal("Kicken · " + p.name,
`<label class="flabel">Grund</label><input class="input" id="x-grund" placeholder="Grund">`,
() => A("spieler", "kick", { id: p.id, grund: $("x-grund").value.trim() }), null, "red");
if (a === "ban") return eingabeModal("Bannen · " + p.name,
`<label class="flabel">Grund</label><input class="input" id="x-grund" placeholder="z. B. Cheating">
<label class="flabel">Dauer</label><select class="input" id="x-dauer"><option>1 Tag</option><option>3 Tage</option><option selected>7 Tage</option><option>30 Tage</option><option>permanent</option></select>`,
() => A("spieler", "ban", { id: p.id, grund: $("x-grund").value.trim(), dauer: $("x-dauer").value }), null, "red");
}
/** Kleiner Eingabedialog; `zielRefresh` = Spieler neu öffnen, sonst nur Liste */
function eingabeModal(titel, felder, aktion, zielRefresh, farbe) {
modal(`<h3>${sicher(titel)}</h3>${felder}
<div class="modal-actions"><button class="btn ghost" id="e-c">Abbrechen</button><button class="btn ${farbe || "primary"}" id="e-ok">Ausführen</button></div>`, true);
$("e-c").addEventListener("click", () => zielRefresh ? spielerModal(zielRefresh.id) : closeModal());
$("e-ok").addEventListener("click", async () => {
try { await aktion(); closeModal(); toast("Ausgeführt", "good"); if (zielRefresh) spielerModal(zielRefresh.id); neu(); }
catch (f) { toast(f.message, "warn"); }
});
}
/* ===== WELTKARTE =================================================== */
const frakFarbe = (f) => ({ police: "#4aa8ff", ambulance: "#f0506e" }[f] || "#34d39a");
App.registriere("weltkarte", async (main) => {
const d = await A("weltkarte", "laden");
main.innerHTML = kopf("Weltkarte", `${d.spieler.length} Spieler live · Marker anklicken`) +
`<div style="display:grid;grid-template-columns:1fr 252px;gap:12px;height:calc(100% - 58px);min-height:340px">
<div class="map" id="map"><div class="water"></div>
<div class="distr" style="left:12%;top:14%">Vinewood</div><div class="distr" style="left:58%;top:20%">Downtown</div><div class="distr" style="left:20%;top:74%">Vespucci</div>
<div id="marker"></div></div>
<div style="overflow-y:auto"><div class="list-h">Spieler <span class="r">${d.spieler.length}</span></div><div id="mapliste"></div></div></div>`;
zeichneMarker(d.spieler);
App.setMapTimer(setInterval(async () => {
try { const r = await A("weltkarte", "laden"); zeichneMarker(r.spieler); } catch (e) {}
}, 4000));
function zeichneMarker(sp) {
// GTA-Weltkoordinaten grob auf 0..100% abbilden
const zx = (x) => Math.max(2, Math.min(98, (x + 4000) / 8000 * 100));
const zy = (y) => Math.max(2, Math.min(98, (4000 - y) / 8000 * 100));
const mk = $("marker"); if (!mk) return;
mk.innerHTML = sp.map((p) => `<div class="mk${p.tot ? " dead" : ""}" data-mk="${p.id}" style="left:${zx(p.x)}%;top:${zy(p.y)}%"><div class="dot" style="background:${frakFarbe(p.fraktion)}">${sicher(initialen(p.name))}</div><div class="lbl">${sicher(p.name.split(" ")[0])}</div></div>`).join("");
const ml = $("mapliste");
if (ml) ml.innerHTML = sp.map((p) => `<div class="row" data-pq="${p.id}" style="margin-bottom:6px;cursor:pointer"><span class="av" style="width:28px;height:28px;font-size:10px;background:${frakFarbe(p.fraktion)}">${sicher(initialen(p.name))}</span><div class="grow"><div class="t">${sicher(p.name)}</div><div class="m">${p.ping} ms</div></div></div>`).join("");
mk.querySelectorAll("[data-mk]").forEach((m) => m.addEventListener("click", () => spielerModal(+m.dataset.mk)));
if (ml) ml.querySelectorAll("[data-pq]").forEach((r) => r.addEventListener("click", () => spielerModal(+r.dataset.pq)));
}
});
/* ===== FAHRZEUGE ================================================== */
App.registriere("fahrzeuge", async (main) => {
const d = await A("fahrzeuge", "laden");
main.innerHTML = kopf("Fahrzeuge", `${d.anzahl} in der Welt`,
`<div style="display:flex;gap:8px">${darf("fahrzeuge.aufraeumen") ? '<button class="btn ghost" id="fz-clean">🧹 Leere aufräumen</button>' : ""}${darf("fahrzeuge.spawn") ? '<button class="btn primary" id="fz-spawn">+ Spawnen</button>' : ""}</div>`) +
`<div class="card" style="padding:6px 12px"><div class="tblwrap"><table class="tbl"><thead><tr><th>Kennzeichen</th><th>Position</th><th>Status</th><th></th></tr></thead><tbody>
${d.fahrzeuge.map((v) => `<tr><td class="mono">${sicher(v.plate || "—")}</td>
<td style="color:var(--muted)">${v.x.toFixed(0)}, ${v.y.toFixed(0)}</td>
<td>${v.leer ? '<span class="pill m">leer</span>' : '<span class="pill g">besetzt</span>'}</td>
<td class="num">${darf("fahrzeuge.reparieren") ? `<button class="btn ghost sm" data-fix="${v.netId}">Reparieren</button>` : ""}${darf("fahrzeuge.herholen") ? ` <button class="btn ghost sm" data-her="${v.netId}">Herholen</button>` : ""}${darf("fahrzeuge.loeschen") ? ` <button class="btn red sm" data-del="${v.netId}">Löschen</button>` : ""}</td></tr>`).join("") || '<tr><td colspan="4" class="empty">Keine Fahrzeuge.</td></tr>'}
</tbody></table></div></div>`;
main._spieler = d.spieler;
const bind = (sel, m, a, extra) => main.querySelectorAll(sel).forEach((b) => b.addEventListener("click", async () => {
try { const r = await A("fahrzeuge", a, Object.assign({ netId: +b.dataset[extra] }, {})); toast(r.entfernt !== undefined ? r.entfernt + " entfernt" : "Erledigt", "good"); neu(); }
catch (f) { toast(f.message, "warn"); }
}));
bind("[data-fix]", "fahrzeuge", "reparieren", "fix");
bind("[data-her]", "fahrzeuge", "herholen", "her");
bind("[data-del]", "fahrzeuge", "loeschen", "del");
const cl = $("fz-clean"); if (cl) cl.addEventListener("click", async () => { try { const r = await A("fahrzeuge", "aufraeumen"); toast(r.entfernt + " entfernt", "good"); neu(); } catch (f) { toast(f.message, "warn"); } });
const sp = $("fz-spawn"); if (sp) sp.addEventListener("click", () => {
modal(`<h3>Fahrzeug spawnen</h3><label class="flabel">Für Spieler</label><select class="input" id="fs-ziel"><option value="">— du selbst —</option>${(main._spieler || []).map((s) => `<option value="${s.id}">${sicher(s.name)} (#${s.id})</option>`).join("")}</select>
<label class="flabel">Modell</label><input class="input" id="fs-modell" placeholder="z. B. adder">
<div class="modal-actions"><button class="btn ghost" id="fs-c">Abbrechen</button><button class="btn primary" id="fs-ok">Spawnen</button></div>`, true);
$("fs-c").addEventListener("click", closeModal);
$("fs-ok").addEventListener("click", async () => {
try { await A("fahrzeuge", "spawn", { ziel: $("fs-ziel").value ? +$("fs-ziel").value : null, modell: $("fs-modell").value.trim() }); closeModal(); toast("Gespawnt", "good"); neu(); }
catch (f) { toast(f.message, "warn"); }
});
});
});
/* ===== WELTSTEUERUNG ============================================== */
App.registriere("welt", async (main) => {
const wetter = ["Klar", "Bewölkt", "Regen", "Gewitter", "Nebel", "Schnee", "Sturm"];
main.innerHTML = kopf("Weltsteuerung", "Zeit, Wetter & Umgebung") +
`<div class="grid" style="grid-template-columns:1fr 1fr">
<div class="card"><div class="list-h">🕒 Zeit</div>
<input type="range" min="0" max="23" value="12" id="w-stunde" style="width:100%;accent-color:var(--accent)">
<div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap"><button class="btn ghost sm" data-h="6">Morgen</button><button class="btn ghost sm" data-h="12">Mittag</button><button class="btn ghost sm" data-h="18">Abend</button><button class="btn ghost sm" data-h="0">Nacht</button></div>
<button class="btn ghost" id="w-zeitfix" style="width:100%;margin-top:10px">Zeit einfrieren (auf Reglerwert)</button></div>
<div class="card"><div class="list-h">🌦️ Wetter</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">${wetter.map((w) => `<button class="btn ghost sm" data-w="${w}">${w}</button>`).join("")}</div>
<button class="btn amber" id="w-blackout" style="width:100%;margin-top:10px">🌑 Blackout umschalten</button></div></div>
<div class="card" style="margin-top:12px"><div class="list-h">🧹 Bereinigung</div><div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn ghost" data-clean="veh">Leere Fahrzeuge</button><button class="btn ghost" data-clean="ped">NPCs</button><button class="btn ghost" data-clean="obj">Objekte</button><button class="btn ghost" data-clean="ground">Leichen</button></div></div>`;
const setze = async (m, a, dd, txt) => { try { await A(m, a, dd); toast(txt, "good"); } catch (f) { toast(f.message, "warn"); } };
main.querySelectorAll("[data-h]").forEach((b) => b.addEventListener("click", () => { $("w-stunde").value = b.dataset.h; setze("welt", "zeit", { stunde: +b.dataset.h }, "Zeit " + b.dataset.h + ":00"); }));
$("w-zeitfix").addEventListener("click", () => setze("welt", "zeit", { stunde: +$("w-stunde").value, fix: true }, "Zeit eingefroren"));
main.querySelectorAll("[data-w]").forEach((b) => b.addEventListener("click", () => setze("welt", "wetter", { wetter: b.dataset.w }, "Wetter: " + b.dataset.w)));
$("w-blackout").addEventListener("click", () => setze("welt", "blackout", {}, "Blackout umgeschaltet"));
main.querySelectorAll("[data-clean]").forEach((b) => b.addEventListener("click", async () => { try { const r = await A("welt", "cleanup", { art: b.dataset.clean }); toast(r.entfernt + " entfernt", "good"); } catch (f) { toast(f.message, "warn"); } }));
});
/* ===== ANKÜNDIGUNGEN ============================================= */
App.registriere("broadcast", async (main) => {
const d = await A("broadcast", "laden");
main.innerHTML = kopf("Ankündigungen", "Nachricht an alle Spieler") +
`<div class="grid" style="grid-template-columns:1fr 1fr;align-items:start"><div class="card">
<label class="flabel">Typ</label><select class="input" id="bc-typ"><option>Info</option><option>Warnung</option><option>Event</option></select>
<label class="flabel">Nachricht</label><textarea class="input" id="bc-msg" placeholder="An den ganzen Server…"></textarea>
<button class="btn primary" id="bc-send" style="width:100%;margin-top:12px">📢 Senden</button></div>
<div class="card"><div class="list-h">Verlauf</div>${(d.verlauf || []).map((b) => `<div class="kv"><span><span class="pill ${b.typ === "Warnung" ? "r" : b.typ === "Event" ? "v" : "b"}">${sicher(b.typ)}</span> ${sicher(b.nachricht)}</span><span style="color:var(--muted)">${zeit(b.erstellt_am)}</span></div>`).join("") || '<div class="empty">Noch keine.</div>'}</div></div>`;
$("bc-send").addEventListener("click", async () => {
const m = $("bc-msg").value.trim(); if (!m) return toast("Nachricht eingeben", "warn");
try { await A("broadcast", "senden", { typ: $("bc-typ").value, nachricht: m }); toast("Gesendet", "good"); neu(); }
catch (f) { toast(f.message, "warn"); }
});
});
/* ===== MODERATION =============================================== */
App.registriere("moderation", async (main) => {
const d = await A("moderation", "laden");
main.innerHTML = kopf("Bans & Verwarnungen", `${d.bans.length} Bans · ${d.warns.length} Warns`,
darf("moderation.banManuell") ? '<button class="btn red" id="mod-ban">+ Manueller Ban</button>' : "") +
`<div class="grid" style="grid-template-columns:1fr 1fr;align-items:start">
<div class="card"><div class="list-h">🔨 Aktive Bans</div>${d.bans.map((b) => `<div class="row" style="margin-bottom:6px"><div class="grow"><div class="t">${sicher(b.name)}</div><div class="m">${sicher(b.grund)} · ${b.bis ? "bis " + zeit(b.bis) : "permanent"} · ${sicher(b.admin_name)}</div></div>${darf("moderation.unban") ? `<button class="btn ghost sm" data-unban="${b.id}">Entbannen</button>` : ""}</div>`).join("") || '<div class="empty">Keine Bans.</div>'}</div>
<div class="card"><div class="list-h">⚠️ Verwarnungen</div>${d.warns.map((w) => `<div class="row" style="margin-bottom:6px"><div class="grow"><div class="t">${sicher(w.name)}</div><div class="m">${sicher(w.grund)} · ${sicher(w.admin_name)} · ${zeit(w.erstellt_am)}</div></div>${darf("moderation.warnWeg") ? `<button class="btn ghost sm" data-warn="${w.id}">Löschen</button>` : ""}</div>`).join("") || '<div class="empty">Keine Verwarnungen.</div>'}</div></div>`;
main.querySelectorAll("[data-unban]").forEach((b) => b.addEventListener("click", async () => { try { await A("moderation", "unban", { id: +b.dataset.unban }); toast("Entbannt", "good"); neu(); } catch (f) { toast(f.message, "warn"); } }));
main.querySelectorAll("[data-warn]").forEach((b) => b.addEventListener("click", async () => { try { await A("moderation", "warnWeg", { id: +b.dataset.warn }); toast("Gelöscht", "good"); neu(); } catch (f) { toast(f.message, "warn"); } }));
const mb = $("mod-ban"); if (mb) mb.addEventListener("click", () => {
modal(`<h3>Manueller Ban</h3><label class="flabel">Name oder Lizenz</label><input class="input" id="mb-name"><label class="flabel">Grund</label><input class="input" id="mb-grund"><label class="flabel">Dauer</label><select class="input" id="mb-dauer"><option>1 Tag</option><option>7 Tage</option><option>30 Tage</option><option selected>permanent</option></select><div class="modal-actions"><button class="btn ghost" id="mb-c">Abbrechen</button><button class="btn red" id="mb-ok">Bannen</button></div>`, true);
$("mb-c").addEventListener("click", closeModal);
$("mb-ok").addEventListener("click", async () => { const n = $("mb-name").value.trim(); if (!n) return toast("Name angeben", "warn"); try { await A("moderation", "banManuell", { name: n, grund: $("mb-grund").value.trim(), dauer: $("mb-dauer").value }); closeModal(); toast("Gebannt", "warn"); neu(); } catch (f) { toast(f.message, "warn"); } });
});
});
/* ===== REPORTS ================================================== */
App.registriere("reports", async (main) => {
const d = await A("reports", "laden");
const prioP = { 1: ["Prio 1", "r"], 2: ["Prio 2", "a"], 3: ["Prio 3", "b"] };
const stP = { offen: ["offen", "a"], bearbeitung: ["in Bearbeitung", "b"], geschlossen: ["geschlossen", "g"] };
main.innerHTML = kopf("Reports / Support", `${d.reports.filter((r) => r.status !== "geschlossen").length} offen · ${d.reports.length} gesamt`) +
`<div style="display:flex;flex-direction:column;gap:10px">${d.reports.map((r) => {
const pp = prioP[r.prioritaet] || ["?", "m"], sp = stP[r.status] || ["?", "m"];
return `<div class="card" style="display:flex;align-items:center;gap:16px;padding:14px 18px;border-left:3px solid var(--${pp[1] === "r" ? "red" : pp[1] === "a" ? "amber" : "accent"})">
<div class="grow"><div style="font-weight:800;font-size:14.5px">${sicher(r.betreff)}</div>
<div style="display:flex;gap:8px;margin-top:6px;flex-wrap:wrap"><span class="pill ${pp[1]}">${pp[0]}</span><span class="pill ${sp[1]}">${sp[0]}</span><span style="font-size:12px;color:var(--muted)">von <b style="color:var(--fg)">${sicher(r.melder_name)}</b>${r.bearbeiter ? " · " + sicher(r.bearbeiter) : ""}</span></div></div>
<div style="display:flex;gap:7px">${r.status === "geschlossen" ? `<button class="btn ghost sm" data-st="${r.id}:offen">Öffnen</button>` : `${darf("reports.claim") ? `<button class="btn ghost sm" data-claim="${r.id}">Übernehmen</button>` : ""}${darf("reports.antwort") ? `<button class="btn primary sm" data-ans="${r.id}:${sicher(r.melder_name)}">Antworten</button>` : ""}<button class="btn green sm" data-st="${r.id}:geschlossen">Schließen</button>`}</div></div>`;
}).join("") || '<div class="empty"><div class="big">🎫</div>Keine Reports.</div>'}</div>`;
main.querySelectorAll("[data-claim]").forEach((b) => b.addEventListener("click", async () => { try { await A("reports", "claim", { id: +b.dataset.claim }); toast("Übernommen", "good"); neu(); } catch (f) { toast(f.message, "warn"); } }));
main.querySelectorAll("[data-st]").forEach((b) => b.addEventListener("click", async () => { const [id, st] = b.dataset.st.split(":"); try { await A("reports", "status", { id: +id, status: st }); toast("Aktualisiert", "good"); neu(); } catch (f) { toast(f.message, "warn"); } }));
main.querySelectorAll("[data-ans]").forEach((b) => b.addEventListener("click", () => {
const [id, name] = b.dataset.ans.split(":");
modal(`<h3>Antwort · ${sicher(name)}</h3><textarea class="input" id="ra" placeholder="Antwort an den Spieler…"></textarea><div class="modal-actions"><button class="btn ghost" id="ra-c">Abbrechen</button><button class="btn primary" id="ra-ok">Senden</button></div>`, true);
$("ra-c").addEventListener("click", closeModal);
$("ra-ok").addEventListener("click", async () => { const t = $("ra").value.trim(); if (!t) return toast("Text eingeben", "warn"); try { await A("reports", "antwort", { id: +id, text: t }); closeModal(); toast("Gesendet", "good"); neu(); } catch (f) { toast(f.message, "warn"); } });
}));
});
/* ===== WIRTSCHAFT =============================================== */
App.registriere("wirtschaft", async (main) => {
const d = await A("wirtschaft", "laden");
main.innerHTML = kopf("Wirtschaft", "Geldmenge, Staatskasse & Steuern") +
`<div class="grid" style="grid-template-columns:repeat(4,1fr)">
<div class="card kpi"><div class="l">💶 Geld im Umlauf</div><div class="v">${kompakt(d.umlauf)}</div><div class="d" style="color:var(--muted)">${d.charaktere} Charaktere</div></div>
<div class="card kpi"><div class="l">🏛️ Staatskasse</div><div class="v">${kompakt(d.staatskasse)}</div></div>
<div class="card kpi"><div class="l">👤 Ø Vermögen</div><div class="v">${kompakt(d.charaktere ? d.umlauf / d.charaktere : 0)}</div></div>
<div class="card kpi"><div class="l">📊 Steuersatz</div><div class="v">${d.steuer}%</div></div></div>
${darf("wirtschaft.geldAlle") ? `<div class="card" style="margin-top:12px"><div class="list-h">Aktionen</div><div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn ghost" id="e-all">💸 Geld an alle</button><button class="btn ghost" id="e-kasse">🏛️ Staatskasse</button><button class="btn ghost" id="e-steuer">📊 Steuersatz</button></div></div>` : ""}`;
const dialog = (titel, feld, aktion) => { modal(`<h3>${titel}</h3>${feld}<div class="modal-actions"><button class="btn ghost" id="w-c">Abbrechen</button><button class="btn primary" id="w-ok">Speichern</button></div>`, true); $("w-c").addEventListener("click", closeModal); $("w-ok").addEventListener("click", async () => { try { await aktion(); closeModal(); toast("Gespeichert", "good"); neu(); } catch (f) { toast(f.message, "warn"); } }); };
const eAll = $("e-all"); if (eAll) eAll.addEventListener("click", () => dialog("Geld an alle", `<label class="flabel">Betrag pro Spieler (Bank)</label><input class="input" id="wx" type="number">`, () => A("wirtschaft", "geldAlle", { betrag: +$("wx").value })));
const eK = $("e-kasse"); if (eK) eK.addEventListener("click", () => dialog("Staatskasse", `<label class="flabel">Neuer Stand</label><input class="input" id="wx" type="number" value="${d.staatskasse}">`, () => A("wirtschaft", "kasse", { wert: +$("wx").value })));
const eS = $("e-steuer"); if (eS) eS.addEventListener("click", () => dialog("Steuersatz", `<label class="flabel">Prozent</label><input class="input" id="wx" type="number" value="${d.steuer}">`, () => A("wirtschaft", "steuer", { wert: +$("wx").value })));
});
/* ===== FRAKTIONEN ============================================== */
App.registriere("fraktionen", async (main) => {
const d = await A("fraktionen", "laden");
main.innerHTML = kopf("Fraktionen", `${d.fraktionen.length} Fraktionen`, darf("fraktionen.speichern") ? '<button class="btn primary" id="fr-neu">+ Neue</button>' : "") +
`<div class="grid" style="grid-template-columns:repeat(2,1fr)">${d.fraktionen.map((f) => `<div class="card"><div style="display:flex;align-items:center;gap:10px"><span class="av" style="background:${sicher(f.farbe)}">${sicher(initialen(f.name))}</span><div class="grow"><div class="t" style="font-weight:800">${sicher(f.name)}</div><div class="m">${f.mitglieder} Mitglieder · ${f.online} online</div></div><span class="pill ${f.gehalt ? "g" : "m"}">${f.gehalt ? euro(f.gehalt) + " /h" : "kein Payday"}</span></div>
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">${darf("fraktionen.speichern") ? `<button class="btn ghost sm" data-edit="${sicher(f.name)}">Bearbeiten</button>` : ""}${darf("fraktionen.payday") && f.gehalt ? `<button class="btn green sm" data-pay="${sicher(f.name)}">Payday</button>` : ""}${darf("fraktionen.loeschen") ? `<button class="btn red sm" data-del="${sicher(f.name)}">Löschen</button>` : ""}</div></div>`).join("") || '<div class="empty">Keine Fraktionen.</div>'}</div>`;
main._fr = d.fraktionen;
const frModal = (f) => { modal(`<h3>${f ? "Fraktion bearbeiten" : "Neue Fraktion"}</h3><label class="flabel">Name</label><input class="input" id="fn" value="${f ? sicher(f.name) : ""}" ${f ? "readonly" : ""}><label class="flabel">Gehalt (€/h)</label><input class="input" id="fg" type="number" value="${f ? f.gehalt : 0}"><label class="flabel">Farbe</label><input class="input" id="ff" value="${f ? sicher(f.farbe) : "#34d39a"}"><div class="modal-actions"><button class="btn ghost" id="f-c">Abbrechen</button><button class="btn primary" id="f-ok">Speichern</button></div>`, true); $("f-c").addEventListener("click", closeModal); $("f-ok").addEventListener("click", async () => { const n = $("fn").value.trim(); if (!n) return toast("Name angeben", "warn"); try { await A("fraktionen", "speichern", { name: n, gehalt: +$("fg").value, farbe: $("ff").value.trim() }); closeModal(); toast("Gespeichert", "good"); neu(); } catch (e) { toast(e.message, "warn"); } }); };
const frn = $("fr-neu"); if (frn) frn.addEventListener("click", () => frModal(null));
main.querySelectorAll("[data-edit]").forEach((b) => b.addEventListener("click", () => frModal(main._fr.find((x) => x.name === b.dataset.edit))));
main.querySelectorAll("[data-pay]").forEach((b) => b.addEventListener("click", async () => { try { const r = await A("fraktionen", "payday", { name: b.dataset.pay }); toast(r.anzahl + " ausgezahlt", "good"); } catch (f) { toast(f.message, "warn"); } }));
main.querySelectorAll("[data-del]").forEach((b) => b.addEventListener("click", async () => { try { await A("fraktionen", "loeschen", { name: b.dataset.del }); toast("Gelöscht", "warn"); neu(); } catch (f) { toast(f.message, "warn"); } }));
});
/* ===== IMMOBILIEN ============================================= */
App.registriere("immobilien", async (main) => {
const d = await A("immobilien", "laden");
main.innerHTML = kopf("Immobilien", `${d.immobilien.length} Objekte`) +
`<div class="card" style="padding:6px 12px"><div class="tblwrap"><table class="tbl"><thead><tr><th>Objekt</th><th>Lage</th><th>Besitzer</th><th class="num">Wert</th><th></th></tr></thead><tbody>
${d.immobilien.map((im) => `<tr><td>🏠 ${sicher(im.name)}</td><td style="color:var(--muted)">${sicher(im.ort || "—")}</td><td>${im.besitzerName ? sicher(im.besitzerName) : '<span class="pill m">frei</span>'}</td><td class="num">${euro(im.preis)}</td>
<td class="num">${darf("immobilien.zuweisen") ? `<button class="btn ghost sm" data-assign="${im.id}">Besitzer</button>` : ""}${im.besitzer && darf("immobilien.raeumen") ? ` <button class="btn red sm" data-evict="${im.id}">Räumen</button>` : ""}</td></tr>`).join("") || '<tr><td colspan="5" class="empty">Keine Objekte.</td></tr>'}
</tbody></table></div></div>`;
main.querySelectorAll("[data-evict]").forEach((b) => b.addEventListener("click", async () => { try { await A("immobilien", "raeumen", { id: +b.dataset.evict }); toast("Geräumt", "warn"); neu(); } catch (f) { toast(f.message, "warn"); } }));
main.querySelectorAll("[data-assign]").forEach((b) => b.addEventListener("click", () => {
modal(`<h3>Besitzer setzen</h3><label class="flabel">Online-Charakter</label><select class="input" id="im-o"><option value="">— frei —</option>${(d.spieler || []).map((s) => `<option value="${s.charakterId}">${sicher(s.name)}</option>`).join("")}</select><div class="modal-actions"><button class="btn ghost" id="im-c">Abbrechen</button><button class="btn primary" id="im-ok">Setzen</button></div>`, true);
$("im-c").addEventListener("click", closeModal);
$("im-ok").addEventListener("click", async () => { try { await A("immobilien", "zuweisen", { id: +b.dataset.assign, besitzer: $("im-o").value ? +$("im-o").value : null }); closeModal(); toast("Aktualisiert", "good"); neu(); } catch (f) { toast(f.message, "warn"); } });
}));
});
/* ===== STAATSPROJEKTE ======================================== */
App.registriere("staat", async (main) => {
const d = await A("staat", "laden");
main.innerHTML = kopf("Staatsprojekte", `Abstimmungen · Staatskasse ${euro(d.staatskasse)}`) +
d.projekte.map((p) => {
const positiv = p.zustimmung >= d.zustimmungMin;
let status = p.aktiviert ? '<span class="pill g">✔ Umgesetzt</span>' : p.offen ? '<span class="pill b">Abstimmung läuft</span>' : positiv ? '<span class="pill a">bereit</span>' : '<span class="pill r">abgelehnt</span>';
let aktion = "";
if (!p.aktiviert && p.offen && darf("staat.beenden")) aktion = `<button class="btn ghost sm" data-end="${p.id}">Abstimmung beenden</button>`;
else if (!p.aktiviert && !p.offen && positiv && darf("staat.aktivieren")) aktion = `<button class="btn green sm" data-act="${p.id}">Aktivieren</button>`;
else if (!p.aktiviert && !p.offen && !positiv && darf("staat.archivieren")) aktion = `<button class="btn ghost sm" data-arch="${p.id}">Archivieren</button>`;
return `<div class="card" style="margin-bottom:10px"><div style="display:flex;gap:12px"><div class="grow"><div style="font-weight:800;font-size:15px">${sicher(p.titel)} ${status}</div>
<div style="font-size:12.5px;color:var(--muted);margin:3px 0 8px">${sicher(p.beschreibung || "")} · Kosten: ${euro(p.kosten)}</div>
<div style="height:9px;border-radius:5px;background:var(--slot);overflow:hidden;display:flex"><div style="width:${p.zustimmung}%;background:var(--green)"></div><div style="width:${100 - p.zustimmung}%;background:var(--red)"></div></div>
<div style="font-size:11.5px;color:var(--muted);margin-top:5px">${p.ja} Dafür · ${p.nein} Dagegen · <b style="color:${positiv ? "var(--green)" : "var(--red)"}">${p.zustimmung}%</b> (nötig: ${d.zustimmungMin}%)</div></div>
<div style="display:flex;flex-direction:column;gap:6px">${aktion}</div></div></div>`;
}).join("") || '<div class="empty">Keine Projekte.</div>';
const act = async (a, id) => { try { await A("staat", a, { id }); toast("Erledigt", "good"); neu(); } catch (f) { toast(f.message, "warn"); } };
main.querySelectorAll("[data-end]").forEach((b) => b.addEventListener("click", () => act("beenden", +b.dataset.end)));
main.querySelectorAll("[data-act]").forEach((b) => b.addEventListener("click", () => act("aktivieren", +b.dataset.act)));
main.querySelectorAll("[data-arch]").forEach((b) => b.addEventListener("click", () => act("archivieren", +b.dataset.arch)));
});
/* ===== RESSOURCEN ============================================ */
App.registriere("ressourcen", async (main) => {
const d = await A("ressourcen", "laden");
main.innerHTML = kopf("Ressourcen", `${d.ressourcen.filter((r) => r.status === "started").length}/${d.ressourcen.length} aktiv`,
darf("ressourcen.neustart") ? '<button class="btn red" id="rz-restart">⟳ Neustart</button>' : "") +
`<div class="card" style="padding:6px 12px"><div class="tblwrap"><table class="tbl"><thead><tr><th>Ressource</th><th>Status</th><th>Version</th><th></th></tr></thead><tbody>
${d.ressourcen.map((r, i) => `<tr><td>🧩 ${sicher(r.name)}</td><td>${r.status === "started" ? '<span class="pill g">läuft</span>' : '<span class="pill r">' + sicher(r.status) + "</span>"}</td><td style="color:var(--muted)">${sicher(r.version)}</td>
<td class="num">${darf("ressourcen.steuern") ? (r.status === "started" ? `<button class="btn ghost sm" data-r="${sicher(r.name)}:restart">Restart</button> <button class="btn red sm" data-r="${sicher(r.name)}:stop">Stop</button>` : `<button class="btn green sm" data-r="${sicher(r.name)}:start">Start</button>`) : ""}</td></tr>`).join("")}
</tbody></table></div></div>`;
main.querySelectorAll("[data-r]").forEach((b) => b.addEventListener("click", async () => { const [name, aktion] = b.dataset.r.split(":"); try { await A("ressourcen", "steuern", { name, aktion }); toast(name + " " + aktion, "good"); neu(); } catch (f) { toast(f.message, "warn"); } }));
const rr = $("rz-restart"); if (rr) rr.addEventListener("click", neustartModal);
});
/* ===== AUDIT-LOG ============================================ */
let logSuche = "", logAdmin = "alle";
App.registriere("logs", async (main) => {
const d = await A("logs", "laden", { suche: logSuche, admin: logAdmin === "alle" ? "" : logAdmin });
main.innerHTML = kopf("Audit-Log", `${d.eintraege.length} Einträge`) +
`<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<div class="searchbar" style="flex:1;min-width:200px"><span>🔎</span><input id="lg-such" placeholder="Aktion, Ziel oder Detail…" value="${sicher(logSuche)}"></div>
<select class="input" id="lg-admin" style="width:auto"><option value="alle">Alle Admins</option>${(d.admins || []).map((a) => `<option ${a === logAdmin ? "selected" : ""}>${sicher(a)}</option>`).join("")}</select></div>
<div class="card" style="padding:6px 12px"><div class="tblwrap"><table class="tbl"><thead><tr><th>Zeit</th><th>Admin</th><th>Aktion</th><th>Ziel</th><th>Detail</th></tr></thead><tbody>
${d.eintraege.map((l) => `<tr><td class="mono" style="color:var(--muted)">${zeit(l.erstellt_am)}</td><td>${sicher(l.admin_name)}</td><td>${sicher(l.aktion)}</td><td style="color:var(--muted)">${sicher(l.ziel || "")}</td><td style="color:var(--muted)">${sicher(l.detail || "")}</td></tr>`).join("") || '<tr><td colspan="5" class="empty">Keine Einträge.</td></tr>'}
</tbody></table></div></div>`;
let t = null;
$("lg-such").addEventListener("input", (e) => { clearTimeout(t); t = setTimeout(() => { logSuche = e.target.value.trim(); neu(); }, 300); });
$("lg-admin").addEventListener("change", (e) => { logAdmin = e.target.value; neu(); });
});
/* ===== ADMIN-TEAM ========================================== */
App.registriere("team", async (main) => {
const d = await A("team", "laden");
const rolleP = { owner: "r", admin: "a", support: "b" };
main.innerHTML = kopf("Admin-Team", `${d.team.filter((a) => a.online).length}/${d.team.length} online`,
darf("team.speichern") ? '<button class="btn primary" id="tm-neu">+ Rolle vergeben</button>' : "") +
`<div class="grid" style="grid-template-columns:repeat(2,1fr)">${d.team.map((a) => `<div class="card"><div style="display:flex;align-items:center;gap:11px"><span class="av" style="width:40px;height:40px;background:${avColor(a.name)}">${sicher(initialen(a.name))}</span>
<div class="grow"><div style="font-weight:800">${sicher(a.name)}</div><div style="font-size:12px"><span class="pill ${rolleP[a.admin_group] || "m"}">${sicher(a.admin_group)}</span></div></div><span class="pill ${a.online ? "g" : "m"}">${a.online ? "online" : "offline"}</span></div>
${darf("team.entfernen") ? `<div style="display:flex;gap:8px;margin-top:12px"><button class="btn ghost sm" data-role="${sicher(a.license)}:${sicher(a.name)}">Rolle ändern</button><button class="btn red sm" data-del="${sicher(a.license)}:${sicher(a.name)}">Entfernen</button></div>` : ""}</div>`).join("") || '<div class="empty">Kein Team.</div>'}</div>
<div class="card" style="margin-top:12px;font-size:12.5px;color:var(--muted)">Die Rolle steht in <b style="color:var(--fg)">aether_users.admin_group</b> — der Core respektiert sie überall. <b style="color:var(--red)">owner</b> › <b style="color:var(--amber)">admin</b> › <b style="color:var(--accent)">support</b>.</div>`;
main._rollen = d.rollen;
const rollenModal = (lizenz, name) => { modal(`<h3>Rolle · ${sicher(name || "neuer Benutzer")}</h3>${lizenz ? "" : '<label class="flabel">Name oder Lizenz</label><input class="input" id="tr-name">'}<label class="flabel">Rolle</label><select class="input" id="tr-rolle">${main._rollen.map((r) => `<option>${r}</option>`).join("")}</select><div class="modal-actions"><button class="btn ghost" id="tr-c">Abbrechen</button><button class="btn primary" id="tr-ok">Speichern</button></div>`, true); $("tr-c").addEventListener("click", closeModal); $("tr-ok").addEventListener("click", async () => { try { await A("team", "speichern", { lizenz: lizenz || null, name: lizenz ? name : ($("tr-name") ? $("tr-name").value.trim() : ""), rolle: $("tr-rolle").value }); closeModal(); toast("Gespeichert", "good"); neu(); } catch (f) { toast(f.message, "warn"); } }); };
const tn = $("tm-neu"); if (tn) tn.addEventListener("click", () => rollenModal(null, null));
main.querySelectorAll("[data-role]").forEach((b) => b.addEventListener("click", () => { const [l, n] = b.dataset.role.split(":"); rollenModal(l, n); }));
main.querySelectorAll("[data-del]").forEach((b) => b.addEventListener("click", async () => { const [l, n] = b.dataset.del.split(":"); try { await A("team", "entfernen", { lizenz: l, name: n }); toast("Entfernt", "warn"); neu(); } catch (f) { toast(f.message, "warn"); } }));
});
/* ===== EINSTELLUNGEN ====================================== */
App.registriere("settings", async (main) => {
const d = await A("settings", "laden");
const sw = (k, label) => `<div class="row" style="margin:0 0 8px"><div class="grow"><div class="t">${label}</div></div><button class="btn ${d.schalter[k] ? "green" : "ghost"} sm" data-set="${k}">${d.schalter[k] ? "AN" : "AUS"}</button></div>`;
main.innerHTML = kopf("Einstellungen", "Server-Konfiguration") +
`<div class="grid" style="grid-template-columns:1fr 1fr;align-items:start"><div class="card"><div class="list-h">Allgemein</div>
<label class="flabel">Servername</label><input class="input" id="st-name" value="${sicher(d.servername)}">
<label class="flabel">Maximale Slots (aus Serverkonfig)</label><input class="input" value="${d.slots}" disabled>
<label class="flabel">Neustart-Intervall (Stunden)</label><input class="input" id="st-rs" type="number" value="${d.neustartStunden}">
<button class="btn primary sm" id="st-save" style="margin-top:12px">Speichern</button></div>
<div class="card"><div class="list-h">Regeln & Systeme</div>${sw("pvp", "PvP")}${sw("whitelist", "Whitelist")}${sw("voice", "Voice-Chat")}${sw("economy", "Wirtschaft")}</div></div>`;
main._schalter = Object.assign({}, d.schalter);
main.querySelectorAll("[data-set]").forEach((b) => b.addEventListener("click", () => { main._schalter[b.dataset.set] = !main._schalter[b.dataset.set]; b.className = "btn " + (main._schalter[b.dataset.set] ? "green" : "ghost") + " sm"; b.textContent = main._schalter[b.dataset.set] ? "AN" : "AUS"; }));
$("st-save").addEventListener("click", async () => { try { await A("settings", "speichern", { servername: $("st-name").value.trim(), neustartStunden: +$("st-rs").value, schalter: main._schalter }); toast("Gespeichert", "good"); } catch (f) { toast(f.message, "warn"); } });
});
})();

View File

@ -0,0 +1,247 @@
/* =====================================================================
aether-admin — Live-Kameras (echtes WebRTC)
Zwei Rollen laufen in dieser Datei, beide über eine WebSocket-
Signalisierung direkt zum Media-Server (SFU) — NICHT über FiveM:
• VIEWER — die geöffnete Panel-Ansicht. Empfängt die Streams der
beobachteten Spieler und zeigt sie als Video-Kacheln.
• PUBLISHER — läuft im Hintergrund auf dem Ziel-Client (auch ohne
geöffnetes Panel). Sendet seinen Aufnahme-Stream in den
Raum.
Die EHRLICHE Stelle: Die NUI/CEF kann das Spielbild NICHT selbst
abgreifen. Der Publisher nutzt daher `window.__aetherCapture` (die
Anbindung einer externen Quelle wie OBS-Virtual-Cam). Ist keine da,
sendet er ein deutlich beschriftetes Platzhalterbild — so ist die
ganze WebRTC-Strecke live und verifizierbar, und man sieht genau,
wo die echte Quelle angeklemmt wird.
===================================================================== */
"use strict";
(() => {
const EIS = [{ urls: "stun:stun.l.google.com:19302" }];
/* ================= PUBLISHER (Hintergrund, Ziel-Client) ============ */
const Publisher = (() => {
const raeume = {}; // [raum] = { ws, pc, stream }
/**
* Besorgt den zu sendenden MediaStream.
* Primärweg ist die Bildschirmfreigabe der NUI (getDisplayMedia) — echtes,
* flüssiges Video des Spielfensters, ganz ohne externes Werkzeug. Klappt sie
* (abhängig davon, ob das FiveM-CEF Bildschirmaufnahme erlaubt), sieht der
* Admin das Live-Bild. Sonst ein markierter Platzhalter.
* @returns {Promise<{stream:MediaStream, typ:string, fehler?:string}>}
*/
async function holeQuelle() {
// 1) Optionaler Override für Fortgeschrittene (z. B. bestimmtes Aufnahmegerät)
if (typeof window.__aetherCapture === "function") {
try { const s = await window.__aetherCapture(); if (s) return { stream: s, typ: "extern" }; } catch (e) {}
}
// 2) Bildschirmfreigabe — der eigentliche „keine externe Quelle"-Weg
try {
if (navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) {
const s = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: { ideal: 30, max: 60 } }, audio: false,
});
return { stream: s, typ: "screen" };
}
return { stream: platzhalterStream(), typ: "platzhalter", fehler: "getDisplayMedia nicht verfügbar" };
} catch (e) {
// Häufig: das FiveM-CEF verweigert die Freigabe (kein Nutzergesten-Kontext)
return { stream: platzhalterStream(), typ: "platzhalter", fehler: String(e && e.name || e) };
}
}
function platzhalterStream() {
const c = document.createElement("canvas");
c.width = 640; c.height = 360;
const ctx = c.getContext("2d");
(function zeichne() {
ctx.fillStyle = "#0b0f16"; ctx.fillRect(0, 0, 640, 360);
ctx.fillStyle = "#f0506e"; ctx.font = "bold 22px sans-serif";
ctx.fillText("Bildschirmfreigabe blockiert", 40, 150);
ctx.fillStyle = "#7e879b"; ctx.font = "14px sans-serif";
ctx.fillText("Das FiveM-CEF dieses Clients erlaubt keine", 40, 185);
ctx.fillText("Bildschirmaufnahme (getDisplayMedia).", 40, 208);
ctx.fillStyle = "#34d39a"; ctx.font = "13px monospace";
ctx.fillText(new Date().toLocaleTimeString("de-DE"), 40, 250);
requestAnimationFrame(zeichne);
})();
return c.captureStream(12);
}
async function starte(raum, url, token) {
if (raeume[raum]) return; // schon aktiv
const quelle = await holeQuelle();
const stream = quelle.stream;
// Dem Admin zurückmelden, ob echte Bildschirmfreigabe oder Platzhalter
try { App.anClient("kameraStatus", { raum: raum, typ: quelle.typ, fehler: quelle.fehler || "" }); } catch (e) {}
const pc = new RTCPeerConnection({ iceServers: EIS });
stream.getTracks().forEach((t) => pc.addTrack(t, stream));
const ws = new WebSocket(url);
raeume[raum] = { ws, pc, stream };
pc.onicecandidate = (e) => { if (e.candidate) sende(ws, { t: "ice", candidate: e.candidate }); };
ws.onopen = () => sende(ws, { t: "join", token: token });
ws.onmessage = async (ev) => {
const m = JSON.parse(ev.data);
if (m.t === "ready") {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
sende(ws, { t: "offer", sdp: pc.localDescription });
} else if (m.t === "answer") {
await pc.setRemoteDescription(m.sdp);
} else if (m.t === "ice" && m.candidate) {
try { await pc.addIceCandidate(m.candidate); } catch (e) {}
}
};
ws.onclose = () => stoppe(raum);
}
function stoppe(raum) {
const r = raeume[raum]; if (!r) return;
try { r.pc.close(); } catch (e) {}
try { r.ws.close(); } catch (e) {}
try { r.stream.getTracks().forEach((t) => t.stop()); } catch (e) {}
delete raeume[raum];
}
function stoppeAlle() { Object.keys(raeume).forEach(stoppe); }
return { starte, stoppe, stoppeAlle };
})();
/* ================= VIEWER (Panel-Ansicht) ========================== */
const Viewer = (() => {
let ws = null, pc = null, aktuellerRaum = null;
let aufTrack = null; // Rückruf, wenn ein Video-Track eintrifft
function verbinde(url, token, raum, onTrack) {
trenne();
aktuellerRaum = raum; aufTrack = onTrack;
pc = new RTCPeerConnection({ iceServers: EIS });
pc.ontrack = (e) => { if (aufTrack) aufTrack(e.streams[0], e.track); };
pc.onicecandidate = (e) => { if (e.candidate) sende(ws, { t: "ice", candidate: e.candidate }); };
ws = new WebSocket(url);
ws.onopen = () => sende(ws, { t: "join", token: token });
ws.onmessage = async (ev) => {
const m = JSON.parse(ev.data);
if (m.t === "offer") {
await pc.setRemoteDescription(m.sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sende(ws, { t: "answer", sdp: pc.localDescription });
} else if (m.t === "ice" && m.candidate) {
try { await pc.addIceCandidate(m.candidate); } catch (e) {}
}
};
}
function trenne() {
try { if (pc) pc.close(); } catch (e) {}
try { if (ws) ws.close(); } catch (e) {}
pc = null; ws = null; aktuellerRaum = null;
}
return { verbinde, trenne, raum: () => aktuellerRaum };
})();
function sende(ws, obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
/* ================= Nachrichten vom Client (Publisher steuern) ====== */
window.addEventListener("message", (e) => {
const d = e.data || {};
if (d.aktion === "webrtcPublish") Publisher.starte(d.raum, d.url, d.token);
else if (d.aktion === "webrtcStop") Publisher.stoppe(d.raum);
else if (d.aktion === "webrtcStopAll") Publisher.stoppeAlle();
else if (d.aktion === "kameraStatus") {
// Rückmeldung eines Ziel-Clients an den beobachtenden Admin
if (d.typ === "screen" || d.typ === "extern") App.toast(`📹 ${d.quelle || "Ziel"}: Bild live`, "good");
else App.toast(`📹 ${d.quelle || "Ziel"}: Bildschirmfreigabe blockiert (${d.fehler || "CEF"})`, "warn");
}
});
/* ================= Kamera-Sektion (Viewer-UI) ====================== */
let aktiveView = null; // { raum, ziele:[{id,name}] }
App.registriere("cams", async (main) => {
if (!App.zustand.media || !App.zustand.media.aktiv) {
main.innerHTML = `<div class="phead"><div><h2>Live-Kameras</h2><p>WebRTC-Spectate</p></div></div>
<div class="card"><div class="empty"><div class="big">📹</div>
Die Live-Kameras sind deaktiviert oder der Media-Server ist nicht konfiguriert.<br>
Siehe <b>media-server/README.md</b> und die Convar <b>aether_admin_media_secret</b>.</div></div>`;
return;
}
let spieler = [];
try { spieler = (await App.aufruf("spieler", "liste")).spieler || []; } catch (e) {}
main.innerHTML = `<div class="phead"><div><h2>Live-Kameras</h2><p>WebRTC-Spectate · Einzel- & Multi-Cam</p></div>
${aktiveView ? '<button class="btn red" id="cam-zu">View schließen</button>' : ""}</div>
<div style="display:grid;grid-template-columns:230px 1fr;gap:12px;align-items:start">
<div class="card"><div class="list-h">Spieler wählen</div><div id="cam-picker" style="max-height:420px;overflow-y:auto">
${spieler.map((p) => `<div class="chk" data-pv="${p.id}"><span class="av" style="background:${App.avColor(p.name)}">${App.sicher(App.initialen(p.name))}</span><div class="grow" style="font-weight:700;font-size:13px">${App.sicher(p.name)}<span style="color:var(--muted);font-weight:500"> · ${App.sicher(p.region)}</span></div></div>`).join("") || '<div class="empty">Niemand online.</div>'}
</div><button class="btn primary" id="cam-start" style="width:100%;margin-top:10px" disabled>View öffnen</button></div>
<div><div class="camgrid" id="cam-grid" style="grid-template-columns:repeat(2,1fr)">${aktiveView ? "" : '<div class="empty" style="padding:60px"><div class="big">📹</div>Spieler links wählen und „View öffnen".</div>'}</div></div></div>`;
const gewaehlt = new Set();
main.querySelectorAll("[data-pv]").forEach((el) => el.addEventListener("click", () => {
const id = +el.dataset.pv;
if (gewaehlt.has(id)) { gewaehlt.delete(id); el.classList.remove("sel"); }
else { gewaehlt.add(id); el.classList.add("sel"); }
$("cam-start").disabled = gewaehlt.size === 0;
}));
const $ = App.$;
$("cam-start").addEventListener("click", () => starteView([...gewaehlt],
spieler.filter((p) => gewaehlt.has(p.id)).map((p) => p.name).join(", ")));
const zu = $("cam-zu"); if (zu) zu.addEventListener("click", schliesseView);
if (aktiveView) baueKacheln(spieler);
});
async function starteView(ids, titel) {
if (!ids.length) return;
let antwort;
try { antwort = await App.aufruf("kamera", "oeffnen", { ziele: ids }); }
catch (f) { return App.toast(f.message, "warn"); }
aktiveView = { raum: antwort.raum, ziele: ids, titel: titel };
App.oeffneSektion("cams"); // neu rendern mit aktiver View
App.toast(`View geöffnet · ${antwort.ziele} Ziel(e)`, "good");
Viewer.verbinde(antwort.url, antwort.token, antwort.raum, (stream) => {
// Neuen Stream als Kachel einhängen
const grid = App.$("cam-grid"); if (!grid) return;
const platz = grid.querySelector(".empty"); if (platz) platz.remove();
const id = "cam_" + Math.random().toString(36).slice(2, 8);
const tile = document.createElement("div");
tile.className = "camtile";
tile.innerHTML = `<video autoplay playsinline muted></video>
<div class="cam-hud"><div class="rec"><i></i>LIVE</div><div class="who">${App.sicher(aktiveView.titel || "Kamera")}</div></div>`;
tile.querySelector("video").srcObject = stream;
grid.appendChild(tile);
});
}
async function schliesseView() {
if (!aktiveView) return;
Viewer.trenne();
try { await App.aufruf("kamera", "schliessen", { raum: aktiveView.raum }); } catch (e) {}
aktiveView = null;
App.oeffneSektion("cams");
}
function baueKacheln() {
// Bereits laufende View nach einem Re-Render: die Video-Tracks kommen
// erneut über pc.ontrack; hier wird nur der leere Zustand entfernt.
}
// Aus dem Spieler-Dialog aufrufbar: direkt eine View auf einen Spieler öffnen
window.Kameras = { starte: (ids, name) => starteView(ids, name) };
})();

View File

@ -0,0 +1,79 @@
# aether-admin — Media-Server (WebRTC-SFU)
Router für die Live-Kameras des Admin-Panels. Er läuft **außerhalb** von
FiveM als eigener Node-Prozess und macht zweierlei:
- **Signaling + Medien (WebSocket, Standard `:8787`)** — Publisher senden
ihren Stream, Viewer empfangen ihn. Der Server leitet die RTP-Pakete
weiter (Selective Forwarding Unit).
- **Token-Ausgabe (HTTP, Standard `:8788`)** — nur der FiveM-Server ruft
das auf, abgesichert über ein gemeinsames Geheimnis.
Der FiveM-Server routet **kein Video** — er prüft nur Rechte, stellt Token
aus und weist Ziel-Clients an zu senden.
## Einrichtung
```bash
cd resources/[aether]/aether-admin/media-server
npm install
AETHER_MEDIA_SECRET="ein-langes-zufaelliges-geheimnis" node server.js
```
### Umgebungsvariablen
| Variable | Standard | Bedeutung |
|---|---|---|
| `AETHER_MEDIA_SECRET` | *(leer)* | Gemeinsames Geheimnis. **Muss** gesetzt sein und exakt der FiveM-Convar `aether_admin_media_secret` entsprechen. |
| `AETHER_MEDIA_WS_PORT` | `8787` | WebSocket-Port (von den NUIs erreichbar). |
| `AETHER_MEDIA_HTTP_PORT` | `8788` | HTTP-Port für die Token-Ausgabe (nur vom FiveM-Server erreichbar). |
### Passende FiveM-Convars (in `server.cfg`)
```cfg
set aether_admin_media_secret "ein-langes-zufaelliges-geheimnis"
set aether_admin_media_http "http://127.0.0.1:8788"
```
Und in `resources/[aether]/aether-admin/shared/config.lua` die öffentliche
WebSocket-Adresse (die die Spieler-Clients erreichen):
```lua
AdminConfig.Media.url = 'ws://DEINE-OEFFENTLICHE-IP:8787'
```
> Für den Produktivbetrieb hinter TLS (`wss://`) einen Reverse-Proxy
> (nginx/Caddy) vor den WS-Port setzen und die `url` auf `wss://…` zeigen.
## Die Videoquelle
Der Publisher in `html/js/webrtc.js` holt sich den Stream in dieser Reihenfolge:
1. **`getDisplayMedia` — der Standardweg ohne externes Werkzeug.** Die NUI
gibt das Spielfenster über die Bildschirmfreigabe-API frei; das ist echtes,
flüssiges Video und braucht **kein OBS und keine Virtual-Cam**.
2. `window.__aetherCapture()` — optionaler Override, falls du eine bestimmte
Quelle erzwingen willst (z. B. ein Aufnahmegerät).
3. Ein rot beschrifteter **Platzhalter** — erscheint nur, wenn die
Bildschirmfreigabe abgelehnt/blockiert wurde, damit die Strecke trotzdem
nachweislich live ist.
**Die entscheidende Abhängigkeit:** Ob `getDisplayMedia` klappt, hängt davon
ab, ob das **FiveM-CEF des Ziel-Clients** Bildschirmaufnahme aus einer
Hintergrund-NUI erlaubt (Nutzergesten-/Berechtigungskontext). Erlaubt es das,
siehst du das Live-Bild flüssig im Panel. Verweigert es das, kann kein
Resource das erzwingen — dann bleibt nur eine externe Quelle über
`__aetherCapture` oder die native Spectate-Funktion (Knopf „Beobachten").
Das Panel meldet pro Ziel-Client zurück, was gegriffen hat: eine Toast-Meldung
„Bild live" oder „Bildschirmfreigabe blockiert (Fehlergrund)".
## Betrieb & Hinweise
- Als Dienst betreiben (pm2/systemd), Neustart bei Absturz.
- **Live-Test nötig:** Ein WebRTC-SFU lässt sich nur im laufenden Betrieb
sinnvoll prüfen. Je nach `werift`-Version können Details der Signaling-
oder RTP-API minimal abweichen — der Code ist die klar strukturierte
Referenz und an einer Stelle (`publisherTrack`) leicht anzupassen.
- Ohne gesetztes Geheimnis lehnt der Server jede Token-Ausgabe ab; das
Panel zeigt dann den Hinweis „Media-Server nicht erreichbar".

View File

@ -0,0 +1,18 @@
{
"name": "aether-admin-media",
"version": "0.1.0",
"private": true,
"description": "WebRTC-SFU + Signaling für die Live-Kameras von aether-admin",
"type": "commonjs",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"werift": "^0.19.4",
"ws": "^8.16.0"
}
}

View File

@ -0,0 +1,198 @@
/* =====================================================================
aether-admin — Media-Server (WebRTC-SFU + Signaling)
Zwei Ohren:
• HTTP (GRANT_PORT) — nur für den FiveM-Server. Der stellt hier
kurzlebige Zugangstoken aus (/grant) und räumt Räume ab (/revoke),
abgesichert über das gemeinsame Geheimnis ADMIN_SECRET.
• WebSocket (WS_PORT) — für die NUIs. Publisher senden ihren Stream,
Viewer empfangen ihn. Der Server routet die Medien (SFU): er nimmt
die RTP-Pakete eines Publishers und schreibt sie in Ausgangs-Tracks,
die an alle Viewer desselben Raums gehen.
Signaling-Protokoll (JSON über WS), passend zu html/js/webrtc.js:
Client → Server: { t:"join", token }
{ t:"offer", sdp } (Publisher ist Offerer)
{ t:"answer", sdp } (Viewer antwortet auf Server-Offer)
{ t:"ice", candidate }
Server → Client: { t:"ready" } (Publisher darf sein Offer senden)
{ t:"offer", sdp } (Server offeriert dem Viewer)
{ t:"answer", sdp } (Antwort an den Publisher)
{ t:"ice", candidate }
HINWEIS: WebRTC-SFUs lassen sich nur im laufenden Betrieb sinnvoll
prüfen. Dieser Server ist die Referenzimplementierung — je nach
werift-Version können Kleinigkeiten anzupassen sein (siehe README).
===================================================================== */
"use strict";
const http = require("http");
const { WebSocketServer } = require("ws");
const { RTCPeerConnection, MediaStreamTrack } = require("werift");
const WS_PORT = parseInt(process.env.AETHER_MEDIA_WS_PORT || "8787", 10);
const GRANT_PORT = parseInt(process.env.AETHER_MEDIA_HTTP_PORT || "8788", 10);
const ADMIN_SECRET = process.env.AETHER_MEDIA_SECRET || "";
const STUN = [{ urls: "stun:stun.l.google.com:19302" }];
if (!ADMIN_SECRET) {
console.warn("[media] WARNUNG: AETHER_MEDIA_SECRET ist leer — /grant lehnt alles ab.");
}
/* ---- Zustand ------------------------------------------------------- */
// token -> { raum, rolle, exp }
const grants = new Map();
// raum -> { publishers:Set, viewers:Set, tracks:MediaStreamTrack[] }
const raeume = new Map();
function raumHolen(id) {
if (!raeume.has(id)) raeume.set(id, { publishers: new Set(), viewers: new Set(), tracks: [] });
return raeume.get(id);
}
// Abgelaufene Token regelmäßig entfernen
setInterval(() => {
const jetzt = Date.now();
for (const [t, g] of grants) if (g.exp < jetzt) grants.delete(t);
}, 10000);
/* =====================================================================
HTTP: Token-Ausgabe (nur vom FiveM-Server)
===================================================================== */
const httpServer = http.createServer((req, res) => {
if (req.method !== "POST") { res.writeHead(405).end(); return; }
if (req.headers["x-admin-secret"] !== ADMIN_SECRET || !ADMIN_SECRET) {
res.writeHead(403).end('{"ok":false}'); return;
}
let body = "";
req.on("data", (c) => { body += c; if (body.length > 1e5) req.destroy(); });
req.on("end", () => {
let daten = {};
try { daten = JSON.parse(body || "{}"); } catch (e) {}
if (req.url === "/grant") {
const gueltig = Math.max(5, Math.min(300, +daten.gueltigSek || 30));
grants.set(daten.token, {
raum: String(daten.raum || ""),
rolle: daten.rolle === "publisher" ? "publisher" : "viewer",
exp: Date.now() + gueltig * 1000,
});
res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}');
} else if (req.url === "/revoke") {
raumAufloesen(String(daten.raum || ""));
res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}');
} else {
res.writeHead(404).end('{"ok":false}');
}
});
});
httpServer.listen(GRANT_PORT, () => console.log(`[media] HTTP /grant auf :${GRANT_PORT}`));
/* =====================================================================
WebSocket: Signaling + Medien
===================================================================== */
const wss = new WebSocketServer({ port: WS_PORT });
wss.on("listening", () => console.log(`[media] WebSocket auf :${WS_PORT}`));
wss.on("connection", (ws) => {
let sitzung = null; // wird nach gültigem join gesetzt
ws.on("message", async (roh) => {
let m; try { m = JSON.parse(roh.toString()); } catch (e) { return; }
// Erster Schritt: Beitritt mit gültigem Token
if (!sitzung) {
if (m.t !== "join") return;
const g = grants.get(m.token);
if (!g || g.exp < Date.now()) { ws.close(4001, "ungueltiges Token"); return; }
grants.delete(m.token); // Einmal-Token
const raum = raumHolen(g.raum);
const pc = new RTCPeerConnection({ iceServers: STUN });
sitzung = { rolle: g.rolle, raum: g.raum, pc, ws };
pc.onIceCandidate.subscribe((candidate) => {
sende(ws, { t: "ice", candidate: alsJson(candidate) });
});
if (g.rolle === "publisher") {
raum.publishers.add(sitzung);
// Publisher ist Offerer → wir bitten ihn, sein Offer zu schicken
pc.onTrack.subscribe((track) => publisherTrack(g.raum, track));
sende(ws, { t: "ready" });
} else {
raum.viewers.add(sitzung);
// Viewer bekommt alle bereits laufenden Tracks und ein Offer
for (const track of raum.tracks) pc.addTransceiver(track, { direction: "sendonly" });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
sende(ws, { t: "offer", sdp: pc.localDescription });
}
return;
}
// Laufende Verhandlung
const pc = sitzung.pc;
if (m.t === "offer") { // von einem Publisher
await pc.setRemoteDescription(m.sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sende(ws, { t: "answer", sdp: pc.localDescription });
} else if (m.t === "answer") { // von einem Viewer
await pc.setRemoteDescription(m.sdp);
} else if (m.t === "ice" && m.candidate) {
try { await pc.addIceCandidate(m.candidate); } catch (e) {}
}
});
ws.on("close", () => {
if (!sitzung) return;
const raum = raeume.get(sitzung.raum);
try { sitzung.pc.close(); } catch (e) {}
if (raum) { raum.publishers.delete(sitzung); raum.viewers.delete(sitzung); }
});
});
/* ---- Ein Publisher-Track kam an → an alle Viewer weiterreichen ----- */
async function publisherTrack(raumId, track) {
const raum = raeume.get(raumId);
if (!raum) return;
// Ausgangs-Track, in den wir die RTP-Pakete des Publishers schreiben
const fwd = new MediaStreamTrack({ kind: track.kind });
track.onReceiveRtp.subscribe((rtp) => { try { fwd.writeRtp(rtp); } catch (e) {} });
raum.tracks.push(fwd);
// Jedem laufenden Viewer den neuen Track zusätzlich anbieten (Renegotiation)
for (const viewer of raum.viewers) {
try {
viewer.pc.addTransceiver(fwd, { direction: "sendonly" });
const offer = await viewer.pc.createOffer();
await viewer.pc.setLocalDescription(offer);
sende(viewer.ws, { t: "offer", sdp: viewer.pc.localDescription });
} catch (e) { /* Viewer könnte gerade weg sein */ }
}
}
function raumAufloesen(raumId) {
const raum = raeume.get(raumId);
if (!raum) return;
for (const s of raum.publishers) try { s.pc.close(); } catch (e) {}
for (const s of raum.viewers) try { s.pc.close(); } catch (e) {}
raeume.delete(raumId);
}
/* ---- Kleine Helfer ------------------------------------------------- */
function sende(ws, obj) {
if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj));
}
function alsJson(candidate) {
if (!candidate) return null;
if (typeof candidate.toJSON === "function") return candidate.toJSON();
return {
candidate: candidate.candidate,
sdpMid: candidate.sdpMid,
sdpMLineIndex: candidate.sdpMLineIndex,
};
}

View File

@ -0,0 +1,95 @@
-- =====================================================================
-- aether-admin — Rechteprüfung (Kopplung an aether-core)
--
-- Die Wahrheit über die Gruppe eines Spielers steht in
-- `aether_users.admin_group` — gepflegt vom Core. Dieses Modul liest
-- sie, hält sie je Session vor und beantwortet die einzige Frage, die
-- der Rest des Codes stellt: „Darf diese Server-ID das?"
--
-- Geprüft wird IMMER hier, nie in der Oberfläche. Was der Client
-- schickt, ist ein Wunsch, kein Nachweis.
-- =====================================================================
Admin = Admin or {}
Admin.Auth = {}
-- Zwischenspeicher: [quelle] = gruppe
local gruppeCache = {}
--- Liest die Rockstar-Lizenz einer Server-ID (ohne Präfix)
--- @param quelle number
--- @return string|nil
function Admin.Auth.Lizenz(quelle)
local lizenz = GetPlayerIdentifierByType(quelle, 'license')
if not lizenz then return nil end
return (lizenz:gsub('license:', ''))
end
--- Anzeigename einer Server-ID
--- @param quelle number
--- @return string
function Admin.Auth.Name(quelle)
return GetPlayerName(quelle) or ('Spieler ' .. tostring(quelle))
end
--- Rechtegruppe einer Server-ID (mit Cache)
--- @param quelle number
--- @return string
function Admin.Auth.Gruppe(quelle)
if gruppeCache[quelle] then return gruppeCache[quelle] end
local lizenz = Admin.Auth.Lizenz(quelle)
if not lizenz then return 'user' end
local gruppe = Admin.DB.GruppeVonLizenz(lizenz)
gruppeCache[quelle] = gruppe
return gruppe
end
--- Verwirft den Cache einer Server-ID (nach Rollenänderung / Disconnect)
--- @param quelle number
function Admin.Auth.CacheLeeren(quelle)
gruppeCache[quelle] = nil
end
--- Verwirft den Cache einer Lizenz, egal unter welcher Server-ID sie online ist
--- @param lizenz string
function Admin.Auth.CacheLeerenNachLizenz(lizenz)
for quelle in pairs(gruppeCache) do
if Admin.Auth.Lizenz(quelle) == lizenz then
gruppeCache[quelle] = nil
end
end
end
--- Erfüllt die Server-ID mindestens die geforderte Gruppe?
--- @param quelle number
--- @param benoetigt string
--- @return boolean
function Admin.Auth.Hat(quelle, benoetigt)
return AdminConfig.Erfuellt(Admin.Auth.Gruppe(quelle), benoetigt)
end
--- Darf die Server-ID das Panel überhaupt öffnen?
--- @param quelle number
--- @return boolean
function Admin.Auth.DarfOeffnen(quelle)
return Admin.Auth.Hat(quelle, AdminConfig.MindestGruppe)
end
--- Baut den „Handelnden"-Kontext, den Module und das Log erwarten
--- @param quelle number
--- @return table { quelle, lizenz, name, gruppe }
function Admin.Auth.Handelnder(quelle)
return {
quelle = quelle,
lizenz = Admin.Auth.Lizenz(quelle),
name = Admin.Auth.Name(quelle),
gruppe = Admin.Auth.Gruppe(quelle),
}
end
-- Cache aufräumen, wenn jemand geht
AddEventHandler('playerDropped', function()
gruppeCache[source] = nil
end)

View File

@ -0,0 +1,150 @@
-- =====================================================================
-- aether-admin — Datenbankzugriff (oxmysql)
--
-- Kapselt die wiederkehrenden Abfragen, die mehrere Fachmodule teilen:
-- Einstellungen, Benutzer-/Charakterdaten, Bans. Domänenspezifische
-- Abfragen (Reports, Warns, Immobilien …) liegen in ihren Modulen.
-- =====================================================================
Admin = Admin or {}
Admin.DB = {}
-- ---------------------------------------------------------------------
-- Persistierte Einstellungen (Schlüssel/Wert)
-- ---------------------------------------------------------------------
--- Liest eine Einstellung als Text
--- @param schluessel string
--- @param standard any Rückfallwert
--- @return string
function Admin.DB.Einstellung(schluessel, standard)
local wert = MySQL.scalar.await(
'SELECT wert FROM aether_admin_settings WHERE schluessel = ?', { schluessel })
if wert == nil then return standard end
return wert
end
--- Liest eine Einstellung als Zahl
--- @param schluessel string
--- @param standard number
--- @return number
function Admin.DB.EinstellungZahl(schluessel, standard)
return tonumber(Admin.DB.Einstellung(schluessel, standard)) or standard or 0
end
--- Liest eine Einstellung als Schalter
--- @param schluessel string
--- @param standard boolean
--- @return boolean
function Admin.DB.EinstellungAn(schluessel, standard)
local wert = Admin.DB.Einstellung(schluessel, standard and '1' or '0')
return wert == '1' or wert == 'true' or wert == true
end
--- Schreibt eine Einstellung
--- @param schluessel string
--- @param wert any
function Admin.DB.SetzeEinstellung(schluessel, wert)
MySQL.prepare.await([[
INSERT INTO aether_admin_settings (schluessel, wert) VALUES (?, ?)
ON DUPLICATE KEY UPDATE wert = VALUES(wert)
]], { schluessel, tostring(wert) })
end
-- ---------------------------------------------------------------------
-- Benutzer & Charaktere (aus aether-core)
-- ---------------------------------------------------------------------
--- Rechtegruppe eines Benutzers direkt aus der Datenbank
--- @param lizenz string
--- @return string
function Admin.DB.GruppeVonLizenz(lizenz)
local wert = MySQL.scalar.await(
'SELECT admin_group FROM aether_users WHERE license = ?', { lizenz })
return wert or 'user'
end
--- Setzt die Rechtegruppe eines Benutzers
--- @param lizenz string
--- @param gruppe string
--- @return boolean
function Admin.DB.SetzeGruppe(lizenz, gruppe)
local betroffen = MySQL.update.await(
'UPDATE aether_users SET admin_group = ? WHERE license = ?', { gruppe, lizenz })
return (betroffen or 0) > 0
end
--- Sucht einen Benutzer über Lizenz oder Namen
--- @param begriff string
--- @return table Liste
function Admin.DB.SucheBenutzer(begriff)
local muster = '%' .. begriff .. '%'
return MySQL.query.await([[
SELECT license, name, admin_group, is_banned
FROM aether_users
WHERE license = ? OR name LIKE ?
ORDER BY last_seen DESC
LIMIT 25
]], { begriff, muster }) or {}
end
-- ---------------------------------------------------------------------
-- Bans · Durchsetzung in aether_users, Gedächtnis in aether_admin_bans
-- ---------------------------------------------------------------------
--- Sperrt einen Benutzer: setzt die Core-Felder und schreibt den Verlauf.
--- @param lizenz string|nil Lizenz (nil = nur Verlaufseintrag, Offline-Ban)
--- @param name string Anzeigename
--- @param grund string
--- @param bis string|nil DATETIME oder nil (dauerhaft)
--- @param adminLizenz string|nil
--- @param adminName string
--- @return number Ban-ID
function Admin.DB.Sperre(lizenz, name, grund, bis, adminLizenz, adminName)
if lizenz then
MySQL.update.await([[
UPDATE aether_users SET is_banned = 1, ban_reason = ?, ban_until = ?
WHERE license = ?
]], { grund, bis, lizenz })
end
return MySQL.insert.await([[
INSERT INTO aether_admin_bans
(license, name, grund, admin_license, admin_name, bis, aktiv)
VALUES (?, ?, ?, ?, ?, ?, 1)
]], { lizenz, name, grund, adminLizenz, adminName, bis })
end
--- Hebt einen Bann auf: löscht die Core-Sperre und markiert den Verlauf.
--- @param banId number
--- @param adminName string
--- @return boolean
function Admin.DB.Entsperre(banId, adminName)
local zeilen = MySQL.query.await(
'SELECT license FROM aether_admin_bans WHERE id = ? AND aktiv = 1', { banId })
if not zeilen or not zeilen[1] then return false end
if zeilen[1].license then
MySQL.update.await([[
UPDATE aether_users SET is_banned = 0, ban_reason = NULL, ban_until = NULL
WHERE license = ?
]], { zeilen[1].license })
end
MySQL.update.await([[
UPDATE aether_admin_bans
SET aktiv = 0, aufgehoben_am = NOW(), aufgehoben_von = ?
WHERE id = ?
]], { adminName, banId })
return true
end
--- Liste aktiver Bans
--- @return table
function Admin.DB.AktiveBans()
return MySQL.query.await([[
SELECT id, license, name, grund, admin_name, bis, erstellt_am
FROM aether_admin_bans WHERE aktiv = 1
ORDER BY id DESC LIMIT 200
]]) or {}
end

View File

@ -0,0 +1,218 @@
-- =====================================================================
-- aether-admin — Live-Kameras (WebRTC-Orchestrierung)
--
-- Das eigentliche Video läuft NICHT über den FiveM-Server. Ein
-- separater Media-Server (SFU, siehe media-server/) routet die Streams.
-- Dieser Code ist nur der Türsteher und Vermittler:
--
-- 1. Prüft, ob der Admin zusehen darf.
-- 2. Lässt sich vom Media-Server kurzlebige Zugangstoken ausstellen
-- (server-zu-server, abgesichert über ein geteiltes Geheimnis).
-- 3. Gibt dem Admin ein Zuschauer-Token und weist die Ziel-Clients an,
-- als Publisher in denselben Raum zu senden.
--
-- Der FiveM-Server sieht kein Bild. Er entscheidet nur, WER wen sehen
-- darf — und protokolliert es.
-- =====================================================================
Admin = Admin or {}
Admin.Kamera = {}
-- Laufende Räume: [raum] = { admin = quelle, ziele = { [serverId]=true }, gestartet }
local raeume = {}
-- Server-Konfiguration aus Convars (Geheimnis NIE im geteilten Code!)
local function Secret() return GetConvar('aether_admin_media_secret', '') end
local function HttpBasis() return GetConvar('aether_admin_media_http', 'http://127.0.0.1:8788') end
--- Ist die Kamera einsatzbereit (aktiv + Geheimnis gesetzt)?
--- @return boolean, string|nil Grund, falls nicht
local function Bereit()
if not AdminConfig.Media.aktiv then
return false, 'Live-Kameras sind in der Konfiguration deaktiviert.'
end
if Secret() == '' then
return false, 'Kein Media-Geheimnis gesetzt (Convar aether_admin_media_secret).'
end
return true
end
--- Erzeugt eine zufällige, undurchsichtige Kennung
--- @param laenge number
--- @return string
local function Zufall(laenge)
local zeichen = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
local teile = {}
for i = 1, laenge do
local pos = math.random(#zeichen)
teile[i] = zeichen:sub(pos, pos)
end
return table.concat(teile)
end
--- Ruft den Media-Server server-zu-server auf und wartet auf die Antwort
--- @param pfad string z. B. '/grant'
--- @param koerper table
--- @return table|nil { status, daten }
local function AnMediaServer(pfad, koerper)
local p = promise.new()
PerformHttpRequest(HttpBasis() .. pfad, function(status, text)
local ok, daten = pcall(json.decode, text or '')
p:resolve({ status = status, daten = ok and daten or nil })
end, 'POST', json.encode(koerper), {
['Content-Type'] = 'application/json',
['x-admin-secret'] = Secret(),
})
return Citizen.Await(p)
end
--- Lässt ein Zugangstoken für einen Raum ausstellen
--- @param raum string
--- @param rolle string 'viewer' | 'publisher'
--- @param identitaet table { name, lizenz }
--- @return string|nil Token
local function StelleTokenAus(raum, rolle, identitaet)
local token = Zufall(40)
local antwort = AnMediaServer('/grant', {
token = token,
raum = raum,
rolle = rolle,
name = identitaet.name,
gueltigSek = AdminConfig.Media.tokenGueltig,
})
if not antwort or antwort.status ~= 200 then
return nil
end
return token
end
--- Weist einen Raum an, wieder abgebaut zu werden (best effort)
--- @param raum string
local function RaumAufloesen(raum)
local eintrag = raeume[raum]
if not eintrag then return end
for serverId in pairs(eintrag.ziele) do
if GetPlayerName(serverId) then
TriggerClientEvent('aether-admin:kamera:stopp', serverId, raum)
end
end
AnMediaServer('/revoke', { raum = raum })
raeume[raum] = nil
end
-- ---------------------------------------------------------------------
-- Aktionen aus dem Panel
-- ---------------------------------------------------------------------
--- Öffnet eine Kamera-View auf eine oder mehrere Ziel-Server-IDs.
--- daten = { ziele = { serverId, ... }, name? }
Admin.Kern.Registriere('kamera', 'oeffnen', function(handelnder, daten)
local bereit, grund = Bereit()
if not bereit then return nil, grund end
local ziele = type(daten.ziele) == 'table' and daten.ziele or {}
if #ziele == 0 then return nil, 'Keine Ziel-Spieler gewählt.' end
local raum = 'r_' .. Zufall(12)
-- Zuschauer-Token für den Admin
local viewerToken = StelleTokenAus(raum, 'viewer', {
name = handelnder.name, lizenz = handelnder.lizenz,
})
if not viewerToken then
return nil, 'Media-Server nicht erreichbar. Läuft er? (siehe media-server/)'
end
raeume[raum] = { admin = handelnder.quelle, ziele = {}, gestartet = os.time() }
-- Jeden erreichbaren Ziel-Client zum Publizieren auffordern
local aufgefordert, namen = 0, {}
for _, roh in ipairs(ziele) do
local serverId = tonumber(roh)
if serverId and GetPlayerName(serverId) then
local pubToken = StelleTokenAus(raum, 'publisher', {
name = GetPlayerName(serverId),
})
if pubToken then
raeume[raum].ziele[serverId] = true
aufgefordert = aufgefordert + 1
namen[#namen + 1] = GetPlayerName(serverId)
TriggerClientEvent('aether-admin:kamera:publiziere', serverId, {
raum = raum, url = AdminConfig.Media.url, token = pubToken,
})
end
end
end
if aufgefordert == 0 then
RaumAufloesen(raum)
return nil, 'Kein Ziel ist erreichbar (offline?).'
end
Admin.Log.Schreibe(handelnder, 'Live-Kamera geöffnet',
table.concat(namen, ', '), aufgefordert .. ' Ziel(e), Raum ' .. raum)
return {
raum = raum,
url = AdminConfig.Media.url,
token = viewerToken,
ziele = aufgefordert,
}
end)
--- Schließt eine Kamera-View
Admin.Kern.Registriere('kamera', 'schliessen', function(handelnder, daten)
local raum = tostring(daten.raum or '')
if not raeume[raum] then return { ok = true } end
if raeume[raum].admin ~= handelnder.quelle and not Admin.Auth.Hat(handelnder.quelle, 'owner') then
return nil, 'Diese View gehört einem anderen Admin.'
end
RaumAufloesen(raum)
Admin.Log.Schreibe(handelnder, 'Live-Kamera geschlossen', nil, 'Raum ' .. raum)
return { ok = true }
end)
-- ---------------------------------------------------------------------
-- Statusmeldung eines Publishers an den beobachtenden Admin
-- (klappte die Bildschirmfreigabe oder nur der Platzhalter?)
-- ---------------------------------------------------------------------
RegisterNetEvent('aether-admin:kamera:status', function(raum, typ, fehler)
local quelle = source
local eintrag = raeume[tostring(raum or '')]
-- Nur echte Publisher dieses Raums dürfen melden
if not eintrag or not eintrag.ziele[quelle] then return end
if not eintrag.admin or not GetPlayerName(eintrag.admin) then return end
TriggerClientEvent('aether-admin:kamera:status', eintrag.admin, {
quelle = GetPlayerName(quelle),
typ = tostring(typ or ''),
fehler = tostring(fehler or ''),
})
end)
-- ---------------------------------------------------------------------
-- Aufräumen
-- ---------------------------------------------------------------------
-- Räume schließen, deren Admin oder alle Ziele weg sind
AddEventHandler('playerDropped', function()
local weg = source
for raum, eintrag in pairs(raeume) do
if eintrag.admin == weg then
RaumAufloesen(raum)
elseif eintrag.ziele[weg] then
eintrag.ziele[weg] = nil
local rest = 0
for _ in pairs(eintrag.ziele) do rest = rest + 1 end
if rest == 0 then RaumAufloesen(raum) end
end
end
end)
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
for raum in pairs(raeume) do RaumAufloesen(raum) end
end)

View File

@ -0,0 +1,89 @@
-- =====================================================================
-- aether-admin — Kern
--
-- Ein einziger Weg führt von der Oberfläche zu einer Aktion: `aufruf`.
-- Der Kern nimmt ihn entgegen, prüft das Recht anhand der Rechte-Matrix
-- und ruft die registrierte Funktion. Jedes Fachmodul meldet nur seine
-- Aktionen an — die Prüfung passiert hier, an einer Stelle, für alle.
-- =====================================================================
Admin = Admin or {}
Admin.Kern = {}
-- Registrierte Aktionen: ['modul.aktion'] = funktion
local aktionen = {}
--- Registriert eine Aktion.
--- @param modul string Modulkennung (z. B. 'spieler')
--- @param aktion string Aktionsname (z. B. 'kick')
--- @param funktion function fun(handelnder, daten): ergebnis, fehler
function Admin.Kern.Registriere(modul, aktion, funktion)
aktionen[modul .. '.' .. aktion] = funktion
end
--- Führt eine Aktion aus — inklusive Rechteprüfung.
--- @param quelle number Server-ID des Admins
--- @param modul string
--- @param aktion string
--- @param daten table Nutzdaten
--- @return any Ergebnis, string|nil Fehler
function Admin.Kern.FuehreAus(quelle, modul, aktion, daten)
local schluessel = tostring(modul) .. '.' .. tostring(aktion)
local funktion = aktionen[schluessel]
if not funktion then
return nil, 'Unbekannte Aktion.'
end
-- Panel-Grundrecht
if not Admin.Auth.DarfOeffnen(quelle) then
return nil, 'Kein Zugriff auf das Admin-Panel.'
end
-- Recht auf genau diese Aktion
local benoetigt = AdminConfig.RechtFuer(schluessel)
if not Admin.Auth.Hat(quelle, benoetigt) then
return nil, ('Dafür fehlt dir die Berechtigung (%s).'):format(benoetigt)
end
local handelnder = Admin.Auth.Handelnder(quelle)
local erfolg, ergebnis, fehler = pcall(funktion, handelnder, daten or {})
if not erfolg then
print(('[aether-admin] Fehler in %s: %s'):format(schluessel, tostring(ergebnis)))
return nil, 'Interner Fehler in der Aktion.'
end
return ergebnis, fehler
end
--- Liefert die für einen Admin sichtbaren Sektionen (nach Gruppe gefiltert)
--- @param quelle number
--- @return table Liste { id, label, icon, grp }
function Admin.Kern.SichtbareSektionen(quelle)
local gruppe = Admin.Auth.Gruppe(quelle)
local liste = {}
for _, eintrag in ipairs(AdminConfig.Sektionen) do
if eintrag.grp then
liste[#liste + 1] = { grp = eintrag.grp }
elseif AdminConfig.Erfuellt(gruppe, eintrag.min) then
liste[#liste + 1] = {
id = eintrag.id, label = eintrag.label, icon = eintrag.icon,
}
end
end
return liste
end
--- Baut das Rechte-Abbild für die Oberfläche: welche Aktionen darf dieser
--- Admin? Die UI blendet danach Knöpfe aus — die echte Prüfung bleibt aber
--- serverseitig. Das hier ist nur Kosmetik, kein Torwächter.
--- @param quelle number
--- @return table { ['modul.aktion'] = true, ... }
function Admin.Kern.RechteAbbild(quelle)
local gruppe = Admin.Auth.Gruppe(quelle)
local abbild = {}
for schluessel, benoetigt in pairs(AdminConfig.Rechte) do
abbild[schluessel] = AdminConfig.Erfuellt(gruppe, benoetigt)
end
return abbild
end

View File

@ -0,0 +1,69 @@
-- =====================================================================
-- aether-admin — Prüfprotokoll
--
-- Jede eingreifende Aktion hinterlässt eine Spur: wer, was, an wem,
-- mit welchem Detail. Einträge werden nur geschrieben und gelesen —
-- nie geändert oder entfernt.
-- =====================================================================
Admin = Admin or {}
Admin.Log = {}
--- Schreibt einen Protokolleintrag
--- @param handelnder table|nil { lizenz, name } — der Admin
--- @param aktion string Was getan wurde
--- @param ziel string|nil An wem / woran
--- @param detail string|nil Zusatz
function Admin.Log.Schreibe(handelnder, aktion, ziel, detail)
MySQL.insert([[
INSERT INTO aether_admin_log (admin_license, admin_name, aktion, ziel, detail)
VALUES (?, ?, ?, ?, ?)
]], {
handelnder and handelnder.lizenz or nil,
handelnder and handelnder.name or 'System',
aktion,
ziel and tostring(ziel):sub(1, 96) or nil,
detail and tostring(detail):sub(1, 512) or nil,
})
end
--- Liest Protokolleinträge mit einfachen Filtern
--- @param filter table { suche?, admin?, limit? }
--- @return table
function Admin.Log.Lies(filter)
filter = filter or {}
local bedingungen, werte = {}, {}
if filter.admin and filter.admin ~= '' then
bedingungen[#bedingungen + 1] = 'admin_name = ?'
werte[#werte + 1] = filter.admin
end
if filter.suche and filter.suche ~= '' then
local muster = '%' .. filter.suche .. '%'
bedingungen[#bedingungen + 1] = '(aktion LIKE ? OR ziel LIKE ? OR detail LIKE ?)'
werte[#werte + 1] = muster
werte[#werte + 1] = muster
werte[#werte + 1] = muster
end
local wo = #bedingungen > 0 and ('WHERE ' .. table.concat(bedingungen, ' AND ')) or ''
werte[#werte + 1] = math.min(tonumber(filter.limit) or 200, 500)
return MySQL.query.await(([[
SELECT admin_name, aktion, ziel, detail, erstellt_am
FROM aether_admin_log
%s
ORDER BY id DESC
LIMIT ?
]]):format(wo), werte) or {}
end
--- Liste der Admins, die je etwas protokolliert haben (für den Filter)
--- @return table
function Admin.Log.Admins()
local zeilen = MySQL.query.await(
'SELECT DISTINCT admin_name FROM aether_admin_log ORDER BY admin_name') or {}
local liste = {}
for _, z in ipairs(zeilen) do liste[#liste + 1] = z.admin_name end
return liste
end

View File

@ -0,0 +1,80 @@
-- =====================================================================
-- aether-admin — Server-Schnittstelle
--
-- Ein einziges Netz-Event bedient das ganze Panel. Jede Anfrage
-- durchläuft die Rechteprüfung im Kern, bevor sie ein Modul erreicht.
-- =====================================================================
Admin = Admin or {}
Admin.Live = Admin.Live or { pos = {}, god = {}, frozen = {}, muted = {} }
-- ---------------------------------------------------------------------
-- Panel öffnen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether-admin:oeffnen', function()
local quelle = source
if not Admin.Auth.DarfOeffnen(quelle) then
TriggerClientEvent('aether-admin:verweigert', quelle,
'Du hast keinen Zugriff auf das Admin-Panel.')
return
end
local gruppe = Admin.Auth.Gruppe(quelle)
TriggerClientEvent('aether-admin:zeige', quelle, {
admin = { name = Admin.Auth.Name(quelle), gruppe = gruppe },
sektionen = Admin.Kern.SichtbareSektionen(quelle),
rechte = Admin.Kern.RechteAbbild(quelle),
serverName = GetConvar('sv_hostname', 'Aether RP'),
media = { aktiv = AdminConfig.Media.aktiv },
})
Admin.Log.Schreibe(Admin.Auth.Handelnder(quelle), 'Panel geöffnet')
end)
-- ---------------------------------------------------------------------
-- Zentraler Aufruf: die Oberfläche fragt, ein Modul antwortet
-- ---------------------------------------------------------------------
RegisterNetEvent('aether-admin:aufruf', function(modul, aktion, daten, ruf)
local quelle = source
local ergebnis, fehler = Admin.Kern.FuehreAus(
quelle, tostring(modul or ''), tostring(aktion or ''), daten or {})
TriggerClientEvent('aether-admin:antwort', quelle, ruf, ergebnis, fehler)
end)
-- ---------------------------------------------------------------------
-- Positions-Cache (für Weltkarte, Teleport, Spectate)
-- ---------------------------------------------------------------------
RegisterNetEvent('aether-admin:pos', function(pos)
if type(pos) == 'table' and pos.x then
Admin.Live.pos[source] = { x = pos.x + 0.0, y = pos.y + 0.0, z = pos.z + 0.0 }
end
end)
-- ---------------------------------------------------------------------
-- Spieler-Report aus dem Spiel (/report)
-- ---------------------------------------------------------------------
RegisterNetEvent('aether-admin:report', function(betreff, nachricht)
local quelle = source
if type(betreff) ~= 'string' or #betreff < 2 then return end
Admin.ErstelleReport(quelle, betreff:sub(1, 160), nachricht)
TriggerClientEvent('aether-admin:client:hinweis', quelle,
'✅ Dein Report wurde an das Team übermittelt.')
end)
-- ---------------------------------------------------------------------
-- Neuen Spielern den aktuellen Weltzustand mitgeben
-- ---------------------------------------------------------------------
AddEventHandler('aether:server:spielerGeladen', function(quelle)
if Admin.SendeWelt then Admin.SendeWelt(quelle) end
end)
-- ---------------------------------------------------------------------
-- Aufräumen
-- ---------------------------------------------------------------------
AddEventHandler('playerDropped', function()
local weg = source
Admin.Live.pos[weg] = nil
Admin.Live.god[weg] = nil
Admin.Live.frozen[weg] = nil
Admin.Live.muted[weg] = nil
end)

View File

@ -0,0 +1,32 @@
-- =====================================================================
-- aether-admin / Modul: Ankündigungen
--
-- Eine Nachricht an den ganzen Server. Sie wird verschickt und der
-- Verlauf bleibt nachlesbar.
-- =====================================================================
Admin = Admin or {}
Admin.Kern.Registriere('broadcast', 'laden', function(handelnder, daten)
return {
verlauf = MySQL.query.await([[
SELECT typ, nachricht, admin_name, erstellt_am
FROM aether_admin_broadcasts ORDER BY id DESC LIMIT 30
]]) or {},
}
end)
Admin.Kern.Registriere('broadcast', 'senden', function(handelnder, daten)
local typ = tostring(daten.typ or 'info')
local text = tostring(daten.nachricht or ''):sub(1, 512)
if #text < 2 then return nil, 'Bitte eine Nachricht eingeben.' end
MySQL.insert.await([[
INSERT INTO aether_admin_broadcasts (typ, nachricht, admin_name)
VALUES (?, ?, ?)
]], { typ, text, handelnder.name })
TriggerClientEvent('aether-admin:client:broadcast', -1, { typ = typ, text = text })
Admin.Log.Schreibe(handelnder, 'Ankündigung', typ, text:sub(1, 80))
return { ok = true }
end)

View File

@ -0,0 +1,59 @@
-- =====================================================================
-- aether-admin / Modul: Dashboard
--
-- Der Überblick. Jede Zahl kommt frisch — nichts wird geschätzt. Die
-- Spielerkurve ist ein echtes, über die Laufzeit gesammeltes Abbild
-- (kein erfundener Verlauf).
-- =====================================================================
Admin = Admin or {}
-- Rollierender Verlauf der Spielerzahl (ein Wert je Sammelintervall)
local verlauf = {}
local MAX_PUNKTE = 24
CreateThread(function()
while true do
local anzahl = #(GetPlayers() or {})
verlauf[#verlauf + 1] = anzahl
if #verlauf > MAX_PUNKTE then table.remove(verlauf, 1) end
Wait(60000) -- jede Minute ein Punkt
end
end)
Admin.Kern.Registriere('dashboard', 'laden', function(handelnder, daten)
local online = #(GetPlayers() or {})
local slots = GetConvarInt('sv_maxclients', 48)
local offeneReports = tonumber(MySQL.scalar.await(
"SELECT COUNT(*) FROM aether_admin_reports WHERE status <> 'geschlossen'")) or 0
local aktiveBans = tonumber(MySQL.scalar.await(
'SELECT COUNT(*) FROM aether_admin_bans WHERE aktiv = 1')) or 0
-- Ressourcen-Kurzstatus
local resAnzahl = GetNumResources() or 0
local laufend = 0
for i = 0, resAnzahl - 1 do
local r = GetResourceByFindIndex(i)
if r and GetResourceState(r) == 'started' then laufend = laufend + 1 end
end
-- Spieler mit hohem Ping (grobe Health-Anzeige)
local hoherPing = 0
for _, sid in ipairs(GetPlayers() or {}) do
if (GetPlayerPing(tonumber(sid)) or 0) >= 120 then hoherPing = hoherPing + 1 end
end
return {
online = online,
slots = slots,
auslastung = slots > 0 and math.floor(online / slots * 100) or 0,
offeneReports = offeneReports,
aktiveBans = aktiveBans,
hoherPing = hoherPing,
ressourcen = { laufend = laufend, gesamt = resAnzahl },
verlauf = #verlauf > 1 and verlauf or { online, online },
letzteLogs = Admin.Log.Lies({ limit = 6 }),
serverName = GetConvar('sv_hostname', 'Aether RP'),
}
end)

View File

@ -0,0 +1,85 @@
-- =====================================================================
-- aether-admin / Modul: Fahrzeuge
--
-- Die Fahrzeuge in der Welt — serverseitig über GetAllVehicles erfasst,
-- also echt und nicht simuliert. Löschen und Aufräumen geschehen direkt
-- serverseitig; Reparieren und Herholen brauchen einen Client in der
-- Nähe und werden dorthin weitergereicht.
-- =====================================================================
Admin = Admin or {}
Admin.Kern.Registriere('fahrzeuge', 'laden', function(handelnder, daten)
local liste = {}
for _, veh in ipairs(GetAllVehicles() or {}) do
local pos = GetEntityCoords(veh)
local leer = GetVehicleNumberOfPassengers(veh) == 0 and IsVehicleSeatFree(veh, -1)
liste[#liste + 1] = {
netId = NetworkGetNetworkIdFromEntity(veh),
plate = (GetVehicleNumberPlateText(veh) or ''):gsub('%s+$', ''),
x = pos.x, y = pos.y, z = pos.z,
leer = leer,
}
if #liste >= 300 then break end
end
-- Online-Spieler als mögliche Empfänger eines Spawns
local online = {}
for _, sid in ipairs(GetPlayers() or {}) do
online[#online + 1] = { id = tonumber(sid), name = GetPlayerName(tonumber(sid)) }
end
return { fahrzeuge = liste, anzahl = #liste, spieler = online,
rechte = Admin.Kern.RechteAbbild(handelnder.quelle) }
end)
Admin.Kern.Registriere('fahrzeuge', 'spawn', function(handelnder, daten)
local modell = tostring(daten.modell or ''):gsub('%s', '')
if modell == '' then return nil, 'Kein Modell gewählt.' end
local ziel = tonumber(daten.ziel) or handelnder.quelle
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
TriggerClientEvent('aether-admin:client:fahrzeug', ziel, modell)
Admin.Log.Schreibe(handelnder, 'Fahrzeug gespawnt', GetPlayerName(ziel), modell)
return { ok = true }
end)
Admin.Kern.Registriere('fahrzeuge', 'loeschen', function(handelnder, daten)
local netId = tonumber(daten.netId)
if not netId then return nil, 'Ungültige Kennung.' end
local veh = NetworkGetEntityFromNetworkId(netId)
if not veh or veh == 0 or not DoesEntityExist(veh) then
return nil, 'Fahrzeug existiert nicht mehr.'
end
DeleteEntity(veh)
Admin.Log.Schreibe(handelnder, 'Fahrzeug gelöscht', nil, 'net ' .. netId)
return { ok = true }
end)
Admin.Kern.Registriere('fahrzeuge', 'reparieren', function(handelnder, daten)
local netId = tonumber(daten.netId)
if not netId then return nil, 'Ungültige Kennung.' end
-- Der Client des Admins repariert, sobald das Fahrzeug in Reichweite ist
TriggerClientEvent('aether-admin:client:fahrzeugFix', handelnder.quelle, netId)
Admin.Log.Schreibe(handelnder, 'Fahrzeug repariert', nil, 'net ' .. netId)
return { ok = true }
end)
Admin.Kern.Registriere('fahrzeuge', 'herholen', function(handelnder, daten)
local netId = tonumber(daten.netId)
if not netId then return nil, 'Ungültige Kennung.' end
TriggerClientEvent('aether-admin:client:fahrzeugHer', handelnder.quelle, netId)
Admin.Log.Schreibe(handelnder, 'Fahrzeug hergeholt', nil, 'net ' .. netId)
return { ok = true }
end)
Admin.Kern.Registriere('fahrzeuge', 'aufraeumen', function(handelnder, daten)
local entfernt = 0
for _, veh in ipairs(GetAllVehicles() or {}) do
if GetVehicleNumberOfPassengers(veh) == 0 and IsVehicleSeatFree(veh, -1) then
DeleteEntity(veh); entfernt = entfernt + 1
end
end
Admin.Log.Schreibe(handelnder, 'Fahrzeuge aufgeräumt', nil, entfernt .. ' entfernt')
return { ok = true, entfernt = entfernt }
end)

View File

@ -0,0 +1,88 @@
-- =====================================================================
-- aether-admin / Modul: Fraktionen
--
-- Mitgliedschaft ergibt sich aus aether_characters.faction. Hier liegen
-- nur die Zusatzangaben (Gehalt, Farbe). Der Payday zahlt das Gehalt an
-- alle Online-Mitglieder über den Core aus.
-- =====================================================================
Admin = Admin or {}
Admin.Kern.Registriere('fraktionen', 'laden', function(handelnder, daten)
local meta = MySQL.query.await(
'SELECT name, gehalt, farbe, aktiv FROM aether_admin_factions ORDER BY name') or {}
-- Mitgliederzahl je Fraktion aus den Charakteren
local zaehl = MySQL.query.await([[
SELECT faction AS name, COUNT(*) AS anzahl
FROM aether_characters WHERE deleted = 0 AND faction IS NOT NULL AND faction <> ''
GROUP BY faction
]]) or {}
local nachName = {}
for _, z in ipairs(zaehl) do nachName[z.name] = tonumber(z.anzahl) end
-- Online-Mitglieder
local online = {}
local alle = exports['aether-core']:HoleAlleSpieler() or {}
for _, d in pairs(alle) do
if d.fraktion then online[d.fraktion] = (online[d.fraktion] or 0) + 1 end
end
for _, f in ipairs(meta) do
f.mitglieder = nachName[f.name] or 0
f.online = online[f.name] or 0
f.aktiv = tonumber(f.aktiv) == 1
end
return { fraktionen = meta, rechte = Admin.Kern.RechteAbbild(handelnder.quelle) }
end)
Admin.Kern.Registriere('fraktionen', 'speichern', function(handelnder, daten)
local name = tostring(daten.name or ''):sub(1, 48)
if #name < 2 then return nil, 'Bitte einen Namen angeben.' end
local gehalt = math.max(0, math.floor(tonumber(daten.gehalt) or 0))
local farbe = tostring(daten.farbe or '#34d39a'):sub(1, 9)
MySQL.prepare.await([[
INSERT INTO aether_admin_factions (name, gehalt, farbe) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE gehalt = VALUES(gehalt), farbe = VALUES(farbe)
]], { name, gehalt, farbe })
Admin.Log.Schreibe(handelnder, 'Fraktion gespeichert', name, gehalt .. ' €/h')
return { ok = true }
end)
Admin.Kern.Registriere('fraktionen', 'loeschen', function(handelnder, daten)
local name = tostring(daten.name or '')
if name == '' then return nil, 'Keine Fraktion angegeben.' end
MySQL.update.await('DELETE FROM aether_admin_factions WHERE name = ?', { name })
-- Mitgliedschaft der Charaktere lösen
MySQL.update.await(
'UPDATE aether_characters SET faction = NULL WHERE faction = ?', { name })
Admin.Log.Schreibe(handelnder, 'Fraktion gelöscht', name)
return { ok = true }
end)
Admin.Kern.Registriere('fraktionen', 'payday', function(handelnder, daten)
local name = tostring(daten.name or '')
local zeilen = MySQL.query.await(
'SELECT gehalt FROM aether_admin_factions WHERE name = ?', { name })
if not zeilen or not zeilen[1] then return nil, 'Fraktion nicht gefunden.' end
local gehalt = tonumber(zeilen[1].gehalt) or 0
if gehalt <= 0 then return nil, 'Diese Fraktion zahlt kein Gehalt.' end
local anzahl = 0
local alle = exports['aether-core']:HoleAlleSpieler() or {}
for serverId, d in pairs(alle) do
if d.fraktion == name then
exports['aether-core']:FuegeGeldHinzu(tonumber(serverId), 'bank', gehalt,
'Payday ' .. name)
anzahl = anzahl + 1
end
end
Admin.Log.Schreibe(handelnder, 'Payday', name, anzahl .. ' × ' .. gehalt .. ' €')
return { ok = true, anzahl = anzahl }
end)

View File

@ -0,0 +1,51 @@
-- =====================================================================
-- aether-admin / Modul: Immobilien
--
-- Solange kein eigenständiges Immobilien-Resource läuft, verwaltet das
-- Panel die Objekte. Der Besitzer ist ein Charakter aus aether_characters.
-- =====================================================================
Admin = Admin or {}
Admin.Kern.Registriere('immobilien', 'laden', function(handelnder, daten)
local liste = MySQL.query.await([[
SELECT i.id, i.name, i.ort, i.preis, i.besitzer,
CONCAT(c.firstname, ' ', c.lastname) AS besitzerName
FROM aether_admin_properties i
LEFT JOIN aether_characters c ON c.id = i.besitzer
ORDER BY i.id
]]) or {}
-- Online-Charaktere zur Auswahl als Besitzer
local online = {}
local alle = exports['aether-core']:HoleAlleSpieler() or {}
for _, d in pairs(alle) do
online[#online + 1] = {
charakterId = d.charakterId,
name = (d.vorname or '?') .. ' ' .. (d.nachname or ''),
}
end
return { immobilien = liste, spieler = online,
rechte = Admin.Kern.RechteAbbild(handelnder.quelle) }
end)
Admin.Kern.Registriere('immobilien', 'zuweisen', function(handelnder, daten)
local id = tonumber(daten.id)
if not id then return nil, 'Ungültige Kennung.' end
local besitzer = tonumber(daten.besitzer) -- nil = frei
MySQL.update.await('UPDATE aether_admin_properties SET besitzer = ? WHERE id = ?',
{ besitzer, id })
Admin.Log.Schreibe(handelnder, 'Immobilie zugewiesen', 'Objekt #' .. id,
besitzer and ('Charakter #' .. besitzer) or 'frei')
return { ok = true }
end)
Admin.Kern.Registriere('immobilien', 'raeumen', function(handelnder, daten)
local id = tonumber(daten.id)
if not id then return nil, 'Ungültige Kennung.' end
MySQL.update.await('UPDATE aether_admin_properties SET besitzer = NULL WHERE id = ?', { id })
Admin.Log.Schreibe(handelnder, 'Immobilie geräumt', 'Objekt #' .. id)
return { ok = true }
end)

View File

@ -0,0 +1,17 @@
-- =====================================================================
-- aether-admin / Modul: Audit-Log
--
-- Liest das Prüfprotokoll mit Filtern. Geschrieben wird es überall dort,
-- wo etwas geschieht — hier wird es nur sichtbar gemacht.
-- =====================================================================
Admin = Admin or {}
Admin.Kern.Registriere('logs', 'laden', function(handelnder, daten)
return {
eintraege = Admin.Log.Lies({
suche = daten.suche, admin = daten.admin, limit = daten.limit,
}),
admins = Admin.Log.Admins(),
}
end)

View File

@ -0,0 +1,59 @@
-- =====================================================================
-- aether-admin / Modul: Bans & Verwarnungen
--
-- Die Durchsetzung der Bans liegt in aether_users (der Core sperrt
-- darüber beim Connect). Dieses Modul liest den Verlauf, hebt Bans auf
-- und erlaubt manuelle Sperren — auch für Offline-Spieler.
-- =====================================================================
Admin = Admin or {}
Admin.Kern.Registriere('moderation', 'laden', function(handelnder, daten)
local bans = Admin.DB.AktiveBans()
local warns = MySQL.query.await([[
SELECT id, name, grund, admin_name, erstellt_am
FROM aether_admin_warns ORDER BY id DESC LIMIT 100
]]) or {}
return {
bans = bans,
warns = warns,
rechte = Admin.Kern.RechteAbbild(handelnder.quelle),
}
end)
Admin.Kern.Registriere('moderation', 'unban', function(handelnder, daten)
local banId = tonumber(daten.id)
if not banId then return nil, 'Ungültige Kennung.' end
if not Admin.DB.Entsperre(banId, handelnder.name) then
return nil, 'Bann nicht gefunden oder bereits aufgehoben.'
end
Admin.Log.Schreibe(handelnder, 'Entbannt', nil, 'Ban #' .. banId)
return { ok = true }
end)
Admin.Kern.Registriere('moderation', 'warnWeg', function(handelnder, daten)
local warnId = tonumber(daten.id)
if not warnId then return nil, 'Ungültige Kennung.' end
MySQL.update.await('DELETE FROM aether_admin_warns WHERE id = ?', { warnId })
Admin.Log.Schreibe(handelnder, 'Verwarnung gelöscht', nil, 'Warn #' .. warnId)
return { ok = true }
end)
Admin.Kern.Registriere('moderation', 'banManuell', function(handelnder, daten)
local name = tostring(daten.name or ''):sub(1, 64)
if #name < 2 then return nil, 'Bitte Name oder Kennung angeben.' end
local grund = tostring(daten.grund or 'Regelbruch'):sub(1, 255)
local dauer = tostring(daten.dauer or 'permanent')
local bis = Admin.BerechneBanEnde(dauer)
-- Sieht die Kennung wie eine Lizenz aus, wird direkt gesperrt (greift beim Connect)
local lizenz = daten.lizenz and tostring(daten.lizenz) or nil
if not lizenz and name:match('^%x%x%x%x%x%x%x%x') and #name >= 32 then
lizenz = name
end
Admin.DB.Sperre(lizenz, name, grund, bis, handelnder.lizenz, handelnder.name)
Admin.Log.Schreibe(handelnder, 'Manueller Ban (' .. dauer .. ')', name, grund)
return { ok = true }
end)

View File

@ -0,0 +1,114 @@
-- =====================================================================
-- aether-admin / Modul: Reports / Support
--
-- Spieler melden über `/report` — das Team übernimmt, antwortet und
-- schließt hier. Antworten gehen als Hinweis zurück an den Melder,
-- falls er online ist.
-- =====================================================================
Admin = Admin or {}
--- Legt einen Report an (aus dem Spiel heraus aufgerufen)
--- @param quelle number Server-ID des Melders
--- @param betreff string
--- @param nachricht string|nil
--- @return number Report-ID
function Admin.ErstelleReport(quelle, betreff, nachricht)
local id = MySQL.insert.await([[
INSERT INTO aether_admin_reports (melder_license, melder_name, betreff, nachricht)
VALUES (?, ?, ?, ?)
]], { Admin.Auth.Lizenz(quelle), GetPlayerName(quelle),
tostring(betreff):sub(1, 160), nachricht and tostring(nachricht):sub(1, 2000) or nil })
-- Team im Dienst benachrichtigen
for _, sid in ipairs(GetPlayers() or {}) do
local s = tonumber(sid)
if Admin.Auth.Hat(s, 'support') then
TriggerClientEvent('aether-admin:client:hinweis', s,
'🎫 Neuer Report von ' .. GetPlayerName(quelle))
end
end
return id
end
Admin.Kern.Registriere('reports', 'laden', function(handelnder, daten)
local liste = MySQL.query.await([[
SELECT id, melder_name, betreff, nachricht, prioritaet, status, bearbeiter,
erstellt_am, aktualisiert_am
FROM aether_admin_reports
ORDER BY (status = 'geschlossen'), prioritaet, id DESC
LIMIT 100
]]) or {}
-- Für ein offenes Detail: Nachrichtenverlauf mitliefern
local detailId = tonumber(daten.detail)
local verlauf = {}
if detailId then
verlauf = MySQL.query.await([[
SELECT autor, ist_admin, nachricht, erstellt_am
FROM aether_admin_report_msgs WHERE report = ? ORDER BY id
]], { detailId }) or {}
end
return { reports = liste, verlauf = verlauf, detail = detailId,
rechte = Admin.Kern.RechteAbbild(handelnder.quelle) }
end)
Admin.Kern.Registriere('reports', 'claim', function(handelnder, daten)
local id = tonumber(daten.id)
if not id then return nil, 'Ungültige Kennung.' end
MySQL.update.await([[
UPDATE aether_admin_reports SET status = 'bearbeitung', bearbeiter = ?
WHERE id = ? AND status <> 'geschlossen'
]], { handelnder.name, id })
Admin.Log.Schreibe(handelnder, 'Report übernommen', 'Report #' .. id)
return { ok = true }
end)
Admin.Kern.Registriere('reports', 'status', function(handelnder, daten)
local id = tonumber(daten.id)
local status = tostring(daten.status or '')
local erlaubt = { offen = true, bearbeitung = true, geschlossen = true }
if not id or not erlaubt[status] then return nil, 'Ungültige Angabe.' end
MySQL.update.await('UPDATE aether_admin_reports SET status = ? WHERE id = ?', { status, id })
Admin.Log.Schreibe(handelnder, 'Report ' .. status, 'Report #' .. id)
return { ok = true }
end)
Admin.Kern.Registriere('reports', 'antwort', function(handelnder, daten)
local id = tonumber(daten.id)
local text = tostring(daten.text or ''):sub(1, 2000)
if not id then return nil, 'Ungültige Kennung.' end
if #text < 2 then return nil, 'Bitte eine Antwort schreiben.' end
local report = MySQL.query.await(
'SELECT melder_license, status FROM aether_admin_reports WHERE id = ?', { id })
if not report or not report[1] then return nil, 'Report nicht gefunden.' end
MySQL.insert.await([[
INSERT INTO aether_admin_report_msgs (report, autor, ist_admin, nachricht)
VALUES (?, ?, 1, ?)
]], { id, handelnder.name, text })
if report[1].status == 'offen' then
MySQL.update.await(
"UPDATE aether_admin_reports SET status = 'bearbeitung', bearbeiter = ? WHERE id = ?",
{ handelnder.name, id })
end
-- Melder benachrichtigen, falls online
local melderQuelle
for _, sid in ipairs(GetPlayers() or {}) do
if Admin.Auth.Lizenz(tonumber(sid)) == report[1].melder_license then
melderQuelle = tonumber(sid); break
end
end
if melderQuelle then
TriggerClientEvent('aether-admin:client:hinweis', melderQuelle,
'🎫 Antwort vom Team: ' .. text:sub(1, 120))
end
Admin.Log.Schreibe(handelnder, 'Report beantwortet', 'Report #' .. id)
return { ok = true }
end)

View File

@ -0,0 +1,68 @@
-- =====================================================================
-- aether-admin / Modul: Ressourcen
--
-- Zeigt die Serverressourcen und erlaubt Start/Stopp/Neustart. Ein
-- vollständiger Server-Neustart wird angekündigt und protokolliert,
-- aber NICHT blind ausgeführt — das Beenden des Prozesses überlässt
-- das Panel bewusst der Server-/txAdmin-Ebene.
-- =====================================================================
Admin = Admin or {}
Admin.Kern.Registriere('ressourcen', 'laden', function(handelnder, daten)
local anzahl = GetNumResources() or 0
local liste = {}
for i = 0, anzahl - 1 do
local name = GetResourceByFindIndex(i)
if name then
liste[#liste + 1] = {
name = name,
status = GetResourceState(name),
version = GetResourceMetadata(name, 'version', 0) or '—',
}
end
end
table.sort(liste, function(a, b) return a.name < b.name end)
return { ressourcen = liste, rechte = Admin.Kern.RechteAbbild(handelnder.quelle) }
end)
Admin.Kern.Registriere('ressourcen', 'steuern', function(handelnder, daten)
local name = tostring(daten.name or '')
local aktion = tostring(daten.aktion or '')
if GetResourceState(name) == 'missing' then return nil, 'Ressource unbekannt.' end
if name == GetCurrentResourceName() then
return nil, 'Das Admin-Panel kann sich nicht selbst steuern.'
end
if aktion == 'start' then StartResource(name)
elseif aktion == 'stop' then StopResource(name)
elseif aktion == 'restart' then
StopResource(name); Wait(150); StartResource(name)
else
return nil, 'Unbekannte Aktion.'
end
Admin.Log.Schreibe(handelnder, 'Ressource ' .. aktion, name)
return { ok = true, status = GetResourceState(name) }
end)
Admin.Kern.Registriere('ressourcen', 'neustart', function(handelnder, daten)
local minuten = math.max(0, math.floor(tonumber(daten.minuten) or 5))
local grund = tostring(daten.grund or ''):sub(1, 120)
TriggerClientEvent('aether-admin:client:broadcast', -1, {
typ = 'Neustart',
text = ('Server-Neustart in %d Minute(n)%s')
:format(minuten, grund ~= '' and (' — ' .. grund) or ''),
})
Admin.Log.Schreibe(handelnder, 'Neustart angekündigt', nil, minuten .. ' min ' .. grund)
-- Bewusst kein automatisches `quit`: das entscheidet die Betriebsebene.
return { ok = true, hinweis = 'Angekündigt und protokolliert. Der eigentliche Neustart läuft über die Server-/txAdmin-Ebene.' }
end)
Admin.Kern.Registriere('ressourcen', 'speichern', function(handelnder, daten)
-- Andere Module können darauf hören und ihren Stand sichern
TriggerEvent('aether-admin:speichereAlle')
Admin.Log.Schreibe(handelnder, 'Speichern ausgelöst')
return { ok = true }
end)

View File

@ -0,0 +1,48 @@
-- =====================================================================
-- aether-admin / Modul: Einstellungen
--
-- Server-Grundeinstellungen, persistiert in aether_admin_settings.
-- Slots sind eine reine Anzeige (kommen aus der Serverkonfiguration).
-- =====================================================================
Admin = Admin or {}
local SCHALTER = { 'pvp', 'whitelist', 'voice', 'economy' }
Admin.Kern.Registriere('settings', 'laden', function(handelnder, daten)
local schalter = {}
for _, k in ipairs(SCHALTER) do schalter[k] = Admin.DB.EinstellungAn(k, true) end
return {
servername = GetConvar('sv_hostname', 'Aether RP'),
slots = GetConvarInt('sv_maxclients', 48),
neustartStunden = Admin.DB.EinstellungZahl('neustart_stunden', 4),
schalter = schalter,
rechte = Admin.Kern.RechteAbbild(handelnder.quelle),
}
end)
Admin.Kern.Registriere('settings', 'speichern', function(handelnder, daten)
if daten.servername then
local name = tostring(daten.servername):sub(1, 96)
Admin.DB.SetzeEinstellung('servername', name)
-- sv_hostname zur Laufzeit setzen (greift für die Serverliste)
ExecuteCommand('sets sv_hostname "' .. name:gsub('"', '') .. '"')
end
if daten.neustartStunden ~= nil then
Admin.DB.SetzeEinstellung('neustart_stunden',
math.max(0, math.floor(tonumber(daten.neustartStunden) or 0)))
end
if type(daten.schalter) == 'table' then
for _, k in ipairs(SCHALTER) do
if daten.schalter[k] ~= nil then
Admin.DB.SetzeEinstellung(k, daten.schalter[k] and '1' or '0')
end
end
end
Admin.Log.Schreibe(handelnder, 'Einstellungen gespeichert')
return { ok = true }
end)

View File

@ -0,0 +1,360 @@
-- =====================================================================
-- aether-admin / Modul: Spieler
--
-- Die Liste der Online-Spieler und alle Eingriffe an ihnen. Nichts
-- davon vertraut der Oberfläche: jeder Aufruf ist serverseitig
-- rechtegeprüft, Zielspieler werden über ihre echte Server-ID
-- aufgelöst, und alles Eingreifende landet im Prüfprotokoll.
-- =====================================================================
Admin = Admin or {}
Admin.Live = Admin.Live or { pos = {}, god = {}, frozen = {}, muted = {} }
-- Grobe Ortsangabe aus Koordinaten (Annäherung, rein informativ)
local ORTE = {
{ name = 'Legion Square', x = 195.0, y = -930.0 },
{ name = 'Mission Row', x = 428.0, y = -984.0 },
{ name = 'Vinewood', x = 300.0, y = 200.0 },
{ name = 'Vespucci', x = -1230.0, y = -1500.0 },
{ name = 'Del Perro', x = -1600.0, y = -300.0 },
{ name = 'Sandy Shores', x = 1960.0, y = 3740.0 },
{ name = 'Paleto Bay', x = -100.0, y = 6330.0 },
{ name = 'LS Airport', x = -1030.0, y = -2730.0 },
{ name = 'Grove Street', x = 90.0, y = -1930.0 },
}
--- Nächstgelegener benannter Ort zu einer Position
--- @param pos table|nil { x, y }
--- @return string
local function OrtVon(pos)
if not pos or not pos.x then return '—' end
local beste, abstand = '—', math.huge
for _, ort in ipairs(ORTE) do
local d = (ort.x - pos.x) ^ 2 + (ort.y - pos.y) ^ 2
if d < abstand then abstand, beste = d, ort.name end
end
return beste
end
--- Verwarnungen eines Benutzers zählen
--- @param lizenz string|nil
--- @return number
local function WarnAnzahl(lizenz)
if not lizenz then return 0 end
return tonumber(MySQL.scalar.await(
'SELECT COUNT(*) FROM aether_admin_warns WHERE license = ?', { lizenz })) or 0
end
--- Baut den Anzeigedatensatz eines Online-Spielers
--- @param serverId number
--- @param daten table Spielerdaten aus aether-core
--- @return table
local function SpielerZeile(serverId, daten)
local lizenz = Admin.Auth.Lizenz(serverId)
return {
id = serverId,
name = (daten.vorname or '?') .. ' ' .. (daten.nachname or ''),
charakterId = daten.charakterId,
job = daten.job or 'unemployed',
jobRang = daten.jobRang or 0,
fraktion = daten.fraktion,
cash = math.floor(daten.bargeld or 0),
bank = math.floor(daten.konto or 0),
ping = GetPlayerPing(serverId) or 0,
region = OrtVon(daten.position),
leben = daten.leben or 200,
istTot = daten.istTot and true or false,
warns = WarnAnzahl(lizenz),
god = Admin.Live.god[serverId] and true or false,
frozen = Admin.Live.frozen[serverId] and true or false,
muted = Admin.Live.muted[serverId] and true or false,
}
end
--- Sammelt alle Online-Spieler
--- @return table Liste
function Admin.SpielerListe()
local alle = exports['aether-core']:HoleAlleSpieler() or {}
local liste = {}
for serverId, daten in pairs(alle) do
liste[#liste + 1] = SpielerZeile(tonumber(serverId), daten)
end
table.sort(liste, function(a, b) return a.id < b.id end)
return liste
end
-- =====================================================================
-- LISTE & DETAILS
-- =====================================================================
Admin.Kern.Registriere('spieler', 'liste', function(handelnder, daten)
return {
spieler = Admin.SpielerListe(),
rechte = Admin.Kern.RechteAbbild(handelnder.quelle),
jobs = { 'unemployed','police','ambulance','mechanic','taxi','trucker','lawyer','reporter','barkeeper' },
}
end)
Admin.Kern.Registriere('spieler', 'details', function(handelnder, daten)
local serverId = tonumber(daten.id)
if not serverId then return nil, 'Ungültige Spieler-ID.' end
local info = exports['aether-core']:HoleSpielerInfo(serverId)
if not info then return nil, 'Spieler ist nicht (mehr) online.' end
local lizenz = Admin.Auth.Lizenz(serverId)
local warns = lizenz and MySQL.query.await([[
SELECT id, grund, admin_name, erstellt_am FROM aether_admin_warns
WHERE license = ? ORDER BY id DESC LIMIT 25
]], { lizenz }) or {}
local notizen = lizenz and MySQL.query.await([[
SELECT id, notiz, admin_name, erstellt_am FROM aether_admin_notes
WHERE license = ? ORDER BY id DESC LIMIT 25
]], { lizenz }) or {}
return {
spieler = SpielerZeile(serverId, info),
lizenz = lizenz,
gruppe = Admin.Auth.Gruppe(serverId),
spielzeit = info.spielzeit,
warns = warns,
notizen = notizen,
rechte = Admin.Kern.RechteAbbild(handelnder.quelle),
jobs = { 'unemployed','police','ambulance','mechanic','taxi','trucker','lawyer','reporter','barkeeper' },
}
end)
-- =====================================================================
-- TELEPORT & ANSICHT
-- =====================================================================
--- Auflösung: Position eines Spielers (aus dem Live-Cache)
local function PosVon(serverId)
return Admin.Live.pos[serverId]
end
Admin.Kern.Registriere('spieler', 'goto', function(handelnder, daten)
local ziel = tonumber(daten.id)
local pos = PosVon(ziel)
if not pos then return nil, 'Position des Ziels ist noch nicht bekannt.' end
TriggerClientEvent('aether-admin:client:teleport', handelnder.quelle, pos)
Admin.Log.Schreibe(handelnder, 'Zu Spieler teleportiert', GetPlayerName(ziel))
return { ok = true }
end)
Admin.Kern.Registriere('spieler', 'bring', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local pos = PosVon(handelnder.quelle)
if not pos then return nil, 'Deine eigene Position ist noch nicht bekannt.' end
TriggerClientEvent('aether-admin:client:teleport', ziel, pos)
Admin.Log.Schreibe(handelnder, 'Spieler hergeholt', GetPlayerName(ziel))
return { ok = true }
end)
Admin.Kern.Registriere('spieler', 'spectate', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
TriggerClientEvent('aether-admin:client:spectate', handelnder.quelle, {
ziel = ziel, pos = PosVon(ziel),
})
Admin.Log.Schreibe(handelnder, 'Beobachtet', GetPlayerName(ziel))
return { ok = true }
end)
-- =====================================================================
-- ZUSTAND
-- =====================================================================
--- Kleiner Baukasten für einfache „an Ziel weiterreichen"-Aktionen
--- @param aktion string Registrierter Aktionsname
--- @param event string Client-Event am Ziel
--- @param protokoll string Protokolltext
local function ZielAktion(aktion, event, protokoll)
Admin.Kern.Registriere('spieler', aktion, function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
TriggerClientEvent(event, ziel)
Admin.Log.Schreibe(handelnder, protokoll, GetPlayerName(ziel))
return { ok = true }
end)
end
ZielAktion('heal', 'aether-admin:client:heal', 'Spieler geheilt')
ZielAktion('revive', 'aether-admin:client:revive', 'Spieler wiederbelebt')
ZielAktion('armor', 'aether-admin:client:armor', 'Rüstung gegeben')
--- Umschalt-Aktion mit gemerktem Zustand (Freeze/God/Mute)
--- @param aktion string
--- @param feld string Schlüssel in Admin.Live
--- @param event string|nil Client-Event am Ziel (nil = nur serverseitig, z. B. Mute)
--- @param nomen string Für das Protokoll
local function UmschaltAktion(aktion, feld, event, nomen)
Admin.Kern.Registriere('spieler', aktion, function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local neu = not Admin.Live[feld][ziel]
Admin.Live[feld][ziel] = neu or nil
if event then TriggerClientEvent(event, ziel, neu) end
Admin.Log.Schreibe(handelnder, nomen .. (neu and ' an' or ' aus'), GetPlayerName(ziel))
return { ok = true, aktiv = neu }
end)
end
UmschaltAktion('freeze', 'frozen', 'aether-admin:client:freeze', 'Einfrieren')
UmschaltAktion('god', 'god', 'aether-admin:client:god', 'Godmode')
UmschaltAktion('mute', 'muted', nil, 'Stummschaltung')
-- =====================================================================
-- GEBEN & SETZEN
-- =====================================================================
Admin.Kern.Registriere('spieler', 'geld', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local konto = daten.konto == 'cash' and 'cash' or 'bank'
local betrag = math.floor(tonumber(daten.betrag) or 0)
if betrag == 0 then return nil, 'Bitte einen Betrag angeben.' end
local ok
if betrag > 0 then
ok = exports['aether-core']:FuegeGeldHinzu(ziel, konto, betrag, 'Admin: ' .. handelnder.name)
else
ok = exports['aether-core']:EntferneGeld(ziel, konto, -betrag, 'Admin: ' .. handelnder.name)
end
if not ok then return nil, 'Nicht möglich (Deckung?).' end
Admin.Log.Schreibe(handelnder, 'Geld ' .. (betrag > 0 and '+' or '') .. betrag,
GetPlayerName(ziel), konto)
return { ok = true }
end)
Admin.Kern.Registriere('spieler', 'item', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local item = tostring(daten.item or '')
local menge = math.max(1, math.floor(tonumber(daten.menge) or 1))
if item == '' then return nil, 'Kein Item gewählt.' end
-- Falls ein Inventar-Resource läuft, dorthin geben; sonst nur vermerken
local ok = pcall(function()
return exports['aether-inventory']:GebeItem(ziel, item, menge)
end)
Admin.Log.Schreibe(handelnder, 'Item gegeben', GetPlayerName(ziel), menge .. '× ' .. item)
return { ok = true, zugestellt = ok }
end)
Admin.Kern.Registriere('spieler', 'fahrzeug', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local modell = tostring(daten.modell or ''):gsub('%s', '')
if modell == '' then return nil, 'Kein Modell gewählt.' end
TriggerClientEvent('aether-admin:client:fahrzeug', ziel, modell)
Admin.Log.Schreibe(handelnder, 'Fahrzeug gespawnt', GetPlayerName(ziel), modell)
return { ok = true }
end)
Admin.Kern.Registriere('spieler', 'job', function(handelnder, daten)
local ziel = tonumber(daten.id)
local charakterId = tonumber(daten.charakterId)
if not charakterId then return nil, 'Charakter unbekannt.' end
local job = tostring(daten.job or 'unemployed')
local fraktion = daten.fraktion ~= '' and tostring(daten.fraktion) or nil
MySQL.update.await(
'UPDATE aether_characters SET job = ?, faction = ? WHERE id = ?',
{ job, fraktion, charakterId })
-- Falls online, den Client informieren (greift spätestens beim Nachladen)
if ziel and GetPlayerName(ziel) then
TriggerClientEvent('aether-admin:client:hinweis', ziel,
'Deine Tätigkeit wurde vom Team angepasst: ' .. job)
end
Admin.Log.Schreibe(handelnder, 'Job/Fraktion gesetzt',
GetPlayerName(ziel) or ('Charakter #' .. charakterId),
job .. (fraktion and (' / ' .. fraktion) or ''))
return { ok = true }
end)
-- =====================================================================
-- MODERATION AM SPIELER
-- =====================================================================
Admin.Kern.Registriere('spieler', 'warn', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local grund = tostring(daten.grund or ''):sub(1, 255)
if #grund < 2 then return nil, 'Bitte einen Grund angeben.' end
local lizenz = Admin.Auth.Lizenz(ziel)
local charakterId = exports['aether-core']:HoleCharakterId(ziel)
MySQL.insert.await([[
INSERT INTO aether_admin_warns (license, `character`, name, grund, admin_license, admin_name)
VALUES (?, ?, ?, ?, ?, ?)
]], { lizenz, charakterId, GetPlayerName(ziel), grund, handelnder.lizenz, handelnder.name })
TriggerClientEvent('aether-admin:client:hinweis', ziel, '⚠️ Verwarnung: ' .. grund)
Admin.Log.Schreibe(handelnder, 'Verwarnung', GetPlayerName(ziel), grund)
return { ok = true }
end)
Admin.Kern.Registriere('spieler', 'notiz', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local text = tostring(daten.text or ''):sub(1, 1000)
if #text < 2 then return nil, 'Bitte einen Text eingeben.' end
MySQL.insert.await([[
INSERT INTO aether_admin_notes (license, name, notiz, admin_name)
VALUES (?, ?, ?, ?)
]], { Admin.Auth.Lizenz(ziel), GetPlayerName(ziel), text, handelnder.name })
Admin.Log.Schreibe(handelnder, 'Notiz', GetPlayerName(ziel))
return { ok = true }
end)
Admin.Kern.Registriere('spieler', 'kick', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local grund = tostring(daten.grund or 'Kein Grund angegeben'):sub(1, 255)
local name = GetPlayerName(ziel)
DropPlayer(ziel, 'Vom Team gekickt: ' .. grund)
Admin.Log.Schreibe(handelnder, 'Kick', name, grund)
return { ok = true }
end)
Admin.Kern.Registriere('spieler', 'ban', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local grund = tostring(daten.grund or 'Regelbruch'):sub(1, 255)
local dauer = tostring(daten.dauer or 'permanent')
local name = GetPlayerName(ziel)
local lizenz = Admin.Auth.Lizenz(ziel)
-- Permanente Bans darf nur, wer 'ban' als owner-Recht hat — hier reicht das
-- Aktionsrecht; die Rechte-Matrix regelt, ob überhaupt gebannt werden darf.
local bis = Admin.BerechneBanEnde(dauer)
Admin.DB.Sperre(lizenz, name, grund, bis, handelnder.lizenz, handelnder.name)
DropPlayer(ziel, 'Gebannt: ' .. grund)
Admin.Log.Schreibe(handelnder, 'Ban (' .. dauer .. ')', name, grund)
return { ok = true }
end)
--- Rechnet eine Dauer-Angabe in ein DATETIME um (oder nil = permanent)
--- @param dauer string
--- @return string|nil
function Admin.BerechneBanEnde(dauer)
local tage = ({ ['1 Tag'] = 1, ['3 Tage'] = 3, ['7 Tage'] = 7, ['30 Tage'] = 30 })[dauer]
if not tage then return nil end
return os.date('%Y-%m-%d %H:%M:%S', os.time() + tage * 86400)
end

View File

@ -0,0 +1,77 @@
-- =====================================================================
-- aether-admin / Modul: Staatsprojekte
--
-- Projekte, über die (später aus einer Handy-App) abgestimmt wird. Ein
-- Projekt lässt sich umsetzen, sobald die Abstimmung beendet ist und
-- genug Zustimmung erreicht wurde — die Kosten kommen aus der Staatskasse.
-- =====================================================================
Admin = Admin or {}
local ZUSTIMMUNG_MIN = 0.6
Admin.Kern.Registriere('staat', 'laden', function(handelnder, daten)
local liste = MySQL.query.await([[
SELECT id, titel, beschreibung, kosten, ja, nein, abstimmung_offen, aktiviert, erstellt_am
FROM aether_admin_projects ORDER BY aktiviert, id DESC
]]) or {}
for _, p in ipairs(liste) do
local gesamt = (tonumber(p.ja) or 0) + (tonumber(p.nein) or 0)
p.zustimmung = gesamt > 0 and math.floor(tonumber(p.ja) / gesamt * 100) or 0
p.offen = tonumber(p.abstimmung_offen) == 1
p.aktiviert = tonumber(p.aktiviert) == 1
end
return {
projekte = liste,
staatskasse = Admin.DB.EinstellungZahl('staatskasse', 0),
zustimmungMin = ZUSTIMMUNG_MIN * 100,
rechte = Admin.Kern.RechteAbbild(handelnder.quelle),
}
end)
Admin.Kern.Registriere('staat', 'beenden', function(handelnder, daten)
local id = tonumber(daten.id)
if not id then return nil, 'Ungültige Kennung.' end
MySQL.update.await('UPDATE aether_admin_projects SET abstimmung_offen = 0 WHERE id = ?', { id })
Admin.Log.Schreibe(handelnder, 'Abstimmung beendet', 'Projekt #' .. id)
return { ok = true }
end)
Admin.Kern.Registriere('staat', 'aktivieren', function(handelnder, daten)
local id = tonumber(daten.id)
if not id then return nil, 'Ungültige Kennung.' end
local zeilen = MySQL.query.await([[
SELECT titel, kosten, ja, nein, abstimmung_offen, aktiviert
FROM aether_admin_projects WHERE id = ?
]], { id })
if not zeilen or not zeilen[1] then return nil, 'Projekt nicht gefunden.' end
local p = zeilen[1]
if tonumber(p.aktiviert) == 1 then return nil, 'Bereits umgesetzt.' end
if tonumber(p.abstimmung_offen) == 1 then return nil, 'Die Abstimmung läuft noch.' end
local gesamt = (tonumber(p.ja) or 0) + (tonumber(p.nein) or 0)
local quote = gesamt > 0 and (tonumber(p.ja) / gesamt) or 0
if quote < ZUSTIMMUNG_MIN then return nil, 'Nicht genug Zustimmung.' end
local kosten = tonumber(p.kosten) or 0
local kasse = Admin.DB.EinstellungZahl('staatskasse', 0)
if kosten > kasse then return nil, 'Staatskasse nicht gedeckt.' end
Admin.DB.SetzeEinstellung('staatskasse', kasse - kosten)
MySQL.update.await('UPDATE aether_admin_projects SET aktiviert = 1 WHERE id = ?', { id })
Admin.Log.Schreibe(handelnder, 'Staatsprojekt aktiviert', p.titel, tostring(kosten))
return { ok = true }
end)
Admin.Kern.Registriere('staat', 'archivieren', function(handelnder, daten)
local id = tonumber(daten.id)
if not id then return nil, 'Ungültige Kennung.' end
MySQL.update.await('DELETE FROM aether_admin_projects WHERE id = ?', { id })
Admin.Log.Schreibe(handelnder, 'Staatsprojekt archiviert', 'Projekt #' .. id)
return { ok = true }
end)

View File

@ -0,0 +1,77 @@
-- =====================================================================
-- aether-admin / Modul: Admin-Team
--
-- Das Team ist keine eigene Liste — es sind die Benutzer in
-- `aether_users` mit einer Gruppe über `user`. Rollen zu vergeben heißt
-- hier, die Gruppe eines Benutzers zu ändern; der Core respektiert sie
-- ab dann überall.
-- =====================================================================
Admin = Admin or {}
-- Nur diese Gruppen sind vergebbar (owner nur von owner — via Rechte-Matrix)
local ROLLEN = { 'support', 'admin', 'owner' }
--- Ist eine Gruppenkennung gültig?
local function GueltigeRolle(g)
for _, r in ipairs(ROLLEN) do if r == g then return true end end
return false
end
Admin.Kern.Registriere('team', 'laden', function(handelnder, daten)
local zeilen = MySQL.query.await([[
SELECT license, name, admin_group FROM aether_users
WHERE admin_group <> 'user'
ORDER BY FIELD(admin_group,'owner','admin','support'), name
]]) or {}
-- Wer davon ist online?
local onlineLizenz = {}
for _, sid in ipairs(GetPlayers() or {}) do
onlineLizenz[Admin.Auth.Lizenz(tonumber(sid))] = true
end
for _, z in ipairs(zeilen) do z.online = onlineLizenz[z.license] and true or false end
return { team = zeilen, rollen = ROLLEN,
rechte = Admin.Kern.RechteAbbild(handelnder.quelle) }
end)
Admin.Kern.Registriere('team', 'speichern', function(handelnder, daten)
local rolle = tostring(daten.rolle or '')
if not GueltigeRolle(rolle) then return nil, 'Unbekannte Rolle.' end
-- Nur ein Owner darf Owner-Rechte vergeben
if rolle == 'owner' and not Admin.Auth.Hat(handelnder.quelle, 'owner') then
return nil, 'Owner-Rechte darf nur ein Owner vergeben.'
end
-- Ziel über Lizenz oder Namenssuche bestimmen
local lizenz = daten.lizenz and tostring(daten.lizenz) or nil
if not lizenz then
local treffer = Admin.DB.SucheBenutzer(tostring(daten.name or ''))
if #treffer == 0 then return nil, 'Kein Benutzer gefunden.' end
if #treffer > 1 then return nil, 'Mehrdeutig — bitte die Lizenz angeben.' end
lizenz = treffer[1].license
end
if not Admin.DB.SetzeGruppe(lizenz, rolle) then
return nil, 'Benutzer nicht gefunden.'
end
Admin.Auth.CacheLeerenNachLizenz(lizenz)
Admin.Log.Schreibe(handelnder, 'Rolle vergeben', daten.name or lizenz, rolle)
return { ok = true }
end)
Admin.Kern.Registriere('team', 'entfernen', function(handelnder, daten)
local lizenz = tostring(daten.lizenz or '')
if lizenz == '' then return nil, 'Keine Lizenz angegeben.' end
if lizenz == handelnder.lizenz then
return nil, 'Du kannst dir nicht selbst die Rechte entziehen.'
end
Admin.DB.SetzeGruppe(lizenz, 'user')
Admin.Auth.CacheLeerenNachLizenz(lizenz)
Admin.Log.Schreibe(handelnder, 'Aus dem Team entfernt', daten.name or lizenz)
return { ok = true }
end)

View File

@ -0,0 +1,87 @@
-- =====================================================================
-- aether-admin / Modul: Weltsteuerung
--
-- Zeit, Wetter, Blackout und Bereinigung. Der Zustand wird an ALLE
-- Clients gespiegelt und neu verbundenen Spielern beim Laden geschickt,
-- damit die Welt für alle gleich aussieht.
-- =====================================================================
Admin = Admin or {}
-- Gemeinsamer Weltzustand (Standard beim Serverstart)
Admin.Welt = Admin.Welt or {
stunde = 12, minute = 0, wetter = 'CLEAR',
zeitFix = false, wetterFix = false, blackout = false,
}
local WETTER = {
['Klar'] = 'CLEAR', ['Bewölkt'] = 'CLOUDS', ['Regen'] = 'RAIN',
['Gewitter'] = 'THUNDER', ['Nebel'] = 'FOGGY', ['Schnee'] = 'XMAS',
['Sturm'] = 'OVERCAST',
}
--- Schickt den aktuellen Weltzustand an einen oder alle Clients
--- @param ziel number|nil Server-ID oder nil = alle
function Admin.SendeWelt(ziel)
TriggerClientEvent('aether-admin:client:welt', ziel or -1, Admin.Welt)
end
Admin.Kern.Registriere('welt', 'zeit', function(handelnder, daten)
local stunde = math.max(0, math.min(23, math.floor(tonumber(daten.stunde) or 12)))
Admin.Welt.stunde = stunde
Admin.Welt.minute = 0
if daten.fix ~= nil then Admin.Welt.zeitFix = daten.fix and true or false end
Admin.SendeWelt()
Admin.Log.Schreibe(handelnder, 'Zeit gesetzt', nil, ('%02d:00'):format(stunde))
return { ok = true, welt = Admin.Welt }
end)
Admin.Kern.Registriere('welt', 'wetter', function(handelnder, daten)
local anzeige = tostring(daten.wetter or 'Klar')
Admin.Welt.wetter = WETTER[anzeige] or 'CLEAR'
if daten.fix ~= nil then Admin.Welt.wetterFix = daten.fix and true or false end
Admin.SendeWelt()
Admin.Log.Schreibe(handelnder, 'Wetter gesetzt', nil, anzeige)
return { ok = true, welt = Admin.Welt }
end)
Admin.Kern.Registriere('welt', 'blackout', function(handelnder, daten)
Admin.Welt.blackout = not Admin.Welt.blackout
Admin.SendeWelt()
Admin.Log.Schreibe(handelnder, 'Blackout ' .. (Admin.Welt.blackout and 'an' or 'aus'))
return { ok = true, blackout = Admin.Welt.blackout }
end)
Admin.Kern.Registriere('welt', 'cleanup', function(handelnder, daten)
local art = tostring(daten.art or '')
local entfernt = 0
if art == 'veh' then
for _, veh in ipairs(GetAllVehicles() or {}) do
-- Nur leere Fahrzeuge entfernen (niemanden aus dem Sitz reißen)
if GetVehicleNumberOfPassengers(veh) == 0 and IsVehicleSeatFree(veh, -1) then
DeleteEntity(veh); entfernt = entfernt + 1
end
end
elseif art == 'ped' then
for _, ped in ipairs(GetAllPeds() or {}) do
if not IsPedAPlayer(ped) then DeleteEntity(ped); entfernt = entfernt + 1 end
end
elseif art == 'obj' then
for _, obj in ipairs(GetAllObjects() or {}) do
DeleteEntity(obj); entfernt = entfernt + 1
end
elseif art == 'ground' then
-- Herumliegende (tote) NPCs
for _, ped in ipairs(GetAllPeds() or {}) do
if not IsPedAPlayer(ped) and IsPedDeadOrDying(ped, true) then
DeleteEntity(ped); entfernt = entfernt + 1
end
end
else
return nil, 'Unbekannte Bereinigung.'
end
Admin.Log.Schreibe(handelnder, 'Bereinigt', art, entfernt .. ' entfernt')
return { ok = true, entfernt = entfernt }
end)

View File

@ -0,0 +1,31 @@
-- =====================================================================
-- aether-admin / Modul: Weltkarte
--
-- Live-Standorte aller Spieler. Die Positionen stammen aus dem
-- Positions-Cache, den die Clients regelmäßig melden (siehe main.lua).
-- =====================================================================
Admin = Admin or {}
Admin.Live = Admin.Live or { pos = {}, god = {}, frozen = {}, muted = {} }
Admin.Kern.Registriere('weltkarte', 'laden', function(handelnder, daten)
local alle = exports['aether-core']:HoleAlleSpieler() or {}
local liste = {}
for serverId, d in pairs(alle) do
local sid = tonumber(serverId)
local pos = Admin.Live.pos[sid] or d.position
liste[#liste + 1] = {
id = sid,
name = (d.vorname or '?') .. ' ' .. (d.nachname or ''),
fraktion = d.fraktion,
ping = GetPlayerPing(sid) or 0,
tot = d.istTot and true or false,
x = pos and pos.x or 0.0,
y = pos and pos.y or 0.0,
}
end
table.sort(liste, function(a, b) return a.id < b.id end)
return { spieler = liste, rechte = Admin.Kern.RechteAbbild(handelnder.quelle) }
end)

View File

@ -0,0 +1,65 @@
-- =====================================================================
-- aether-admin / Modul: Wirtschaft
--
-- Geldmenge, Staatskasse und Steuer. Die Geldmenge wird live aus allen
-- Charakteren summiert — keine Schätzung.
-- =====================================================================
Admin = Admin or {}
Admin.Kern.Registriere('wirtschaft', 'laden', function(handelnder, daten)
local summe = MySQL.query.await([[
SELECT COALESCE(SUM(cash), 0) AS bargeld,
COALESCE(SUM(bank), 0) AS konto,
COUNT(*) AS anzahl
FROM aether_characters WHERE deleted = 0
]])[1] or { bargeld = 0, konto = 0, anzahl = 0 }
local umlauf = tonumber(summe.bargeld) + tonumber(summe.konto)
local online = #(GetPlayers() or {})
return {
umlauf = umlauf,
staatskasse = Admin.DB.EinstellungZahl('staatskasse', 0),
steuer = Admin.DB.EinstellungZahl('steuersatz', 8),
charaktere = tonumber(summe.anzahl),
online = online,
rechte = Admin.Kern.RechteAbbild(handelnder.quelle),
}
end)
Admin.Kern.Registriere('wirtschaft', 'geldAlle', function(handelnder, daten)
local betrag = math.floor(tonumber(daten.betrag) or 0)
if betrag == 0 then return nil, 'Bitte einen Betrag angeben.' end
local anzahl = 0
for _, sid in ipairs(GetPlayers() or {}) do
local s = tonumber(sid)
if exports['aether-core']:HoleSpielerInfo(s) then
if betrag > 0 then
exports['aether-core']:FuegeGeldHinzu(s, 'bank', betrag, 'Admin-Auszahlung')
else
exports['aether-core']:EntferneGeld(s, 'bank', -betrag, 'Admin-Abzug')
end
anzahl = anzahl + 1
end
end
Admin.Log.Schreibe(handelnder, 'Geld an alle', anzahl .. ' Spieler',
(betrag > 0 and '+' or '') .. betrag)
return { ok = true, anzahl = anzahl }
end)
Admin.Kern.Registriere('wirtschaft', 'kasse', function(handelnder, daten)
local wert = math.floor(tonumber(daten.wert) or 0)
Admin.DB.SetzeEinstellung('staatskasse', wert)
Admin.Log.Schreibe(handelnder, 'Staatskasse gesetzt', nil, tostring(wert))
return { ok = true }
end)
Admin.Kern.Registriere('wirtschaft', 'steuer', function(handelnder, daten)
local wert = math.max(0, math.min(100, math.floor(tonumber(daten.wert) or 0)))
Admin.DB.SetzeEinstellung('steuersatz', wert)
Admin.Log.Schreibe(handelnder, 'Steuersatz gesetzt', nil, wert .. '%')
return { ok = true }
end)

View File

@ -0,0 +1,193 @@
-- =====================================================================
-- aether-admin — Konfiguration (Client + Server)
--
-- Alles, was Betreiber anpassen wollen, ohne Code zu ändern. Kein Wert
-- hier vergibt selbst ein Recht — er beschreibt nur, welches Recht wofür
-- nötig ist. Die Prüfung selbst passiert immer serverseitig.
-- =====================================================================
AdminConfig = {}
-- ---------------------------------------------------------------------
-- Rechtegruppen
--
-- BEWUSST identisch zu Config.AdminGruppen aus aether-core. Die Hoheit
-- über die Gruppe eines Spielers liegt in `aether_users.admin_group`;
-- dieses Modul liest sie nur und rechnet mit derselben Rangordnung.
-- ---------------------------------------------------------------------
AdminConfig.Gruppen = {
['user'] = 0,
['support'] = 1,
['admin'] = 2,
['owner'] = 3,
}
-- Ab welcher Gruppe das Panel überhaupt geöffnet werden darf
AdminConfig.MindestGruppe = 'support'
-- ---------------------------------------------------------------------
-- Rechte-Matrix · [modul.aktion] = benötigte Gruppe
--
-- Fehlt eine Aktion hier, gilt AdminConfig.StandardRecht. Lesen ist
-- günstig, Eingreifen teuer, Unwiderrufliches am teuersten.
-- ---------------------------------------------------------------------
AdminConfig.StandardRecht = 'admin'
AdminConfig.Rechte = {
-- Überblick & Lesen
['dashboard.laden'] = 'support',
['spieler.liste'] = 'support',
['spieler.details'] = 'support',
['weltkarte.laden'] = 'support',
['logs.laden'] = 'admin',
['team.laden'] = 'support',
['ressourcen.laden'] = 'admin',
['wirtschaft.laden'] = 'admin',
['fraktionen.laden'] = 'admin',
['immobilien.laden'] = 'admin',
['fahrzeuge.laden'] = 'admin',
['staat.laden'] = 'admin',
['moderation.laden'] = 'support',
['reports.laden'] = 'support',
['settings.laden'] = 'admin',
['broadcast.laden'] = 'support',
-- Spieler: Ansicht & Zustand
['spieler.goto'] = 'admin',
['spieler.bring'] = 'admin',
['spieler.spectate'] = 'admin',
['spieler.kamera'] = 'admin',
['spieler.heal'] = 'admin',
['spieler.revive'] = 'admin',
['spieler.armor'] = 'admin',
['spieler.freeze'] = 'admin',
['spieler.god'] = 'admin',
['spieler.mute'] = 'support',
['spieler.notiz'] = 'support',
-- Spieler: Geben & Setzen
['spieler.geld'] = 'admin',
['spieler.item'] = 'admin',
['spieler.fahrzeug'] = 'admin',
['spieler.job'] = 'admin',
-- Moderation
['spieler.warn'] = 'support',
['spieler.kick'] = 'admin',
['spieler.ban'] = 'admin',
['moderation.unban'] = 'admin',
['moderation.warnWeg'] = 'support',
['moderation.banManuell']= 'admin',
-- Reports
['reports.claim'] = 'support',
['reports.antwort'] = 'support',
['reports.status'] = 'support',
-- Welt
['welt.zeit'] = 'admin',
['welt.wetter'] = 'admin',
['welt.blackout'] = 'admin',
['welt.cleanup'] = 'admin',
['broadcast.senden'] = 'admin',
-- Fahrzeuge
['fahrzeuge.spawn'] = 'admin',
['fahrzeuge.reparieren'] = 'admin',
['fahrzeuge.herholen'] = 'admin',
['fahrzeuge.loeschen'] = 'admin',
['fahrzeuge.aufraeumen'] = 'admin',
-- Wirtschaft / Fraktionen / Immobilien / Staat
['wirtschaft.geldAlle'] = 'owner',
['wirtschaft.kasse'] = 'owner',
['wirtschaft.steuer'] = 'owner',
['fraktionen.speichern'] = 'admin',
['fraktionen.loeschen'] = 'owner',
['fraktionen.payday'] = 'admin',
['immobilien.zuweisen'] = 'admin',
['immobilien.raeumen'] = 'admin',
['staat.beenden'] = 'admin',
['staat.aktivieren'] = 'owner',
['staat.archivieren'] = 'admin',
-- System (nur Owner)
['ressourcen.steuern'] = 'owner',
['ressourcen.neustart'] = 'owner',
['settings.speichern'] = 'owner',
['team.speichern'] = 'owner',
['team.entfernen'] = 'owner',
-- Kameras (WebRTC)
['kamera.oeffnen'] = 'admin',
['kamera.schliessen'] = 'admin',
}
-- ---------------------------------------------------------------------
-- Sektionen der Seitenleiste · min = ab welcher Gruppe sichtbar
-- Reihenfolge wie im Panel. `grp` gruppiert nur optisch.
-- ---------------------------------------------------------------------
AdminConfig.Sektionen = {
{ grp = 'Überblick' },
{ id = 'dashboard', label = 'Dashboard', icon = '📊', min = 'support' },
{ id = 'weltkarte', label = 'Weltkarte', icon = '🗺️', min = 'support' },
{ grp = 'Verwaltung' },
{ id = 'spieler', label = 'Spieler', icon = '👥', min = 'support' },
{ id = 'fahrzeuge', label = 'Fahrzeuge', icon = '🚗', min = 'admin' },
{ id = 'immobilien', label = 'Immobilien', icon = '🏠', min = 'admin' },
{ id = 'fraktionen', label = 'Fraktionen', icon = '🚔', min = 'admin' },
{ id = 'wirtschaft', label = 'Wirtschaft', icon = '💰', min = 'admin' },
{ id = 'staat', label = 'Staatsprojekte', icon = '🏛️', min = 'admin' },
{ grp = 'Überwachung' },
{ id = 'cams', label = 'Live-Kameras', icon = '📹', min = 'admin' },
{ grp = 'Welt' },
{ id = 'welt', label = 'Weltsteuerung', icon = '🌍', min = 'admin' },
{ id = 'broadcast', label = 'Ankündigungen', icon = '📢', min = 'support' },
{ grp = 'Moderation' },
{ id = 'moderation', label = 'Bans & Warns', icon = '🔨', min = 'support' },
{ id = 'reports', label = 'Reports', icon = '🎫', min = 'support' },
{ grp = 'System' },
{ id = 'ressourcen', label = 'Ressourcen', icon = '🧩', min = 'admin' },
{ id = 'logs', label = 'Audit-Log', icon = '📜', min = 'admin' },
{ id = 'team', label = 'Admin-Team', icon = '⭐', min = 'support' },
{ id = 'settings', label = 'Einstellungen', icon = '⚙️', min = 'owner' },
}
-- ---------------------------------------------------------------------
-- Live-Kameras (WebRTC)
--
-- Öffentliche Angaben. Das gemeinsame Geheimnis für die Token-Signatur
-- liegt NICHT hier, sondern serverseitig in der Convar `aether_admin_media_secret`
-- — sonst könnte jeder Client es aus dem geteilten Code lesen.
-- ---------------------------------------------------------------------
AdminConfig.Media = {
-- Ist die WebRTC-Kamera aktiv? Bei false zeigt das Panel nur den Hinweis.
aktiv = true,
-- WebSocket-Adresse des Media-Servers (Signaling + SFU), vom NUI erreichbar.
-- Auf den öffentlichen Host/Port des Media-Servers zeigen lassen.
url = 'ws://127.0.0.1:8787',
-- Wie lange ein ausgestelltes Zugangstoken gilt (Sekunden)
tokenGueltig = 30,
}
--- Rechnet den Rang einer Gruppe aus (unbekannt = 0)
--- @param gruppe string|nil
--- @return number
function AdminConfig.Rang(gruppe)
return AdminConfig.Gruppen[gruppe or 'user'] or 0
end
--- Erfüllt `gruppe` mindestens `benoetigt`?
--- @param gruppe string|nil Gruppe des Spielers
--- @param benoetigt string|nil Geforderte Gruppe
--- @return boolean
function AdminConfig.Erfuellt(gruppe, benoetigt)
return AdminConfig.Rang(gruppe) >= AdminConfig.Rang(benoetigt)
end
--- Welche Gruppe braucht eine Aktion?
--- @param schluessel string 'modul.aktion'
--- @return string
function AdminConfig.RechtFuer(schluessel)
return AdminConfig.Rechte[schluessel] or AdminConfig.StandardRecht
end

View File

@ -0,0 +1,180 @@
-- =====================================================================
-- Aether Bank — Clientlogik
-- Findet Geldautomaten in der Welt, oeffnet die Oberflaeche und reicht
-- alle Wuensche an den Server weiter. Entschieden wird dort.
-- =====================================================================
-- Ist der Automat gerade offen?
local istOffen = false
-- Ist der Spieler im Spiel?
local istGeladen = false
-- Taste E zum Benutzen
local TASTE_BENUTZEN = 38
-- Modelle, die als Geldautomat gelten
local AUTOMATEN = {
'prop_atm_01',
'prop_atm_02',
'prop_atm_03',
'prop_fleeca_atm',
}
-- Vorberechnete Hashes der Automaten-Modelle
local automatenHashes = {}
for _, name in ipairs(AUTOMATEN) do
automatenHashes[#automatenHashes + 1] = GetHashKey(name)
end
--- Schickt eine Nachricht an die Oberflaeche
local function SendeAnNui(nachricht)
SendNuiMessage(json.encode(nachricht))
end
--- Zeigt eine Meldung oben links im Spiel
local function ZeigeSpielmeldung(text)
if not text or text == '' then return end
BeginTextCommandThefeedPost('STRING')
AddTextComponentSubstringPlayerName(text)
EndTextCommandThefeedPostTicker(false, true)
end
--- Schliesst den Automaten
local function Schliessen()
if not istOffen then return end
istOffen = false
SetNuiFocus(false, false)
SendeAnNui({ aktion = 'schliessen' })
TriggerServerEvent('aether:bank:schliessen')
pcall(function() exports['aether-hud']:SetzeSichtbar(true) end)
end
-- ---------------------------------------------------------------------
-- Ereignisse
-- ---------------------------------------------------------------------
AddEventHandler('aether:client:bereit', function()
istGeladen = true
end)
--- Server erlaubt das Oeffnen: Oberflaeche zeigen (PIN-Eingabe folgt)
RegisterNetEvent('aether:bank:zeige', function()
istOffen = true
SetNuiFocus(true, true)
pcall(function() exports['aether-hud']:SetzeSichtbar(false) end)
SendeAnNui({ aktion = 'oeffnen' })
end)
--- Ergebnis der PIN-Pruefung
RegisterNetEvent('aether:bank:pinErgebnis', function(erfolg, konto)
SendeAnNui({ aktion = 'pinErgebnis', erfolg = erfolg, konto = konto })
end)
--- Aktualisierter Kontostand nach einer Buchung
RegisterNetEvent('aether:bank:konto', function(konto)
konto.aktion = 'konto'
SendeAnNui(konto)
end)
--- Erfolgsmeldung (wird als Abschlussbildschirm angezeigt)
RegisterNetEvent('aether:bank:erfolg', function(titel, text)
SendeAnNui({ aktion = 'erfolg', titel = titel, text = text })
end)
--- Ablehnung einer Aktion
RegisterNetEvent('aether:bank:fehler', function(text)
if istOffen then
SendeAnNui({ aktion = 'fehler', text = text })
else
ZeigeSpielmeldung(text)
end
end)
--- Benachrichtigung ausserhalb des Automaten (z. B. Geld erhalten)
RegisterNetEvent('aether:bank:benachrichtigung', function(text)
ZeigeSpielmeldung(text)
end)
-- ---------------------------------------------------------------------
-- Automaten in der Naehe finden und benutzen
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
local pause = 700
if istGeladen and not istOffen then
local position = GetEntityCoords(PlayerPedId())
-- Naechstgelegenen Automaten suchen
local automat = nil
for _, hash in ipairs(automatenHashes) do
local objekt = GetClosestObjectOfType(position.x, position.y, position.z,
1.6, hash, false, false, false)
if objekt ~= 0 then automat = objekt break end
end
if automat then
pause = 0 -- fuer die Textanzeige jeden Frame laufen
local automatPos = GetEntityCoords(automat)
SetTextFont(4)
SetTextScale(0.32, 0.32)
SetTextColour(255, 255, 255, 215)
SetTextCentre(true)
SetTextOutline()
SetTextEntry('STRING')
AddTextComponentString('[E] Geldautomat benutzen')
SetDrawOrigin(automatPos.x, automatPos.y, automatPos.z + 0.8, 0)
DrawText(0.0, 0.0)
ClearDrawOrigin()
if IsControlJustReleased(0, TASTE_BENUTZEN) then
TriggerServerEvent('aether:bank:oeffnen')
end
end
end
Wait(pause)
end
end)
-- ---------------------------------------------------------------------
-- NUI-Callbacks
-- ---------------------------------------------------------------------
RegisterNUICallback('schliessen', function(_, rueckgabe)
Schliessen()
rueckgabe({ ok = true })
end)
RegisterNUICallback('pinPruefen', function(daten, rueckgabe)
TriggerServerEvent('aether:bank:pinPruefen', tostring(daten.pin or ''))
rueckgabe({ ok = true })
end)
RegisterNUICallback('pinAendern', function(daten, rueckgabe)
TriggerServerEvent('aether:bank:pinAendern', tostring(daten.alt or ''), tostring(daten.neu or ''))
rueckgabe({ ok = true })
end)
RegisterNUICallback('auszahlen', function(daten, rueckgabe)
TriggerServerEvent('aether:bank:auszahlen', tonumber(daten.betrag) or 0)
rueckgabe({ ok = true })
end)
RegisterNUICallback('einzahlen', function(daten, rueckgabe)
TriggerServerEvent('aether:bank:einzahlen', tonumber(daten.betrag) or 0)
rueckgabe({ ok = true })
end)
RegisterNUICallback('ueberweisen', function(daten, rueckgabe)
TriggerServerEvent('aether:bank:ueberweisen',
tostring(daten.ziel or ''), tonumber(daten.betrag) or 0, tostring(daten.zweck or ''))
rueckgabe({ ok = true })
end)
-- ---------------------------------------------------------------------
-- Aufraeumen
-- ---------------------------------------------------------------------
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false)
end)

View File

@ -0,0 +1,34 @@
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
name 'aether-bank'
author 'Jerrit Fritzsche'
description 'Geldautomat: PIN, Ein-/Auszahlen, Überweisen, Kontoauszug'
version '0.1.0'
ui_page 'html/index.html'
files {
'html/index.html',
}
-- Core-Dateien mitladen (eigener Lua-State je Resource)
shared_scripts {
'@aether-core/shared/config.lua',
'@aether-core/shared/utils.lua',
}
client_scripts {
'client/main.lua',
}
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/main.lua',
}
dependencies {
'aether-core',
'oxmysql',
}

View File

@ -0,0 +1,938 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
</head>
<body>
<title>Fleeca — Bankautomat</title>
<style>
/* ===================== Design-Tokens ===================== */
:root {
--page-bg: #070a09;
/* ATM-Bildschirm (bewusst durchgängig dunkel — ein Gerätescreen) */
--scr-fg: #eaf3ee;
--scr-muted: #85a196;
--scr-surface: #12241d;
--scr-surface2: #182e25;
--scr-border: rgba(255,255,255,0.08);
--scr-accent: #2fd39a;
--scr-accent-soft: rgba(47,211,154,0.14);
--scr-red: #f0708a;
--scr-amber: #f5b23d;
--scr-blue: #7c9cff;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
min-height: 100%;
background:
radial-gradient(1100px 700px at 15% 10%, rgba(47,211,154,0.10), transparent 55%),
radial-gradient(1000px 800px at 90% 90%, rgba(124,156,255,0.08), transparent 55%),
var(--page-bg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #cdd6d1;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 22px; padding: 40px 18px; overflow-x: hidden;
}
/* ambiente, leicht bewegte Deko im Hintergrund (durch das Display sichtbar) */
.ambient { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; }
.ambient .blob { position: absolute; border-radius: 50%; filter: blur(70px); opacity: 0.5; }
.ambient .b1 { width: 420px; height: 420px; background: #1f6b52; top: -120px; left: -80px; }
.ambient .b2 { width: 380px; height: 380px; background: #2a3f7a; bottom: -140px; right: -60px; }
.ambient .word { position: absolute; inset: 0; display: grid; place-items: center;
font-size: 22vw; font-weight: 800; letter-spacing: -0.04em; color: rgba(255,255,255,0.02);
user-select: none; }
.stage-hint { position: relative; z-index: 2; font-size: 13px; color: #6f7d77; text-align: center; max-width: 560px; }
.stage-hint b { color: #aebbb5; font-weight: 600; }
/* ===================== Gerät (Tablet) ===================== */
.atm {
position: relative; z-index: 3;
width: min(1060px, 95vw);
aspect-ratio: 16 / 10;
background: linear-gradient(150deg, #23282b, #0c0f11 70%);
border-radius: 30px;
padding: 16px;
box-shadow:
0 2px 3px rgba(255,255,255,0.10) inset,
0 0 0 2px #04070a,
0 50px 90px -30px rgba(0,0,0,0.85);
/* Kernanforderung: verlässt die Maus das Display, wird es transparent */
opacity: 0.32;
transition: opacity .45s ease, box-shadow .45s ease;
}
.atm:hover {
opacity: 1;
box-shadow:
0 2px 3px rgba(255,255,255,0.12) inset,
0 0 0 2px #04070a,
0 60px 110px -30px rgba(0,0,0,0.9),
0 30px 80px -40px rgba(47,211,154,0.35);
}
/* Kamera-Punkt oben mittig */
.atm::before {
content: ""; position: absolute; top: 24px; left: 50%; transform: translateX(-50%);
width: 7px; height: 7px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #3a5568, #05070c);
z-index: 5;
}
/* Fleeca-Schriftzug auf dem Rahmen */
.atm::after {
content: "FLEECA"; position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
font-size: 11px; letter-spacing: 0.5em; font-weight: 700; color: rgba(255,255,255,0.22);
}
.screen {
position: relative; width: 100%; height: 100%; border-radius: 18px; overflow: hidden;
background:
radial-gradient(120% 80% at 20% 0%, #123a2c 0%, transparent 55%),
radial-gradient(120% 90% at 100% 100%, #10233a 0%, transparent 50%),
linear-gradient(160deg, #0b1a15, #060d0a);
color: var(--scr-fg); font-size: 15px;
display: flex; flex-direction: column;
}
/* ===================== Login / PIN ===================== */
.view { position: absolute; inset: 0; display: none; flex-direction: column; }
.view.active { display: flex; }
.view.fade { animation: viewIn .35s ease; }
@keyframes viewIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .view.fade { animation: none; } }
.login { align-items: stretch; justify-content: center; height: 100%;
display: grid; grid-template-columns: 1.05fr 1fr; }
.login-left { padding: 46px; display: flex; flex-direction: column; justify-content: center; gap: 20px;
border-right: 1px solid var(--scr-border);
background: linear-gradient(160deg, rgba(47,211,154,0.06), transparent); }
.brand { display: flex; align-items: center; gap: 12px; }
.brand .mark { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center;
background: linear-gradient(150deg, #2fd39a, #159c74); box-shadow: 0 8px 22px rgba(47,211,154,0.45); }
.brand .name { font-size: 24px; font-weight: 800; letter-spacing: -0.02em; }
.brand .name span { color: var(--scr-accent); }
.login-left h1 { font-size: 30px; font-weight: 700; margin: 6px 0 0; letter-spacing: -0.02em; text-wrap: balance; }
.login-left p { color: var(--scr-muted); font-size: 15px; line-height: 1.6; margin: 0; max-width: 340px; }
.bank-card {
margin-top: 8px; width: 300px; max-width: 100%; aspect-ratio: 1.6 / 1; border-radius: 18px;
padding: 20px; color: #fff; position: relative; overflow: hidden;
background: linear-gradient(135deg, #1c3b32 0%, #0e211b 70%);
border: 1px solid rgba(47,211,154,0.3); box-shadow: 0 20px 40px -20px rgba(0,0,0,0.7);
}
.bank-card::after { content: ""; position: absolute; width: 200px; height: 200px; border-radius: 50%;
background: radial-gradient(circle, rgba(47,211,154,0.35), transparent 70%); top: -70px; right: -50px; }
.bank-card .chip { width: 38px; height: 28px; border-radius: 6px; background: linear-gradient(135deg,#e9d9a0,#b89b53); margin-bottom: 26px; }
.bank-card .num { font-family: ui-monospace, monospace; letter-spacing: 0.18em; font-size: 15px; }
.bank-card .who { margin-top: 12px; font-size: 12px; opacity: 0.85; text-transform: uppercase; letter-spacing: 0.06em; }
.login-right { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 30px; }
.pin-title { font-size: 19px; font-weight: 700; }
.pin-sub { font-size: 13.5px; color: var(--scr-muted); margin-top: 4px; margin-bottom: 22px; text-align: center; }
.pin-dots { display: flex; gap: 16px; margin-bottom: 26px; }
.pin-dots.shake { animation: shake .4s; }
@keyframes shake { 0%,100%{transform:translateX(0)} 20%,60%{transform:translateX(-8px)} 40%,80%{transform:translateX(8px)} }
.pin-dot { width: 15px; height: 15px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.4); transition: .12s; }
.pin-dot.on { background: var(--scr-accent); border-color: var(--scr-accent); box-shadow: 0 0 12px rgba(47,211,154,0.6); }
.keypad { display: grid; grid-template-columns: repeat(3, 78px); gap: 14px; }
.key { height: 68px; border-radius: 16px; border: 1px solid var(--scr-border);
background: var(--scr-surface2); color: var(--scr-fg); font-size: 24px; font-weight: 500;
cursor: pointer; font-family: inherit; transition: .1s; }
.key:hover { border-color: var(--scr-accent); }
.key:active { background: var(--scr-accent); color: #06231a; transform: scale(0.96); }
.key.wide { font-size: 16px; font-weight: 600; }
.key.enter { background: var(--scr-accent); color: #06231a; border: none; }
/* ===================== App-Shell ===================== */
.shell { display: grid; grid-template-columns: 216px 1fr; grid-template-rows: auto 1fr; height: 100%;
grid-template-areas: "brand top" "nav main"; }
.shell-brand { grid-area: brand; padding: 18px 20px; border-right: 1px solid var(--scr-border);
border-bottom: 1px solid var(--scr-border); display: flex; align-items: center; gap: 10px; }
.shell-brand .mark { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center;
background: linear-gradient(150deg, #2fd39a, #159c74); }
.shell-brand .name { font-size: 18px; font-weight: 800; letter-spacing: -0.02em; }
.shell-brand .name span { color: var(--scr-accent); }
.shell-top { grid-area: top; padding: 0 26px; border-bottom: 1px solid var(--scr-border);
display: flex; align-items: center; gap: 20px; }
.shell-top .greet { font-weight: 600; }
.shell-top .greet small { display: block; color: var(--scr-muted); font-weight: 400; font-size: 12px; }
.shell-top .clock { margin-left: auto; text-align: right; font-variant-numeric: tabular-nums; }
.shell-top .clock .t { font-size: 18px; font-weight: 700; }
.shell-top .clock .d { font-size: 11.5px; color: var(--scr-muted); }
.top-balance { text-align: right; padding-left: 20px; border-left: 1px solid var(--scr-border); }
.top-balance .l { font-size: 11px; color: var(--scr-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.top-balance .v { font-size: 20px; font-weight: 800; font-variant-numeric: tabular-nums; }
.nav { grid-area: nav; border-right: 1px solid var(--scr-border); padding: 14px 12px; display: flex; flex-direction: column; gap: 4px; }
.nav button { display: flex; align-items: center; gap: 12px; padding: 11px 13px; border-radius: 12px;
background: none; border: none; color: var(--scr-muted); font-family: inherit; font-size: 14.5px; font-weight: 600;
cursor: pointer; text-align: left; transition: .12s; }
.nav button svg { width: 20px; height: 20px; flex-shrink: 0; }
.nav button:hover { background: var(--scr-surface); color: var(--scr-fg); }
.nav button.active { background: var(--scr-accent-soft); color: var(--scr-accent); }
.nav .spacer { flex: 1; }
.nav .logout { color: var(--scr-red); }
.nav .logout:hover { background: rgba(240,112,138,0.12); color: var(--scr-red); }
.main { grid-area: main; overflow-y: auto; padding: 24px 26px; }
.main::-webkit-scrollbar { width: 8px; }
.main::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 18px; gap: 16px; }
.page-head h2 { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.page-head p { margin: 4px 0 0; color: var(--scr-muted); font-size: 13.5px; }
/* Karten / Kacheln */
.grid { display: grid; gap: 16px; }
.card { background: var(--scr-surface); border: 1px solid var(--scr-border); border-radius: 18px; padding: 18px; }
.card.pad0 { padding: 0; }
.stat { display: flex; flex-direction: column; gap: 6px; }
.stat .l { font-size: 12px; color: var(--scr-muted); text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700; }
.stat .v { font-size: 26px; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }
.stat .d { font-size: 12.5px; font-weight: 600; }
.up { color: var(--scr-accent); } .down { color: var(--scr-red); }
.balance-hero { background: linear-gradient(140deg, #14352a, #0c1f19); border: 1px solid rgba(47,211,154,0.25);
border-radius: 20px; padding: 22px 24px; position: relative; overflow: hidden; }
.balance-hero .l { font-size: 12px; color: #9fd8c4; letter-spacing: 0.06em; text-transform: uppercase; }
.balance-hero .v { font-size: 44px; font-weight: 800; margin: 4px 0; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.balance-hero .iban { font-family: ui-monospace, monospace; letter-spacing: 0.12em; color: #9fd8c4; font-size: 13px; }
/* Buttons & Inputs */
.btn { background: var(--scr-accent); color: #06231a; border: none; border-radius: 13px; padding: 14px 20px;
font-size: 15px; font-weight: 700; cursor: pointer; font-family: inherit; transition: .12s; }
.btn:hover { filter: brightness(1.07); }
.btn:active { transform: scale(0.98); }
.btn.ghost { background: var(--scr-surface2); color: var(--scr-fg); border: 1px solid var(--scr-border); }
.btn.red { background: var(--scr-red); color: #2a0910; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.field-label { font-size: 12px; color: var(--scr-muted); font-weight: 700; text-transform: uppercase;
letter-spacing: 0.04em; margin: 14px 2px 7px; display: block; }
.input { width: 100%; background: var(--scr-surface2); border: 1px solid var(--scr-border); border-radius: 12px;
padding: 14px 15px; color: var(--scr-fg); font-size: 16px; font-family: inherit; outline: none;
font-variant-numeric: tabular-nums; }
.input:focus { border-color: var(--scr-accent); }
/* Betrags-Presets */
.amount-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.amount-opt { padding: 20px 12px; border-radius: 14px; border: 1px solid var(--scr-border);
background: var(--scr-surface2); color: var(--scr-fg); font-size: 22px; font-weight: 700; cursor: pointer;
font-family: inherit; font-variant-numeric: tabular-nums; transition: .12s; }
.amount-opt:hover { border-color: var(--scr-accent); }
.amount-opt.sel { background: var(--scr-accent); color: #06231a; border-color: var(--scr-accent); }
.amount-display { text-align: center; font-size: 40px; font-weight: 800; font-variant-numeric: tabular-nums; padding: 6px 0 4px; }
/* Transaktionen / Kontoauszug */
.tx { display: flex; align-items: center; gap: 14px; padding: 13px 6px; border-bottom: 1px solid var(--scr-border); }
.tx:last-child { border-bottom: none; }
.tx .ic { width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center; flex-shrink: 0;
background: var(--scr-surface2); font-size: 18px; }
.tx .grow { flex: 1; min-width: 0; }
.tx .t { font-weight: 600; }
.tx .m { font-size: 12.5px; color: var(--scr-muted); }
.tx .amt { font-weight: 800; font-variant-numeric: tabular-nums; text-align: right; }
.tx .amt.plus { color: var(--scr-accent); }
.tx .bal { font-size: 12px; color: var(--scr-muted); text-align: right; font-variant-numeric: tabular-nums; }
.seg { display: inline-flex; background: var(--scr-surface2); border-radius: 11px; padding: 3px; gap: 2px; }
.seg button { border: none; background: none; color: var(--scr-muted); font-weight: 600; padding: 7px 14px;
border-radius: 8px; cursor: pointer; font-family: inherit; font-size: 13px; transition: .12s; }
.seg button.active { background: var(--scr-accent); color: #06231a; }
.pill { font-size: 11px; font-weight: 800; padding: 4px 10px; border-radius: 999px; }
.pill.green { background: var(--scr-accent-soft); color: var(--scr-accent); }
.pill.red { background: rgba(240,112,138,0.14); color: var(--scr-red); }
/* ===================== Aktien-artiges Chart ===================== */
.chart-wrap { position: relative; width: 100%; }
.chart-wrap svg { display: block; width: 100%; }
.chart-cross { position: absolute; top: 0; width: 1px; background: rgba(255,255,255,0.28); pointer-events: none;
display: none; }
.chart-cursor { position: absolute; width: 12px; height: 12px; border-radius: 50%; background: var(--scr-accent);
border: 2px solid #0b1a15; transform: translate(-50%, -50%); pointer-events: none; display: none;
box-shadow: 0 0 0 4px rgba(47,211,154,0.25); }
.chart-tip { position: absolute; pointer-events: none; display: none; transform: translate(-50%, -120%);
background: rgba(8,18,14,0.96); border: 1px solid var(--scr-border); border-radius: 10px; padding: 7px 11px;
font-size: 12.5px; white-space: nowrap; z-index: 4; box-shadow: 0 10px 30px -8px rgba(0,0,0,0.7); }
.chart-tip .v { font-weight: 800; font-variant-numeric: tabular-nums; }
.chart-tip .d { color: var(--scr-muted); font-size: 11px; }
/* Dispensing / Bestätigung */
.flash { position: absolute; inset: 0; z-index: 30; display: none; flex-direction: column;
align-items: center; justify-content: center; text-align: center; padding: 40px; gap: 16px;
background: radial-gradient(120% 80% at 50% 0%, #123a2c, #060d0a); }
.flash.show { display: flex; }
.flash .big { font-size: 66px; }
.flash h3 { font-size: 26px; font-weight: 800; margin: 0; }
.flash p { color: var(--scr-muted); margin: 0; max-width: 420px; }
.cash { width: 130px; height: 74px; border-radius: 10px; position: relative; margin-bottom: 6px;
background: linear-gradient(160deg,#3ea883,#1c6b52); border: 1px solid rgba(255,255,255,0.25);
animation: cashOut .8s ease; }
.cash::after { content: "€"; position: absolute; inset: 0; display: grid; place-items: center; font-size: 30px; font-weight: 800; color: rgba(255,255,255,0.85); }
@keyframes cashOut { from { transform: translateY(-30px) scale(0.9); opacity: 0; } to { transform: none; opacity: 1; } }
.spinner { width: 46px; height: 46px; border-radius: 50%; border: 4px solid var(--scr-surface2);
border-top-color: var(--scr-accent); animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.toast { position: absolute; bottom: 22px; left: 50%; transform: translateX(-50%) translateY(20px);
background: rgba(8,18,14,0.96); border: 1px solid var(--scr-border); color: var(--scr-fg);
padding: 12px 20px; border-radius: 13px; font-size: 13.5px; font-weight: 600; z-index: 40;
opacity: 0; pointer-events: none; transition: .3s; box-shadow: 0 12px 30px -8px rgba(0,0,0,0.7); }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@media (max-width: 720px) {
.login { grid-template-columns: 1fr; }
.login-left { display: none; }
.shell { grid-template-columns: 64px 1fr; }
.shell-brand .name, .nav button span { display: none; }
.nav button { justify-content: center; }
}
</style>
<div class="ambient">
<div class="blob b1"></div><div class="blob b2"></div>
<div class="word">FLEECA</div>
</div>
<!-- Nur für die eigenständige Browser-Ansicht; in FiveM ausgeblendet -->
<div class="stage-hint" id="browser-hinweis">
Bewege die Maus auf den Automaten, um ihn zu aktivieren — <b>verlässt du das Display, wird es transparent</b>.
Test-PIN: <b>1234</b>
</div>
<!-- ===================== Bankautomat ===================== -->
<div class="atm">
<div class="screen">
<!-- LOGIN / PIN-Eingabe -->
<div class="view active" id="v-login">
<div class="login">
<div class="login-left">
<div class="brand"><span class="mark">
<svg width="24" height="24" viewBox="0 0 24 24" fill="#06231a"><path d="M12 2 2 7v2h20V7L12 2ZM4 10v7H2v3h20v-3h-2v-7h-3v7h-3v-7h-3v7H7v-7H4Z"/></svg>
</span><span class="name">Fleeca<span>.</span></span></div>
<h1>Willkommen am Automaten</h1>
<p>Stecke deine Karte ein und gib deine PIN ein, um auf dein Konto zuzugreifen.</p>
<div class="bank-card">
<div class="chip"></div>
<div class="num" id="card-num">•••• •••• •••• 2118</div>
<div class="who" id="card-who">Max Weber</div>
</div>
</div>
<div class="login-right">
<div class="pin-title">PIN eingeben</div>
<div class="pin-sub">4-stelligen Code zur Freigabe eingeben</div>
<div class="pin-dots" id="login-dots"></div>
<div class="keypad" id="login-pad"></div>
</div>
</div>
</div>
<!-- APP-SHELL (nach Login) -->
<div class="view" id="v-app">
<div class="shell">
<div class="shell-brand"><span class="mark">
<svg width="20" height="20" viewBox="0 0 24 24" fill="#06231a"><path d="M12 2 2 7v2h20V7L12 2ZM4 10v7H2v3h20v-3h-2v-7h-3v7h-3v-7h-3v7H7v-7H4Z"/></svg>
</span><span class="name">Fleeca<span>.</span></span></div>
<div class="shell-top">
<div class="greet">Guten Tag, Max Weber<small>Girokonto · verfügbar</small></div>
<div class="top-balance"><div class="l">Kontostand</div><div class="v" id="top-bal">48.320 €</div></div>
<div class="clock"><div class="t" id="atm-time">14:32</div><div class="d" id="atm-date">Di, 21. Juli 2026</div></div>
</div>
<div class="nav" id="atm-nav"></div>
<div class="main" id="atm-main"></div>
</div>
</div>
<!-- Overlay: Aktion läuft / Bestätigung -->
<div class="flash" id="flash"></div>
<div class="toast" id="toast"></div>
</div>
</div>
<script>
"use strict";
/* =====================================================================
KONTO-DATEN — bewusst getrennt vom UI (später an DB/Server anbinden)
===================================================================== */
/* Kontodaten kommen vom Server. Die PIN ist bewusst NICHT enthalten —
sie wird ausschließlich serverseitig geprüft und nie übertragen. */
const konto = {
inhaber: "—",
iban: "—",
saldo: 0,
bargeld: 0,
transaktionen: []
};
/* Kontostand-Verlauf aus den ECHTEN Buchungen ableiten.
Jede Buchung trägt den Kontostand danach (Spalte `balance`), daraus
entsteht die Kurve. Gibt es zu wenige Buchungen, bleibt sie flach. */
function erzeugeVerlauf(maxPunkte, labelFn) {
// Buchungen kommen neueste zuerst -> für die Kurve umdrehen
const buchungen = konto.transaktionen.slice(0, maxPunkte).reverse();
if (buchungen.length < 2) {
// Zu wenig Historie: flache Linie auf dem aktuellen Stand
const werte = [konto.saldo, konto.saldo];
return { werte, labels: ["", "jetzt"] };
}
const werte = buchungen.map(t => Math.round(t.saldo || 0));
werte[werte.length - 1] = konto.saldo; // aktuell gültiger Stand am Ende
const labels = buchungen.map((t, i) =>
labelFn ? labelFn(t, i, buchungen.length) : "");
return { werte, labels };
}
/* Drei Zeiträume — hier als „letzte N Buchungen", weil die Kurve aus
echten Buchungen entsteht und nicht aus erfundenen Tageswerten. */
const verlaeufe = {
"7T": () => erzeugeVerlauf(10, (t, i, n) => (i === 0 || i === n - 1) ? (t.zeit || "").slice(5, 10) : ""),
"30T": () => erzeugeVerlauf(30, (t, i, n) => (i % 5 === 0 || i === n - 1) ? (t.zeit || "").slice(5, 10) : ""),
"12M": () => erzeugeVerlauf(60, (t, i, n) => (i % 10 === 0 || i === n - 1) ? (t.zeit || "").slice(0, 7) : "")
};
/* =====================================================================
HILFSFUNKTIONEN
===================================================================== */
const $ = id => document.getElementById(id);
// Euro deutsch formatieren
const euro = betrag => betrag.toLocaleString("de-DE", { minimumFractionDigits: 0 }) + " €";
// gegen HTML-Injektion absichern
function sicher(text) { const d = document.createElement("div"); d.textContent = text; return d.innerHTML; }
// kurze Einblend-Nachricht
let toastTimer = null;
function toast(text) {
const t = $("toast"); t.textContent = text; t.classList.add("show");
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 2200);
}
// Kontostand-Anzeigen (oben) aktualisieren
function aktualisiereSaldo() { $("top-bal").textContent = euro(konto.saldo); }
/* =====================================================================
UHR
===================================================================== */
function aktualisiereUhr() {
const j = new Date();
const hh = String(j.getHours()).padStart(2, "0"), mm = String(j.getMinutes()).padStart(2, "0");
if ($("atm-time")) $("atm-time").textContent = hh + ":" + mm;
const wt = ["So","Mo","Di","Mi","Do","Fr","Sa"][j.getDay()];
const mo = ["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"][j.getMonth()];
if ($("atm-date")) $("atm-date").textContent = wt + ", " + j.getDate() + ". " + mo + " " + j.getFullYear();
}
aktualisiereUhr(); setInterval(aktualisiereUhr, 1000);
/* =====================================================================
PIN-TASTENFELD (Login & PIN-Änderung)
===================================================================== */
// Rendert Ziffernpunkte + Tastenfeld in Container; ruft onFertig(code, reset) bei 4 Ziffern.
function bauePinPad(dotsEl, padEl, onFertig) {
let code = "";
const zeichneDots = () => {
dotsEl.innerHTML = [0,1,2,3].map(i => '<span class="pin-dot' + (i < code.length ? ' on' : '') + '"></span>').join("");
};
const tasten = ["1","2","3","4","5","6","7","8","9","clear","0","del"];
padEl.innerHTML = tasten.map(t => {
if (t === "clear") return '<button class="key wide" data-k="clear">C</button>';
if (t === "del") return '<button class="key wide" data-k="del">⌫</button>';
return '<button class="key" data-k="' + t + '">' + t + '</button>';
}).join("");
padEl.querySelectorAll(".key").forEach(b => b.addEventListener("click", () => {
const k = b.dataset.k;
if (k === "del") code = code.slice(0, -1);
else if (k === "clear") code = "";
else if (code.length < 4) code += k;
zeichneDots();
if (code.length === 4) setTimeout(() => onFertig(code, () => { code = ""; zeichneDots(); }), 150);
}));
zeichneDots();
}
/* =====================================================================
LOGIN
===================================================================== */
// Wird gesetzt, damit die Antwort des Servers das Eingabefeld leeren kann
let pinZuruecksetzen = null;
function initLogin() {
$("card-who").textContent = konto.inhaber;
bauePinPad($("login-dots"), $("login-pad"), (code, reset) => {
pinZuruecksetzen = reset;
if (!IST_FIVEM) {
// Browser-Ansicht ohne Server: Test-PIN 1234
if (code === "1234") { reset(); zeigeView("v-app"); starteApp(); }
else { pinFehlgeschlagen(); }
return;
}
// Die PIN wird ausschließlich auf dem Server geprüft
nui("pinPruefen", { pin: code });
});
}
/* Fehlgeschlagene PIN-Eingabe anzeigen */
function pinFehlgeschlagen() {
$("login-dots").classList.add("shake");
setTimeout(() => $("login-dots").classList.remove("shake"), 450);
toast("Falsche PIN");
if (pinZuruecksetzen) pinZuruecksetzen();
}
// Zwischen Login und App umschalten
function zeigeView(id) {
document.querySelectorAll(".view").forEach(v => v.classList.remove("active"));
const v = $(id); v.classList.add("active"); v.classList.add("fade");
setTimeout(() => v.classList.remove("fade"), 400);
}
/* =====================================================================
NAVIGATION / APP-START
===================================================================== */
const navPunkte = [
{ id: "uebersicht", label: "Übersicht", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="5" rx="2"/><rect x="13" y="10" width="8" height="11" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/></svg>' },
{ id: "auszahlen", label: "Auszahlen", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7 7 7-7"/></svg>' },
{ id: "einzahlen", label: "Einzahlen", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7-7 7 7"/></svg>' },
{ id: "ueberweisen", label: "Überweisen", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8h13l-3-3M20 16H7l3 3"/></svg>' },
{ id: "auszug", label: "Kontoauszug", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 8h6M9 12h6M9 16h4"/></svg>' },
{ id: "statistik", label: "Statistiken", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15l4-5 4 3 6-8"/><path d="M4 20h16"/></svg>' },
{ id: "pin", label: "PIN ändern", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>' }
];
function starteApp() {
// Navigation aufbauen
const nav = $("atm-nav");
nav.innerHTML = navPunkte.map(p =>
'<button data-sec="' + p.id + '">' + p.icon + '<span>' + p.label + '</span></button>').join("") +
'<div class="spacer"></div>' +
'<button class="logout" data-sec="logout"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg><span>Abmelden</span></button>';
nav.querySelectorAll("[data-sec]").forEach(b => b.addEventListener("click", () => {
if (b.dataset.sec === "logout") { abmelden(); return; }
setzeSektion(b.dataset.sec);
}));
aktualisiereSaldo();
setzeSektion("uebersicht");
}
function abmelden() {
zeigeView("v-login");
toast("Abgemeldet — Karte entnommen");
}
// aktive Sektion rendern
function setzeSektion(sec) {
$("atm-nav").querySelectorAll("[data-sec]").forEach(b =>
b.classList.toggle("active", b.dataset.sec === sec));
const renderer = sektionen[sec];
if (renderer) renderer();
}
/* =====================================================================
SEKTIONEN
===================================================================== */
const sektionen = {};
// ---- Übersicht (Dashboard) ----
sektionen.uebersicht = function () {
const einnahmen = konto.transaktionen.filter(t => t.betrag > 0).reduce((s, t) => s + t.betrag, 0);
const ausgaben = konto.transaktionen.filter(t => t.betrag < 0).reduce((s, t) => s + t.betrag, 0);
$("atm-main").innerHTML =
'<div class="page-head"><div><h2>Übersicht</h2><p>Dein Konto auf einen Blick</p></div>' +
'<div class="seg" id="ueb-range"><button data-r="7T">7T</button><button data-r="30T" class="active">30T</button><button data-r="12M">12M</button></div>' +
'</div>' +
'<div class="grid" style="grid-template-columns:1.4fr 1fr;align-items:start">' +
'<div class="card pad0" style="padding:18px">' +
'<div class="balance-hero" style="margin-bottom:16px">' +
'<div class="l">Verfügbarer Kontostand</div><div class="v">' + euro(konto.saldo) + '</div>' +
'<div class="iban">' + sicher(konto.iban) + '</div></div>' +
'<div class="chart-wrap" id="ueb-chart"></div>' +
'</div>' +
'<div class="grid" style="grid-template-columns:1fr 1fr">' +
statKarte("Einnahmen", euro(einnahmen), "▲ diesen Monat", "up") +
statKarte("Ausgaben", euro(Math.abs(ausgaben)), "▼ diesen Monat", "down") +
'<div class="card" style="grid-column:span 2">' +
'<div class="stat"><div class="l">Letzte Buchungen</div></div>' +
konto.transaktionen.slice(0, 4).map(txZeile).join("") + '</div>' +
'</div>' +
'</div>';
let range = "30T";
const zeichne = () => baueVerlaufChart($("ueb-chart"), verlaeufe[range](), { hoehe: 250 });
zeichne();
$("ueb-range").querySelectorAll("[data-r]").forEach(b => b.addEventListener("click", () => {
range = b.dataset.r;
$("ueb-range").querySelectorAll("[data-r]").forEach(x => x.classList.remove("active"));
b.classList.add("active"); zeichne();
}));
};
function statKarte(label, wert, delta, cls) {
return '<div class="card stat"><div class="l">' + label + '</div><div class="v">' + wert + '</div>' +
'<div class="d ' + cls + '">' + delta + '</div></div>';
}
function txZeile(t) {
return '<div class="tx"><span class="ic">' + t.icon + '</span>' +
'<div class="grow"><div class="t">' + sicher(t.titel) + '</div><div class="m">' + sicher(t.zeit) + ' · ' + sicher(t.kat) + '</div></div>' +
'<div class="amt ' + (t.betrag > 0 ? "plus" : "") + '">' + (t.betrag > 0 ? "+" : "") + euro(t.betrag) + '</div></div>';
}
// ---- Auszahlen ----
let auszahlBetrag = 0;
sektionen.auszahlen = function () {
auszahlBetrag = 0;
const presets = [20, 50, 100, 200, 500, 1000];
$("atm-main").innerHTML =
'<div class="page-head"><div><h2>Auszahlen</h2><p>Betrag wählen — der Automat gibt das Geld aus</p></div></div>' +
'<div class="grid" style="grid-template-columns:1fr 340px;align-items:start">' +
'<div class="card"><div class="field-label">Schnellauswahl</div>' +
'<div class="amount-grid" id="az-presets">' +
presets.map(p => '<button class="amount-opt" data-a="' + p + '">' + euro(p) + '</button>').join("") + '</div>' +
'<div class="field-label">Eigener Betrag</div>' +
'<input class="input" id="az-custom" type="number" inputmode="numeric" placeholder="0" min="0" step="10">' +
'</div>' +
'<div class="card"><div class="stat"><div class="l">Auszahlungsbetrag</div></div>' +
'<div class="amount-display" id="az-display">0 €</div>' +
'<div class="m" style="text-align:center;color:var(--scr-muted);font-size:12.5px;margin-bottom:16px">Verfügbar: ' + euro(konto.saldo) + '</div>' +
'<button class="btn" id="az-go" style="width:100%" disabled>Geld auszahlen</button></div>' +
'</div>';
const setzeBetrag = (b) => {
auszahlBetrag = b || 0;
$("az-display").textContent = euro(auszahlBetrag);
$("az-go").disabled = !(auszahlBetrag > 0);
$("az-presets").querySelectorAll("[data-a]").forEach(x => x.classList.toggle("sel", +x.dataset.a === auszahlBetrag));
};
$("az-presets").querySelectorAll("[data-a]").forEach(b => b.addEventListener("click", () => {
$("az-custom").value = ""; setzeBetrag(+b.dataset.a);
}));
$("az-custom").addEventListener("input", e => setzeBetrag(parseInt(e.target.value, 10)));
$("az-go").addEventListener("click", () => {
if (auszahlBetrag < 1) { toast("Bitte einen Betrag wählen"); return; }
// Deckung prüft der Server — hier nur die Wartemeldung zeigen
zeigeFlash('<div class="spinner"></div><h3>Auszahlung wird verarbeitet…</h3><p>Bitte warten.</p>');
nui("auszahlen", { betrag: auszahlBetrag });
});
};
// ---- Einzahlen ----
sektionen.einzahlen = function () {
$("atm-main").innerHTML =
'<div class="page-head"><div><h2>Einzahlen</h2><p>Bargeld auf dein Konto einzahlen</p></div></div>' +
'<div class="grid" style="grid-template-columns:1fr 340px;align-items:start">' +
'<div class="card"><div class="field-label">Betrag (Bargeld)</div>' +
'<input class="input" id="ez-amount" type="number" inputmode="numeric" placeholder="0" min="0" step="10">' +
'<div class="field-label">Schnellauswahl</div>' +
'<div class="amount-grid" id="ez-presets">' +
[50,100,500].map(p => '<button class="amount-opt" data-a="' + p + '">' + euro(p) + '</button>').join("") + '</div>' +
'</div>' +
'<div class="card"><div class="stat"><div class="l">Neuer Kontostand</div></div>' +
'<div class="amount-display" id="ez-preview">' + euro(konto.saldo) + '</div>' +
'<button class="btn" id="ez-go" style="width:100%" disabled>Einzahlen</button></div>' +
'</div>';
const lese = () => parseInt($("ez-amount").value, 10) || 0;
const update = () => {
const b = lese();
$("ez-preview").textContent = euro(konto.saldo + b);
$("ez-go").disabled = !(b > 0);
};
$("ez-presets").querySelectorAll("[data-a]").forEach(b => b.addEventListener("click", () => { $("ez-amount").value = b.dataset.a; update(); }));
$("ez-amount").addEventListener("input", update);
$("ez-go").addEventListener("click", () => {
const b = lese(); if (b <= 0) return;
// Ob genug Bargeld dabei ist, prüft der Server
zeigeFlash('<div class="spinner"></div><h3>Einzahlung wird gezählt…</h3><p>Bitte warten.</p>');
nui("einzahlen", { betrag: b });
});
};
// ---- Überweisen ----
sektionen.ueberweisen = function () {
$("atm-main").innerHTML =
'<div class="page-head"><div><h2>Überweisen</h2><p>Geld an ein anderes Konto senden</p></div></div>' +
'<div class="grid" style="grid-template-columns:1fr 340px;align-items:start">' +
'<div class="card">' +
'<div class="field-label">Empfänger (IBAN)</div><input class="input" id="ue-to" placeholder="z. B. DE12 3456 7890 12">' +
'<div class="field-label">Betrag</div><input class="input" id="ue-amount" type="number" inputmode="numeric" placeholder="0" min="0">' +
'<div class="field-label">Verwendungszweck</div><input class="input" id="ue-note" placeholder="optional">' +
'</div>' +
'<div class="card"><div class="stat"><div class="l">Verfügbar</div><div class="v" style="font-size:22px">' + euro(konto.saldo) + '</div></div>' +
'<div style="height:16px"></div><button class="btn" id="ue-go" style="width:100%">Überweisung senden</button></div>' +
'</div>';
$("ue-go").addEventListener("click", () => {
const to = $("ue-to").value.trim(); const b = parseInt($("ue-amount").value, 10) || 0;
const note = $("ue-note").value.trim();
if (!to) { toast("Bitte Empfänger-IBAN angeben"); return; }
if (b <= 0) { toast("Bitte gültigen Betrag angeben"); return; }
// Empfänger und Deckung prüft der Server
zeigeFlash('<div class="spinner"></div><h3>Überweisung wird gesendet…</h3><p>Bitte warten.</p>');
nui("ueberweisen", { ziel: to, betrag: b, zweck: note });
});
};
// ---- Kontoauszug ----
let auszugFilter = "alle";
sektionen.auszug = function () {
$("atm-main").innerHTML =
'<div class="page-head"><div><h2>Kontoauszug</h2><p>Alle Buchungen deines Kontos</p></div>' +
'<div class="seg" id="au-filter"><button data-f="alle" class="active">Alle</button><button data-f="ein">Eingang</button><button data-f="aus">Ausgang</button></div>' +
'</div>' +
'<div class="card pad0" style="padding:8px 18px"><div id="au-list"></div></div>';
zeichneAuszug();
$("au-filter").querySelectorAll("[data-f]").forEach(b => b.addEventListener("click", () => {
auszugFilter = b.dataset.f;
$("au-filter").querySelectorAll("[data-f]").forEach(x => x.classList.remove("active"));
b.classList.add("active"); zeichneAuszug();
}));
};
function zeichneAuszug() {
// laufenden Saldo rückwärts berechnen (Auszug zeigt Kontostand nach Buchung)
let lauf = konto.saldo;
const mitSaldo = konto.transaktionen.map(t => { const eintrag = { ...t, saldo: lauf }; lauf -= t.betrag; return eintrag; });
const gefiltert = mitSaldo.filter(t => auszugFilter === "alle" || (auszugFilter === "ein" ? t.betrag > 0 : t.betrag < 0));
$("au-list").innerHTML = gefiltert.length ? gefiltert.map(t =>
'<div class="tx"><span class="ic">' + t.icon + '</span>' +
'<div class="grow"><div class="t">' + sicher(t.titel) + '</div><div class="m">' + sicher(t.zeit) + ' · ' + sicher(t.kat) + '</div></div>' +
'<div><div class="amt ' + (t.betrag > 0 ? "plus" : "") + '">' + (t.betrag > 0 ? "+" : "") + euro(t.betrag) + '</div>' +
'<div class="bal">Saldo: ' + euro(t.saldo) + '</div></div></div>').join("")
: '<div style="text-align:center;color:var(--scr-muted);padding:40px">Keine Buchungen.</div>';
}
// ---- Statistiken ----
sektionen.statistik = function () {
const einnahmen = konto.transaktionen.filter(t => t.betrag > 0).reduce((s, t) => s + t.betrag, 0);
const ausgaben = Math.abs(konto.transaktionen.filter(t => t.betrag < 0).reduce((s, t) => s + t.betrag, 0));
const sparquote = einnahmen > 0 ? Math.round((einnahmen - ausgaben) / einnahmen * 100) : 0;
$("atm-main").innerHTML =
'<div class="page-head"><div><h2>Statistiken</h2><p>Kontoentwicklung im Zeitverlauf</p></div>' +
'<div class="seg" id="st-range"><button data-r="7T">7T</button><button data-r="30T" class="active">30T</button><button data-r="12M">12M</button></div>' +
'</div>' +
'<div class="card" style="margin-bottom:16px">' +
'<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">' +
'<div class="stat"><div class="l">Kontostand-Verlauf</div><div class="v" style="font-size:30px">' + euro(konto.saldo) + '</div></div>' +
'<span class="pill green" id="st-delta">▲ 0 %</span></div>' +
'<div class="chart-wrap" id="st-chart"></div>' +
'</div>' +
'<div class="grid" style="grid-template-columns:1fr 1fr 1fr">' +
statKarte("Einnahmen (Monat)", euro(einnahmen), "▲ Zuflüsse", "up") +
statKarte("Ausgaben (Monat)", euro(ausgaben), "▼ Abflüsse", "down") +
statKarte("Sparquote", sparquote + " %", (sparquote >= 0 ? "▲" : "▼") + " Überschuss", sparquote >= 0 ? "up" : "down") +
'</div>';
let range = "30T";
const zeichne = () => {
const daten = verlaeufe[range]();
baueVerlaufChart($("st-chart"), daten, { hoehe: 300 });
const w = daten.werte, delta = w[0] ? Math.round((w[w.length - 1] - w[0]) / Math.abs(w[0]) * 100) : 0;
const d = $("st-delta"); d.textContent = (delta >= 0 ? "▲ +" : "▼ ") + delta + " %";
d.className = "pill " + (delta >= 0 ? "green" : "red");
};
zeichne();
$("st-range").querySelectorAll("[data-r]").forEach(b => b.addEventListener("click", () => {
range = b.dataset.r;
$("st-range").querySelectorAll("[data-r]").forEach(x => x.classList.remove("active"));
b.classList.add("active"); zeichne();
}));
};
// ---- PIN ändern ----
sektionen.pin = function () {
$("atm-main").innerHTML =
'<div class="page-head"><div><h2>PIN ändern</h2><p>Wähle einen neuen 4-stelligen Code für deine Karte</p></div></div>' +
'<div class="card" style="max-width:420px;margin:0 auto">' +
'<div style="text-align:center"><div class="pin-title" id="pin-step-title">Aktuelle PIN eingeben</div>' +
'<div class="pin-sub" id="pin-step-sub">Zur Bestätigung deiner Identität</div>' +
'<div class="pin-dots" id="pin-dots" style="justify-content:center"></div>' +
'<div class="keypad" id="pin-pad" style="justify-content:center;margin:0 auto"></div></div>' +
'</div>';
/* Die alte PIN wird hier nicht geprüft — das macht der Server beim
Speichern. Die Oberfläche sammelt nur die drei Eingaben ein. */
bauePinPad($("pin-dots"), $("pin-pad"), (alt, reset) => {
reset();
$("pin-step-title").textContent = "Neue PIN festlegen";
$("pin-step-sub").textContent = "4-stelligen Code eingeben";
// Schritt 2: neue PIN
bauePinPad($("pin-dots"), $("pin-pad"), (neu, reset2) => {
reset2();
$("pin-step-title").textContent = "Neue PIN bestätigen";
$("pin-step-sub").textContent = "Code erneut eingeben";
// Schritt 3: bestätigen
bauePinPad($("pin-dots"), $("pin-pad"), (best, reset3) => {
if (best !== neu) { toast("PINs stimmen nicht überein"); reset3(); return; }
nui("pinAendern", { alt: alt, neu: neu });
reset3();
});
});
});
};
/* =====================================================================
FLASH-OVERLAY (Aktion läuft / Bestätigung)
===================================================================== */
function zeigeFlash(html) { const f = $("flash"); f.innerHTML = html; f.classList.add("show"); }
function versteckeFlash() { $("flash").classList.remove("show"); }
/* =====================================================================
AKTIEN-ARTIGES VERLAUFS-CHART (glatte Linie, Flächenverlauf, Hover-Fadenkreuz)
===================================================================== */
function baueVerlaufChart(wrapper, daten, opts) {
const werte = daten.werte, labels = daten.labels, n = werte.length;
const W = Math.max(320, Math.round(wrapper.clientWidth || 600));
const H = opts.hoehe || 260;
const padL = 58, padR = 14, padT = 14, padB = 26;
const min = Math.min(...werte), max = Math.max(...werte), span = (max - min) || 1;
const lo = min - span * 0.14, hi = max + span * 0.14;
const px = i => padL + (W - padL - padR) * (n === 1 ? 0.5 : i / (n - 1));
const py = v => padT + (H - padT - padB) * (1 - (v - lo) / (hi - lo));
const punkte = werte.map((v, i) => ({ x: px(i), y: py(v) }));
// glatte Kurve (Catmull-Rom -> kubische Bézier)
let linie = "M" + punkte[0].x.toFixed(1) + " " + punkte[0].y.toFixed(1);
for (let i = 0; i < punkte.length - 1; i++) {
const p0 = punkte[i - 1] || punkte[i], p1 = punkte[i], p2 = punkte[i + 1], p3 = punkte[i + 2] || p2;
const c1x = p1.x + (p2.x - p0.x) / 6, c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6, c2y = p2.y - (p3.y - p1.y) / 6;
linie += " C" + c1x.toFixed(1) + " " + c1y.toFixed(1) + " " + c2x.toFixed(1) + " " + c2y.toFixed(1) + " " + p2.x.toFixed(1) + " " + p2.y.toFixed(1);
}
const flaeche = linie + " L" + punkte[n - 1].x.toFixed(1) + " " + (H - padB) + " L" + punkte[0].x.toFixed(1) + " " + (H - padB) + " Z";
// Gitterlinien + Y-Beschriftung (4 Stufen)
let gitter = "", ylabels = "";
for (let s = 0; s <= 3; s++) {
const wert = lo + (hi - lo) * (s / 3);
const y = py(wert);
gitter += '<line x1="' + padL + '" y1="' + y.toFixed(1) + '" x2="' + (W - padR) + '" y2="' + y.toFixed(1) + '" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>';
ylabels += '<text x="' + (padL - 8) + '" y="' + (y + 4).toFixed(1) + '" text-anchor="end" fill="#6f8880" font-size="11">' + Math.round(wert / 1000) + "k</text>";
}
// X-Beschriftung
let xlabels = "";
labels.forEach((l, i) => { if (l) xlabels += '<text x="' + px(i).toFixed(1) + '" y="' + (H - 8) + '" text-anchor="middle" fill="#6f8880" font-size="11">' + sicher(l) + '</text>'; });
const end = punkte[n - 1];
const uid = "g" + Math.random().toString(36).slice(2, 7);
wrapper.innerHTML =
'<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none" style="height:' + H + 'px">' +
'<defs><linearGradient id="' + uid + '" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0%" stop-color="rgba(47,211,154,0.34)"/><stop offset="100%" stop-color="rgba(47,211,154,0)"/></linearGradient></defs>' +
gitter + ylabels + xlabels +
'<path d="' + flaeche + '" fill="url(#' + uid + ')"/>' +
'<path d="' + linie + '" fill="none" stroke="#2fd39a" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>' +
'<circle cx="' + end.x.toFixed(1) + '" cy="' + end.y.toFixed(1) + '" r="4.5" fill="#2fd39a" stroke="#0b1a15" stroke-width="2"/>' +
'</svg>' +
'<div class="chart-cross" id="' + uid + '-cross"></div>' +
'<div class="chart-cursor" id="' + uid + '-cursor"></div>' +
'<div class="chart-tip" id="' + uid + '-tip"></div>';
// Hover-Fadenkreuz + Tooltip
const svg = wrapper.querySelector("svg");
const cross = $(uid + "-cross"), cursor = $(uid + "-cursor"), tip = $(uid + "-tip");
svg.addEventListener("mousemove", e => {
const rect = svg.getBoundingClientRect();
const mxView = (e.clientX - rect.left) * (W / rect.width);
let i = Math.round((mxView - padL) / ((W - padL - padR) / (n - 1)));
i = Math.max(0, Math.min(n - 1, i));
const sx = px(i) * (rect.width / W), sy = py(werte[i]) * (rect.height / H);
cross.style.display = "block"; cross.style.left = sx + "px"; cross.style.height = rect.height + "px";
cursor.style.display = "block"; cursor.style.left = sx + "px"; cursor.style.top = sy + "px";
tip.style.display = "block"; tip.style.left = sx + "px"; tip.style.top = sy + "px";
tip.innerHTML = '<div class="v">' + euro(werte[i]) + '</div><div class="d">' + (labels[i] || ("Punkt " + (i + 1))) + '</div>';
});
svg.addEventListener("mouseleave", () => {
cross.style.display = cursor.style.display = tip.style.display = "none";
});
}
/* =====================================================================
NUI-BRÜCKE — Verbindung zum FiveM-Client
===================================================================== */
const IST_FIVEM = (typeof GetParentResourceName === "function");
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-bank";
/* Schickt eine Aktion an den Client; der reicht sie an den Server weiter. */
function nui(aktion, daten) {
if (!IST_FIVEM) { toast("(Browser-Ansicht — keine Serververbindung)"); return Promise.resolve(); }
return fetch(`https://${RESSOURCE}/${aktion}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(daten || {}),
}).catch(() => {});
}
/* Übernimmt Kontodaten vom Server (niemals mit PIN) */
function setzeKonto(daten) {
if (!daten) return;
konto.inhaber = daten.inhaber || "—";
konto.iban = daten.iban || "—";
konto.saldo = daten.saldo || 0;
konto.bargeld = daten.bargeld || 0;
konto.transaktionen = daten.transaktionen || [];
aktualisiereSaldo();
}
window.addEventListener("message", (ereignis) => {
const n = ereignis.data || {};
if (n.aktion === "oeffnen") {
document.body.style.display = "";
zeigeView("v-login"); // jede Sitzung beginnt mit der PIN-Eingabe
initLogin();
}
else if (n.aktion === "pinErgebnis") {
if (n.erfolg) {
if (pinZuruecksetzen) pinZuruecksetzen();
setzeKonto(n.konto);
zeigeView("v-app");
starteApp();
} else {
pinFehlgeschlagen();
}
}
else if (n.aktion === "konto") {
setzeKonto(n);
if (n.hinweis) toast(n.hinweis);
}
else if (n.aktion === "erfolg") {
// Abschlussbildschirm nach einer Buchung
zeigeFlash('<div class="big">✅</div><h3>' + sicher(n.titel || "Erledigt") + '</h3>' +
'<p>' + sicher(n.text || "") + '</p><button class="btn" id="flash-ok">Fertig</button>');
$("flash-ok").addEventListener("click", () => { versteckeFlash(); setzeSektion("uebersicht"); });
}
else if (n.aktion === "fehler") {
versteckeFlash();
toast(n.text || "Aktion fehlgeschlagen");
}
else if (n.aktion === "schliessen") {
document.body.style.display = "none";
}
});
/* ESC schließt den Automaten */
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && IST_FIVEM) nui("schliessen", {});
});
/* =====================================================================
START
===================================================================== */
if (IST_FIVEM) {
document.body.style.display = "none"; // wartet auf "oeffnen"
// Der Test-PIN-Hinweis gehört nicht ins Spiel
const hinweis = $("browser-hinweis");
if (hinweis) hinweis.style.display = "none";
} else {
// Eigenständige Browser-Ansicht: Beispieldaten, Test-PIN 1234
konto.inhaber = "Max Weber";
konto.iban = "DE59 4021 5589 0032 118";
konto.saldo = 48320;
konto.bargeld = 1250;
konto.transaktionen = [
{ titel: "Gehalt — LSPD", betrag: 2400, saldo: 48320, zeit: "2026-07-22 14:00", kat: "Einkommen", icon: "💼" },
{ titel: "Barauszahlung Automat", betrag: -85, saldo: 45920, zeit: "2026-07-22 13:41", kat: "Auszahlung", icon: "🏧" },
{ titel: "Überweisung an Lena Berger", betrag: -300, saldo: 46005, zeit: "2026-07-21 19:12", kat: "Transfer", icon: "↗" },
{ titel: "Bareinzahlung Automat", betrag: 5000, saldo: 46305, zeit: "2026-07-19 10:02", kat: "Einzahlung", icon: "💵" },
];
initLogin();
}
</script>
</body>
</html>

View File

@ -0,0 +1,295 @@
-- =====================================================================
-- Aether Bank — Serverlogik
-- Alle Geldbewegungen und die PIN-Pruefung laufen ausschliesslich hier.
-- Die PIN verlaesst den Server niemals.
-- =====================================================================
local Core = exports['aether-core']
-- Wie viele Buchungen an die Oberflaeche geschickt werden
local MAX_BUCHUNGEN = 60
-- Hoechstbetrag je Einzelvorgang (Schutz vor absurden Eingaben)
local MAX_BETRAG = 10000000
-- Merkt sich, wer sich an einem Automaten bereits mit PIN angemeldet hat
local angemeldet = {}
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Liefert die Charakter-ID eines Spielers
--- @param quelle number Server-ID
--- @return number|nil
local function CharakterVon(quelle)
return Core:HoleCharakterId(quelle)
end
--- Prueft, ob ein Betrag eine gueltige, positive Ganzzahl ist
--- @param betrag any Eingabe
--- @return number|nil Bereinigter Betrag oder nil
local function PruefeBetrag(betrag)
if type(betrag) ~= 'number' then return nil end
betrag = math.floor(betrag)
if betrag < 1 or betrag > MAX_BETRAG then return nil end
return betrag
end
--- Ordnet einer Buchungsart Symbol und Kategorie zu (nur fuer die Anzeige)
--- @param art string Buchungsart aus der Datenbank
--- @return string Symbol, string Kategorie
local function Darstellung(art)
local tabelle = {
deposit = { '💵', 'Einzahlung' },
withdraw = { '🏧', 'Auszahlung' },
transfer = { '↗', 'Transfer' },
salary = { '💼', 'Einkommen' },
fine = { '⚖️', 'Strafe' },
}
local eintrag = tabelle[art] or { '•', 'Sonstiges' }
return eintrag[1], eintrag[2]
end
--- Laedt die letzten Buchungen eines Charakters fuer die Anzeige
--- @param charakterId number Charakter-ID
--- @return table Liste von Buchungen
local function HoleBuchungen(charakterId)
local zeilen = MySQL.query.await([[
SELECT type, amount, balance, label, created_at
FROM aether_transactions
WHERE `character` = ?
ORDER BY id DESC
LIMIT ?
]], { charakterId, MAX_BUCHUNGEN }) or {}
local liste = {}
for _, zeile in ipairs(zeilen) do
local icon, kategorie = Darstellung(zeile.type)
liste[#liste + 1] = {
titel = zeile.label ~= '' and zeile.label or kategorie,
betrag = zeile.amount,
saldo = zeile.balance,
zeit = tostring(zeile.created_at):sub(1, 16),
kat = kategorie,
icon = icon,
}
end
return liste
end
--- Baut den Datensatz, den die Oberflaeche anzeigt (ohne PIN!)
--- @param quelle number Server-ID
--- @return table|nil
local function BaueKonto(quelle)
local charakterId = CharakterVon(quelle)
if not charakterId then return nil end
local daten = Core:HoleSpielerInfo(quelle)
if not daten then return nil end
return {
inhaber = (daten.vorname or '') .. ' ' .. (daten.nachname or ''),
iban = daten.iban or '—',
saldo = daten.konto or 0,
bargeld = daten.bargeld or 0,
transaktionen = HoleBuchungen(charakterId),
}
end
--- Schickt den aktuellen Kontostand an die Oberflaeche
--- @param quelle number Server-ID
--- @param hinweis string|nil Rueckmeldung
local function SendeKonto(quelle, hinweis)
local konto = BaueKonto(quelle)
if not konto then return end
konto.hinweis = hinweis
TriggerClientEvent('aether:bank:konto', quelle, konto)
end
--- Lehnt eine Aktion mit Begruendung ab
local function Ablehnen(quelle, grund)
TriggerClientEvent('aether:bank:fehler', quelle, grund)
end
-- ---------------------------------------------------------------------
-- Automat oeffnen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:bank:oeffnen', function()
local quelle = source
if not CharakterVon(quelle) then return end
angemeldet[quelle] = false -- jede Sitzung beginnt mit PIN-Eingabe
TriggerClientEvent('aether:bank:zeige', quelle)
end)
-- ---------------------------------------------------------------------
-- PIN pruefen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:bank:pinPruefen', function(pin)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId or type(pin) ~= 'string' then return end
local gespeichert = MySQL.scalar.await(
'SELECT bank_pin FROM aether_characters WHERE id = ?', { charakterId })
if gespeichert ~= pin then
angemeldet[quelle] = false
TriggerClientEvent('aether:bank:pinErgebnis', quelle, false)
return
end
angemeldet[quelle] = true
TriggerClientEvent('aether:bank:pinErgebnis', quelle, true, BaueKonto(quelle))
end)
-- ---------------------------------------------------------------------
-- PIN aendern
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:bank:pinAendern', function(alt, neu)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
if type(alt) ~= 'string' or type(neu) ~= 'string' then return end
-- Neue PIN muss genau vier Ziffern haben
if not neu:match('^%d%d%d%d$') then
Ablehnen(quelle, 'Die neue PIN muss aus vier Ziffern bestehen.')
return
end
local gespeichert = MySQL.scalar.await(
'SELECT bank_pin FROM aether_characters WHERE id = ?', { charakterId })
if gespeichert ~= alt then
Ablehnen(quelle, 'Die aktuelle PIN ist falsch.')
return
end
MySQL.update.await('UPDATE aether_characters SET bank_pin = ? WHERE id = ?', { neu, charakterId })
TriggerClientEvent('aether:bank:erfolg', quelle, 'PIN geändert', 'Deine neue PIN ist ab sofort gültig.')
end)
-- ---------------------------------------------------------------------
-- Auszahlen (Konto -> Bargeld)
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:bank:auszahlen', function(betrag)
local quelle = source
if not angemeldet[quelle] then return end
betrag = PruefeBetrag(betrag)
if not betrag then Ablehnen(quelle, 'Ungültiger Betrag.') return end
-- Erst abbuchen (prueft die Deckung), dann als Bargeld gutschreiben
if not Core:EntferneGeld(quelle, 'konto', betrag, 'Barauszahlung Automat') then
Ablehnen(quelle, 'Nicht genügend Guthaben.')
return
end
Core:FuegeGeldHinzu(quelle, 'bargeld', betrag, 'Barauszahlung')
SendeKonto(quelle)
TriggerClientEvent('aether:bank:erfolg', quelle,
'Bitte entnehmen Sie Ihr Geld', betrag .. ' € wurden ausgezahlt.')
end)
-- ---------------------------------------------------------------------
-- Einzahlen (Bargeld -> Konto)
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:bank:einzahlen', function(betrag)
local quelle = source
if not angemeldet[quelle] then return end
betrag = PruefeBetrag(betrag)
if not betrag then Ablehnen(quelle, 'Ungültiger Betrag.') return end
if not Core:EntferneGeld(quelle, 'bargeld', betrag, 'Bareinzahlung') then
Ablehnen(quelle, 'Du hast nicht so viel Bargeld dabei.')
return
end
Core:FuegeGeldHinzu(quelle, 'konto', betrag, 'Bareinzahlung Automat')
SendeKonto(quelle)
TriggerClientEvent('aether:bank:erfolg', quelle,
'Einzahlung erfolgreich', betrag .. ' € wurden gutgeschrieben.')
end)
-- ---------------------------------------------------------------------
-- Ueberweisen (Konto -> fremdes Konto, auch offline)
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:bank:ueberweisen', function(ziel, betrag, zweck)
local quelle = source
if not angemeldet[quelle] then return end
local charakterId = CharakterVon(quelle)
if not charakterId then return end
betrag = PruefeBetrag(betrag)
if not betrag then Ablehnen(quelle, 'Ungültiger Betrag.') return end
if type(ziel) ~= 'string' or #ziel < 3 then
Ablehnen(quelle, 'Bitte eine gültige IBAN angeben.')
return
end
zweck = type(zweck) == 'string' and zweck:sub(1, 64) or ''
-- Empfaenger ueber die IBAN suchen
local empfaenger = MySQL.query.await([[
SELECT id, firstname, lastname, bank
FROM aether_characters
WHERE iban = ? AND deleted = 0
LIMIT 1
]], { ziel })
if not empfaenger or not empfaenger[1] then
Ablehnen(quelle, 'Kein Konto mit dieser IBAN gefunden.')
return
end
local ziel_ = empfaenger[1]
if ziel_.id == charakterId then
Ablehnen(quelle, 'Du kannst dir nicht selbst überweisen.')
return
end
local empfaengerName = ziel_.firstname .. ' ' .. ziel_.lastname
local verwendung = zweck ~= '' and (' (' .. zweck .. ')') or ''
-- Abbuchen (prueft die Deckung)
if not Core:EntferneGeld(quelle, 'konto', betrag,
'Überweisung an ' .. empfaengerName .. verwendung) then
Ablehnen(quelle, 'Nicht genügend Guthaben.')
return
end
-- Gutschrift: bei Online-Spielern ueber den Core, sonst direkt in der Datenbank
local zielQuelle = Core:HoleQuelleNachCharakter(ziel_.id)
if zielQuelle then
Core:FuegeGeldHinzu(zielQuelle, 'konto', betrag,
'Überweisung von Konto' .. verwendung)
TriggerClientEvent('aether:bank:benachrichtigung', zielQuelle,
('Du hast %d € erhalten.'):format(betrag))
else
local neuerStand = (ziel_.bank or 0) + betrag
MySQL.update.await('UPDATE aether_characters SET bank = ? WHERE id = ?',
{ neuerStand, ziel_.id })
MySQL.insert('INSERT INTO aether_transactions (`character`, type, amount, balance, label) VALUES (?, ?, ?, ?, ?)',
{ ziel_.id, 'transfer', betrag, neuerStand, 'Überweisung erhalten' .. verwendung })
end
Aether.Info(('Überweisung: Charakter #%d -> #%d, %d €'):format(charakterId, ziel_.id, betrag))
SendeKonto(quelle)
TriggerClientEvent('aether:bank:erfolg', quelle,
'Überweisung ausgeführt', ('%d € an %s gesendet.'):format(betrag, empfaengerName))
end)
-- ---------------------------------------------------------------------
-- Aufraeumen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:bank:schliessen', function()
angemeldet[source] = nil
end)
AddEventHandler('playerDropped', function()
angemeldet[source] = nil
end)

View File

@ -0,0 +1,132 @@
-- =====================================================================
-- Aether Character — Aussehen auf den GTA-Ped uebertragen
--
-- WICHTIG: Der Charakter-Ersteller arbeitet mit einer stilisierten
-- SVG-Vorschau und eigenen Werten (Paletten-Index, Slider 0–100).
-- GTA kennt diese Werte nicht — die folgenden Tabellen sind daher eine
-- *Annaeherung*. Sie sind bewusst als einfache Zuordnungstabellen
-- gebaut, damit du die Werte jederzeit nachjustieren kannst.
-- =====================================================================
AetherAussehen = {}
-- ---------------------------------------------------------------------
-- Zuordnungstabellen (Index aus dem Ersteller -> GTA-Wert)
-- ---------------------------------------------------------------------
-- Hautton-Index -> Elternteil-Haut fuer SetPedHeadBlendData (0–45)
-- Niedrige Werte = hellere Haut, hohe Werte = dunklere Haut
local HAUT_ZU_ELTERN = { [0]=0, [1]=1, [2]=2, [3]=3, [4]=4, [5]=5, [6]=6, [7]=7 }
-- Frisur-Index -> Drawable der Komponente 2 (Haare), je Geschlecht
local FRISUR = {
maennlich = { [0]=1, [1]=2, [2]=6, [3]=4, [4]=10, [5]=0 }, -- Kurz/Mittel/Undercut/Lang/Zopf/Glatze
weiblich = { [0]=1, [1]=3, [2]=12, [3]=8, [4]=9, [5]=0 },
}
-- Haarfarben-Index -> GTA-Haarfarbe (0–63)
local HAARFARBE = { [0]=0, [1]=2, [2]=5, [3]=8, [4]=10, [5]=12, [6]=6, [7]=17, [8]=18, [9]=63 }
-- Augenfarben-Index -> GTA-Augenfarbe (0–31)
local AUGENFARBE = { [0]=1, [1]=2, [2]=0, [3]=3, [4]=5, [5]=4 }
-- Bart-Index -> Overlay-Drawable (Overlay 1 = Bart); -1 bedeutet rasiert
local BART = { [0]=-1, [1]=10, [2]=2, [3]=5 }
-- Augenbrauen-Index (0/1/2) -> Overlay-Drawable (Overlay 2)
local AUGENBRAUEN = { [0]=4, [1]=1, [2]=6 }
-- Kleidungsfarben-Index -> Textur-Variante der jeweiligen Komponente
local KLEID_TEXTUR = { [0]=0, [1]=1, [2]=2, [3]=3, [4]=4, [5]=5, [6]=6, [7]=7, [8]=8, [9]=9 }
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Rechnet einen Slider (0–100) in einen GTA-Gesichtswert (-1.0 bis 1.0) um
--- @param wert number Sliderwert 0–100
--- @return number Gesichtswert
local function SliderZuGesicht(wert)
return ((tonumber(wert) or 50) - 50) / 50.0
end
--- Liefert den Schluessel fuer die geschlechtsabhaengigen Tabellen
--- @param geschlecht string 'männlich' | 'weiblich' | 'divers'
--- @return string
local function GeschlechtSchluessel(geschlecht)
return (geschlecht == 'weiblich') and 'weiblich' or 'maennlich'
end
-- ---------------------------------------------------------------------
-- Anwendung
-- ---------------------------------------------------------------------
--- Liefert den passenden Ped-Modellnamen zum Geschlecht
--- @param geschlecht string
--- @return string Modellname
function AetherAussehen.HoleModell(geschlecht)
if geschlecht == 'weiblich' then return 'mp_f_freemode_01' end
return 'mp_m_freemode_01'
end
--- Uebertraegt ein Aussehen-Objekt des Erstellers auf einen Ped
--- @param ped number Ped-Handle
--- @param aussehen table Aussehen aus dem Charakter-Ersteller
function AetherAussehen.Anwenden(ped, aussehen)
if not aussehen or not DoesEntityExist(ped) then return end
local schluessel = GeschlechtSchluessel(aussehen.geschlecht)
local hautEltern = HAUT_ZU_ELTERN[aussehen.skin or 1] or 1
-- Grundgesicht: Eltern und Hautton mischen
SetPedHeadBlendData(ped,
0, 0, 0, -- Gesichtsform (Vater, Mutter, Mischung)
hautEltern, hautEltern, 0,
0.5, 1.0, 0.0, -- Gewichtung Form / Haut / Drittwert
false)
-- Gesichtszuege aus den Slidern (GTA-Index -> Sliderwert)
SetPedFaceFeature(ped, 0, SliderZuGesicht(aussehen.nase)) -- Nasenbreite
SetPedFaceFeature(ped, 6, SliderZuGesicht(aussehen.lippen)) -- Lippendicke
SetPedFaceFeature(ped, 7, SliderZuGesicht(aussehen.kiefer)) -- Kieferbreite
SetPedFaceFeature(ped, 11, SliderZuGesicht(aussehen.gesichtBreite)) -- Wangenbreite
-- Haare
local frisur = (FRISUR[schluessel] or FRISUR.maennlich)[aussehen.hairStyle or 0] or 0
SetPedComponentVariation(ped, 2, frisur, 0, 0)
local haarfarbe = HAARFARBE[aussehen.hairColor or 0] or 0
SetPedHairColor(ped, haarfarbe, haarfarbe)
-- Augenfarbe (Native ohne offiziellen Namen)
local augenfarbe = AUGENFARBE[aussehen.augen or 0] or 0
Citizen.InvokeNative(0x50B56988B170AFDF, ped, augenfarbe)
-- Augenbrauen (Overlay 2)
local braue = AUGENBRAUEN[math.floor((aussehen.augenbraue or 50) / 50)] or 1
SetPedHeadOverlay(ped, 2, braue, 1.0)
SetPedHeadOverlayColor(ped, 2, 1, haarfarbe, haarfarbe)
-- Bart (Overlay 1) — nur bei nicht-weiblichen Peds sinnvoll
local bart = BART[aussehen.bart or 0] or -1
if bart >= 0 then
SetPedHeadOverlay(ped, 1, bart, 1.0)
SetPedHeadOverlayColor(ped, 1, 1, haarfarbe, haarfarbe)
else
SetPedHeadOverlay(ped, 1, 255, 0.0) -- 255 = kein Overlay
end
-- Kleidung: Farbe wird als Texturvariante der Komponente gesetzt
local oberteil = KLEID_TEXTUR[aussehen.top or 0] or 0
local hose = KLEID_TEXTUR[aussehen.hose or 0] or 0
local schuhe = KLEID_TEXTUR[aussehen.schuhe or 0] or 0
SetPedComponentVariation(ped, 11, 1, oberteil, 0) -- Oberkoerper (Jacke/Shirt)
SetPedComponentVariation(ped, 8, 1, 0, 0) -- Unterhemd
SetPedComponentVariation(ped, 3, 1, 0, 0) -- Arme
SetPedComponentVariation(ped, 4, 1, hose, 0) -- Beine
SetPedComponentVariation(ped, 6, 1, schuhe, 0) -- Schuhe
-- Koerperbau als Fettigkeits-/Muskel-Overlay (Overlay 10 = Sommersprossen frei,
-- daher nutzen wir Overlay 0 nicht; Statur ueber Gesichtszug 19 annaehern)
SetPedFaceFeature(ped, 19, SliderZuGesicht(aussehen.koerper))
end

View File

@ -0,0 +1,157 @@
-- =====================================================================
-- Aether Character — Clientlogik
-- Zeigt die Charakterauswahl, verwaltet die Kamera und meldet die
-- Auswahl an den Server. Der Core uebernimmt danach das Spawnen.
-- =====================================================================
-- Ist die Auswahl gerade offen?
local auswahlOffen = false
-- Ort, an dem der Spieler waehrend der Auswahl geparkt wird
local PARKPLATZ = { x = -1037.7, y = -2737.6, z = 20.2 }
-- ---------------------------------------------------------------------
-- Auswahl oeffnen / schliessen
-- ---------------------------------------------------------------------
--- Oeffnet die Charakterauswahl mit der Liste vom Server
--- @param charaktere table Liste der Charaktere
--- @param maxSlots number Maximale Anzahl Charaktere
local function OeffneAuswahl(charaktere, maxSlots)
auswahlOffen = true
-- Eigenen Spieler unsichtbar parken — sichtbar ist nur der Vorschau-Ped
local ped = PlayerPedId()
SetEntityVisible(ped, false, false)
FreezeEntityPosition(ped, true)
SetEntityCoordsNoOffset(ped, PARKPLATZ.x, PARKPLATZ.y, PARKPLATZ.z, false, false, false)
-- 3D-Vorschau mit dem Aussehen des ersten Charakters starten
local erstes = charaktere and charaktere[1] or nil
Vorschau.Starten(erstes and erstes.aussehen or nil, 'koerper')
-- Ladebildschirm beenden, damit die Auswahl sichtbar wird
ShutdownLoadingScreen()
ShutdownLoadingScreenNui()
DoScreenFadeIn(800)
SetNuiFocus(true, true)
SendNuiMessage(json.encode({
aktion = 'oeffnen',
charaktere = charaktere,
maxSlots = maxSlots,
}))
end
--- Schliesst die Auswahl und gibt die Steuerung frei
local function SchliesseAuswahl()
if not auswahlOffen then return end
auswahlOffen = false
SendNuiMessage(json.encode({ aktion = 'schliessen' }))
SetNuiFocus(false, false)
DoScreenFadeOut(400)
Wait(400)
Vorschau.Beenden()
end
-- ---------------------------------------------------------------------
-- Ereignisse vom Server
-- ---------------------------------------------------------------------
--- Server fordert die Charakterauswahl an
RegisterNetEvent('aether:character:zeigeAuswahl', function(charaktere, maxSlots)
OeffneAuswahl(charaktere, maxSlots)
end)
--- Aktualisierte Liste nach Erstellen oder Loeschen
RegisterNetEvent('aether:character:liste', function(charaktere, maxSlots, hinweis)
SendNuiMessage(json.encode({
aktion = 'liste',
charaktere = charaktere,
maxSlots = maxSlots,
hinweis = hinweis,
}))
end)
--- Server lehnt eine Aktion ab (z. B. Slot voll, ungueltiger Name)
RegisterNetEvent('aether:character:fehler', function(text)
SendNuiMessage(json.encode({ aktion = 'fehler', text = text }))
end)
--- Charakter wurde gewaehlt: Auswahl sofort schliessen, damit der Core
--- ungestoert spawnen kann (Netz-Event kommt gleichzeitig beim Core an)
RegisterNetEvent('aether:client:spielerGeladen', function()
if auswahlOffen then
SchliesseAuswahl()
end
end)
--- Der Core hat den Ped gesetzt und gespawnt: jetzt das Aussehen anwenden.
--- Eigenes Event, damit wir nicht mit dem Modellwechsel des Cores kollidieren.
AddEventHandler('aether:client:bereit', function(daten)
if daten and daten.aussehen then
AetherAussehen.Anwenden(PlayerPedId(), daten.aussehen)
end
local ped = PlayerPedId()
FreezeEntityPosition(ped, false)
SetEntityVisible(ped, true, false)
end)
-- ---------------------------------------------------------------------
-- NUI-Callbacks (Aktionen aus der Oberflaeche)
-- ---------------------------------------------------------------------
--- Aussehen hat sich geaendert: sofort auf den Vorschau-Ped uebertragen
RegisterNUICallback('vorschau', function(daten, rueckgabe)
if daten.aussehen then
Vorschau.Anwenden(daten.aussehen)
end
if daten.modus then
Vorschau.SetzeModus(daten.modus)
end
rueckgabe({ ok = true })
end)
--- Ansicht wechseln (Gesicht / Koerper)
RegisterNUICallback('vorschauModus', function(daten, rueckgabe)
Vorschau.SetzeModus(daten.modus or 'koerper')
rueckgabe({ ok = true })
end)
--- Vorschau-Ped drehen
RegisterNUICallback('vorschauDrehen', function(daten, rueckgabe)
Vorschau.Drehen(tonumber(daten.grad) or 0.0)
rueckgabe({ ok = true })
end)
--- Spieler waehlt einen Charakter zum Spielen
RegisterNUICallback('spielen', function(daten, rueckgabe)
TriggerServerEvent('aether:character:spielen', daten.charakterId)
rueckgabe({ ok = true })
end)
--- Spieler legt einen neuen Charakter an
RegisterNUICallback('erstellen', function(daten, rueckgabe)
TriggerServerEvent('aether:character:erstellen', daten)
rueckgabe({ ok = true })
end)
--- Spieler loescht einen Charakter
RegisterNUICallback('loeschen', function(daten, rueckgabe)
TriggerServerEvent('aether:character:loeschen', daten.charakterId)
rueckgabe({ ok = true })
end)
-- ---------------------------------------------------------------------
-- Aufraeumen beim Stoppen der Ressource
-- ---------------------------------------------------------------------
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false)
Vorschau.Beenden()
FreezeEntityPosition(PlayerPedId(), false)
SetEntityVisible(PlayerPedId(), true, false)
end)

View File

@ -0,0 +1,189 @@
-- =====================================================================
-- Aether Character — 3D-Vorschau
-- Spawnt einen lokalen Ped als lebende Vorschau und richtet eine Kamera
-- darauf. Die NUI liegt mit einem transparenten Bereich darueber.
-- =====================================================================
Vorschau = {}
-- Ort, an dem der Vorschau-Ped steht (ruhige Ecke am Spawnbereich)
local VORSCHAU_POS = { x = -1040.0, y = -2748.0, z = 21.36 }
-- Kameraeinstellungen je Ansicht
-- abstand = Entfernung vor dem Ped, hoehe = Kamerahoehe, ziel = Blickpunkt am Ped
local MODI = {
gesicht = { abstand = 1.05, hoehe = 0.68, ziel = 0.68, sicht = 28.0 },
koerper = { abstand = 2.90, hoehe = 0.25, ziel = 0.20, sicht = 40.0 },
}
-- Laufender Zustand der Vorschau
local vorschauPed = nil -- Handle des Vorschau-Peds
local vorschauKamera= nil -- Handle der Kamera
local aktuellesModell = nil -- Aktuell geladenes Ped-Modell (fuer Geschlechtswechsel)
local aktuelleDrehung = 0.0 -- Zusaetzliche Drehung durch den Spieler
local aktuellerModus = 'koerper'
-- ---------------------------------------------------------------------
-- Interne Helfer
-- ---------------------------------------------------------------------
--- Laedt ein Ped-Modell und wartet, bis es bereit ist
--- @param modellName string Name des Modells
--- @return number|nil Modell-Hash oder nil bei Fehlschlag
local function LadeModell(modellName)
local modell = GetHashKey(modellName)
RequestModel(modell)
local versuche = 0
while not HasModelLoaded(modell) and versuche < 100 do
Wait(50)
versuche = versuche + 1
end
if not HasModelLoaded(modell) then return nil end
return modell
end
--- Setzt die Kamera auf die gewuenschte Ansicht
--- @param modus string 'gesicht' | 'koerper'
--- @param weich boolean|nil Sanft ueberblenden statt springen
local function SetzeKameraAuf(modus, weich)
if not vorschauKamera then return end
local einstellung = MODI[modus] or MODI.koerper
aktuellerModus = modus
-- Kamera steht noerdlich vor dem Ped; der Ped schaut sie an.
-- Fest im Raum verankert, damit das Drehen des Peds die Kamera nicht mitbewegt.
SetCamCoord(vorschauKamera,
VORSCHAU_POS.x,
VORSCHAU_POS.y + einstellung.abstand,
VORSCHAU_POS.z + einstellung.hoehe)
PointCamAtCoord(vorschauKamera,
VORSCHAU_POS.x, VORSCHAU_POS.y, VORSCHAU_POS.z + einstellung.ziel)
SetCamFov(vorschauKamera, einstellung.sicht)
if weich then
-- Kurze Ueberblendung fuer einen weichen Wechsel
SetCamActiveWithInterp(vorschauKamera, vorschauKamera, 400, 1, 1)
end
end
-- ---------------------------------------------------------------------
-- Oeffentliche Schnittstelle
-- ---------------------------------------------------------------------
--- Startet die Vorschau: erzeugt Ped und Kamera
--- @param aussehen table|nil Startaussehen (bestimmt auch das Geschlecht)
--- @param modus string|nil Startansicht ('gesicht' | 'koerper')
function Vorschau.Starten(aussehen, modus)
-- Umgebung laden, damit der Ped nicht durch den Boden faellt
RequestCollisionAtCoord(VORSCHAU_POS.x, VORSCHAU_POS.y, VORSCHAU_POS.z)
local modellName = AetherAussehen.HoleModell(aussehen and aussehen.geschlecht)
local modell = LadeModell(modellName)
if not modell then
print('^1[Aether]^7 Vorschau-Modell konnte nicht geladen werden: ' .. modellName)
return
end
-- Ped nur lokal erzeugen (nicht im Netzwerk sichtbar)
vorschauPed = CreatePed(4, modell, VORSCHAU_POS.x, VORSCHAU_POS.y, VORSCHAU_POS.z,
0.0, false, false)
aktuellesModell = modellName
aktuelleDrehung = 0.0
SetEntityHeading(vorschauPed, 0.0) -- schaut Richtung Kamera
FreezeEntityPosition(vorschauPed, true)
SetEntityInvincible(vorschauPed, true)
SetBlockingOfNonTemporaryEvents(vorschauPed, true)
SetModelAsNoLongerNeeded(modell)
if aussehen then
AetherAussehen.Anwenden(vorschauPed, aussehen)
end
-- Kamera erzeugen und aktivieren
vorschauKamera = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
SetzeKameraAuf(modus or 'koerper', false)
SetCamActive(vorschauKamera, true)
RenderScriptCams(true, false, 0, true, true)
end
--- Uebertraegt ein Aussehen auf den Vorschau-Ped.
--- Wechselt das Geschlecht, wird der Ped neu erzeugt.
--- @param aussehen table Aussehen aus dem Ersteller
function Vorschau.Anwenden(aussehen)
if not aussehen then return end
-- Geschlechtswechsel erfordert ein anderes Modell
local gewuenschtesModell = AetherAussehen.HoleModell(aussehen.geschlecht)
if gewuenschtesModell ~= aktuellesModell then
local drehung = aktuelleDrehung
local modus = aktuellerModus
if vorschauPed and DoesEntityExist(vorschauPed) then
DeletePed(vorschauPed)
end
local modell = LadeModell(gewuenschtesModell)
if not modell then return end
vorschauPed = CreatePed(4, modell, VORSCHAU_POS.x, VORSCHAU_POS.y, VORSCHAU_POS.z,
0.0, false, false)
aktuellesModell = gewuenschtesModell
FreezeEntityPosition(vorschauPed, true)
SetEntityInvincible(vorschauPed, true)
SetBlockingOfNonTemporaryEvents(vorschauPed, true)
SetModelAsNoLongerNeeded(modell)
-- Vorherige Drehung und Ansicht wiederherstellen
aktuelleDrehung = drehung
SetEntityHeading(vorschauPed, aktuelleDrehung)
SetzeKameraAuf(modus, false)
end
if vorschauPed and DoesEntityExist(vorschauPed) then
AetherAussehen.Anwenden(vorschauPed, aussehen)
end
end
--- Wechselt zwischen Gesichts- und Koerperansicht
--- @param modus string 'gesicht' | 'koerper'
function Vorschau.SetzeModus(modus)
SetzeKameraAuf(modus, true)
end
--- Dreht den Vorschau-Ped um einen Winkel
--- @param grad number Drehung in Grad (positiv = nach rechts)
function Vorschau.Drehen(grad)
if not vorschauPed or not DoesEntityExist(vorschauPed) then return end
aktuelleDrehung = (aktuelleDrehung + (grad or 0.0)) % 360.0
SetEntityHeading(vorschauPed, aktuelleDrehung)
end
--- Beendet die Vorschau und raeumt Ped und Kamera auf
function Vorschau.Beenden()
if vorschauKamera then
RenderScriptCams(false, false, 0, true, true)
DestroyCam(vorschauKamera, false)
vorschauKamera = nil
end
if vorschauPed and DoesEntityExist(vorschauPed) then
DeletePed(vorschauPed)
end
vorschauPed = nil
aktuellesModell = nil
aktuelleDrehung = 0.0
end
--- Ist gerade eine Vorschau aktiv?
--- @return boolean
function Vorschau.IstAktiv()
return vorschauPed ~= nil
end

View File

@ -0,0 +1,39 @@
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
name 'aether-character'
author 'Jerrit Fritzsche'
description 'Multicharacter-Auswahl und Charakter-Ersteller'
version '0.1.0'
-- Oberflaeche der Charakterauswahl
ui_page 'html/index.html'
files {
'html/index.html',
}
-- Geteilte Dateien des Cores mitladen.
-- Noetig, weil jede FiveM-Resource einen eigenen Lua-State hat:
-- `Config` und `Aether.*` waeren sonst hier nicht verfuegbar.
shared_scripts {
'@aether-core/shared/config.lua',
'@aether-core/shared/utils.lua',
}
client_scripts {
'client/aussehen.lua',
'client/vorschau.lua',
'client/main.lua',
}
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/main.lua',
}
dependencies {
'aether-core',
'oxmysql',
}

View File

@ -0,0 +1,519 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
</head>
<body>
<title>Aether — Charaktererstellung</title>
<style>
:root{
--bg:#07080c;--fg:#eef0f6;--muted:#8a8fa3;--surface:rgba(22,24,33,0.72);--surface2:rgba(36,39,52,0.85);
--slot:#12141c;--border:rgba(255,255,255,0.1);--accent:#c9a24a;--ink:#241a06;--accent-soft:rgba(201,162,74,0.16);
--red:#f0506e;--green:#34d39a;
}
*{box-sizing:border-box;} html,body{margin:0;height:100%;}
/* In FiveM ist der Hintergrund transparent — dahinter steht der echte 3D-Ped. */
body{min-height:100%;background:transparent;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);
display:flex;align-items:center;justify-content:center;overflow:hidden;}
.stage{position:relative;width:100%;height:100%;overflow:hidden;}
.view{position:absolute;inset:0;display:none;} .view.active{display:flex;}
.fade{animation:fin .35s ease;} @keyframes fin{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:none;}}
/* Vorschau-Bühne: transparentes Fenster auf den 3D-Ped */
.preview{position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;
background:transparent;}
.preview .floor{display:none;} /* Boden kommt aus der Spielwelt */
.preview svg{position:relative;z-index:1;height:88%;max-width:92%;filter:drop-shadow(0 30px 40px rgba(0,0,0,0.55));}
/* Drehsteuerung unter dem Ped */
.turnbar{position:absolute;bottom:22px;left:50%;transform:translateX(-50%);z-index:3;display:flex;gap:8px;
background:rgba(0,0,0,0.45);border:1px solid var(--border);border-radius:12px;padding:6px;backdrop-filter:blur(6px);}
.turnbar button{border:none;background:rgba(255,255,255,0.06);color:var(--fg);border-radius:8px;
width:44px;height:34px;font-size:15px;cursor:pointer;font-family:inherit;transition:.12s;}
.turnbar button:hover{background:var(--accent);color:var(--ink);}
.turnbar .hint{color:var(--muted);font-size:11px;display:flex;align-items:center;padding:0 8px;font-weight:600;}
/* ============ Eigenständige Browser-Ansicht (ohne FiveM) ============
Stellt den früheren Kasten-Look samt SVG-Vorschau wieder her. */
body.browser{background:radial-gradient(1200px 800px at 50% -10%,rgba(201,162,74,0.1),transparent 55%),var(--bg);
padding:24px 16px;}
body.browser .stage{width:min(1280px,97vw);height:auto;aspect-ratio:16/10;border-radius:22px;
background:radial-gradient(120% 90% at 50% 0%,#171a24,#0a0c11 70%);border:1px solid var(--border);
box-shadow:0 50px 100px -30px rgba(0,0,0,0.85);}
body.browser .preview{background:radial-gradient(70% 60% at 50% 40%,rgba(201,162,74,0.08),transparent 70%),linear-gradient(180deg,#12141c,#0a0c11);}
body.browser .preview .floor{display:block;position:absolute;bottom:6%;left:50%;transform:translateX(-50%);
width:52%;height:5%;border-radius:50%;background:radial-gradient(ellipse,rgba(201,162,74,0.25),transparent 70%);}
body.browser .turnbar{display:none;} /* Drehen gibt es nur mit echtem Ped */
.view-toggle{position:absolute;top:16px;left:50%;transform:translateX(-50%);z-index:3;display:flex;background:rgba(0,0,0,0.4);border:1px solid var(--border);border-radius:11px;padding:4px;gap:2px;}
.view-toggle button{border:none;background:none;color:var(--muted);font-family:inherit;font-weight:700;font-size:12.5px;padding:7px 14px;border-radius:8px;cursor:pointer;}
.view-toggle button.active{background:var(--accent);color:var(--ink);}
.turn{transform-box:fill-box;transform-origin:center;animation:turn 8s ease-in-out infinite;}
@keyframes turn{0%,100%{transform:rotateY(-16deg);}50%{transform:rotateY(16deg);}}
@media (prefers-reduced-motion:reduce){.turn{animation:none;}}
/* ============ Auswahl ============ */
.sel-wrap{width:100%;display:grid;grid-template-columns:340px 1fr;}
.sel-list{padding:26px 22px;border-right:1px solid var(--border);display:flex;flex-direction:column;overflow-y:auto;
background:rgba(10,12,17,0.92);backdrop-filter:blur(10px);}
.sel-list h1{font-size:24px;margin:0 0 3px;font-weight:800;letter-spacing:-0.02em;} .sel-list .sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
.char-slot{display:flex;align-items:center;gap:13px;padding:13px;border-radius:15px;background:var(--surface);border:1px solid var(--border);cursor:pointer;margin-bottom:10px;transition:.14s;}
.char-slot:hover{border-color:var(--accent);transform:translateX(2px);} .char-slot.active{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent) inset;background:var(--accent-soft);}
.char-slot .thumb{width:52px;height:52px;border-radius:12px;overflow:hidden;flex-shrink:0;background:#0c0e14;display:grid;place-items:center;}
.char-slot .thumb svg{height:96%;} .char-slot .cn{font-size:15px;font-weight:800;} .char-slot .cm{font-size:12px;color:var(--muted);}
/* Initialen als Slot-Vorschau (in FiveM statt SVG-Gesicht) */
.char-slot .thumb .ini{font-size:18px;font-weight:800;color:var(--accent);letter-spacing:0.03em;}
.char-slot.empty{border-style:dashed;justify-content:center;color:var(--muted);font-weight:700;font-size:14px;} .char-slot.empty:hover{color:var(--accent);}
.char-slot.empty .plus{font-size:20px;margin-right:6px;}
.sel-detail{position:relative;display:grid;grid-template-rows:1fr auto;}
.sel-info{position:absolute;bottom:0;left:0;right:0;z-index:2;padding:26px 30px;background:linear-gradient(0deg,rgba(8,9,13,0.92),transparent);}
.sel-info h2{font-size:32px;margin:0;font-weight:800;letter-spacing:-0.02em;} .sel-info .role{color:var(--accent);font-weight:700;font-size:14px;margin-top:2px;}
.sel-stats{display:flex;gap:24px;margin:14px 0 18px;flex-wrap:wrap;} .sel-stats .st .l{font-size:10.5px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;font-weight:800;} .sel-stats .st .v{font-size:17px;font-weight:800;font-variant-numeric:tabular-nums;}
.sel-actions{display:flex;gap:10px;}
/* ============ Creator ============ */
.cr-wrap{width:100%;display:grid;grid-template-columns:1fr 400px;}
.cr-panel{border-left:1px solid var(--border);display:grid;grid-template-rows:auto auto 1fr auto;min-height:0;
background:rgba(10,12,17,0.94);backdrop-filter:blur(10px);}
.cr-head{padding:20px 22px 12px;} .cr-head h1{font-size:21px;margin:0;font-weight:800;letter-spacing:-0.02em;} .cr-head p{margin:2px 0 0;color:var(--muted);font-size:12.5px;}
.cr-tabs{display:flex;gap:3px;padding:0 22px;flex-wrap:wrap;} .cr-tabs button{border:none;background:none;color:var(--muted);font-family:inherit;font-weight:700;font-size:12.5px;padding:9px 12px;border-radius:9px 9px 0 0;cursor:pointer;border-bottom:2px solid transparent;}
.cr-tabs button.active{color:var(--accent);border-bottom-color:var(--accent);}
.cr-body{overflow-y:auto;padding:18px 22px;} .cr-body::-webkit-scrollbar{width:7px;} .cr-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:4px;}
.cr-foot{padding:14px 22px;border-top:1px solid var(--border);display:flex;gap:10px;}
.flabel{font-size:11px;color:var(--muted);font-weight:800;text-transform:uppercase;letter-spacing:0.05em;margin:16px 2px 8px;display:block;} .flabel:first-child{margin-top:0;}
.input,select.input{width:100%;background:var(--slot);border:1px solid var(--border);border-radius:10px;padding:11px 12px;color:var(--fg);font-size:14px;font-family:inherit;outline:none;} .input:focus{border-color:var(--accent);}
.swatches{display:flex;flex-wrap:wrap;gap:8px;}
.sw{width:34px;height:34px;border-radius:9px;cursor:pointer;border:2px solid transparent;position:relative;} .sw.sel{border-color:#fff;box-shadow:0 0 0 2px var(--accent);}
.sw[title]:hover::after{content:attr(title);position:absolute;bottom:120%;left:50%;transform:translateX(-50%);background:#000;color:#fff;font-size:10px;padding:2px 6px;border-radius:5px;white-space:nowrap;z-index:5;}
.opts{display:flex;flex-wrap:wrap;gap:7px;} .opt{padding:9px 14px;border-radius:9px;background:var(--slot);border:1px solid var(--border);color:var(--fg);font-family:inherit;font-weight:700;font-size:12.5px;cursor:pointer;} .opt.sel{background:var(--accent);color:var(--ink);border-color:var(--accent);}
.slider{display:flex;align-items:center;gap:12px;margin-bottom:12px;} .slider .sl-l{width:110px;font-size:12.5px;font-weight:600;} .slider input[type=range]{flex:1;accent-color:var(--accent);}
.slider .sl-v{width:34px;text-align:right;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums;}
.btn{border:none;border-radius:11px;padding:12px 18px;font-size:14px;font-weight:800;cursor:pointer;font-family:inherit;transition:.12s;}
.btn.primary{background:var(--accent);color:var(--ink);flex:1;} .btn.primary:hover{filter:brightness(1.08);}
.btn.ghost{background:var(--surface2);color:var(--fg);border:1px solid var(--border);} .btn.red{background:var(--red);color:#2a0910;} .btn.green{background:var(--green);color:#06231a;}
.btn:disabled{opacity:.4;cursor:not-allowed;} .btn:active{transform:scale(0.98);}
.overlay{position:absolute;inset:0;z-index:40;background:rgba(5,6,10,0.7);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;} .overlay.show{display:flex;}
.modal{width:360px;max-width:92%;background:rgba(18,20,28,0.98);border:1px solid var(--border);border-radius:17px;padding:22px;text-align:center;box-shadow:0 30px 70px -18px rgba(0,0,0,0.8);}
.modal .mic{font-size:38px;} .modal h3{margin:8px 0 4px;font-size:19px;} .modal p{color:var(--muted);font-size:13px;margin:0 0 18px;} .modal-actions{display:grid;grid-template-columns:1fr 1fr;gap:9px;}
.toast{position:absolute;bottom:20px;left:50%;transform:translateX(-50%) translateY(18px);background:rgba(12,14,20,0.97);border:1px solid var(--border);color:var(--fg);padding:11px 20px;border-radius:12px;font-size:13.5px;font-weight:600;z-index:50;opacity:0;pointer-events:none;transition:.3s;} .toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
</style>
<div class="stage">
<!-- AUSWAHL -->
<div class="view active" id="v-select"><div class="sel-wrap">
<div class="sel-list"><h1>Charaktere</h1><div class="sub">Wähle einen Charakter oder erstelle einen neuen.</div><div id="slot-list"></div></div>
<div class="sel-detail preview" id="sel-preview">
<div class="floor"></div>
<div id="sel-avatar"></div>
<div class="turnbar">
<button data-drehen="-30" title="Nach links drehen">◀</button>
<span class="hint">Drehen</span>
<button data-drehen="30" title="Nach rechts drehen">▶</button>
</div>
<div class="sel-info" id="sel-info"></div>
</div>
</div></div>
<!-- CREATOR -->
<div class="view" id="v-create"><div class="cr-wrap">
<div class="preview" id="cr-preview">
<div class="view-toggle" id="cr-viewtoggle"><button data-m="gesicht" class="active">Gesicht</button><button data-m="koerper">Körper</button></div>
<div class="floor"></div>
<div id="cr-avatar"></div>
<div class="turnbar">
<button data-drehen="-30" title="Nach links drehen">◀</button>
<span class="hint">Drehen</span>
<button data-drehen="30" title="Nach rechts drehen">▶</button>
</div>
</div>
<div class="cr-panel">
<div class="cr-head"><h1 id="cr-title">Charakter erstellen</h1><p>Gestalte Aussehen und Identität</p></div>
<div class="cr-tabs" id="cr-tabs"></div>
<div class="cr-body" id="cr-body"></div>
<div class="cr-foot"><button class="btn ghost" id="cr-cancel">Abbrechen</button><button class="btn primary" id="cr-save">Charakter erstellen</button></div>
</div>
</div></div>
<div class="overlay" id="overlay"></div><div class="toast" id="toast"></div>
</div>
<script>
"use strict";
let MAX_SLOTS=5; // wird vom Server aus Config.MaxCharaktere gesetzt
/* =====================================================================
PALETTEN & OPTIONEN
===================================================================== */
const hautToene=[{n:"Hell",c:"#f1cba6"},{n:"Rosé",c:"#e8b48f"},{n:"Gebräunt",c:"#d29b6f"},{n:"Oliv",c:"#b98552"},{n:"Braun",c:"#9a6a42"},{n:"Dunkelbraun",c:"#7a5232"},{n:"Tief",c:"#5c3d26"},{n:"Ebenholz",c:"#43291a"}];
const haarFarben=[{n:"Schwarz",c:"#1c1a1e"},{n:"Dunkelbraun",c:"#3a2417"},{n:"Braun",c:"#5a3a22"},{n:"Kastanie",c:"#7a3f2a"},{n:"Blond",c:"#c9a24a"},{n:"Hellblond",c:"#e3ca88"},{n:"Rot",c:"#a3441f"},{n:"Grau",c:"#9a9aa0"},{n:"Weiß",c:"#e8e8ec"},{n:"Petrol (gefärbt)",c:"#2f8f9a"}];
const augenFarben=[{n:"Braun",c:"#6b4423"},{n:"Haselnuss",c:"#8a6b3a"},{n:"Blau",c:"#3f7ac4"},{n:"Grün",c:"#4a8a5a"},{n:"Grau",c:"#7a8a95"},{n:"Bernstein",c:"#b07a2a"}];
const kleidFarben=[{n:"Schwarz",c:"#20222a"},{n:"Weiß",c:"#e6e8ee"},{n:"Grau",c:"#6b7180"},{n:"Navy",c:"#27324f"},{n:"Rot",c:"#b23a3a"},{n:"Grün",c:"#2f6b4a"},{n:"Blau",c:"#3563a8"},{n:"Beige",c:"#c7b18a"},{n:"Bordeaux",c:"#6b2740"},{n:"Oliv",c:"#5a5a32"}];
const haarStile=["Kurz","Mittel","Undercut","Lang","Zopf","Glatze"];
const bartStile=["Rasiert","Stoppeln","Kurzbart","Vollbart"];
const augenbrauen=["Dünn","Normal","Dicht"];
const herkuenfte=["Los Santos","Vice City","Liberty City","San Fierro","Europa","Südamerika","Asien","Afrika"];
/* Standard-Aussehen für einen neuen Charakter */
function standardAussehen(g){return {geschlecht:g||"männlich",skin:1,hairColor:1,hairStyle:g==="weiblich"?3:0,augen:0,bart:0,augenbraue:1,gesichtBreite:50,kiefer:g==="weiblich"?40:55,nase:50,lippen:g==="weiblich"?62:45,koerper:g==="weiblich"?42:55,top:3,hose:0,schuhe:0};}
/* =====================================================================
GESPEICHERTE CHARAKTERE (kommen vom Server per NUI)
===================================================================== */
let charaktere=[]; // wird beim Öffnen vom Server befüllt
let auswahlIndex=0; // aktuell markierter Slot in der Auswahl
let entwurf=null; // Arbeitskopie im Creator
let editIndex=null; // Index beim Bearbeiten (null = neu)
let crModus="gesicht"; // Vorschau-Modus im Creator
let crTab="identitaet"; // aktiver Reiter
/* =====================================================================
HELFER
===================================================================== */
const $=id=>document.getElementById(id);
function sicher(t){const d=document.createElement("div");d.textContent=t==null?"":t;return d.innerHTML;}
const euro=n=>n.toLocaleString("de-DE")+" €";
let toastTimer=null; function toast(t){const e=$("toast");e.textContent=t;e.classList.add("show");clearTimeout(toastTimer);toastTimer=setTimeout(()=>e.classList.remove("show"),2400);}
function modal(html){$("overlay").innerHTML=`<div class="modal">${html}</div>`;$("overlay").classList.add("show");}
function closeModal(){$("overlay").classList.remove("show");$("overlay").innerHTML="";}
/* Farbe abdunkeln/aufhellen (f<1 dunkler, >1 heller) */
function schattiere(hex,f){const n=parseInt(hex.slice(1),16);let r=(n>>16)&255,g=(n>>8)&255,b=n&255;r=Math.max(0,Math.min(255,Math.round(r*f)));g=Math.max(0,Math.min(255,Math.round(g*f)));b=Math.max(0,Math.min(255,Math.round(b*f)));return "#"+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1);}
/* =====================================================================
AVATAR — parametrische Vektor-Darstellung (Gesicht & Körper)
===================================================================== */
function haarPfad(stil,cx,cy,fw,haar,haarD){
// gibt {hinten, vorne} Haar-SVG zurück
const oben=cy-66;
if(stil==="Glatze")return {hinten:"",vorne:`<path d="M ${cx-fw+4} ${cy-14} C ${cx-fw+2} ${cy-52} ${cx+fw-2} ${cy-52} ${cx+fw-4} ${cy-14} C ${cx+fw-16} ${cy-40} ${cx-fw+16} ${cy-40} ${cx-fw+4} ${cy-14} Z" fill="${schattiere(haar,1.05)}" opacity="0.12"/>`};
if(stil==="Lang"||stil==="Zopf"){
const hinten=`<path d="M ${cx-fw-6} ${cy-20} C ${cx-fw-14} ${cy+60} ${cx-fw-6} ${cy+110} ${cx-fw+10} ${cy+130} L ${cx+fw-10} ${cy+130} C ${cx+fw+6} ${cy+110} ${cx+fw+14} ${cy+60} ${cx+fw+6} ${cy-20} C ${cx+fw} ${oben} ${cx-fw} ${oben} ${cx-fw-6} ${cy-20} Z" fill="${haarD}"/>`;
const vorne=`<path d="M ${cx-fw-2} ${cy-6} C ${cx-fw-4} ${cy-58} ${cx+fw+4} ${cy-58} ${cx+fw+2} ${cy-6} C ${cx+fw-6} ${cy-30} ${cx+16} ${cy-40} ${cx+6} ${cy-24} C ${cx} ${cy-40} ${cx-16} ${cy-38} ${cx-fw+6} ${cy-30} Z" fill="${haar}"/>`+(stil==="Zopf"?`<ellipse cx="${cx}" cy="${cy+118}" rx="10" ry="16" fill="${haarD}"/>`:"");
return {hinten,vorne};
}
if(stil==="Undercut"){
return {hinten:"",vorne:`<path d="M ${cx-fw+6} ${cy-18} C ${cx-fw+2} ${cy-56} ${cx+fw-2} ${cy-56} ${cx+fw-6} ${cy-18} C ${cx+fw-8} ${cy-36} ${cx+8} ${cy-44} ${cx-4} ${cy-30} C ${cx-10} ${cy-40} ${cx-fw+4} ${cy-38} ${cx-fw+6} ${cy-18} Z" fill="${haar}"/>`};
}
if(stil==="Mittel"){
return {hinten:`<path d="M ${cx-fw-4} ${cy-12} C ${cx-fw-6} ${cy+30} ${cx-fw+2} ${cy+44} ${cx-fw+8} ${cy+48} L ${cx+fw-8} ${cy+48} C ${cx+fw-2} ${cy+44} ${cx+fw+6} ${cy+30} ${cx+fw+4} ${cy-12} C ${cx+fw} ${oben} ${cx-fw} ${oben} ${cx-fw-4} ${cy-12} Z" fill="${haarD}"/>`,
vorne:`<path d="M ${cx-fw} ${cy-8} C ${cx-fw-2} ${cy-56} ${cx+fw+2} ${cy-56} ${cx+fw} ${cy-8} C ${cx+fw-8} ${cy-32} ${cx+12} ${cy-42} ${cx+2} ${cy-26} C ${cx-4} ${cy-42} ${cx-12} ${cy-40} ${cx-fw+8} ${cy-30} Z" fill="${haar}"/>`};
}
// Kurz (Standard)
return {hinten:"",vorne:`<path d="M ${cx-fw+4} ${cy-16} C ${cx-fw+2} ${cy-54} ${cx+fw-2} ${cy-54} ${cx+fw-4} ${cy-16} C ${cx+fw-8} ${cy-36} ${cx+10} ${cy-42} ${cx} ${cy-28} C ${cx-10} ${cy-42} ${cx-fw+8} ${cy-36} ${cx-fw+4} ${cy-16} Z" fill="${haar}"/>`};
}
function bartPfad(stil,cx,cy,fw,jawW,chinY,haar){
if(stil==="Rasiert")return "";
const bC=schattiere(haar,0.92);
if(stil==="Stoppeln")return `<path d="M ${cx-jawW+2} ${cy+30} C ${cx-jawW} ${chinY-14} ${cx} ${chinY} ${cx} ${chinY} C ${cx} ${chinY} ${cx+jawW} ${chinY-14} ${cx+jawW-2} ${cy+30} C ${cx+jawW-10} ${cy+52} ${cx-jawW+10} ${cy+52} ${cx-jawW+2} ${cy+30} Z" fill="${bC}" opacity="0.28"/>`;
if(stil==="Kurzbart")return `<path d="M ${cx-jawW+3} ${cy+26} C ${cx-jawW+1} ${chinY-12} ${cx} ${chinY} ${cx} ${chinY} C ${cx} ${chinY} ${cx+jawW-1} ${chinY-12} ${cx+jawW-3} ${cy+26} C ${cx+jawW-12} ${cy+50} ${cx-jawW+12} ${cy+50} ${cx-jawW+3} ${cy+26} Z" fill="${bC}" opacity="0.7"/><rect x="${cx-11}" y="${cy+14}" width="22" height="6" rx="3" fill="${bC}" opacity="0.7"/>`;
// Vollbart
return `<path d="M ${cx-jawW+1} ${cy+20} C ${cx-jawW-2} ${chinY-6} ${cx} ${chinY+6} ${cx} ${chinY+6} C ${cx} ${chinY+6} ${cx+jawW+2} ${chinY-6} ${cx+jawW-1} ${cy+20} C ${cx+jawW-6} ${cy+56} ${cx-jawW+6} ${cy+56} ${cx-jawW+1} ${cy+20} Z" fill="${bC}"/><rect x="${cx-12}" y="${cy+12}" width="24" height="7" rx="3" fill="${bC}"/>`;
}
function faceSvg(a){
const skin=hautToene[a.skin].c, skinD=schattiere(skin,0.86), skinL=schattiere(skin,1.07);
const haar=haarFarben[a.hairColor].c, haarD=schattiere(haar,0.8);
const auge=augenFarben[a.augen].c, brow=schattiere(haar,0.85), lip=schattiere(skin,0.7);
const topC=kleidFarben[a.top].c;
const cx=110, cy=104, chinY=176;
const fw=38+a.gesichtBreite/100*16; // Gesichts-Halbbreite
const jawW=fw*(0.56+a.kiefer/100*0.34); // Kieferbreite
const noseW=5+a.nase/100*8;
const lipW=13+a.lippen/100*10, lipY=chinY-30;
const browT=2.4+a.augenbraue/100*3.2, eyeY=cy+2, eyeDx=18;
const kopf=`M ${cx-fw} ${cy} C ${cx-fw} ${cy-74} ${cx+fw} ${cy-74} ${cx+fw} ${cy} C ${cx+fw} ${cy+38} ${cx+jawW} ${chinY-18} ${cx} ${chinY} C ${cx-jawW} ${chinY-18} ${cx-fw} ${cy+38} ${cx-fw} ${cy} Z`;
const haare=haarPfad(a.hairStyle,cx,cy,fw,haar,haarD);
const bart=bartPfad(a.bart,cx,cy,fw,jawW,chinY,haar);
return `<svg viewBox="0 0 220 260" class="${crModus==='gesicht'?'':''}"><defs>
<linearGradient id="skinG" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="${skinL}"/><stop offset="60%" stop-color="${skin}"/><stop offset="100%" stop-color="${skinD}"/></linearGradient></defs>
<!-- Schultern / Oberteil -->
<path d="M ${cx-96} 260 C ${cx-96} 214 ${cx-40} 196 ${cx} 196 C ${cx+40} 196 ${cx+96} 214 ${cx+96} 260 Z" fill="${topC}"/>
<path d="M ${cx-14} 196 h28 v-24 h-28 Z" fill="${skinD}"/>
${haare.hinten}
<!-- Ohren -->
<ellipse cx="${cx-fw+2}" cy="${cy+8}" rx="8" ry="12" fill="${skinD}"/><ellipse cx="${cx+fw-2}" cy="${cy+8}" rx="8" ry="12" fill="${skinD}"/>
<!-- Kopf -->
<path d="${kopf}" fill="url(#skinG)"/>
<!-- Augenbrauen -->
<path d="M ${cx-eyeDx-9} ${eyeY-13} q 9 -${browT+3} 18 0" stroke="${brow}" stroke-width="${browT}" fill="none" stroke-linecap="round"/>
<path d="M ${cx+eyeDx-9} ${eyeY-13} q 9 -${browT+3} 18 0" stroke="${brow}" stroke-width="${browT}" fill="none" stroke-linecap="round"/>
<!-- Augen -->
<g><ellipse cx="${cx-eyeDx}" cy="${eyeY}" rx="8" ry="5" fill="#fff"/><circle cx="${cx-eyeDx}" cy="${eyeY}" r="3.6" fill="${auge}"/><circle cx="${cx-eyeDx}" cy="${eyeY}" r="1.7" fill="#12100e"/>
<ellipse cx="${cx+eyeDx}" cy="${eyeY}" rx="8" ry="5" fill="#fff"/><circle cx="${cx+eyeDx}" cy="${eyeY}" r="3.6" fill="${auge}"/><circle cx="${cx+eyeDx}" cy="${eyeY}" r="1.7" fill="#12100e"/>
<path d="M ${cx-eyeDx-8} ${eyeY-3} q 8 -4 16 0" stroke="${skinD}" stroke-width="1.4" fill="none"/><path d="M ${cx+eyeDx-8} ${eyeY-3} q 8 -4 16 0" stroke="${skinD}" stroke-width="1.4" fill="none"/></g>
<!-- Nase -->
<path d="M ${cx} ${eyeY+4} C ${cx-2} ${eyeY+18} ${cx-noseW} ${eyeY+26} ${cx-noseW+1} ${eyeY+30} q ${noseW-1} 5 ${2*(noseW-1)} 0 C ${cx+noseW} ${eyeY+26} ${cx+2} ${eyeY+18} ${cx} ${eyeY+4}" fill="${schattiere(skin,0.93)}"/>
<!-- Lippen -->
<path d="M ${cx-lipW/2} ${lipY} q ${lipW/2} -4 ${lipW} 0 q -${lipW/2} 8 -${lipW} 0 Z" fill="${lip}"/><path d="M ${cx-lipW/2} ${lipY} q ${lipW/2} 3 ${lipW} 0" stroke="${schattiere(lip,0.7)}" stroke-width="1" fill="none"/>
${bart}
${haare.vorne}
</svg>`;
}
function bodySvg(a){
const skin=hautToene[a.skin].c, skinD=schattiere(skin,0.86);
const haar=haarFarben[a.hairColor].c;
const topC=kleidFarben[a.top].c, hoseC=kleidFarben[a.hose].c, schuhC=kleidFarben[a.schuhe].c;
const cx=110, torsoW=30+a.koerper/100*20; // Rumpfbreite je Körperbau
return `<svg viewBox="0 0 220 330"><g class="turn">
<!-- Kopf + Haare -->
<ellipse cx="${cx}" cy="42" rx="21" ry="24" fill="${skin}"/>
<path d="M ${cx-21} 34 C ${cx-22} 8 ${cx+22} 8 ${cx+21} 34 C ${cx+14} 20 ${cx-14} 20 ${cx-21} 34 Z" fill="${haar}"/>
<rect x="${cx-8}" y="62" width="16" height="14" fill="${skinD}"/>
<!-- Oberkörper (Oberteil) -->
<path d="M ${cx-torsoW} 78 Q ${cx} 70 ${cx+torsoW} 78 L ${cx+torsoW-4} 176 Q ${cx} 186 ${cx-torsoW+4} 176 Z" fill="${topC}"/>
<!-- Arme -->
<path d="M ${cx-torsoW} 80 q -18 6 -22 54 l 10 4 q 6 -40 16 -50 Z" fill="${topC}"/><path d="M ${cx+torsoW} 80 q 18 6 22 54 l -10 4 q -6 -40 -16 -50 Z" fill="${topC}"/>
<ellipse cx="${cx-torsoW-14}" cy="140" rx="6" ry="8" fill="${skin}"/><ellipse cx="${cx+torsoW+14}" cy="140" rx="6" ry="8" fill="${skin}"/>
<!-- Beine (Hose) -->
<path d="M ${cx-torsoW+6} 174 L ${cx-6} 176 L ${cx-8} 288 L ${cx-24} 288 Z" fill="${hoseC}"/><path d="M ${cx+torsoW-6} 174 L ${cx+6} 176 L ${cx+8} 288 L ${cx+24} 288 Z" fill="${hoseC}"/>
<!-- Schuhe -->
<path d="M ${cx-26} 288 h20 l2 14 h-30 Z" fill="${schuhC}"/><path d="M ${cx+26} 288 h-20 l-2 14 h30 Z" fill="${schuhC}"/>
</g></svg>`;
}
function avatarSvg(a,modus){return modus==="koerper"?bodySvg(a):faceSvg(a);}
/* =====================================================================
AUSWAHL-BILDSCHIRM
===================================================================== */
function renderAuswahl(){
$("v-create").classList.remove("active");$("v-select").classList.add("active","fade");setTimeout(()=>$("v-select").classList.remove("fade"),350);
const list=$("slot-list");
let html=charaktere.map((c,i)=>`<div class="char-slot ${i===auswahlIndex?'active':''}" data-slot="${i}">
<div class="thumb">${slotThumb(c)}</div><div class="grow"><div class="cn">${sicher(c.vorname+' '+c.nachname)}</div><div class="cm">${sicher(c.job)} · ${c.stunden} Std</div></div></div>`).join("");
if(charaktere.length<MAX_SLOTS)html+=`<div class="char-slot empty" data-neu="1"><span class="plus">+</span> Neuen Charakter erstellen</div>`;
list.innerHTML=html;
list.querySelectorAll("[data-slot]").forEach(s=>s.addEventListener("click",()=>{auswahlIndex=+s.dataset.slot;renderAuswahl();}));
const neu=list.querySelector("[data-neu]");if(neu)neu.addEventListener("click",()=>starteCreator(null));
renderAuswahlDetail();
}
/* Kleines Vorschaubild im Slot.
In FiveM Initialen (passt besser zum realistischen 3D-Ped),
im Browser die stilisierte SVG-Zeichnung. */
function slotThumb(c){
if(!IST_FIVEM) return faceSvg(c.aussehen);
const initialen=((c.vorname||"?")[0]+(c.nachname||"?")[0]).toUpperCase();
return `<span class="ini">${sicher(initialen)}</span>`;
}
function renderAuswahlDetail(){
const c=charaktere[auswahlIndex];
if(!c){$("sel-avatar").innerHTML="";$("sel-info").innerHTML='<div class="empty">Kein Charakter.</div>';return;}
// 3D-Ped auf den markierten Charakter umstellen (bzw. SVG im Browser)
if(IST_FIVEM){ $("sel-avatar").innerHTML=""; nui("vorschau",{aussehen:c.aussehen,modus:"koerper"}); }
else $("sel-avatar").innerHTML=faceSvg(c.aussehen);
$("sel-info").innerHTML=`<h2>${sicher(c.vorname+' '+c.nachname)}</h2><div class="role">${sicher(c.job)} · ${sicher(c.herkunft)}</div>
<div class="sel-stats"><div class="st"><div class="l">Vermögen</div><div class="v">${euro(c.geld)}</div></div>
<div class="st"><div class="l">Spielzeit</div><div class="v">${c.stunden} Std</div></div>
<div class="st"><div class="l">Geboren</div><div class="v">${sicher(c.geburt)}</div></div>
<div class="st"><div class="l">Zuletzt</div><div class="v">${sicher(c.zuletzt)}</div></div></div>
<div class="sel-actions"><button class="btn primary" id="d-play">▶ Spielen</button><button class="btn red" id="d-del">Löschen</button></div>`;
$("d-play").addEventListener("click",()=>{
// Auswahl an den Server melden — dieser spawnt den Charakter
$("d-play").disabled=true;
toast("Lade "+c.vorname+" ...");
nui("spielen",{charakterId:c.id});
});
$("d-del").addEventListener("click",()=>loeschenFrage(auswahlIndex));
}
function loeschenFrage(i){const c=charaktere[i];
modal(`<div class="mic">⚠️</div><h3>${sicher(c.vorname+' '+c.nachname)} löschen?</h3><p>Dieser Charakter wird unwiderruflich entfernt.</p>
<div class="modal-actions"><button class="btn ghost" id="ld-cancel">Abbrechen</button><button class="btn red" id="ld-ok">Löschen</button></div>`);
$("ld-cancel").addEventListener("click",closeModal);
$("ld-ok").addEventListener("click",()=>{
// Löschen übernimmt der Server; die neue Liste kommt per Nachricht zurück
closeModal();
nui("loeschen",{charakterId:c.id});
});
}
/* =====================================================================
CREATOR
===================================================================== */
function starteCreator(index){
editIndex=index;
if(index===null){entwurf={vorname:"",nachname:"",geschlecht:"männlich",geburt:"",herkunft:herkuenfte[0],aussehen:standardAussehen("männlich")};}
else{const c=charaktere[index];entwurf={vorname:c.vorname,nachname:c.nachname,geschlecht:c.geschlecht,geburt:c.geburt,herkunft:c.herkunft,aussehen:{...c.aussehen}};}
crModus="gesicht";crTab="identitaet";
$("v-select").classList.remove("active");$("v-create").classList.add("active","fade");setTimeout(()=>$("v-create").classList.remove("fade"),350);
$("cr-title").textContent=index===null?"Charakter erstellen":"Charakter bearbeiten";
$("cr-save").textContent=index===null?"Charakter erstellen":"Änderungen speichern";
document.querySelectorAll("#cr-viewtoggle button").forEach(b=>b.classList.toggle("active",b.dataset.m===crModus));
renderTabs();renderControls();
// Vorschau auf den Entwurf umstellen und ins Gesicht zoomen
if(IST_FIVEM) nui("vorschau",{aussehen:entwurf.aussehen,modus:"gesicht"});
else updatePreview();
}
/* Vorschau aktualisieren.
In FiveM: Aussehen an den Client schicken, der es live auf den 3D-Ped überträgt.
Im Browser: die stilisierte SVG-Vorschau als Fallback zeichnen. */
let vorschauTimer=null;
function updatePreview(){
if(!IST_FIVEM){ $("cr-avatar").innerHTML=avatarSvg(entwurf.aussehen,crModus); return; }
// Slider feuern sehr oft — Aufrufe kurz bündeln
clearTimeout(vorschauTimer);
vorschauTimer=setTimeout(()=>nui("vorschau",{aussehen:entwurf.aussehen}),60);
}
const crTabsDef=[["identitaet","Identität"],["gesicht","Gesicht"],["merkmale","Merkmale"],["haare","Haare & Bart"],["kleidung","Kleidung"]];
function renderTabs(){
$("cr-tabs").innerHTML=crTabsDef.map(t=>`<button data-t="${t[0]}" class="${t[0]===crTab?'active':''}">${t[1]}</button>`).join("");
$("cr-tabs").querySelectorAll("[data-t]").forEach(b=>b.addEventListener("click",()=>{crTab=b.dataset.t;if(crTab==="kleidung")setModus("koerper");else if(crModus==="koerper"&&crTab!=="merkmale")setModus("gesicht");renderTabs();renderControls();}));
}
function setModus(m){
crModus=m;
document.querySelectorAll("#cr-viewtoggle button").forEach(b=>b.classList.toggle("active",b.dataset.m===m));
if(IST_FIVEM) nui("vorschauModus",{modus:m==="koerper"?"koerper":"gesicht"});
else updatePreview();
}
/* Bausteine für Controls */
function swGroup(label,pal,curr,cb){const id="g"+Math.random().toString(36).slice(2,7);
setTimeout(()=>{const el=$(id);if(el)el.querySelectorAll("[data-i]").forEach(s=>s.addEventListener("click",()=>{el.querySelectorAll("[data-i]").forEach(x=>x.classList.remove("sel"));s.classList.add("sel");cb(+s.dataset.i);}));},0);
return `<label class="flabel">${label}</label><div class="swatches" id="${id}">${pal.map((p,i)=>`<div class="sw ${i===curr?'sel':''}" data-i="${i}" style="background:${p.c}" title="${sicher(p.n)}"></div>`).join("")}</div>`;
}
function optGroup(label,opts,curr,cb){const id="o"+Math.random().toString(36).slice(2,7);
setTimeout(()=>{const el=$(id);if(el)el.querySelectorAll("[data-i]").forEach(s=>s.addEventListener("click",()=>{el.querySelectorAll("[data-i]").forEach(x=>x.classList.remove("sel"));s.classList.add("sel");cb(+s.dataset.i);}));},0);
return `<label class="flabel">${label}</label><div class="opts" id="${id}">${opts.map((o,i)=>`<button class="opt ${i===curr?'sel':''}" data-i="${i}">${sicher(o)}</button>`).join("")}</div>`;
}
function sliderRow(label,val,cb){const id="s"+Math.random().toString(36).slice(2,7);
setTimeout(()=>{const el=$(id);if(el)el.addEventListener("input",()=>{$(id+"v").textContent=el.value;cb(+el.value);});},0);
return `<div class="slider"><span class="sl-l">${label}</span><input type="range" id="${id}" min="0" max="100" value="${val}"><span class="sl-v" id="${id}v">${val}</span></div>`;
}
function renderControls(){
const a=entwurf.aussehen, body=$("cr-body");
if(crTab==="identitaet"){
body.innerHTML=`<label class="flabel">Vorname</label><input class="input" id="i-vor" value="${sicher(entwurf.vorname)}" placeholder="Vorname">
<label class="flabel">Nachname</label><input class="input" id="i-nach" value="${sicher(entwurf.nachname)}" placeholder="Nachname">
<label class="flabel">Geschlecht</label><div class="opts" id="i-geschlecht">${["männlich","weiblich","divers"].map(g=>`<button class="opt ${entwurf.geschlecht===g?'sel':''}" data-g="${g}">${g}</button>`).join("")}</div>
<label class="flabel">Geburtsdatum</label><input class="input" id="i-geb" value="${sicher(entwurf.geburt)}" placeholder="TT.MM.JJJJ">
<label class="flabel">Herkunft</label><select class="input" id="i-herk">${herkuenfte.map(h=>`<option ${entwurf.herkunft===h?'selected':''}>${sicher(h)}</option>`).join("")}</select>`;
$("i-vor").addEventListener("input",e=>entwurf.vorname=e.target.value);
$("i-nach").addEventListener("input",e=>entwurf.nachname=e.target.value);
$("i-geb").addEventListener("input",e=>entwurf.geburt=e.target.value);
$("i-herk").addEventListener("change",e=>entwurf.herkunft=e.target.value);
$("i-geschlecht").querySelectorAll("[data-g]").forEach(b=>b.addEventListener("click",()=>{entwurf.geschlecht=b.dataset.g;a.geschlecht=b.dataset.g;$("i-geschlecht").querySelectorAll("[data-g]").forEach(x=>x.classList.remove("sel"));b.classList.add("sel");}));
}
else if(crTab==="gesicht"){
body.innerHTML=swGroup("Hautton",hautToene,a.skin,i=>{a.skin=i;updatePreview();})+swGroup("Augenfarbe",augenFarben,a.augen,i=>{a.augen=i;updatePreview();});
}
else if(crTab==="merkmale"){
body.innerHTML=`<div style="font-size:12px;color:var(--muted);margin-bottom:10px">Feinjustierung der Gesichtszüge &amp; Statur.</div>`
+sliderRow("Gesichtsbreite",a.gesichtBreite,v=>{a.gesichtBreite=v;updatePreview();})
+sliderRow("Kiefer",a.kiefer,v=>{a.kiefer=v;updatePreview();})
+sliderRow("Nase",a.nase,v=>{a.nase=v;updatePreview();})
+sliderRow("Lippen",a.lippen,v=>{a.lippen=v;updatePreview();})
+sliderRow("Körperbau",a.koerper,v=>{a.koerper=v;if(crModus!=='koerper')setModus('koerper');else updatePreview();});
}
else if(crTab==="haare"){
body.innerHTML=optGroup("Frisur",haarStile,a.hairStyle,i=>{a.hairStyle=i;updatePreview();})
+swGroup("Haarfarbe",haarFarben,a.hairColor,i=>{a.hairColor=i;updatePreview();})
+optGroup("Augenbrauen",augenbrauen,Math.round(a.augenbraue/50),i=>{a.augenbraue=i*50;updatePreview();})
+optGroup("Bart",bartStile,a.bart,i=>{a.bart=i;updatePreview();});
}
else if(crTab==="kleidung"){
body.innerHTML=swGroup("Oberteil",kleidFarben,a.top,i=>{a.top=i;updatePreview();})
+swGroup("Hose",kleidFarben,a.hose,i=>{a.hose=i;updatePreview();})
+swGroup("Schuhe",kleidFarben,a.schuhe,i=>{a.schuhe=i;updatePreview();});
}
}
/* Speichern */
function speichereCharakter(){
if(!entwurf.vorname.trim()||!entwurf.nachname.trim()){crTab="identitaet";renderTabs();renderControls();toast("Bitte Vor- und Nachnamen angeben");return;}
if(!entwurf.geburt.trim()){crTab="identitaet";renderTabs();renderControls();toast("Bitte Geburtsdatum angeben (TT.MM.JJJJ)");return;}
entwurf.aussehen.geschlecht=entwurf.geschlecht;
// Anlegen übernimmt der Server; die neue Liste kommt per Nachricht zurück
$("cr-save").disabled=true;
nui("erstellen",{
vorname:entwurf.vorname.trim(),
nachname:entwurf.nachname.trim(),
geschlecht:entwurf.geschlecht,
geburt:entwurf.geburt.trim(),
herkunft:entwurf.herkunft,
aussehen:entwurf.aussehen,
});
}
/* Events */
document.querySelectorAll("#cr-viewtoggle button").forEach(b=>b.addEventListener("click",()=>setModus(b.dataset.m)));
/* Drehknöpfe unter dem Ped (beide Ansichten) */
document.querySelectorAll("[data-drehen]").forEach(b=>
b.addEventListener("click",()=>nui("vorschauDrehen",{grad:+b.dataset.drehen})));
$("cr-cancel").addEventListener("click",renderAuswahl);
$("cr-save").addEventListener("click",speichereCharakter);
$("overlay").addEventListener("click",e=>{if(e.target===$("overlay"))closeModal();});
/* =====================================================================
NUI-BRÜCKE — Verbindung zum FiveM-Client
===================================================================== */
/* Läuft die Seite in FiveM (3D-Ped) oder eigenständig im Browser (SVG-Fallback)? */
const IST_FIVEM = (typeof GetParentResourceName === "function");
/* Name dieser Ressource (für die Callback-Adresse) */
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-character";
/* Schickt eine Aktion an den Client (client/main.lua) */
function nui(aktion, daten){
return fetch(`https://${RESSOURCE}/${aktion}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(daten || {}),
}).catch(() => {}); // Im Browser (ohne FiveM) still fehlschlagen lassen
}
/* Übernimmt eine Charakterliste vom Server und zeigt die Auswahl */
function setzeCharaktere(liste, maxSlots){
charaktere = Array.isArray(liste) ? liste : [];
if (maxSlots) MAX_SLOTS = maxSlots;
auswahlIndex = Math.max(0, Math.min(auswahlIndex, charaktere.length - 1));
// Noch kein Charakter vorhanden? Dann direkt in den Ersteller springen
if (charaktere.length === 0) starteCreator(null);
else renderAuswahl();
}
/* Nachrichten vom Client entgegennehmen */
window.addEventListener("message", (ereignis) => {
const nachricht = ereignis.data || {};
if (nachricht.aktion === "oeffnen") {
document.body.style.display = "";
setzeCharaktere(nachricht.charaktere, nachricht.maxSlots);
}
else if (nachricht.aktion === "liste") {
// Aktualisierte Liste nach Erstellen/Löschen
const saveBtn = $("cr-save"); if (saveBtn) saveBtn.disabled = false;
setzeCharaktere(nachricht.charaktere, nachricht.maxSlots);
if (nachricht.hinweis) toast(nachricht.hinweis);
}
else if (nachricht.aktion === "schliessen") {
document.body.style.display = "none";
}
else if (nachricht.aktion === "fehler") {
// Serverseitige Ablehnung: Bedienelemente wieder freigeben
const saveBtn = $("cr-save"); if (saveBtn) saveBtn.disabled = false;
const playBtn = $("d-play"); if (playBtn) playBtn.disabled = false;
toast(nachricht.text || "Aktion fehlgeschlagen");
}
});
/* START — im Browser direkt anzeigen, in FiveM wartet die Seite auf "oeffnen" */
if (IST_FIVEM) {
document.body.style.display = "none"; // In FiveM zunächst verstecken
} else {
// Eigenständige Ansicht: Kasten-Layout und SVG-Vorschau aktivieren
document.body.classList.add("browser");
// Eigenständige Vorschau im Browser: Beispieldaten zum Ansehen des Layouts
charaktere = [
{id:1,vorname:"Max",nachname:"Weber",geschlecht:"männlich",geburt:"14.03.1996",herkunft:"Los Santos",
job:"Spediteur",geld:48320,stunden:142,zuletzt:"vor 2 Std",
aussehen:{geschlecht:"männlich",skin:1,hairColor:2,hairStyle:0,augen:2,bart:1,augenbraue:1,
gesichtBreite:55,kiefer:60,nase:52,lippen:45,koerper:58,top:3,hose:0,schuhe:0}},
];
renderAuswahl();
}
</script>
</body>
</html>

View File

@ -0,0 +1,221 @@
-- =====================================================================
-- Aether Character — Serverlogik
-- Liefert die Charakterliste, legt Charaktere an, loescht sie und
-- uebergibt die Auswahl an den Core.
--
-- Grundsatz: Alles, was vom Client kommt, wird hier geprueft.
-- =====================================================================
local Core = exports['aether-core']
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Liest die Rockstar-Lizenz eines Spielers aus
--- @param quelle number Server-ID
--- @return string|nil
local function HoleLizenz(quelle)
local lizenz = GetPlayerIdentifierByType(quelle, 'license')
if not lizenz then return nil end
return lizenz:gsub('license:', '')
end
--- Wandelt ein Datum von TT.MM.JJJJ nach JJJJ-MM-TT (Datenbankformat)
--- @param text string Eingabe des Spielers
--- @return string|nil Datum im DB-Format oder nil bei ungueltiger Eingabe
local function DatumFuerDatenbank(text)
if type(text) ~= 'string' then return nil end
local tag, monat, jahr = text:match('^(%d%d)%.(%d%d)%.(%d%d%d%d)$')
if not tag then return nil end
tag, monat, jahr = tonumber(tag), tonumber(monat), tonumber(jahr)
-- Grobe Plausibilitaetspruefung
if monat < 1 or monat > 12 or tag < 1 or tag > 31 then return nil end
if jahr < 1900 or jahr > 2010 then return nil end
return ('%04d-%02d-%02d'):format(jahr, monat, tag)
end
--- Wandelt ein Datum von JJJJ-MM-TT nach TT.MM.JJJJ (Anzeige)
--- @param wert any Wert aus der Datenbank
--- @return string
local function DatumFuerAnzeige(wert)
local text = tostring(wert or '')
local jahr, monat, tag = text:match('^(%d%d%d%d)-(%d%d)-(%d%d)')
if not jahr then return '—' end
return ('%s.%s.%s'):format(tag, monat, jahr)
end
--- Kuerzt und saeubert einen Namen (nur Buchstaben, Leerzeichen, Bindestrich)
--- @param text any Eingabe
--- @return string|nil Bereinigter Name oder nil, wenn ungueltig
local function SaeubereName(text)
if type(text) ~= 'string' then return nil end
local name = text:gsub('^%s+', ''):gsub('%s+$', '')
if #name < 2 or #name > 32 then return nil end
-- Nur Buchstaben (inkl. Umlaute), Leerzeichen und Bindestriche zulassen
if name:find('[^%a%sÄÖÜäöüß%-]') then return nil end
return name
end
--- Baut die Charakterliste in dem Format, das die Oberflaeche erwartet
--- @param lizenz string Rockstar-Lizenz
--- @return table Liste fuer die NUI
local function BaueListe(lizenz)
local datensaetze = MySQL.query.await([[
SELECT id, firstname, lastname, gender, dob, nationality,
job, cash, bank, playtime, appearance, last_played
FROM aether_characters
WHERE license = ? AND deleted = 0
ORDER BY last_played DESC
]], { lizenz }) or {}
local liste = {}
for _, datensatz in ipairs(datensaetze) do
-- Aussehen aus JSON zurueckwandeln
local aussehen = nil
if datensatz.appearance then
local erfolg, entschluesselt = pcall(json.decode, datensatz.appearance)
if erfolg then aussehen = entschluesselt end
end
liste[#liste + 1] = {
id = datensatz.id,
vorname = datensatz.firstname,
nachname = datensatz.lastname,
geschlecht = datensatz.gender,
geburt = DatumFuerAnzeige(datensatz.dob),
herkunft = datensatz.nationality,
job = datensatz.job,
geld = (datensatz.cash or 0) + (datensatz.bank or 0),
stunden = math.floor((datensatz.playtime or 0) / 60),
zuletzt = tostring(datensatz.last_played or ''):sub(1, 16),
aussehen = aussehen,
}
end
return liste
end
--- Schickt die aktuelle Liste an einen Spieler
--- @param quelle number Server-ID
--- @param hinweis string|nil Kurze Rueckmeldung fuer die Oberflaeche
local function SendeListe(quelle, hinweis)
local lizenz = HoleLizenz(quelle)
if not lizenz then return end
TriggerClientEvent('aether:character:liste', quelle,
BaueListe(lizenz), Config.MaxCharaktere, hinweis)
end
-- ---------------------------------------------------------------------
-- Auswahl anzeigen (wird vom Core ausgeloest)
-- ---------------------------------------------------------------------
AddEventHandler('aether:server:charakterAuswahlNoetig', function(quelle)
local lizenz = HoleLizenz(quelle)
if not lizenz then return end
TriggerClientEvent('aether:character:zeigeAuswahl', quelle,
BaueListe(lizenz), Config.MaxCharaktere)
end)
-- ---------------------------------------------------------------------
-- Charakter waehlen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:character:spielen', function(charakterId)
local quelle = source
local lizenz = HoleLizenz(quelle)
if not lizenz or type(charakterId) ~= 'number' then return end
-- Gehoert der Charakter wirklich diesem Spieler?
local treffer = MySQL.query.await(
'SELECT id FROM aether_characters WHERE id = ? AND license = ? AND deleted = 0',
{ charakterId, lizenz })
if not treffer or not treffer[1] then
TriggerClientEvent('aether:character:fehler', quelle, 'Dieser Charakter gehört dir nicht.')
return
end
-- Core laedt den Charakter und spawnt den Spieler
local erfolg = Core:LadeCharakter(quelle, charakterId)
if not erfolg then
TriggerClientEvent('aether:character:fehler', quelle, 'Charakter konnte nicht geladen werden.')
end
end)
-- ---------------------------------------------------------------------
-- Charakter erstellen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:character:erstellen', function(daten)
local quelle = source
local lizenz = HoleLizenz(quelle)
if not lizenz or type(daten) ~= 'table' then return end
-- Slotgrenze pruefen
local vorhandene = MySQL.scalar.await(
'SELECT COUNT(*) FROM aether_characters WHERE license = ? AND deleted = 0', { lizenz })
if (vorhandene or 0) >= Config.MaxCharaktere then
TriggerClientEvent('aether:character:fehler', quelle,
('Du hast bereits %d Charaktere.'):format(Config.MaxCharaktere))
return
end
-- Namen pruefen
local vorname = SaeubereName(daten.vorname)
local nachname = SaeubereName(daten.nachname)
if not vorname or not nachname then
TriggerClientEvent('aether:character:fehler', quelle,
'Bitte gültige Vor- und Nachnamen angeben (nur Buchstaben, 2–32 Zeichen).')
return
end
-- Geburtsdatum pruefen
local geburt = DatumFuerDatenbank(daten.geburt)
if not geburt then
TriggerClientEvent('aether:character:fehler', quelle,
'Ungültiges Geburtsdatum. Format: TT.MM.JJJJ (Jahr 1900–2010).')
return
end
-- Geschlecht auf erlaubte Werte begrenzen
local geschlecht = daten.geschlecht
if geschlecht ~= 'männlich' and geschlecht ~= 'weiblich' and geschlecht ~= 'divers' then
geschlecht = 'männlich'
end
-- Charakter anlegen (eigene Abfrage, da `Aether.DB` im Core-State liegt)
local neueId = MySQL.insert.await([[
INSERT INTO aether_characters
(license, firstname, lastname, dob, gender, nationality, appearance,
cash, bank, iban, job, job_grade)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
]], {
lizenz, vorname, nachname, geburt, geschlecht,
tostring(daten.herkunft or 'Los Santos'):sub(1, 48),
type(daten.aussehen) == 'table' and json.encode(daten.aussehen) or nil,
Config.StartWerte.bargeld,
Config.StartWerte.konto,
Aether.ErzeugeIban(),
Config.StartWerte.job,
Config.StartWerte.jobRang,
})
Aether.Info(('Charakter #%d erstellt: %s %s'):format(neueId, vorname, nachname))
SendeListe(quelle, ('%s %s wurde erstellt.'):format(vorname, nachname))
end)
-- ---------------------------------------------------------------------
-- Charakter loeschen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:character:loeschen', function(charakterId)
local quelle = source
local lizenz = HoleLizenz(quelle)
if not lizenz or type(charakterId) ~= 'number' then return end
-- Weich loeschen; die Lizenzpruefung in der Abfrage sichert das Eigentum ab
MySQL.update.await(
'UPDATE aether_characters SET deleted = 1 WHERE id = ? AND license = ?',
{ charakterId, lizenz })
Aether.Info(('Charakter #%d geloescht (Lizenz %s).'):format(charakterId, lizenz))
SendeListe(quelle, 'Charakter gelöscht.')
end)

View File

@ -0,0 +1,165 @@
-- =====================================================================
-- Aether Core — Clientlogik
-- Meldet sich beim Server an, spawnt den Charakter und haelt die Daten aktuell.
-- =====================================================================
Aether = Aether or {}
-- Daten des eigenen Charakters (vom Server befuellt)
Aether.SpielerDaten = nil
-- Ist der Spieler fertig geladen und im Spiel?
Aether.IstGeladen = false
-- ---------------------------------------------------------------------
-- Startvorgang
-- ---------------------------------------------------------------------
CreateThread(function()
-- Warten, bis die Spielwelt bereit ist
while not NetworkIsSessionStarted() do
Wait(100)
end
-- Standard-Spawnverhalten von FiveM unterbinden.
-- Abgesichert, falls spawnmanager nicht laeuft (dann uebernimmt der Core allein).
local erfolg = pcall(function()
exports.spawnmanager:setAutoSpawn(false)
end)
if not erfolg then
Aether.Debug('spawnmanager nicht verfuegbar — Core spawnt eigenstaendig.')
end
-- Bildschirm schwarz halten, bis der Charakter geladen ist
DoScreenFadeOut(0)
SetEntityVisible(PlayerPedId(), false, false)
Aether.Debug('Client bereit — fordere Spielerdaten an.')
TriggerServerEvent('aether:server:spielerBereit')
end)
-- ---------------------------------------------------------------------
-- Spieler wurde geladen: Ped setzen und spawnen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:client:spielerGeladen', function(daten)
Aether.SpielerDaten = daten
-- Passendes Standardmodell laden (bis der Charakter-Ersteller migriert ist)
local modellName = Config.StandardPed[daten.geschlecht] or Config.StandardPed.m
local modell = GetHashKey(modellName)
RequestModel(modell)
local versuche = 0
while not HasModelLoaded(modell) and versuche < 100 do
Wait(50)
versuche = versuche + 1
end
if HasModelLoaded(modell) then
SetPlayerModel(PlayerId(), modell)
SetModelAsNoLongerNeeded(modell)
else
Aether.Fehler('Ped-Modell konnte nicht geladen werden: ' .. modellName)
end
-- Spieler an die gespeicherte Position setzen
local ped = PlayerPedId()
local pos = daten.position or Config.SpawnPunkt
SetEntityCoordsNoOffset(ped, pos.x + 0.0, pos.y + 0.0, pos.z + 0.0, false, false, false)
SetEntityHeading(ped, pos.heading or 0.0)
-- Zustand wiederherstellen
SetEntityHealth(ped, daten.leben or 200)
FreezeEntityPosition(ped, false)
SetEntityVisible(ped, true, false)
-- Warten, bis die Umgebung geladen ist, dann Bild freigeben
local wartezeit = 0
while not HasCollisionLoadedAroundEntity(ped) and wartezeit < 100 do
Wait(50)
wartezeit = wartezeit + 1
end
ShutdownLoadingScreen()
ShutdownLoadingScreenNui()
DoScreenFadeIn(1000)
Aether.IstGeladen = true
Aether.Info(('Willkommen, %s %s!'):format(daten.vorname, daten.nachname))
-- Andere Ressourcen (HUD, Handy, Inventar) informieren.
-- WICHTIG: anderer Name als das Netz-Event, sonst ruft sich dieser
-- Handler endlos selbst auf.
TriggerEvent('aether:client:bereit', daten)
end)
-- ---------------------------------------------------------------------
-- Aktualisierungen vom Server
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:client:geldAktualisiert', function(geld)
if not Aether.SpielerDaten then return end
Aether.SpielerDaten.bargeld = geld.bargeld
Aether.SpielerDaten.konto = geld.konto
Aether.SpielerDaten.schwarzgeld = geld.schwarzgeld
-- Weiterreichen ans HUD
TriggerEvent('aether:client:geldGeaendert', geld)
end)
RegisterNetEvent('aether:client:zustandAktualisiert', function(zustand)
if not Aether.SpielerDaten then return end
Aether.SpielerDaten.hunger = zustand.hunger
Aether.SpielerDaten.durst = zustand.durst
Aether.SpielerDaten.ausdauer = zustand.ausdauer
-- Weiterreichen ans HUD
TriggerEvent('aether:client:zustandGeaendert', zustand)
end)
--- Schaden durch Hunger/Durst zufuegen
RegisterNetEvent('aether:client:erleideSchaden', function(menge)
local ped = PlayerPedId()
local leben = GetEntityHealth(ped)
SetEntityHealth(ped, math.max(0, leben - (menge or 5)))
end)
-- ---------------------------------------------------------------------
-- Position regelmaessig an den Server melden (fuer das Speichern)
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
Wait(10000) -- alle 10 Sekunden
if Aether.IstGeladen then
local ped = PlayerPedId()
local koordinaten = GetEntityCoords(ped)
TriggerServerEvent('aether:server:aktualisierePosition', {
x = Aether.Runde(koordinaten.x, 2),
y = Aether.Runde(koordinaten.y, 2),
z = Aether.Runde(koordinaten.z, 2),
heading = Aether.Runde(GetEntityHeading(ped), 2),
})
-- Lebenswert mitschicken, damit er beim Speichern stimmt
TriggerServerEvent('aether:server:aktualisiereZustand', {
leben = GetEntityHealth(ped),
})
end
end
end)
-- ---------------------------------------------------------------------
-- Kein Charakter vorhanden (spaeter uebernimmt das aether-character)
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:client:keinCharakter', function()
Aether.Fehler('Kein Charakter vorhanden — Charaktererstellung ist noch nicht migriert.')
end)
-- =====================================================================
-- EXPORTS fuer andere Module
-- Beispiel: local daten = exports['aether-core']:HoleSpielerDaten()
-- =====================================================================
--- Liefert die Daten des eigenen Charakters
exports('HoleSpielerDaten', function()
return Aether.SpielerDaten
end)
--- Ist der Spieler fertig geladen?
exports('IstGeladen', function()
return Aether.IstGeladen
end)

View File

@ -0,0 +1,76 @@
-- =====================================================================
-- Aether Core — NUI-Helfer
-- Einheitliches Oeffnen/Schliessen von Benutzeroberflaechen.
-- Damit muss nicht jedes Modul Fokus- und Cursor-Logik neu bauen.
-- =====================================================================
Aether = Aether or {}
-- Name der Ressource, deren Oberflaeche gerade offen ist (nil = keine)
Aether.OffeneNui = nil
--- Oeffnet eine NUI: setzt Fokus und Mauszeiger
--- @param ressource string Name der Ressource, die die Oberflaeche besitzt
--- @param nachricht table|nil Daten, die an die Oberflaeche geschickt werden
function Aether.OeffneNui(ressource, nachricht)
-- Es darf immer nur eine Oberflaeche gleichzeitig offen sein
if Aether.OffeneNui and Aether.OffeneNui ~= ressource then
Aether.SchliesseNui()
end
Aether.OffeneNui = ressource
SetNuiFocus(true, true)
if nachricht then
SendNuiMessage(json.encode(nachricht))
end
end
--- Schliesst die aktuell offene NUI und gibt die Steuerung zurueck
function Aether.SchliesseNui()
if not Aether.OffeneNui then return end
SetNuiFocus(false, false)
Aether.OffeneNui = nil
end
--- Schickt Daten an die offene Oberflaeche, ohne den Fokus zu aendern
--- @param nachricht table Zu sendende Daten
function Aether.SendeAnNui(nachricht)
SendNuiMessage(json.encode(nachricht))
end
-- ---------------------------------------------------------------------
-- Sicherheitsnetz: bei ESC immer den Fokus freigeben
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
if Aether.OffeneNui then
Wait(0)
-- Taste 322 = ESC
if IsControlJustReleased(0, 322) then
Aether.SchliesseNui()
-- Der offenen Ressource Bescheid geben, damit sie aufraeumen kann
TriggerEvent('aether:client:nuiGeschlossen', Aether.OffeneNui)
end
else
Wait(250)
end
end
end)
-- =====================================================================
-- EXPORTS fuer andere Module
-- Beispiel: exports['aether-core']:OeffneNui('aether-phone', { aktion = 'oeffnen' })
-- =====================================================================
exports('OeffneNui', function(ressource, nachricht)
Aether.OeffneNui(ressource, nachricht)
end)
exports('SchliesseNui', function()
Aether.SchliesseNui()
end)
exports('SendeAnNui', function(nachricht)
Aether.SendeAnNui(nachricht)
end)

View File

@ -0,0 +1,32 @@
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
name 'aether-core'
author 'Jerrit Fritzsche'
description 'Fundament des Aether-Frameworks: Spieler, Charaktere, Geld, Zustand'
version '0.1.0'
-- Geteilter Code (laeuft auf Client und Server)
shared_scripts {
'shared/config.lua',
'shared/utils.lua',
}
-- Clientseitiger Code
client_scripts {
'client/main.lua',
'client/nui.lua',
}
-- Serverseitiger Code (MySQL.lua muss vor eigenem Code geladen werden)
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/database.lua',
'server/player.lua',
'server/main.lua',
}
dependencies {
'oxmysql',
}

View File

@ -0,0 +1,131 @@
-- =====================================================================
-- Aether Core — Datenbankzugriff (oxmysql)
-- Kapselt alle SQL-Abfragen, damit der Rest des Codes kein SQL enthaelt.
-- =====================================================================
Aether = Aether or {}
Aether.DB = {}
-- ---------------------------------------------------------------------
-- Benutzer
-- ---------------------------------------------------------------------
--- Legt einen Benutzer an, falls er noch nicht existiert, und gibt ihn zurueck
--- @param lizenz string Rockstar-Lizenz des Spielers
--- @param name string Aktueller Spielername
--- @return table Benutzerdatensatz
function Aether.DB.HoleOderErstelleBenutzer(lizenz, name)
local vorhandene = MySQL.query.await(
'SELECT * FROM aether_users WHERE license = ?', { lizenz })
if vorhandene and vorhandene[1] then
-- Namen und Zeitstempel aktualisieren
MySQL.update.await(
'UPDATE aether_users SET name = ?, last_seen = NOW() WHERE license = ?',
{ name, lizenz })
return vorhandene[1]
end
-- Neuen Benutzer anlegen
MySQL.insert.await(
'INSERT INTO aether_users (license, name) VALUES (?, ?)', { lizenz, name })
Aether.Info(('Neuer Benutzer angelegt: %s (%s)'):format(name, lizenz))
local neue = MySQL.query.await(
'SELECT * FROM aether_users WHERE license = ?', { lizenz })
return neue[1]
end
-- ---------------------------------------------------------------------
-- Charaktere
-- ---------------------------------------------------------------------
--- Laedt alle nicht geloeschten Charaktere eines Benutzers
--- @param lizenz string Rockstar-Lizenz
--- @return table Liste der Charaktere
function Aether.DB.HoleCharaktere(lizenz)
return MySQL.query.await(
'SELECT * FROM aether_characters WHERE license = ? AND deleted = 0 ORDER BY last_played DESC',
{ lizenz }) or {}
end
--- Laedt einen einzelnen Charakter anhand seiner ID
--- @param charakterId number Charakter-ID
--- @return table|nil
function Aether.DB.HoleCharakter(charakterId)
local ergebnis = MySQL.query.await(
'SELECT * FROM aether_characters WHERE id = ? AND deleted = 0', { charakterId })
return ergebnis and ergebnis[1] or nil
end
--- Legt einen neuen Charakter an und gibt dessen ID zurueck
--- @param lizenz string Rockstar-Lizenz
--- @param daten table Felder: firstname, lastname, dob, gender, nationality, appearance
--- @return number Neue Charakter-ID
function Aether.DB.ErstelleCharakter(lizenz, daten)
return MySQL.insert.await([[
INSERT INTO aether_characters
(license, firstname, lastname, dob, gender, nationality, appearance,
cash, bank, iban, job, job_grade)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
]], {
lizenz,
daten.firstname or 'Max',
daten.lastname or 'Mustermann',
daten.dob or '1995-01-01',
daten.gender or 'm',
daten.nationality or 'Los Santos',
daten.appearance and json.encode(daten.appearance) or nil,
Config.StartWerte.bargeld,
Config.StartWerte.konto,
Aether.ErzeugeIban(),
Config.StartWerte.job,
Config.StartWerte.jobRang,
})
end
--- Speichert den aktuellen Stand eines Charakters zurueck in die Datenbank
--- @param charakterId number Charakter-ID
--- @param daten table Zu speichernde Werte
function Aether.DB.SpeichereCharakter(charakterId, daten)
MySQL.update([[
UPDATE aether_characters SET
cash = ?, bank = ?, dirty_money = ?,
job = ?, job_grade = ?, faction = ?, faction_rank = ?,
health = ?, hunger = ?, thirst = ?, stamina = ?,
position = ?, is_dead = ?, playtime = ?, last_played = NOW()
WHERE id = ?
]], {
daten.cash, daten.bank, daten.dirty_money,
daten.job, daten.job_grade, daten.faction, daten.faction_rank,
daten.health, daten.hunger, daten.thirst, daten.stamina,
json.encode(daten.position or Config.SpawnPunkt),
daten.is_dead and 1 or 0,
daten.playtime or 0,
charakterId,
})
end
--- Markiert einen Charakter als geloescht (Slot wird wieder frei)
--- @param charakterId number Charakter-ID
--- @param lizenz string Lizenz zur Sicherheit (nur eigene Charaktere loeschbar)
function Aether.DB.LoescheCharakter(charakterId, lizenz)
MySQL.update.await(
'UPDATE aether_characters SET deleted = 1 WHERE id = ? AND license = ?',
{ charakterId, lizenz })
end
-- ---------------------------------------------------------------------
-- Transaktionen (Bank-Modul)
-- ---------------------------------------------------------------------
--- Schreibt eine Banktransaktion mit
--- @param charakterId number Charakter-ID
--- @param art string deposit/withdraw/transfer/salary/fine
--- @param betrag number Betrag (positiv = Eingang)
--- @param kontostand number Kontostand nach der Buchung
--- @param bezeichnung string Verwendungszweck
function Aether.DB.SchreibeTransaktion(charakterId, art, betrag, kontostand, bezeichnung)
MySQL.insert('INSERT INTO aether_transactions (`character`, type, amount, balance, label) VALUES (?, ?, ?, ?, ?)',
{ charakterId, art, betrag, kontostand, bezeichnung or '' })
end

View File

@ -0,0 +1,305 @@
-- =====================================================================
-- Aether Core — Serverlogik
-- Verbindungsaufbau, Laden/Speichern der Spieler und Exports fuer Module.
-- =====================================================================
Aether = Aether or {}
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Liest die Rockstar-Lizenz eines verbundenen Spielers aus
--- @param quelle number Server-ID
--- @return string|nil Lizenz (ohne Praefix) oder nil
local function HoleLizenz(quelle)
local lizenz = GetPlayerIdentifierByType(quelle, 'license')
if not lizenz then return nil end
return lizenz:gsub('license:', '')
end
-- ---------------------------------------------------------------------
-- Verbindungsaufbau (Warteschlange / Bannpruefung)
-- ---------------------------------------------------------------------
AddEventHandler('playerConnecting', function(name, setKickReason, deferrals)
local quelle = source
deferrals.defer()
Wait(0)
deferrals.update('Aether — Verbindung wird geprueft ...')
local lizenz = HoleLizenz(quelle)
if not lizenz then
deferrals.done('Keine gueltige Rockstar-Lizenz gefunden. Bitte starte FiveM neu.')
return
end
-- Benutzer anlegen oder laden
local benutzer = Aether.DB.HoleOderErstelleBenutzer(lizenz, name)
if not benutzer then
deferrals.done('Datenbankfehler beim Laden deines Profils. Bitte melde dich beim Team.')
return
end
-- Bannpruefung
if benutzer.is_banned == 1 then
local text = benutzer.ban_reason or 'Kein Grund angegeben'
if benutzer.ban_until then
deferrals.done(('Du bist bis %s gesperrt.\nGrund: %s'):format(benutzer.ban_until, text))
else
deferrals.done(('Du bist dauerhaft gesperrt.\nGrund: %s'):format(text))
end
return
end
deferrals.update('Aether — Willkommen zurueck!')
Wait(200)
deferrals.done()
end)
-- ---------------------------------------------------------------------
-- Charakter in das Spiel laden
-- ---------------------------------------------------------------------
--- Erzeugt das Spielerobjekt aus einem Charakter-Datensatz und spawnt den Spieler
--- @param quelle number Server-ID
--- @param lizenz string Rockstar-Lizenz
--- @param charakter table Datensatz aus aether_characters
--- @return table Spielerobjekt
local function LadeUndSpawne(quelle, lizenz, charakter)
local spieler = Aether.ErzeugeSpieler(quelle, lizenz, charakter)
Aether.Info(('Spieler geladen: %s %s (ID %d, Charakter #%d)')
:format(spieler.vorname, spieler.nachname, quelle, spieler.charakterId))
-- Daten an den Client schicken, damit er spawnen kann
TriggerClientEvent('aether:client:spielerGeladen', quelle, spieler:HoleDaten())
-- Andere Ressourcen informieren (z. B. HUD, Inventar)
TriggerEvent('aether:server:spielerGeladen', quelle, spieler)
return spieler
end
-- ---------------------------------------------------------------------
-- Spieler ist bereit (wird vom Client ausgeloest)
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:server:spielerBereit', function()
local quelle = source
local lizenz = HoleLizenz(quelle)
if not lizenz then return end
-- Bereits geladen? Dann nichts tun (schuetzt vor doppeltem Laden)
if Aether.Spieler[quelle] then return end
-- Standardweg: `aether-character` zeigt die Charakterauswahl
if not Config.AutoCharakter then
TriggerEvent('aether:server:charakterAuswahlNoetig', quelle)
return
end
-- Notbetrieb ohne aether-character: Startcharakter anlegen und direkt laden
local charaktere = Aether.DB.HoleCharaktere(lizenz)
if #charaktere == 0 then
local spielerName = GetPlayerName(quelle)
local neueId = Aether.DB.ErstelleCharakter(lizenz, {
firstname = spielerName:sub(1, 32),
lastname = 'Neuling',
})
Aether.Info(('Startcharakter #%d fuer %s angelegt.'):format(neueId, spielerName))
charaktere = Aether.DB.HoleCharaktere(lizenz)
end
LadeUndSpawne(quelle, lizenz, charaktere[1])
end)
-- ---------------------------------------------------------------------
-- Position und Zustand vom Client uebernehmen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:server:aktualisierePosition', function(position)
local spieler = Aether.Spieler[source]
if not spieler or type(position) ~= 'table' then return end
spieler.position = position
end)
RegisterNetEvent('aether:server:aktualisiereZustand', function(zustand)
local spieler = Aether.Spieler[source]
if not spieler or type(zustand) ~= 'table' then return end
-- Nur bekannte Felder uebernehmen (Client darf nichts Beliebiges setzen)
if zustand.leben then spieler.leben = zustand.leben end
if zustand.hunger then spieler.hunger = Aether.Begrenze(zustand.hunger, 0, 100) end
if zustand.durst then spieler.durst = Aether.Begrenze(zustand.durst, 0, 100) end
if zustand.ausdauer then spieler.ausdauer = Aether.Begrenze(zustand.ausdauer, 0, 100) end
end)
-- ---------------------------------------------------------------------
-- Verbindungsabbruch: speichern und aufraeumen
-- ---------------------------------------------------------------------
AddEventHandler('playerDropped', function(grund)
local quelle = source
local spieler = Aether.Spieler[quelle]
if not spieler then return end
spieler:Speichere()
Aether.Info(('Spieler gespeichert und entfernt: %s %s (Grund: %s)')
:format(spieler.vorname, spieler.nachname, grund))
TriggerEvent('aether:server:spielerEntfernt', quelle, spieler)
Aether.Spieler[quelle] = nil
end)
-- ---------------------------------------------------------------------
-- Automatisches Speichern aller Spieler
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
Wait(Config.SpeicherIntervall)
local anzahl = 0
for _, spieler in pairs(Aether.Spieler) do
spieler:Speichere()
anzahl = anzahl + 1
end
if anzahl > 0 then
Aether.Debug(('Automatisch gespeichert: %d Spieler.'):format(anzahl))
end
end
end)
-- ---------------------------------------------------------------------
-- Hunger und Durst langsam senken
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
Wait(Config.Zustand.tickIntervall)
for _, spieler in pairs(Aether.Spieler) do
spieler:SetzeZustand('hunger', spieler.hunger - Config.Zustand.hungerProTick)
spieler:SetzeZustand('durst', spieler.durst - Config.Zustand.durstProTick)
-- Bei leerem Magen oder Durst Schaden zufuegen
if spieler.hunger <= Config.Zustand.schadenAbWert
or spieler.durst <= Config.Zustand.schadenAbWert then
TriggerClientEvent('aether:client:erleideSchaden', spieler.quelle,
Config.Zustand.schadenProTick)
end
end
end
end)
-- ---------------------------------------------------------------------
-- Beim Stoppen der Ressource alles sichern (z. B. bei /restart)
-- ---------------------------------------------------------------------
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
for _, spieler in pairs(Aether.Spieler) do
spieler:Speichere()
end
Aether.Info('Alle Spieler vor dem Stoppen gespeichert.')
end)
-- =====================================================================
-- EXPORTS — Schnittstelle fuer alle anderen Aether-Module
-- Beispiel: local spieler = exports['aether-core']:HoleSpieler(source)
-- =====================================================================
--- Liefert das Spielerobjekt zu einer Server-ID
exports('HoleSpieler', function(quelle)
return Aether.Spieler[quelle]
end)
--- Liefert alle aktiven Spielerobjekte
exports('HoleAlleSpieler', function()
return Aether.Spieler
end)
--- Laedt einen gewaehlten Charakter und spawnt den Spieler.
--- Wird von `aether-character` aufgerufen, nachdem der Spieler gewaehlt hat.
--- @param quelle number Server-ID
--- @param charakterId number Charakter-ID
--- @return boolean Erfolg
exports('LadeCharakter', function(quelle, charakterId)
local lizenz = HoleLizenz(quelle)
if not lizenz then return false end
-- Doppeltes Laden verhindern
if Aether.Spieler[quelle] then return false end
local charakter = Aether.DB.HoleCharakter(charakterId)
if not charakter then
Aether.Fehler(('Charakter #%s nicht gefunden.'):format(tostring(charakterId)))
return false
end
-- Sicherheitspruefung: Der Charakter muss dem Spieler gehoeren
if charakter.license ~= lizenz then
Aether.Fehler(('Spieler %d wollte fremden Charakter #%d laden.'):format(quelle, charakterId))
return false
end
LadeUndSpawne(quelle, lizenz, charakter)
return true
end)
--- Liefert das Spielerobjekt zu einer Charakter-ID
exports('HoleSpielerNachCharakter', function(charakterId)
for _, spieler in pairs(Aether.Spieler) do
if spieler.charakterId == charakterId then return spieler end
end
return nil
end)
--- Liefert die Server-ID zu einer Charakter-ID, falls der Spieler online ist.
--- Bewusst schlank: Exports serialisieren, ganze Spielerobjekte waeren teuer.
--- @param charakterId number Charakter-ID
--- @return number|nil Server-ID oder nil
exports('HoleQuelleNachCharakter', function(charakterId)
for quelle, spieler in pairs(Aether.Spieler) do
if spieler.charakterId == charakterId then return quelle end
end
return nil
end)
--- Liefert die Charakter-ID eines Spielers (oder nil)
--- @param quelle number Server-ID
--- @return number|nil
exports('HoleCharakterId', function(quelle)
local spieler = Aether.Spieler[quelle]
return spieler and spieler.charakterId or nil
end)
--- Liefert die Daten eines Spielers als einfache Tabelle.
--- Wichtig: Exports serialisieren — Methoden ueberleben die Grenze nicht,
--- daher hier bewusst nur reine Werte.
--- @param quelle number Server-ID
--- @return table|nil
exports('HoleSpielerInfo', function(quelle)
local spieler = Aether.Spieler[quelle]
if not spieler then return nil end
return spieler:HoleDaten()
end)
--- Aendert einen Zustandswert um einen Betrag (positiv oder negativ)
--- @param quelle number Server-ID
--- @param art string 'hunger' | 'durst' | 'ausdauer'
--- @param aenderung number Betrag
--- @return boolean Erfolg
exports('AendereZustand', function(quelle, art, aenderung)
local spieler = Aether.Spieler[quelle]
if not spieler then return false end
if art ~= 'hunger' and art ~= 'durst' and art ~= 'ausdauer' then return false end
spieler:SetzeZustand(art, (spieler[art] or 0) + aenderung)
return true
end)
--- Fuegt einem Spieler Geld hinzu
exports('FuegeGeldHinzu', function(quelle, art, betrag, grund)
local spieler = Aether.Spieler[quelle]
if not spieler then return false end
return spieler:FuegeGeldHinzu(art, betrag, grund)
end)
--- Zieht einem Spieler Geld ab (mit Deckungspruefung)
exports('EntferneGeld', function(quelle, art, betrag, grund)
local spieler = Aether.Spieler[quelle]
if not spieler then return false end
return spieler:EntferneGeld(art, betrag, grund)
end)

View File

@ -0,0 +1,204 @@
-- =====================================================================
-- Aether Core — Spielerobjekt
-- Kapselt die Daten eines eingeloggten Charakters samt Methoden.
-- =====================================================================
Aether = Aether or {}
-- Alle aktiven Spieler, indiziert nach Server-ID
Aether.Spieler = {}
-- Methodentabelle (Vorlage fuer jedes Spielerobjekt)
local SpielerMethoden = {}
SpielerMethoden.__index = SpielerMethoden
-- ---------------------------------------------------------------------
-- Erzeugung
-- ---------------------------------------------------------------------
--- Erzeugt ein Spielerobjekt aus einem Charakter-Datensatz
--- @param quelle number Server-ID des Spielers
--- @param lizenz string Rockstar-Lizenz
--- @param charakter table Datensatz aus aether_characters
--- @return table Spielerobjekt
function Aether.ErzeugeSpieler(quelle, lizenz, charakter)
-- Gespeicherte Position einlesen (oder Standard-Spawn nutzen)
local position = Config.SpawnPunkt
if charakter.position then
local erfolg, entschluesselt = pcall(json.decode, charakter.position)
if erfolg and entschluesselt then position = entschluesselt end
end
-- Aussehen einlesen (JSON aus dem Charakter-Ersteller)
local aussehen = nil
if charakter.appearance then
local erfolg, entschluesselt = pcall(json.decode, charakter.appearance)
if erfolg then aussehen = entschluesselt end
end
local spieler = setmetatable({}, SpielerMethoden)
spieler.quelle = quelle
spieler.lizenz = lizenz
spieler.charakterId = charakter.id
-- Identitaet
spieler.vorname = charakter.firstname
spieler.nachname = charakter.lastname
spieler.geburtstag = charakter.dob
spieler.geschlecht = charakter.gender
spieler.herkunft = charakter.nationality
spieler.aussehen = aussehen
-- Geld
spieler.bargeld = charakter.cash
spieler.konto = charakter.bank
spieler.schwarzgeld = charakter.dirty_money
spieler.iban = charakter.iban
-- Arbeit und Zugehoerigkeit
spieler.job = charakter.job
spieler.jobRang = charakter.job_grade
spieler.fraktion = charakter.faction
spieler.fraktionRang= charakter.faction_rank
-- Zustand
spieler.leben = charakter.health
spieler.hunger = charakter.hunger
spieler.durst = charakter.thirst
spieler.ausdauer = charakter.stamina
spieler.istTot = charakter.is_dead == 1
spieler.position = position
spieler.spielzeit = charakter.playtime or 0
Aether.Spieler[quelle] = spieler
return spieler
end
-- ---------------------------------------------------------------------
-- Geld
-- ---------------------------------------------------------------------
--- Liefert den Kontostand einer Geldart
--- @param art string 'bargeld' | 'konto' | 'schwarzgeld'
--- @return number
function SpielerMethoden:HoleGeld(art)
return self[art] or 0
end
--- Fuegt Geld hinzu und meldet die Aenderung an den Client
--- @param art string 'bargeld' | 'konto' | 'schwarzgeld'
--- @param betrag number Positiver Betrag
--- @param grund string|nil Verwendungszweck fuer die Transaktionsliste
--- @return boolean Erfolg
function SpielerMethoden:FuegeGeldHinzu(art, betrag, grund)
if betrag <= 0 then return false end
self[art] = (self[art] or 0) + betrag
-- Kontobewegungen zusaetzlich protokollieren
if art == 'konto' then
Aether.DB.SchreibeTransaktion(self.charakterId, 'deposit', betrag, self.konto, grund)
end
self:SendeGeld()
return true
end
--- Zieht Geld ab, sofern gedeckt
--- @param art string 'bargeld' | 'konto' | 'schwarzgeld'
--- @param betrag number Positiver Betrag
--- @param grund string|nil Verwendungszweck
--- @return boolean Erfolg (false = nicht genug Geld)
function SpielerMethoden:EntferneGeld(art, betrag, grund)
if betrag <= 0 then return false end
if (self[art] or 0) < betrag then return false end -- Deckungspruefung
self[art] = self[art] - betrag
if art == 'konto' then
Aether.DB.SchreibeTransaktion(self.charakterId, 'withdraw', -betrag, self.konto, grund)
end
self:SendeGeld()
return true
end
--- Schickt die aktuellen Geldwerte an den Client (fuer HUD und Handy)
function SpielerMethoden:SendeGeld()
TriggerClientEvent('aether:client:geldAktualisiert', self.quelle, {
bargeld = self.bargeld,
konto = self.konto,
schwarzgeld = self.schwarzgeld,
})
end
-- ---------------------------------------------------------------------
-- Zustand
-- ---------------------------------------------------------------------
--- Setzt einen Zustandswert (Hunger/Durst/Ausdauer) und begrenzt ihn auf 0–100
--- @param art string 'hunger' | 'durst' | 'ausdauer'
--- @param wert number Neuer Wert
function SpielerMethoden:SetzeZustand(art, wert)
self[art] = Aether.Begrenze(wert, 0, 100)
TriggerClientEvent('aether:client:zustandAktualisiert', self.quelle, {
hunger = self.hunger,
durst = self.durst,
ausdauer = self.ausdauer,
})
end
-- ---------------------------------------------------------------------
-- Daten fuer den Client
-- ---------------------------------------------------------------------
--- Baut das Datenpaket, das der Client zum Spielen braucht
--- @return table
function SpielerMethoden:HoleDaten()
return {
charakterId = self.charakterId,
vorname = self.vorname,
nachname = self.nachname,
geburtstag = self.geburtstag,
geschlecht = self.geschlecht,
aussehen = self.aussehen,
bargeld = self.bargeld,
konto = self.konto,
schwarzgeld = self.schwarzgeld,
iban = self.iban,
job = self.job,
jobRang = self.jobRang,
fraktion = self.fraktion,
fraktionRang= self.fraktionRang,
leben = self.leben,
hunger = self.hunger,
durst = self.durst,
ausdauer = self.ausdauer,
istTot = self.istTot,
position = self.position,
}
end
-- ---------------------------------------------------------------------
-- Speichern
-- ---------------------------------------------------------------------
--- Schreibt den aktuellen Stand des Spielers in die Datenbank
function SpielerMethoden:Speichere()
Aether.DB.SpeichereCharakter(self.charakterId, {
cash = self.bargeld,
bank = self.konto,
dirty_money = self.schwarzgeld,
job = self.job,
job_grade = self.jobRang,
faction = self.fraktion,
faction_rank = self.fraktionRang,
health = self.leben,
hunger = math.floor(self.hunger),
thirst = math.floor(self.durst),
stamina = math.floor(self.ausdauer),
position = self.position,
is_dead = self.istTot,
playtime = self.spielzeit,
})
end

View File

@ -0,0 +1,78 @@
-- =====================================================================
-- Aether Core — Zentrale Konfiguration
-- Wird auf Client und Server geladen und ist ueberall als `Config` verfuegbar.
-- =====================================================================
Config = {}
-- Debug-Ausgaben in der Konsole (im Livebetrieb auf false setzen)
Config.Debug = true
-- Sprache aller Systemmeldungen
Config.Sprache = 'de'
-- ---------------------------------------------------------------------
-- Charaktere
-- ---------------------------------------------------------------------
-- Wie viele Charaktere darf ein Spieler gleichzeitig besitzen?
Config.MaxCharaktere = 5
-- Legt der Core selbst automatisch einen Startcharakter an?
-- false = `aether-character` uebernimmt die Auswahl und Erstellung (Standard).
-- true = Notbetrieb ohne aether-character (springt direkt ins Spiel).
Config.AutoCharakter = false
-- Startwerte fuer einen frisch erstellten Charakter
Config.StartWerte = {
bargeld = 500, -- Startbargeld
konto = 2500, -- Startguthaben auf dem Konto
job = 'unemployed',
jobRang = 0,
}
-- ---------------------------------------------------------------------
-- Spawn
-- ---------------------------------------------------------------------
-- Standardposition, wenn der Charakter noch keine gespeicherte Position hat
Config.SpawnPunkt = {
x = -1037.7,
y = -2737.6,
z = 20.2,
heading = 330.0,
}
-- Standard-Ped-Modelle je Geschlecht (bis der Charakter-Ersteller migriert ist)
Config.StandardPed = {
m = 'mp_m_freemode_01',
w = 'mp_f_freemode_01',
}
-- ---------------------------------------------------------------------
-- Zustandswerte (passend zu den HUD-Statusringen)
-- ---------------------------------------------------------------------
Config.Zustand = {
-- Intervall, in dem Hunger/Durst sinken (in Millisekunden)
tickIntervall = 60000,
-- Wie viel pro Tick abgezogen wird
hungerProTick = 1.0,
durstProTick = 1.4,
-- Ab welchem Wert Schaden genommen wird und wie viel
schadenAbWert = 0,
schadenProTick = 5,
}
-- ---------------------------------------------------------------------
-- Speichern
-- ---------------------------------------------------------------------
-- Intervall des automatischen Speicherns aller Spieler (in Millisekunden)
Config.SpeicherIntervall = 300000 -- 5 Minuten
-- ---------------------------------------------------------------------
-- Rechtegruppen (aufsteigend nach Befugnis)
-- ---------------------------------------------------------------------
Config.AdminGruppen = {
['user'] = 0,
['support'] = 1,
['admin'] = 2,
['owner'] = 3,
}

View File

@ -0,0 +1,97 @@
-- =====================================================================
-- Aether Core — Hilfsfunktionen (Client und Server)
-- Global als `Aether` verfuegbar.
-- =====================================================================
Aether = Aether or {}
-- ---------------------------------------------------------------------
-- Konsolenausgaben mit einheitlichem Praefix
-- ---------------------------------------------------------------------
--- Gibt eine Debug-Meldung aus (nur wenn Config.Debug aktiv ist)
--- @param nachricht string Auszugebender Text
function Aether.Debug(nachricht)
if not Config.Debug then return end
print(('^5[Aether]^7 %s'):format(nachricht))
end
--- Gibt eine Informationsmeldung aus
--- @param nachricht string Auszugebender Text
function Aether.Info(nachricht)
print(('^2[Aether]^7 %s'):format(nachricht))
end
--- Gibt eine Fehlermeldung aus
--- @param nachricht string Auszugebender Text
function Aether.Fehler(nachricht)
print(('^1[Aether FEHLER]^7 %s'):format(nachricht))
end
-- ---------------------------------------------------------------------
-- Allgemeine Helfer
-- ---------------------------------------------------------------------
--- Begrenzt einen Wert auf einen Bereich
--- @param wert number Eingabewert
--- @param minimum number Untere Grenze
--- @param maximum number Obere Grenze
--- @return number
function Aether.Begrenze(wert, minimum, maximum)
if wert < minimum then return minimum end
if wert > maximum then return maximum end
return wert
end
--- Rundet eine Zahl auf eine bestimmte Anzahl Nachkommastellen
--- @param wert number Eingabewert
--- @param stellen number|nil Nachkommastellen (Standard 0)
--- @return number
function Aether.Runde(wert, stellen)
local faktor = 10 ^ (stellen or 0)
return math.floor(wert * faktor + 0.5) / faktor
end
--- Erzeugt eine zufaellige Zeichenkette nach einem Muster
--- A = Grossbuchstabe, 1 = Ziffer, alles andere bleibt stehen
--- @param muster string z. B. 'AA-1111'
--- @return string
function Aether.ZufallsText(muster)
local buchstaben = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
return (muster:gsub('.', function(zeichen)
if zeichen == 'A' then
local pos = math.random(#buchstaben)
return buchstaben:sub(pos, pos)
elseif zeichen == '1' then
return tostring(math.random(0, 9))
end
return zeichen
end))
end
--- Erzeugt eine IBAN-artige Kontonummer fuer das Bank-Modul
--- @return string
function Aether.ErzeugeIban()
return Aether.ZufallsText('DE11 1111 1111 11')
end
--- Formatiert einen Betrag als Euro-Text
--- @param betrag number Betrag
--- @return string
function Aether.Euro(betrag)
local text = tostring(math.floor(betrag or 0))
-- Tausenderpunkte von hinten einfuegen
local formatiert = text:reverse():gsub('(%d%d%d)', '%1.'):reverse()
formatiert = formatiert:gsub('^%.', '')
return formatiert .. ' €'
end
--- Prueft, ob eine Rechtegruppe mindestens die geforderte Stufe hat
--- @param gruppe string Gruppe des Spielers
--- @param benoetigt string Geforderte Gruppe
--- @return boolean
function Aether.HatRecht(gruppe, benoetigt)
local eigene = Config.AdminGruppen[gruppe or 'user'] or 0
local noetig = Config.AdminGruppen[benoetigt or 'user'] or 0
return eigene >= noetig
end

View File

@ -0,0 +1,187 @@
-- =====================================================================
-- Aether Death — Clientlogik
-- Erkennt den Tod, legt den Spieler zu Boden und zeigt den Screen.
-- =====================================================================
local istVerletzt = false
local istGeladen = false
local function SendeAnNui(nachricht)
SendNuiMessage(json.encode(nachricht))
end
--- Legt den Spieler in den Verletzt-Zustand
local function LegeZuBoden()
local ped = PlayerPedId()
-- Am Leben halten, aber bewegungsunfaehig — GTA soll nicht respawnen
SetEntityHealth(ped, 101)
SetEntityInvincible(ped, true)
NetworkSetFriendlyFireOption(false)
-- Liegende Haltung
RequestAnimSet('move_m@injured')
SetPedMoveRateOverride(ped, 0.0)
SetPedToRagdoll(ped, 8000, 8000, 0, false, false, false)
-- Steuerung einschraenken (nur Umsehen bleibt moeglich)
SetPlayerInvincible(PlayerId(), true)
end
--- Hebt den Verletzt-Zustand wieder auf
--- @param leben number Lebensenergie nach dem Aufstehen
local function StelleWiederHer(leben)
local ped = PlayerPedId()
ClearPedTasksImmediately(ped)
SetEntityHealth(ped, leben or 200)
SetEntityInvincible(ped, false)
SetPlayerInvincible(PlayerId(), false)
SetPedMoveRateOverride(ped, 1.0)
ResetPedMovementClipset(ped, 0.0)
istVerletzt = false
SetNuiFocus(false, false)
SendeAnNui({ aktion = 'schliessen' })
pcall(function() exports['aether-hud']:SetzeSichtbar(true) end)
end
-- ---------------------------------------------------------------------
-- Todeserkennung
-- ---------------------------------------------------------------------
AddEventHandler('aether:client:bereit', function()
istGeladen = true
end)
CreateThread(function()
while true do
Wait(300)
if istGeladen and not istVerletzt then
local ped = PlayerPedId()
-- GTA meldet den Spieler als tot, sobald die Lebensenergie faellt
if IsEntityDead(ped) or GetEntityHealth(ped) <= 100 then
istVerletzt = true
LegeZuBoden()
TriggerServerEvent('aether:death:gefallen')
end
end
end
end)
--- Verhindert, dass GTA den Spieler automatisch respawnt
CreateThread(function()
while true do
if istVerletzt then
Wait(0)
-- Steuerung sperren, Umsehen erlauben
DisableAllControlActions(0)
EnableControlAction(0, 1, true) -- Kamera horizontal
EnableControlAction(0, 2, true) -- Kamera vertikal
EnableControlAction(0, 245, true) -- Chat
-- Automatischen Respawn unterbinden
local ped = PlayerPedId()
if GetEntityHealth(ped) < 101 then
SetEntityHealth(ped, 101)
end
IgnoreNextRestart(true)
SetFadeOutAfterDeath(false)
else
Wait(500)
end
end
end)
-- ---------------------------------------------------------------------
-- Ereignisse vom Server
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:death:zeige', function(zustand)
istVerletzt = true
LegeZuBoden()
SetNuiFocus(true, true)
pcall(function() exports['aether-hud']:SetzeSichtbar(false) end)
zustand.aktion = 'oeffnen'
SendeAnNui(zustand)
end)
RegisterNetEvent('aether:death:zustand', function(zustand)
zustand.aktion = 'zustand'
SendeAnNui(zustand)
end)
RegisterNetEvent('aether:death:hinweis', function(text)
SendeAnNui({ aktion = 'hinweis', text = text })
end)
--- Ein Sanitaeter hat geholfen
RegisterNetEvent('aether:death:wiederbelebt', function(daten)
SendeAnNui({ aktion = 'behandelt' })
Wait(2500) -- Ergebnisbildschirm kurz stehen lassen
StelleWiederHer(daten.leben)
end)
--- Aufgegeben oder verblutet: im Krankenhaus aufwachen
RegisterNetEvent('aether:death:krankenhaus', function(daten)
SendeAnNui({
aktion = 'krankenhaus',
klinik = daten.klinik,
kosten = daten.kosten,
bezahlt = daten.bezahlt,
grund = daten.grund,
})
DoScreenFadeOut(800)
Wait(1200)
local ped = PlayerPedId()
local ziel = daten.position
SetEntityCoordsNoOffset(ped, ziel.x, ziel.y, ziel.z, false, false, false)
SetEntityHeading(ped, ziel.w or 0.0)
StelleWiederHer(daten.leben)
-- Warten, bis die Umgebung geladen ist
local versuche = 0
while not HasCollisionLoadedAroundEntity(ped) and versuche < 60 do
Wait(50)
versuche = versuche + 1
end
DoScreenFadeIn(1000)
end)
-- ---------------------------------------------------------------------
-- NUI-Callbacks
-- ---------------------------------------------------------------------
RegisterNUICallback('emsRufen', function(_, rueckgabe)
TriggerServerEvent('aether:death:emsRufen')
rueckgabe({ ok = true })
end)
RegisterNUICallback('aufgeben', function(_, rueckgabe)
TriggerServerEvent('aether:death:aufgeben')
rueckgabe({ ok = true })
end)
--- Um Hilfe rufen: Umstehende hoeren es (rein oertlich)
RegisterNUICallback('hilfeRufen', function(_, rueckgabe)
local ped = PlayerPedId()
PlayPain(ped, 6, 0)
SendeAnNui({ aktion = 'hinweis', text = 'Du schreist um Hilfe …' })
rueckgabe({ ok = true })
end)
-- ---------------------------------------------------------------------
-- Aufraeumen
-- ---------------------------------------------------------------------
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false)
local ped = PlayerPedId()
SetEntityInvincible(ped, false)
SetPlayerInvincible(PlayerId(), false)
SetPedMoveRateOverride(ped, 1.0)
end)

View File

@ -0,0 +1,34 @@
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
name 'aether-death'
author 'Jerrit Fritzsche'
description 'Verletzt-Zustand, Verbluten, Rettungsdienst und Krankenhaus'
version '0.1.0'
ui_page 'html/index.html'
files {
'html/index.html',
}
shared_scripts {
'@aether-core/shared/config.lua',
'@aether-core/shared/utils.lua',
'shared/config.lua',
}
client_scripts {
'client/main.lua',
}
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/main.lua',
}
dependencies {
'aether-core',
'oxmysql',
}

View File

@ -0,0 +1,405 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Aether — Tod / Respawn</title>
<style>
:root{
--fg:#f2eef0; /* Standard-Textfarbe */
--muted:#a7a0ac; /* gedämpfter Text */
--panel:rgba(20,10,14,0.72); /* halbtransparente Fläche */
--panel2:rgba(30,16,20,0.9); /* dichtere Fläche */
--border:rgba(255,255,255,0.1);/* dezente Rahmen */
--danger:#f0506e; /* Warn-/Blutrot (Leitfarbe des Screens) */
--danger2:#ff2e57; /* kräftigeres Rot für Akzente */
--accent:#7c5cf6; /* Aether-Akzent (Violett) für Sekundäres */
--good:#34d39a; /* Erfolg (behandelt) */
--ems:#f5a742; /* Rettungsdienst-Orange */
}
*{box-sizing:border-box;}
html,body{margin:0;height:100%;overflow:hidden;background:transparent;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);}
/* In FiveM liegt die echte Spielwelt dahinter: Kulisse und Demo entfallen. */
body.ingame .scene{display:none;}
body.ingame .demo{display:none;}
/* ============================================================
ENTSÄTTIGTE HINTERGRUND-KULISSE (Boden-Perspektive)
============================================================ */
.scene{position:fixed;inset:0;z-index:0;overflow:hidden;filter:grayscale(0.85) brightness(0.45);
background:linear-gradient(180deg,#14121a 0%,#1e1a26 40%,#2a2430 60%,#171320 60%);}
.scene .city{position:absolute;left:0;right:0;bottom:32%;height:26%;
background:linear-gradient(90deg,#1c1826 0 9%,transparent 9% 14%,#241f30 14% 22%,transparent 22% 27%,
#1a1624 27% 35%,transparent 35% 41%,#241f30 41% 48%,transparent 48% 55%,#1c1826 55% 63%,transparent 63% 69%,
#201a2c 69% 78%,transparent 78% 84%,#241f30 84% 92%,transparent 92%);}
.scene .ground{position:absolute;left:0;right:0;bottom:0;height:32%;background:linear-gradient(180deg,#0f0c14,#000);}
/* Pulsierende blutrote Vignette über allem */
.vignette{position:fixed;inset:0;z-index:1;pointer-events:none;
background:radial-gradient(120% 100% at 50% 60%,transparent 20%,rgba(90,0,15,0.55) 75%,rgba(50,0,8,0.85) 100%);
animation:blut 3.2s ease-in-out infinite;}
@keyframes blut{50%{opacity:0.72;}}
.vignette.kritisch{animation-duration:1.1s;background:radial-gradient(120% 100% at 50% 60%,transparent 8%,rgba(140,0,20,0.6) 65%,rgba(70,0,10,0.92) 100%);}
/* ============================================================
ZENTRALER INHALTSBLOCK (wechselt je nach Phase)
============================================================ */
.wrap{position:relative;z-index:2;height:100%;display:grid;place-items:center;padding:24px;}
.center{width:min(560px,94vw);text-align:center;animation:auf .5s ease;}
@keyframes auf{from{opacity:0;transform:translateY(14px);}to{opacity:1;transform:none;}}
.status-label{font-size:13px;font-weight:800;letter-spacing:0.32em;text-transform:uppercase;color:var(--danger);
margin-bottom:10px;animation:flackern 2.4s ease-in-out infinite;}
@keyframes flackern{45%{opacity:1;}50%{opacity:0.45;}55%{opacity:1;}}
.title{font-size:clamp(44px,9vw,74px);font-weight:900;letter-spacing:-0.02em;line-height:0.95;margin:0;
color:#fff;text-shadow:0 0 30px rgba(240,80,110,0.45);}
.subtitle{color:var(--muted);font-size:15.5px;margin:14px auto 0;max-width:400px;line-height:1.55;}
/* EKG-Streifen */
.ecg{margin:26px auto 6px;width:min(440px,86vw);height:80px;border-radius:14px;overflow:hidden;position:relative;
background:rgba(0,0,0,0.4);border:1px solid var(--border);}
.ecg .track{display:flex;width:200%;height:100%;animation:ecgScroll var(--beat,1s) linear infinite;}
.ecg .track svg{width:50%;height:100%;flex:none;}
.ecg .line{fill:none;stroke:var(--danger2);stroke-width:2.4;filter:drop-shadow(0 0 6px rgba(255,46,87,0.8));}
@keyframes ecgScroll{to{transform:translateX(-50%);}}
.ecg.flat .track{animation:none;transform:none;}
.ecg.flat .line{stroke:#8a2740;filter:none;}
/* Countdown-Anzeige */
.timer{margin-top:8px;}
.timer .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.1em;font-weight:800;}
.timer .v{font-size:40px;font-weight:900;font-variant-numeric:tabular-nums;letter-spacing:0.02em;}
.timer.warnung .v{color:var(--danger);}
/* Statuszeile zum Rettungsdienst */
.emsline{margin:16px auto 0;display:inline-flex;align-items:center;gap:9px;background:var(--panel);
border:1px solid var(--border);border-radius:999px;padding:8px 16px;font-size:13px;font-weight:700;backdrop-filter:blur(8px);}
.emsline .dot{width:8px;height:8px;border-radius:50%;background:var(--muted);flex:none;}
.emsline.aktiv .dot{background:var(--ems);box-shadow:0 0 8px var(--ems);animation:blink 1s steps(2) infinite;}
.emsline.da .dot{background:var(--good);box-shadow:0 0 8px var(--good);animation:none;}
@keyframes blink{50%{opacity:0.3;}}
/* Aktions-Buttons */
.actions{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-top:24px;}
.btn{border:1px solid var(--border);background:var(--panel2);color:var(--fg);border-radius:12px;padding:13px 20px;
font-family:inherit;font-size:14px;font-weight:800;cursor:pointer;transition:.12s;display:inline-flex;align-items:center;gap:8px;backdrop-filter:blur(8px);}
.btn:hover:not(:disabled){transform:translateY(-2px);border-color:rgba(255,255,255,0.25);}
.btn:disabled{opacity:0.4;cursor:not-allowed;}
.btn.ems{background:linear-gradient(150deg,var(--ems),#d97b1a);color:#231603;border-color:transparent;}
.btn.give{background:var(--panel2);}
.btn.good{background:linear-gradient(150deg,var(--good),#1f9e77);color:#052018;border-color:transparent;}
.btn:active:not(:disabled){transform:scale(0.98);}
.hint{font-size:11.5px;color:var(--muted);margin-top:14px;}
/* Große Ikone für Ergebnis-Screens (behandelt / verstorben) */
.bigicon{font-size:64px;margin-bottom:6px;}
.infocard{margin:20px auto 0;background:var(--panel);border:1px solid var(--border);border-radius:14px;
padding:14px 18px;max-width:340px;text-align:left;backdrop-filter:blur(8px);}
.infocard .r{display:flex;justify-content:space-between;gap:14px;font-size:13px;padding:5px 0;}
.infocard .r .k{color:var(--muted);} .infocard .r .val{font-weight:800;font-variant-numeric:tabular-nums;}
.infocard .r .val.kosten{color:var(--ems);}
/* Demo-Hinweisleiste unten (nur Prototyp) */
.demo{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:3;display:flex;gap:8px;align-items:center;
background:var(--panel2);border:1px solid var(--border);border-radius:12px;padding:7px 10px;backdrop-filter:blur(10px);}
.demo span{font-size:10.5px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:0.06em;margin:0 4px;}
.demo button{border:1px solid var(--border);background:rgba(255,255,255,0.05);color:var(--fg);border-radius:8px;
padding:6px 11px;font-family:inherit;font-size:11.5px;font-weight:700;cursor:pointer;transition:.12s;}
.demo button:hover{border-color:var(--accent);color:#fff;}
</style>
</head>
<body>
<!-- Kulisse + Vignette -->
<div class="scene"><div class="city"></div><div class="ground"></div></div>
<div class="vignette" id="vignette"></div>
<!-- Wechselnder Inhaltsbereich -->
<div class="wrap"><div class="center" id="center"></div></div>
<!-- Demo-Steuerung (nur Prototyp — in FiveM entfällt sie) -->
<div class="demo">
<span>Demo</span>
<button id="dRevive">🚑 Sanitäter behandelt sofort</button>
<button id="dReset">↺ Zurücksetzen</button>
</div>
<script>
"use strict";
/* Kurzer Zugriffshelfer auf Elemente per ID */
const $=id=>document.getElementById(id);
/* Sekunden als M:SS formatieren */
const mmss=s=>Math.floor(s/60)+":"+String(Math.max(0,s)%60).padStart(2,"0");
/* Betrag als Euro formatieren */
const euro=n=>Math.round(n).toLocaleString("de-DE")+" €";
/* =====================================================================
KONSTANTEN
===================================================================== */
let BLEEDOUT_START=300; // Verblutungs-Countdown in Sekunden (5:00)
let RESPAWN_SPERRE=45; // Mindestwartezeit, bevor „Aufgeben" möglich ist
const BEHANDLUNG_DAUER=6; // Dauer der Behandlung durch den Sanitäter
let KRANKENHAUS_KOSTEN=1500; // Behandlungspauschale beim Aufwachen im Krankenhaus
/* Ein Heartbeat-Segment (PQRST) als SVG-Pfad für den EKG-Streifen */
const EKG_PFAD="M0,40 L70,40 L82,40 L90,16 L98,60 L106,26 L114,40 L150,40 L200,40";
/* =====================================================================
ZUSTAND (im echten Betrieb per NUI vom Client gesetzt / gesteuert)
Bewusst vom UI getrennt gehalten.
===================================================================== */
const tod={
phase:"down", // down | behandelt | krankenhaus
restBleed:BLEEDOUT_START, // verbleibende Zeit bis zum Verbluten
respawnSperre:RESPAWN_SPERRE, // Restsperre für „Aufgeben"
emsGerufen:false, // wurde der Rettungsdienst gerufen?
emsEta:0, // verbleibende Zeit bis der Sanitäter eintrifft
sanitaeterDa:false, // ist der Sanitäter am Einsatzort?
behandlungRest:0, // Restzeit der laufenden Behandlung
};
/* =====================================================================
RENDERING JE PHASE
===================================================================== */
/* Baut den „Verwundet"-Screen einmalig auf (EKG-Animation soll durchlaufen) */
function renderDown(){
$("center").innerHTML=`
<div class="status-label">● Schwer verletzt</div>
<h1 class="title">VERWUNDET</h1>
<p class="subtitle">Du liegst am Boden und verlierst Blut. Ruf den Rettungsdienst — oder warte auf Hilfe.</p>
<div class="ecg" id="ecg"><div class="track">
<svg viewBox="0 0 200 80" preserveAspectRatio="none"><path class="line" d="${EKG_PFAD}"></path></svg>
<svg viewBox="0 0 200 80" preserveAspectRatio="none"><path class="line" d="${EKG_PFAD}"></path></svg>
</div></div>
<div class="timer" id="timer"><div class="l">Verbleibende Zeit</div><div class="v" id="timerV">${mmss(tod.restBleed)}</div></div>
<div class="emsline" id="emsline"><span class="dot"></span><span id="emsText">Kein Notruf abgesetzt</span></div>
<div class="actions">
<button class="btn ems" id="btnEms">🚑 Rettungsdienst rufen</button>
<button class="btn" id="btnHelp">🆘 Um Hilfe rufen</button>
<button class="btn give" id="btnGive" disabled>🏳️ Aufgeben</button>
</div>
<div class="hint" id="hint"></div>`;
// Button-Ereignisse verknüpfen
$("btnEms").addEventListener("click",rufeEms);
$("btnHelp").addEventListener("click",rufeHilfe);
$("btnGive").addEventListener("click",aufgeben);
updateDown();
}
/* Aktualisiert die dynamischen Teile des „Verwundet"-Screens (jede Sekunde) */
function updateDown(){
// Countdown + Warnfärbung bei wenig Restzeit
$("timerV").textContent=mmss(tod.restBleed);
$("timer").classList.toggle("warnung",tod.restBleed<=60);
$("vignette").classList.toggle("kritisch",tod.restBleed<=60);
// EKG-Takt an die Restzeit koppeln (weniger Zeit = langsamerer Herzschlag)
const anteil=Math.max(0,tod.restBleed/BLEEDOUT_START);
$("ecg").style.setProperty("--beat",(2.2-1.3*anteil).toFixed(2)+"s");
// Statuszeile zum Rettungsdienst
const emsline=$("emsline");
if(!tod.emsGerufen){emsline.className="emsline";$("emsText").textContent="Kein Notruf abgesetzt";}
else if(!tod.sanitaeterDa){emsline.className="emsline aktiv";$("emsText").textContent="🚑 Rettungsdienst unterwegs · Eintreffen in "+mmss(tod.emsEta);}
else {emsline.className="emsline da";$("emsText").textContent="🩹 Sanitäter behandelt dich … ("+tod.behandlungRest+"s)";}
// „Aufgeben" erst nach Ablauf der Respawn-Sperre freigeben
const btnGive=$("btnGive");
btnGive.disabled=tod.respawnSperre>0||tod.sanitaeterDa;
btnGive.innerHTML=tod.respawnSperre>0?`🔒 Aufgeben in ${tod.respawnSperre}s`:"🏳️ Aufgeben";
}
/* Zeigt den „Behandelt"-Screen nach erfolgreicher Wiederbelebung */
function renderBehandelt(){
$("vignette").className="vignette";
$("center").innerHTML=`
<div class="bigicon">🩹</div>
<div class="status-label" style="color:var(--good)">● Stabilisiert</div>
<h1 class="title" style="text-shadow:0 0 30px rgba(52,211,153,0.4)">BEHANDELT</h1>
<p class="subtitle">Ein Sanitäter hat dich rechtzeitig erreicht und wiederbelebt. Du bist wieder auf den Beinen.</p>
<div class="infocard">
<div class="r"><span class="k">Leben</span><span class="val" style="color:var(--good)">100 %</span></div>
<div class="r"><span class="k">Behandelt von</span><span class="val">EMS · Los Santos</span></div>
</div>
<div class="actions"><button class="btn good" id="btnWeiter">✔ Weiterspielen</button></div>
<div class="hint">Tipp: Trag Verletzungen aus, statt sie zu ignorieren — gutes Rollenspiel zahlt sich aus.</div>`;
$("btnWeiter").addEventListener("click",neustart);
}
/* Zeigt den „Im Krankenhaus aufgewacht"-Screen (nach Aufgeben oder Verbluten) */
function renderKrankenhaus(grund){
$("vignette").className="vignette";
$("center").innerHTML=`
<div class="bigicon">🏥</div>
<div class="status-label" style="color:var(--accent)">● Notaufnahme</div>
<h1 class="title" style="text-shadow:0 0 30px rgba(124,92,246,0.4)">KRANKENHAUS</h1>
<p class="subtitle">${grund} Das Pflegepersonal hat dich versorgt — die Behandlung ist allerdings nicht umsonst.</p>
<div class="infocard">
<div class="r"><span class="k">Grund</span><span class="val">${tod.emsGerufen?'Kein Sanitäter rechtzeitig':'Aufgegeben'}</span></div>
<div class="r"><span class="k">Behandlungskosten</span><span class="val kosten">− ${euro(KRANKENHAUS_KOSTEN)}</span></div>
<div class="r"><span class="k">Leben</span><span class="val" style="color:var(--good)">100 %</span></div>
</div>
<div class="actions"><button class="btn" id="btnWeiter">➜ Krankenhaus verlassen</button></div>
<div class="hint">Die Kosten gehen als Rechnung an dich (EMS-Finanzen im Fraktions-CAD).</div>`;
$("btnWeiter").addEventListener("click",neustart);
}
/* =====================================================================
AKTIONEN
===================================================================== */
/* Rettungsdienst rufen: Notruf absetzen und Eintreff-Zeit starten */
function rufeEms(){
if(tod.emsGerufen)return;
if(IST_FIVEM){ nui("emsRufen",{}); $("btnEms").disabled=true; return; }
tod.emsGerufen=true;
tod.emsEta=rndInt(30,60); // zufällige Eintreffzeit 0:30–1:00
$("btnEms").disabled=true;
$("hint").textContent="Notruf abgesetzt — ein Sanitäter wurde alarmiert.";
updateDown();
}
/* Um Hilfe rufen: rein kosmetischer Ruf an Umstehende */
function rufeHilfe(){
if(IST_FIVEM){ nui("hilfeRufen",{}); return; }
$("hint").textContent="Du schreist um Hilfe … vielleicht hört dich jemand in der Nähe.";
}
/* Aufgeben: im Krankenhaus aufwachen */
function aufgeben(){
if(tod.respawnSperre>0||tod.sanitaeterDa)return;
if(IST_FIVEM){ nui("aufgeben",{}); return; }
tod.phase="krankenhaus";
renderKrankenhaus("Du hast aufgegeben und wachst in der Notaufnahme wieder auf.");
}
/* Sanitäter beginnt die Behandlung (durch Eintreffen oder Demo-Button) */
function starteBehandlung(){
if(tod.sanitaeterDa||tod.phase!=="down")return;
tod.sanitaeterDa=true;
tod.emsGerufen=true; // Demo-Sofortbehandlung setzt „gerufen" mit
tod.behandlungRest=BEHANDLUNG_DAUER;
updateDown();
}
/* Behandlung erfolgreich abgeschlossen → wiederbelebt */
function wiederbelebt(){
tod.phase="behandelt";
renderBehandelt();
}
/* Verbluten ohne rechtzeitige Hilfe → Krankenhaus */
function verblutet(){
tod.phase="krankenhaus";
renderKrankenhaus("Niemand hat dich rechtzeitig erreicht — du bist verblutet.");
}
/* Alles auf Anfang zurücksetzen (für erneutes Durchspielen) */
function neustart(){
Object.assign(tod,{phase:"down",restBleed:BLEEDOUT_START,respawnSperre:RESPAWN_SPERRE,
emsGerufen:false,emsEta:0,sanitaeterDa:false,behandlungRest:0});
renderDown();
}
/* Ganzzahl-Zufall zwischen a und b (inklusive) */
function rndInt(a,b){return a+Math.floor(Math.random()*(b-a+1));}
/* =====================================================================
SEKUNDEN-TAKT (treibt alle Countdowns; in FiveM von echten Events ersetzt)
===================================================================== */
setInterval(()=>{
if(IST_FIVEM)return; // In FiveM führt der Server den Countdown
if(tod.phase!=="down")return; // in den Ergebnis-Screens läuft nichts weiter
// Respawn-Sperre herunterzählen
if(tod.respawnSperre>0)tod.respawnSperre--;
if(tod.sanitaeterDa){
// Laufende Behandlung: bei 0 wiederbeleben
tod.behandlungRest--;
if(tod.behandlungRest<=0){wiederbelebt();return;}
} else {
// Verblutungs-Countdown
tod.restBleed--;
// Sanitäter unterwegs: ETA herunterzählen, bei 0 Behandlung starten
if(tod.emsGerufen&&tod.emsEta>0){tod.emsEta--; if(tod.emsEta<=0)starteBehandlung();}
// Zeit abgelaufen ohne Rettung → verblutet
if(tod.restBleed<=0){verblutet();return;}
}
updateDown();
},1000);
/* Demo-Buttons */
$("dRevive").addEventListener("click",()=>{if(tod.phase==="down")starteBehandlung();});
$("dReset").addEventListener("click",neustart);
/* =====================================================================
NUI-BRÜCKE — Verbindung zum FiveM-Client
===================================================================== */
const IST_FIVEM = (typeof GetParentResourceName === "function");
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-death";
function nui(aktion, daten){
if(!IST_FIVEM) return Promise.resolve();
return fetch(`https://${RESSOURCE}/${aktion}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(daten || {}),
}).catch(() => {});
}
window.addEventListener("message", (ereignis) => {
const n = ereignis.data || {};
if(n.aktion === "oeffnen"){
document.body.style.display = "";
if(n.bleedoutMax) BLEEDOUT_START = n.bleedoutMax;
Object.assign(tod, {
phase: "down",
restBleed: n.restBleed,
respawnSperre: n.respawnSperre,
emsGerufen: false,
sanitaeterDa: false,
});
renderDown();
}
else if(n.aktion === "zustand"){
/* Der Server ist die Wahrheit: Countdown und Sperre kommen von dort. */
if(tod.phase !== "down") return;
tod.restBleed = n.restBleed;
tod.respawnSperre = n.respawnSperre;
tod.emsGerufen = n.emsGerufen;
updateDown();
}
else if(n.aktion === "hinweis"){
const el = document.getElementById("hint");
if(el) el.textContent = n.text || "";
}
else if(n.aktion === "behandelt"){
tod.phase = "behandelt";
renderBehandelt();
}
else if(n.aktion === "krankenhaus"){
tod.phase = "krankenhaus";
if(n.kosten) KRANKENHAUS_KOSTEN = n.kosten;
const grund = (n.grund === "verblutet")
? "Niemand hat dich rechtzeitig erreicht — du bist verblutet."
: "Du hast aufgegeben und wachst in der Notaufnahme wieder auf.";
renderKrankenhaus(grund + (n.klinik ? " (" + n.klinik + ")" : ""));
// In FiveM übernimmt der Client das Weiterspielen — Knopf ausblenden
const weiter = document.getElementById("btnWeiter");
if(weiter) weiter.style.display = "none";
}
else if(n.aktion === "schliessen"){
document.body.style.display = "none";
}
});
/* In FiveM zunächst verstecken — die Seite wartet auf "oeffnen" */
if(IST_FIVEM){ document.body.classList.add("ingame"); document.body.style.display = "none"; }
/* =====================================================================
START
===================================================================== */
if(!IST_FIVEM) renderDown();
</script>
</body>
</html>

View File

@ -0,0 +1,249 @@
-- =====================================================================
-- Aether Death — Serverlogik
-- Der Server fuehrt den Verblutungs-Countdown und entscheidet ueber
-- Wiederbelebung, Aufgeben und Krankenhausaufenthalt.
-- =====================================================================
local Core = exports['aether-core']
-- Verletzte Spieler: [quelle] = { seit, restBleed, sperre, emsGerufen, position }
local verletzte = {}
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
local function CharakterVon(quelle)
return Core:HoleCharakterId(quelle)
end
--- Schickt den aktuellen Zustand an den verletzten Spieler
--- @param quelle number Server-ID
local function SendeZustand(quelle)
local eintrag = verletzte[quelle]
if not eintrag then return end
TriggerClientEvent('aether:death:zustand', quelle, {
restBleed = eintrag.restBleed,
respawnSperre = eintrag.sperre,
emsGerufen = eintrag.emsGerufen,
bleedoutMax = Tod.BLEEDOUT,
})
end
--- Beendet den Verletzt-Zustand eines Spielers
--- @param quelle number Server-ID
local function BeendeVerletzung(quelle)
verletzte[quelle] = nil
local charakterId = CharakterVon(quelle)
if charakterId then
MySQL.update('UPDATE aether_characters SET is_dead = 0 WHERE id = ?', { charakterId })
end
end
-- ---------------------------------------------------------------------
-- Spieler geht zu Boden
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:death:gefallen', function()
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
if verletzte[quelle] then return end -- schon verletzt
local position = GetEntityCoords(GetPlayerPed(quelle))
verletzte[quelle] = {
seit = os.time(),
restBleed = Tod.BLEEDOUT,
sperre = Tod.RESPAWN_SPERRE,
emsGerufen = false,
position = { x = position.x, y = position.y, z = position.z },
}
MySQL.update('UPDATE aether_characters SET is_dead = 1 WHERE id = ?', { charakterId })
TriggerClientEvent('aether:death:zeige', quelle, {
restBleed = Tod.BLEEDOUT,
respawnSperre = Tod.RESPAWN_SPERRE,
bleedoutMax = Tod.BLEEDOUT,
})
-- Andere Module informieren (z. B. Job beenden, HUD anpassen)
TriggerEvent('aether:death:spielerGefallen', quelle)
end)
-- ---------------------------------------------------------------------
-- Rettungsdienst rufen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:death:emsRufen', function()
local quelle = source
local eintrag = verletzte[quelle]
if not eintrag or eintrag.emsGerufen then return end
eintrag.emsGerufen = true
local person = Core:HoleSpielerInfo(quelle)
local name = person and (person.vorname .. ' ' .. person.nachname) or 'Unbekannt'
-- Notruf weiterreichen. Ein Rettungsdienst-Modul kann diesen Event
-- abfangen und daraus einen Einsatz erzeugen.
TriggerEvent('aether:death:notruf', quelle, {
name = name,
position = eintrag.position,
})
SendeZustand(quelle)
TriggerClientEvent('aether:death:hinweis', quelle, 'Notruf abgesetzt. Warte auf Hilfe.')
end)
-- ---------------------------------------------------------------------
-- Aufgeben: im Krankenhaus aufwachen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:death:aufgeben', function()
local quelle = source
local eintrag = verletzte[quelle]
if not eintrag then return end
-- Sperrzeit pruefen: nicht sofort aufgeben koennen
if eintrag.sperre > 0 then
TriggerClientEvent('aether:death:hinweis', quelle,
('Noch %d Sekunden, bevor du aufgeben kannst.'):format(eintrag.sperre))
return
end
local klinik = Tod.NaechstesKrankenhaus(
vector3(eintrag.position.x, eintrag.position.y, eintrag.position.z))
-- Behandlungskosten abbuchen (erst Bargeld, dann Konto)
local bezahlt = Core:EntferneGeld(quelle, 'bargeld', Tod.KRANKENHAUS_KOSTEN, 'Behandlung Krankenhaus')
if not bezahlt then
bezahlt = Core:EntferneGeld(quelle, 'konto', Tod.KRANKENHAUS_KOSTEN, 'Behandlung Krankenhaus')
end
BeendeVerletzung(quelle)
TriggerClientEvent('aether:death:krankenhaus', quelle, {
klinik = klinik.name,
position = { x = klinik.pos.x, y = klinik.pos.y, z = klinik.pos.z, w = klinik.pos.w },
leben = Tod.LEBEN_NACH_KLINIK,
kosten = bezahlt and Tod.KRANKENHAUS_KOSTEN or 0,
bezahlt = bezahlt,
grund = 'aufgegeben',
})
end)
-- ---------------------------------------------------------------------
-- Countdown: Verbluten und Sperrzeit
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
Wait(1000)
for quelle, eintrag in pairs(verletzte) do
if eintrag.sperre > 0 then
eintrag.sperre = eintrag.sperre - 1
end
eintrag.restBleed = eintrag.restBleed - 1
-- Zeit abgelaufen: Der Spieler verblutet und wacht in der Klinik auf
if eintrag.restBleed <= 0 then
local klinik = Tod.NaechstesKrankenhaus(
vector3(eintrag.position.x, eintrag.position.y, eintrag.position.z))
local bezahlt = Core:EntferneGeld(quelle, 'bargeld', Tod.KRANKENHAUS_KOSTEN, 'Behandlung Krankenhaus')
if not bezahlt then
bezahlt = Core:EntferneGeld(quelle, 'konto', Tod.KRANKENHAUS_KOSTEN, 'Behandlung Krankenhaus')
end
BeendeVerletzung(quelle)
TriggerClientEvent('aether:death:krankenhaus', quelle, {
klinik = klinik.name,
position = { x = klinik.pos.x, y = klinik.pos.y, z = klinik.pos.z, w = klinik.pos.w },
leben = Tod.LEBEN_NACH_KLINIK,
kosten = bezahlt and Tod.KRANKENHAUS_KOSTEN or 0,
bezahlt = bezahlt,
grund = 'verblutet',
})
else
SendeZustand(quelle)
end
end
end
end)
-- ---------------------------------------------------------------------
-- Position des Verletzten aktuell halten (er kann noch kriechen)
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
Wait(5000)
for quelle, eintrag in pairs(verletzte) do
local ped = GetPlayerPed(quelle)
if ped and ped ~= 0 then
local position = GetEntityCoords(ped)
eintrag.position = { x = position.x, y = position.y, z = position.z }
end
end
end
end)
-- ---------------------------------------------------------------------
-- Aufraeumen
-- ---------------------------------------------------------------------
AddEventHandler('playerDropped', function()
verletzte[source] = nil
end)
-- =====================================================================
-- EXPORTS — fuer den Rettungsdienst
-- =====================================================================
--- Belebt einen verletzten Spieler wieder.
--- Wird vom EMS aufgerufen; prueft die Entfernung des Helfers.
--- @param quelle number Server-ID des Verletzten
--- @param helfer number|nil Server-ID des Sanitaeters (nil = ohne Pruefung)
--- @return boolean Erfolg
exports('Wiederbeleben', function(quelle, helfer)
local eintrag = verletzte[quelle]
if not eintrag then return false end
-- Entfernungspruefung, wenn ein Helfer angegeben ist
if helfer then
local hier = GetEntityCoords(GetPlayerPed(helfer))
local dort = GetEntityCoords(GetPlayerPed(quelle))
if #(hier - dort) > Tod.WIEDERBELEBUNG_WEITE then
return false
end
end
BeendeVerletzung(quelle)
TriggerClientEvent('aether:death:wiederbelebt', quelle, {
leben = Tod.LEBEN_NACH_HILFE,
})
return true
end)
--- Ist ein Spieler gerade verletzt am Boden?
--- @param quelle number Server-ID
--- @return boolean
exports('IstVerletzt', function(quelle)
return verletzte[quelle] ~= nil
end)
--- Liefert alle verletzten Spieler mit Position (fuer die EMS-Karte)
--- @return table
exports('HoleVerletzte', function()
local liste = {}
for quelle, eintrag in pairs(verletzte) do
liste[#liste + 1] = {
quelle = quelle,
position = eintrag.position,
emsGerufen = eintrag.emsGerufen,
restBleed = eintrag.restBleed,
}
end
return liste
end)

View File

@ -0,0 +1,39 @@
-- =====================================================================
-- Aether Death — Einstellungen rund um Verletzung und Wiederbelebung
-- =====================================================================
Tod = Tod or {}
-- Zeit bis zum Verbluten (Sekunden)
Tod.BLEEDOUT = 300
-- Mindestwartezeit, bevor „Aufgeben" moeglich ist (Sekunden)
Tod.RESPAWN_SPERRE = 45
-- Behandlungspauschale beim Aufwachen im Krankenhaus
Tod.KRANKENHAUS_KOSTEN = 1500
-- Reichweite, in der ein Sanitaeter wiederbeleben kann (Meter)
Tod.WIEDERBELEBUNG_WEITE = 3.0
-- Lebensenergie nach einer Wiederbelebung (GTA zaehlt 100–200)
Tod.LEBEN_NACH_HILFE = 150
-- Lebensenergie nach dem Krankenhaus
Tod.LEBEN_NACH_KLINIK = 200
-- Krankenhaeuser: Aufwachpunkte nach dem Aufgeben oder Verbluten
Tod.Krankenhaeuser = {
{ name = 'Pillbox Hill Medical', pos = vector4(298.6, -584.5, 43.3, 70.0) },
{ name = 'Sandy Shores Medical', pos = vector4(1839.6, 3672.9, 34.3, 210.0) },
{ name = 'Paleto Bay Medical', pos = vector4(-247.8, 6331.2, 32.4, 315.0) },
}
--- Liefert das naechstgelegene Krankenhaus zu einer Position
--- @param position vector3 Ausgangsposition
--- @return table Krankenhaus
function Tod.NaechstesKrankenhaus(position)
local bestes, besterAbstand = Tod.Krankenhaeuser[1], math.huge
for _, klinik in ipairs(Tod.Krankenhaeuser) do
local abstand = #(position - vector3(klinik.pos.x, klinik.pos.y, klinik.pos.z))
if abstand < besterAbstand then
bestes, besterAbstand = klinik, abstand
end
end
return bestes
end

View File

@ -0,0 +1,129 @@
-- =====================================================================
-- Aether Documents — Clientlogik
-- Öffnet die Börse und zeigt vorgezeigte Dokumente anderer Personen an.
-- =====================================================================
local istOffen = false
local istGeladen = false
-- Taste zum Oeffnen der Boerse (F2)
local TASTE_BOERSE = 289
--- Schickt eine Nachricht an die Oberflaeche
local function SendeAnNui(nachricht)
SendNuiMessage(json.encode(nachricht))
end
--- Zeigt eine Meldung oben links im Spiel
local function ZeigeSpielmeldung(text)
if not text or text == '' then return end
BeginTextCommandThefeedPost('STRING')
AddTextComponentSubstringPlayerName(text)
EndTextCommandThefeedPostTicker(false, true)
end
--- Schliesst die Boerse
local function Schliessen()
if not istOffen then return end
istOffen = false
SetNuiFocus(false, false)
SendeAnNui({ aktion = 'schliessen' })
pcall(function() exports['aether-hud']:SetzeSichtbar(true) end)
end
-- ---------------------------------------------------------------------
-- Ereignisse
-- ---------------------------------------------------------------------
AddEventHandler('aether:client:bereit', function()
istGeladen = true
end)
--- Server schickt den Zustand und moechte, dass wir oeffnen
RegisterNetEvent('aether:documents:zeige', function(zustand)
istOffen = true
SetNuiFocus(true, true)
pcall(function() exports['aether-hud']:SetzeSichtbar(false) end)
zustand.aktion = 'oeffnen'
zustand.typen = Dokumente.Typen
SendeAnNui(zustand)
end)
--- Aktualisierter Zustand nach einer Aktion
RegisterNetEvent('aether:documents:zustand', function(zustand)
if not istOffen then
ZeigeSpielmeldung(zustand.hinweis)
return
end
zustand.aktion = 'zustand'
zustand.typen = Dokumente.Typen
SendeAnNui(zustand)
end)
--- Ablehnung einer Aktion
RegisterNetEvent('aether:documents:fehler', function(text)
if istOffen then
SendeAnNui({ aktion = 'fehler', text = text })
else
ZeigeSpielmeldung(text)
end
end)
--- Erfolgsmeldung
RegisterNetEvent('aether:documents:erfolg', function(text)
if istOffen then
SendeAnNui({ aktion = 'hinweis', text = text })
else
ZeigeSpielmeldung(text)
end
end)
--- Jemand zeigt uns ein Dokument: als Vollbild-Ansicht einblenden
RegisterNetEvent('aether:documents:gezeigt', function(dokument)
SetNuiFocus(false, false)
SendeAnNui({ aktion = 'vorgezeigt', dokument = dokument })
end)
-- ---------------------------------------------------------------------
-- Oeffnen per Taste
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
Wait(0)
if istGeladen and not istOffen and IsControlJustReleased(0, TASTE_BOERSE) then
if not IsEntityDead(PlayerPedId()) then
TriggerServerEvent('aether:documents:oeffnen')
end
end
end
end)
-- ---------------------------------------------------------------------
-- NUI-Callbacks
-- ---------------------------------------------------------------------
RegisterNUICallback('schliessen', function(_, rueckgabe)
Schliessen()
rueckgabe({ ok = true })
end)
RegisterNUICallback('verschieben', function(daten, rueckgabe)
TriggerServerEvent('aether:documents:verschieben', daten.dokumentId, daten.zielBoerse)
rueckgabe({ ok = true })
end)
RegisterNUICallback('vorzeigen', function(daten, rueckgabe)
TriggerServerEvent('aether:documents:vorzeigen', daten.dokumentId, daten.zielId)
rueckgabe({ ok = true })
end)
RegisterNUICallback('verlieren', function(daten, rueckgabe)
TriggerServerEvent('aether:documents:verlieren', daten.dokumentId)
rueckgabe({ ok = true })
end)
-- ---------------------------------------------------------------------
-- Aufraeumen
-- ---------------------------------------------------------------------
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false)
end)

View File

@ -0,0 +1,36 @@
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
name 'aether-documents'
author 'Jerrit Fritzsche'
description 'Börse mit Ausweis, Führerschein, Waffenschein und weiteren Dokumenten'
version '0.1.0'
ui_page 'html/index.html'
files {
'html/index.html',
}
-- Core-Dateien mitladen (eigener Lua-State je Resource)
shared_scripts {
'@aether-core/shared/config.lua',
'@aether-core/shared/utils.lua',
'shared/config.lua',
}
client_scripts {
'client/main.lua',
}
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/main.lua',
}
dependencies {
'aether-core',
'aether-inventory',
'oxmysql',
}

View File

@ -0,0 +1,709 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
</head>
<body>
<title>Aether — Börse &amp; Dokumente</title>
<style>
/* ===================== Tokens ===================== */
:root {
--page-bg: #0a0b10;
--fg: #e9ecf4;
--muted: #868da2;
--surface: rgba(24,26,36,0.7);
--surface2: rgba(38,41,55,0.8);
--border: rgba(255,255,255,0.09);
--accent: #c79a5a; /* Leder-Gold */
--red: #f0506e;
--green: #34d39a;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
min-height: 100%;
background:
radial-gradient(1100px 700px at 15% 8%, rgba(199,154,90,0.08), transparent 55%),
radial-gradient(1000px 800px at 85% 92%, rgba(74,168,255,0.06), transparent 55%),
var(--page-bg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--fg);
display: flex; align-items: center; justify-content: center; padding: 32px 18px; overflow-x: hidden;
}
/* ===================== Börse (Wallet) ===================== */
.wallet {
position: relative; width: min(1080px, 96vw); aspect-ratio: 16 / 10;
border-radius: 22px; overflow: hidden; display: flex; flex-direction: column;
background:
linear-gradient(150deg, #3a2c1f, #241a12 60%, #1a130d),
var(--page-bg);
border: 1px solid rgba(199,154,90,0.28);
box-shadow: 0 50px 100px -30px rgba(0,0,0,0.85),
0 0 0 1px rgba(0,0,0,0.4), 0 2px 0 rgba(255,255,255,0.05) inset;
}
/* Ledernaht */
.wallet::before {
content: ""; position: absolute; inset: 10px; border-radius: 16px; pointer-events: none; z-index: 1;
border: 1.5px dashed rgba(199,154,90,0.35);
}
.wallet-head { position: relative; z-index: 2; display: flex; align-items: center; gap: 14px;
padding: 18px 26px; border-bottom: 1px solid rgba(199,154,90,0.2); }
.wallet-head .logo { width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; font-size: 20px;
background: linear-gradient(150deg, #c79a5a, #8a6a34); box-shadow: 0 6px 16px rgba(199,154,90,0.4); }
.wallet-head h1 { font-size: 19px; margin: 0; font-weight: 800; letter-spacing: -0.02em; }
.wallet-head h1 small { display: block; font-size: 11.5px; font-weight: 500; color: #b79b74; letter-spacing: 0; }
.wallet-head .count { margin-left: auto; font-size: 12.5px; color: #b79b74; font-weight: 600; }
/* Börsen-Wechsel */
.wtabs { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.wtab { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; padding: 7px 14px; border-radius: 11px;
background: rgba(0,0,0,0.25); border: 1px solid rgba(199,154,90,0.25); color: #cbb794; cursor: pointer; font-family: inherit; transition: .12s; }
.wtab:hover { border-color: var(--accent); }
.wtab.active { background: linear-gradient(150deg,#c79a5a,#8a6a34); color: #241a0c; border-color: transparent; }
.wtab .wn { font-size: 13px; font-weight: 800; }
.wtab .wc { font-size: 10.5px; font-weight: 600; opacity: 0.85; font-variant-numeric: tabular-nums; }
.wtab.buy { justify-content: center; align-items: center; color: var(--accent); border-style: dashed; font-weight: 800; font-size: 13px; }
.wtab.buy:hover { background: rgba(199,154,90,0.12); }
.wallet-body { position: relative; z-index: 2; flex: 1; display: grid; grid-template-columns: 268px 1fr; min-height: 0; }
.doclist { padding: 18px; overflow-y: auto; border-right: 1px solid rgba(199,154,90,0.18); display: flex; flex-direction: column; gap: 9px; }
.doclist::-webkit-scrollbar { width: 6px; } .doclist::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
.doctab { display: flex; align-items: center; gap: 12px; padding: 12px 13px; border-radius: 13px; cursor: pointer;
background: var(--surface); border: 1px solid var(--border); position: relative; overflow: hidden; transition: .14s; }
.doctab::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--dc, #888); }
.doctab:hover { background: var(--surface2); transform: translateX(2px); }
.doctab.active { border-color: var(--dc); box-shadow: 0 0 0 1px var(--dc) inset; }
.doctab .di { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; font-size: 17px; flex-shrink: 0;
background: rgba(255,255,255,0.06); }
.doctab .dn { font-size: 14px; font-weight: 700; }
.doctab .dm { font-size: 11.5px; color: var(--muted); }
.stage { padding: 26px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; min-height: 0; overflow-y: auto; }
.stage-actions { display: flex; gap: 10px; }
.btn { border: none; border-radius: 12px; padding: 12px 22px; font-size: 14.5px; font-weight: 700; cursor: pointer; font-family: inherit; transition: .12s; }
.btn.primary { background: var(--accent); color: #241a0c; }
.btn.primary:hover { filter: brightness(1.08); }
.btn.danger { background: var(--surface2); color: var(--red); border: 1px solid var(--border); }
.btn.danger:hover { background: rgba(240,80,110,0.14); }
.btn.ghost { background: var(--surface2); color: var(--fg); border: 1px solid var(--border); }
.btn:active { transform: scale(0.97); }
.empty { text-align: center; color: var(--muted); }
.empty .big { font-size: 52px; margin-bottom: 10px; }
/* ===================== Dokument-Basis (ID-1 Format) ===================== */
.doccard {
width: 100%; max-width: 480px; aspect-ratio: 1.586 / 1; border-radius: 16px; position: relative;
overflow: hidden; color: #10141a; box-shadow: 0 30px 60px -20px rgba(0,0,0,0.7), 0 0 0 1px rgba(0,0,0,0.3);
font-size: 13px; user-select: none;
}
.doccard .foto { border-radius: 8px; background: linear-gradient(160deg,#c9d2dc,#8b97a6); overflow: hidden;
display: grid; place-items: end center; }
.doccard .foto svg { width: 100%; height: 100%; }
.lbl { font-size: 8px; letter-spacing: 0.05em; text-transform: uppercase; opacity: 0.7; font-weight: 700; }
.val { font-size: 12.5px; font-weight: 700; margin-top: 1px; }
.mono { font-family: ui-monospace, "SF Mono", monospace; letter-spacing: 0.06em; }
/* Hologramm-Schimmer */
.holo { position: absolute; inset: 0; pointer-events: none; mix-blend-mode: overlay; opacity: 0.5;
background: linear-gradient(115deg, transparent 30%, rgba(255,255,255,0.5) 45%, transparent 60%);
background-size: 300% 300%; animation: holo 5s linear infinite; }
@keyframes holo { 0% { background-position: 120% 0; } 100% { background-position: -120% 0; } }
@media (prefers-reduced-motion: reduce) { .holo { animation: none; opacity: 0.25; } }
/* ---- Personalausweis ---- */
.doc-perso {
color: #16241d;
background:
repeating-linear-gradient(60deg, rgba(255,255,255,0.05) 0 2px, transparent 2px 6px),
repeating-linear-gradient(-60deg, rgba(0,80,50,0.05) 0 2px, transparent 2px 7px),
linear-gradient(150deg, #e6efe6, #cfe3d6 55%, #bcd9c8);
}
.doc-perso .top { display: flex; justify-content: space-between; align-items: flex-start; padding: 12px 16px 0; }
.doc-perso .title { font-size: 15px; font-weight: 800; letter-spacing: 0.02em; color: #0f4d36; }
.doc-perso .sub { font-size: 8.5px; letter-spacing: 0.18em; color: #2a6b52; font-weight: 700; }
.doc-perso .body { display: grid; grid-template-columns: 92px 1fr; gap: 14px; padding: 10px 16px; }
.doc-perso .foto { height: 116px; }
.doc-perso .fields { display: grid; grid-template-columns: 1fr 1fr; gap: 7px 12px; align-content: start; }
.doc-perso .mrz { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(255,255,255,0.55);
font-family: ui-monospace, monospace; font-size: 10px; letter-spacing: 0.14em; padding: 4px 16px; color: #223; border-top: 1px solid rgba(0,0,0,0.1); }
.doc-perso .emblem { width: 34px; height: 34px; }
/* ---- Führerschein ---- */
.doc-fs { color: #2a1420; background: linear-gradient(150deg, #f7dfe6, #efc3d2 55%, #e7a9bf); }
.doc-fs .eu { position: absolute; top: 12px; right: 14px; width: 44px; height: 44px; border-radius: 50%;
background: radial-gradient(circle, #1a3a8a, #0f245e); display: grid; place-items: center; }
.doc-fs .top { padding: 12px 16px 0; }
.doc-fs .title { font-size: 15px; font-weight: 800; color: #9a1b46; letter-spacing: 0.03em; }
.doc-fs .sub { font-size: 8.5px; letter-spacing: 0.16em; color: #b24a6c; font-weight: 700; }
.doc-fs .body { display: grid; grid-template-columns: 88px 1fr; gap: 14px; padding: 10px 16px 6px; }
.doc-fs .foto { height: 104px; background: linear-gradient(160deg,#e7c7d2,#c99cad); }
.doc-fs .fields { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 12px; align-content: start; }
.doc-fs .klassen { position: absolute; bottom: 10px; left: 16px; right: 16px; display: flex; gap: 6px; align-items: center; }
.doc-fs .kl { background: #9a1b46; color: #fff; font-weight: 800; font-size: 11px; padding: 3px 9px; border-radius: 6px; }
/* ---- Waffenschein ---- */
.doc-ws { color: #f0e6d8; background: linear-gradient(155deg, #241a1a, #17110f 60%, #0f0b0a); display: flex; flex-direction: column; }
.doc-ws .band { position: absolute; top: 0; left: 0; right: 0; height: 5px; background: linear-gradient(90deg,#8a1f1f,#e0503f,#8a1f1f); }
.doc-ws .top { padding: 14px 18px 2px; display: flex; justify-content: space-between; align-items: flex-start; }
.doc-ws .title { font-size: 16px; font-weight: 800; letter-spacing: 0.05em; color: #e0503f; }
.doc-ws .sub { font-size: 8.5px; letter-spacing: 0.14em; color: #b98f6a; font-weight: 700; }
.doc-ws .ws-info { display: flex; gap: 16px; flex-wrap: wrap; padding: 4px 18px 8px; font-size: 11px; }
.doc-ws .ws-info b { font-size: 12px; }
.doc-ws .wtable { margin: 0 18px; }
.doc-ws .wrow { display: grid; grid-template-columns: 1.5fr 0.7fr 1.2fr; gap: 8px; font-size: 10.5px; padding: 3.5px 0; border-top: 1px solid rgba(255,255,255,0.12); }
.doc-ws .wrow.h { color: #b98f6a; font-weight: 800; text-transform: uppercase; font-size: 8px; letter-spacing: 0.06em; border: none; }
.doc-ws .stamp { position: absolute; right: 14px; bottom: 12px; width: 66px; height: 66px; border-radius: 50%;
border: 2px solid rgba(224,80,63,0.5); color: rgba(224,80,63,0.65); display: grid; place-items: center; text-align: center;
font-size: 8px; font-weight: 800; transform: rotate(-14deg); line-height: 1.3; letter-spacing: 0.04em; opacity: 0.75; }
/* Visitenkarte: nur Handykontakt (Name + Nummer) */
.doc-vk .vk-tag { font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--muted); font-weight: 700; }
.doc-vk .vk-name { font-size: 30px; font-weight: 800; letter-spacing: -0.02em; margin-top: 6px; }
.doc-vk .vk-phone { color: #f5a623; font-weight: 800; font-size: 23px; margin-top: 10px; font-variant-numeric: tabular-nums; letter-spacing: 0.03em; }
/* ---- Gesundheitskarte ---- */
.doc-gk { color: #08343f; background: linear-gradient(150deg, #d6f0f2, #a9dfe6 55%, #7fcdd8); }
.doc-gk .top { padding: 14px 18px 0; display: flex; justify-content: space-between; align-items: center; }
.doc-gk .title { font-size: 14px; font-weight: 800; color: #0b6b7a; letter-spacing: 0.02em; }
.doc-gk .cross { width: 30px; height: 30px; border-radius: 7px; background: #e2413f; display: grid; place-items: center; color: #fff; font-weight: 900; font-size: 20px; line-height: 1; }
.doc-gk .chip { width: 44px; height: 34px; border-radius: 6px; margin: 14px 0 0 18px;
background: linear-gradient(135deg,#e9d9a0,#b89b53); position: relative; }
.doc-gk .chip::after { content:""; position:absolute; inset:6px; border:1px solid rgba(0,0,0,0.25); border-radius:3px; }
.doc-gk .body { padding: 8px 18px; display: grid; grid-template-columns: 1fr 1fr; gap: 6px 14px; }
.doc-gk .insn { position: absolute; bottom: 12px; left: 18px; font-size: 15px; font-weight: 800; letter-spacing: 0.02em; color: #0b6b7a; }
/* ---- Visitenkarte ---- */
.doc-vk { color: #eef1f6; background: linear-gradient(150deg, #1c2333, #12161f 70%); border: 1px solid rgba(245,166,35,0.3); }
.doc-vk .accentbar { position: absolute; left: 0; top: 0; bottom: 0; width: 8px; background: linear-gradient(180deg,#f5a623,#c9791a); }
.doc-vk .vk-in { padding: 22px 22px 22px 30px; height: 100%; display: flex; flex-direction: column; justify-content: center; }
.doc-vk .vk-name { font-size: 26px; font-weight: 800; letter-spacing: -0.02em; }
.doc-vk .vk-role { color: #f5a623; font-weight: 700; font-size: 13px; margin-top: 2px; letter-spacing: 0.04em; }
.doc-vk .vk-firma { color: var(--muted); font-size: 12px; margin-top: 2px; }
.doc-vk .vk-lines { margin-top: 16px; display: grid; gap: 5px; font-size: 12.5px; }
.doc-vk .vk-lines div { display: flex; align-items: center; gap: 9px; }
.doc-vk .vk-lines .ic { opacity: 0.8; }
.doc-vk .vk-logo { position: absolute; top: 20px; right: 22px; width: 44px; height: 44px; border-radius: 11px;
background: linear-gradient(150deg,#f5a623,#c9791a); display: grid; place-items: center; color: #241a06; font-weight: 900; font-size: 20px; }
/* ===================== Overlays ===================== */
.overlay { position: absolute; inset: 0; z-index: 30; background: rgba(6,7,11,0.72); backdrop-filter: blur(6px);
display: none; align-items: center; justify-content: center; flex-direction: column; gap: 20px; padding: 30px; }
.overlay.show { display: flex; }
.present-caption { text-align: center; color: #dfe4ee; }
.present-caption .pc-h { font-size: 17px; font-weight: 800; }
.present-caption .pc-s { font-size: 13px; color: var(--muted); margin-top: 3px; }
.present-card { transform: scale(1.18); filter: drop-shadow(0 30px 60px rgba(0,0,0,0.6)); animation: raise .4s ease; }
@keyframes raise { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1.18); opacity: 1; } }
.show-to { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; max-width: 520px; }
.show-to button { display: flex; align-items: center; gap: 8px; background: var(--surface2); border: 1px solid var(--border);
color: var(--fg); border-radius: 999px; padding: 8px 14px; font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
.show-to button:hover { border-color: var(--accent); }
.show-to .pa { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 800; color: #fff; }
.modal { width: 380px; max-width: 92%; background: rgba(18,20,28,0.98); border: 1px solid var(--border);
border-radius: 18px; padding: 24px; text-align: center; box-shadow: 0 30px 70px -18px rgba(0,0,0,0.8); }
.modal .mic { font-size: 40px; }
.modal h3 { margin: 8px 0 4px; font-size: 19px; }
.modal p { margin: 0 0 20px; color: var(--muted); font-size: 13.5px; line-height: 1.5; }
.modal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.toast { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(18px);
background: rgba(12,14,20,0.97); border: 1px solid var(--border); color: var(--fg); padding: 11px 20px;
border-radius: 13px; font-size: 13.5px; font-weight: 600; z-index: 40; opacity: 0; pointer-events: none; transition: .3s; }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.hint-bar { position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%); z-index: 5; font-size: 12px; color: #6f7789; }
.hint-bar b { color: #9aa2b4; }
</style>
<div class="wallet" id="wallet">
<div class="wallet-head">
<span class="logo">👛</span>
<h1>Börse<small>Max Weber · Dokumente &amp; Karten</small></h1>
<div class="wtabs" id="wtabs"></div>
</div>
<div class="wallet-body">
<div class="doclist" id="doclist"></div>
<div class="stage" id="stage"></div>
</div>
<div class="overlay" id="overlay"></div>
<div class="toast" id="toast"></div>
</div>
<div class="hint-bar"><b>Karte wählen</b> · <b>Vorzeigen</b> präsentiert das Dokument · <b>Verlieren</b> entfernt es aus der Börse</div>
<script>
"use strict";
/* =====================================================================
PERSON — Stammdaten des Charakters (später aus dem Charakter-Datensatz)
===================================================================== */
/* Personendaten. In FiveM vom Server befüllt (Name/Geburtsdatum kommen
live aus dem Charakter, die Dokumentnummern aus der Datenbank). */
let person = {
vorname: "Max", nachname: "Weber", name: "Max Weber",
geb: "14.03.1996", gebOrt: "Los Santos", nationalitaet: "San Andreas",
geschlecht: "männlich", groesse: "182 cm", augen: "braun",
adresse: "Vespucci Blvd 4B",
persoNr: "LSA-2847-1196", persoGueltig: "14.03.2034",
fsNr: "DL-77 4821 SA", fsKlassen: ["A", "B", "C1"], fsAusstellung: "2016", fsGueltig: "2032",
wsNr: "WA-1102-LS", wsGueltig: "31.12.2027",
wsWaffen: [
{ art: "Pistole P99", kaliber: "9 mm", serie: "P99-8842-LS" },
{ art: "Glock 19", kaliber: "9 mm", serie: "GL19-3391-LS" },
{ art: "MP5 (MPX)", kaliber: "9 mm", serie: "MP5-0075-LS" },
{ art: "Schrotflinte 870", kaliber: "12/70", serie: "SG12-2210-LS" }
],
gkVersicherung: "San Andreas Gesundheit", gkNr: "A123 456 789", gkGueltig: "12/28",
telefon: "555-0182"
};
/* Personen in der Nähe (zum "Vorzeigen").
Der Server schickt bewusst keine Namen mit — im Rollenspiel kennt man
eine Person erst nach Ausweis oder Visitenkarte. */
let spielerNah = [];
/* =====================================================================
DOKUMENTE — jedes ist ein einzelnes Item mit eigenem Kartendesign
===================================================================== */
/* Dokumente des Spielers — in FiveM vom Server geladen.
Jedes trägt seine Datenbank-ID mit, damit Aktionen zuordenbar sind. */
let dokumente = [
{ id: "perso", name: "Personalausweis", icon: "🪪", accent: "#2f9e73", info: "gültig bis " + person.persoGueltig },
{ id: "fs", name: "Führerschein", icon: "🚗", accent: "#d5638a", info: "Klassen " + person.fsKlassen.join(", ") },
{ id: "ws", name: "Waffenschein", icon: "🔫", accent: "#e0503f", info: "gültig bis " + person.wsGueltig },
{ id: "gk", name: "Gesundheitskarte", icon: "➕", accent: "#2fb0c4", info: person.gkVersicherung },
{ id: "vk", name: "Visitenkarte", icon: "💼", accent: "#f5a623", info: person.telefon }
];
/* Mehrere Börsen möglich (jede Börse ist ein Item im Inventar).
Jede fasst max. 10 Dokumente; ein Dokument liegt in genau einer Börse. */
let MAX_SLOTS = 10;
let boersen = [
{ name: "Börse 1", docs: dokumente.map(d => d.id) },
{ name: "Börse 2", docs: [] }
];
let aktiveBoerse = 0; // aktuell geöffnete Börse
let aktiv = boersen[0].docs[0] || null; // aktuell ausgewähltes Dokument
const akt = () => boersen[aktiveBoerse]; // aktuelle Börse
/* =====================================================================
HILFSFUNKTIONEN
===================================================================== */
const $ = id => document.getElementById(id);
function sicher(t) { const d = document.createElement("div"); d.textContent = t; return d.innerHTML; }
let toastTimer = null;
function toast(text) { const t = $("toast"); t.textContent = text; t.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 2200); }
// wert darf HTML enthalten (z. B. <span class="mono">) — Werte stammen aus vertrauenswürdigen Stammdaten
const feld = (label, wert) => '<div><div class="lbl">' + label + '</div><div class="val">' + wert + '</div></div>';
// Personen-Silhouette (Passfoto)
const fotoSvg = '<svg viewBox="0 0 100 120" preserveAspectRatio="xMidYMax meet"><circle cx="50" cy="42" r="24" fill="#5a6472"/><path d="M12 120 q0 -34 38 -34 q38 0 38 34 Z" fill="#5a6472"/></svg>';
// Sternenkreis (EU-Stil) für den Führerschein
function sternRing() {
let s = ""; for (let i = 0; i < 12; i++) { const a = i / 12 * 2 * Math.PI - Math.PI / 2; s += '<text x="' + (22 + 15 * Math.cos(a)).toFixed(1) + '" y="' + (26 + 15 * Math.sin(a)).toFixed(1) + '" font-size="6" fill="#f5c542" text-anchor="middle">★</text>'; }
return '<svg width="44" height="44" viewBox="0 0 44 44">' + s + '<text x="22" y="25" font-size="9" fill="#f5c542" text-anchor="middle" font-weight="800">SA</text></svg>';
}
/* =====================================================================
KARTEN-RENDERER — jedes Dokument sieht anders aus
===================================================================== */
const kartenRenderer = {
perso: () =>
'<div class="doccard doc-perso">' +
'<div class="holo"></div>' +
'<div class="top"><div><div class="title">PERSONALAUSWEIS</div><div class="sub">REPUBLIK SAN ANDREAS · IDENTITY CARD</div></div>' +
'<svg class="emblem" viewBox="0 0 34 34"><circle cx="17" cy="17" r="16" fill="none" stroke="#0f4d36" stroke-width="1.5"/><path d="M17 5 l3 8 h8 l-6 5 2 9 -7 -5 -7 5 2 -9 -6 -5 h8 Z" fill="#0f4d36"/></svg></div>' +
'<div class="body">' +
'<div class="foto">' + fotoSvg + '</div>' +
'<div class="fields">' +
feld("Name", person.nachname) + feld("Vornamen", person.vorname) +
feld("Geburtsdatum", person.geb) + feld("Geburtsort", person.gebOrt) +
feld("Staatsangeh.", person.nationalitaet) + feld("Größe", person.groesse) +
'<div style="grid-column:span 2">' + feld("Ausweisnr.", '<span class="mono">' + person.persoNr + '</span> · gültig bis ' + person.persoGueltig) + '</div>' +
'</div>' +
'</div>' +
'<div class="mrz">IDSAA' + person.persoNr.replace(/[^0-9]/g, "") + '&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;<br>9603140M3403147SAA&lt;&lt;WEBER&lt;&lt;MAX&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;</div>' +
'</div>',
fs: () =>
'<div class="doccard doc-fs">' +
'<div class="holo"></div>' +
'<div class="eu">' + sternRing() + '</div>' +
'<div class="top"><div class="title">FÜHRERSCHEIN</div><div class="sub">REPUBLIK SAN ANDREAS · DRIVING LICENCE</div></div>' +
'<div class="body">' +
'<div class="foto">' + fotoSvg + '</div>' +
'<div class="fields">' +
feld("1. Name", person.nachname) + feld("2. Vorname", person.vorname) +
feld("3. Geburtsdatum", person.geb) + feld("4a. Ausstellung", person.fsAusstellung) +
feld("4b. Gültig bis", person.fsGueltig) + feld("5. Nr.", '<span class="mono">' + person.fsNr + '</span>') +
'</div>' +
'</div>' +
'<div class="klassen"><span class="lbl" style="color:#9a1b46">9. Klassen:</span>' +
person.fsKlassen.map(k => '<span class="kl">' + k + '</span>').join("") + '</div>' +
'</div>',
ws: () =>
'<div class="doccard doc-ws">' +
'<div class="band"></div>' +
'<div class="stamp">GEPRÜFT<br>LSPD<br>★ SA ★</div>' +
'<div class="top"><div><div class="title">WAFFENSCHEIN</div><div class="sub">INNENMINISTERIUM SAN ANDREAS</div></div>' +
'<div style="text-align:right"><div class="lbl" style="color:#b98f6a">Schein-Nr.</div><div class="val mono">' + person.wsNr + '</div></div></div>' +
'<div class="ws-info">' +
'<span><span class="lbl">Inhaber</span> <b>' + sicher(person.name) + '</b></span>' +
'<span><span class="lbl">Geboren</span> <b>' + sicher(person.geb) + '</b></span>' +
'<span><span class="lbl">Gültig bis</span> <b>' + sicher(person.wsGueltig) + '</b></span>' +
'</div>' +
'<div class="wtable">' +
'<div class="wrow h"><span>Registrierte Waffe (' + person.wsWaffen.length + ')</span><span>Kaliber</span><span>Seriennummer</span></div>' +
person.wsWaffen.map(w => '<div class="wrow"><span>' + sicher(w.art) + '</span><span>' + sicher(w.kaliber) + '</span><span class="mono">' + sicher(w.serie) + '</span></div>').join("") +
'</div>' +
'</div>',
gk: () =>
'<div class="doccard doc-gk">' +
'<div class="holo"></div>' +
'<div class="top"><div class="title">GESUNDHEITSKARTE</div><div class="cross">+</div></div>' +
'<div class="chip"></div>' +
'<div class="body" style="margin-top:6px">' +
feld("Versicherte/r", person.name) + feld("Vers.-Nr.", '<span class="mono">' + person.gkNr + '</span>') +
feld("Geboren", person.geb) + feld("Gültig", person.gkGueltig) +
'</div>' +
'<div class="insn">' + sicher(person.gkVersicherung) + '</div>' +
'</div>',
vk: () =>
'<div class="doccard doc-vk">' +
'<div class="accentbar"></div>' +
'<div class="vk-logo">📱</div>' +
'<div class="vk-in">' +
'<div class="vk-tag">Handykontakt</div>' +
'<div class="vk-name">' + sicher(person.name) + '</div>' +
'<div class="vk-phone">' + sicher(person.telefon) + '</div>' +
'</div>' +
'</div>'
};
function renderKarte(id) { return kartenRenderer[id] ? kartenRenderer[id]() : ""; }
/* =====================================================================
BÖRSE RENDERN
===================================================================== */
// Börsen-Wechsel-Leiste (Tabs) inkl. Shop-Kauf
function renderBoersenTabs() {
const wt = $("wtabs");
wt.innerHTML = boersen.map((b, i) =>
'<button class="wtab' + (i === aktiveBoerse ? " active" : "") + '" data-b="' + i + '">' +
'<span class="wn">' + sicher(b.name) + '</span><span class="wc">' + b.docs.length + " / " + MAX_SLOTS + '</span></button>').join("") +
'<button class="wtab buy" id="buy-boerse" title="Weitere Börse im Shop kaufen">+ Börse</button>';
wt.querySelectorAll("[data-b]").forEach(t => t.addEventListener("click", () => {
aktiveBoerse = +t.dataset.b; aktiv = akt().docs[0] || null; render();
}));
$("buy-boerse").addEventListener("click", kaufeBoerse);
}
// Neue Börse "kaufen" (Shop-Demo)
function kaufeBoerse() {
if (IST_FIVEM) {
// Börsen sind Items — sie werden im 24/7 gekauft, nicht hier erzeugt
toast("Börsen bekommst du im 24/7-Laden");
return;
}
if (boersen.length >= 4) { toast("Maximale Anzahl Börsen erreicht"); return; }
boersen.push({ name: "Börse " + (boersen.length + 1), docs: [] });
aktiveBoerse = boersen.length - 1; aktiv = null;
toast("Neue Börse gekauft");
render();
}
function render() {
renderBoersenTabs();
const besitz = akt().docs;
const liste = $("doclist");
if (besitz.length === 0) {
liste.innerHTML = '<div class="empty" style="margin:auto"><div class="big">👛</div><div>' + sicher(akt().name) + ' ist leer.</div>' +
'<div style="font-size:12px;margin-top:6px">Dokumente aus einer<br>anderen Börse hierher verschieben.</div></div>';
$("stage").innerHTML = '<div class="empty"><div class="big">📭</div><div style="font-weight:700;color:var(--fg)">Keine Dokumente in ' + sicher(akt().name) + '</div></div>';
return;
}
liste.innerHTML = besitz.map(id => {
const d = dokumente.find(x => x.id === id);
return '<div class="doctab' + (id === aktiv ? " active" : "") + '" data-doc="' + id + '" style="--dc:' + d.accent + '">' +
'<span class="di" style="color:' + d.accent + '">' + d.icon + '</span>' +
'<div><div class="dn">' + sicher(d.name) + '</div><div class="dm">' + sicher(d.info) + '</div></div></div>';
}).join("");
liste.querySelectorAll("[data-doc]").forEach(t => t.addEventListener("click", () => { aktiv = t.dataset.doc; render(); }));
// Bühne rechts: aktive Karte + Aktionen
if (!besitz.includes(aktiv)) aktiv = besitz[0];
$("stage").innerHTML = renderKarte(aktiv) +
'<div class="stage-actions">' +
'<button class="btn primary" id="act-show">Vorzeigen</button>' +
(boersen.length > 1 ? '<button class="btn ghost" id="act-move">Verschieben</button>' : "") +
'<button class="btn danger" id="act-lose">Verlieren</button>' +
'</div>';
$("act-show").addEventListener("click", vorzeigen);
if ($("act-move")) $("act-move").addEventListener("click", verschiebenFrage);
$("act-lose").addEventListener("click", verlierenFrage);
}
/* Dokument in eine andere Börse verschieben (Ziel muss freien Platz haben) */
function verschiebenFrage() {
const d = dokumente.find(x => x.id === aktiv);
const ziele = boersen.map((b, i) => ({ b, i })).filter(o => o.i !== aktiveBoerse && o.b.docs.length < MAX_SLOTS);
if (ziele.length === 0) { toast("Keine andere Börse mit freiem Platz"); return; }
$("overlay").innerHTML =
'<div class="modal"><div class="mic">📂</div><h3>' + sicher(d.name) + ' verschieben</h3>' +
'<p>In welche Börse soll das Dokument?</p>' +
'<div style="display:grid;gap:8px">' + ziele.map(o =>
'<button class="btn ghost" data-t="' + o.i + '" style="width:100%;display:flex;justify-content:space-between">' +
sicher(o.b.name) + '<span style="opacity:0.7">' + o.b.docs.length + " / " + MAX_SLOTS + '</span></button>').join("") +
'</div><div style="height:10px"></div><button class="btn ghost" id="m-cancel" style="width:100%">Abbrechen</button></div>';
$("overlay").classList.add("show");
$("m-cancel").addEventListener("click", schliesseOverlay);
$("overlay").querySelectorAll("[data-t]").forEach(b => b.addEventListener("click", () => {
const zi = +b.dataset.t;
schliesseOverlay();
if (IST_FIVEM) { nui("verschieben", { dokumentId: d.dbId, zielBoerse: zi }); return; }
// Browser-Ansicht: lokal verschieben
akt().docs = akt().docs.filter(id => id !== aktiv);
boersen[zi].docs.push(aktiv);
toast(d.name + " → " + boersen[zi].name);
aktiv = akt().docs[0] || null;
render();
}));
}
/* =====================================================================
VORZEIGEN — Dokument präsentieren (optional gezielt an Spieler)
===================================================================== */
function vorzeigen() {
const d = dokumente.find(x => x.id === aktiv);
$("overlay").innerHTML =
'<div class="present-caption"><div class="pc-h">Du zeigst deinen ' + sicher(d.name) + ' vor</div>' +
'<div class="pc-s">Halte ihn hoch — oder wähle gezielt eine Person</div></div>' +
'<div class="present-card">' + renderKarte(aktiv) + '</div>' +
'<div class="show-to" id="show-to">' +
spielerNah.map((sp, i) => '<button data-p="' + i + '"><span class="pa" style="background:' + sp.farbe + '">' + (sp.bekannt ? sp.name.substring(0, 1) : "?") + '</span>' +
(sp.bekannt ? sicher(sp.name) : "Unbekannt #" + sp.id) + '</button>').join("") +
'</div>' +
'<button class="btn ghost" id="present-close">Einstecken</button>';
$("overlay").classList.add("show");
$("present-close").addEventListener("click", schliesseOverlay);
$("show-to").querySelectorAll("[data-p]").forEach(b => b.addEventListener("click", () => {
const sp = spielerNah[+b.dataset.p];
schliesseOverlay();
if (IST_FIVEM) { nui("vorzeigen", { dokumentId: d.dbId, zielId: sp.id }); return; }
toast(d.name + " vorgezeigt an Unbekannt #" + sp.id);
}));
}
/* =====================================================================
VERLIEREN — Dokument aus der Börse entfernen
===================================================================== */
function verlierenFrage() {
const d = dokumente.find(x => x.id === aktiv);
$("overlay").innerHTML =
'<div class="modal"><div class="mic">⚠️</div><h3>' + sicher(d.name) + ' verlieren?</h3>' +
'<p>Das Dokument wird aus deiner Börse entfernt. Im Spiel müsstest du es neu beantragen.</p>' +
'<div class="modal-actions"><button class="btn ghost" id="v-cancel">Abbrechen</button>' +
'<button class="btn danger" id="v-ok">Verlieren</button></div></div>';
$("overlay").classList.add("show");
$("v-cancel").addEventListener("click", schliesseOverlay);
$("v-ok").addEventListener("click", () => {
schliesseOverlay();
if (IST_FIVEM) { nui("verlieren", { dokumentId: d.dbId }); return; }
akt().docs = akt().docs.filter(id => id !== aktiv);
toast("Du hast deinen " + d.name + " verloren");
aktiv = akt().docs[0] || null;
render();
});
}
function schliesseOverlay() { $("overlay").classList.remove("show"); $("overlay").innerHTML = ""; }
$("overlay").addEventListener("click", e => { if (e.target === $("overlay")) schliesseOverlay(); });
/* =====================================================================
NUI-BRÜCKE — Verbindung zum FiveM-Client
===================================================================== */
const IST_FIVEM = (typeof GetParentResourceName === "function");
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-documents";
function nui(aktion, daten) {
if (!IST_FIVEM) return Promise.resolve();
return fetch(`https://${RESSOURCE}/${aktion}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(daten || {}),
}).catch(() => {});
}
/* Überführt die Serverdaten in die Felder, die die Kartendesigns erwarten.
Name und Geburtsdatum kommen aus dem Charakter, der Rest aus dem
jeweiligen Dokument — so bleiben die vorhandenen Designs unverändert. */
function setzeZustand(z) {
MAX_SLOTS = z.maxSlots || 10;
// Personendaten aus dem Charakter
person.vorname = z.person.vorname || "";
person.nachname = z.person.nachname || "";
person.name = (person.vorname + " " + person.nachname).trim();
person.geb = z.person.geburt || "—";
person.geschlecht = z.person.geschlecht || "—";
// Dokumentspezifische Felder aus den einzelnen Dokumenten übernehmen
dokumente = (z.dokumente || []).map(d => {
const w = d.daten || {};
if (d.typ === "perso") {
person.persoNr = w.nummer || "—";
person.persoGueltig = d.gueltigBis || "—";
person.gebOrt = w.gebOrt || "Los Santos";
person.groesse = w.groesse || "—";
person.nationalitaet = w.nationalitaet || "San Andreas";
person.adresse = w.adresse || "—";
person.augen = w.augen || "—";
} else if (d.typ === "fs") {
person.fsNr = w.nummer || "—";
person.fsKlassen = w.klassen || ["B"];
person.fsGueltig = d.gueltigBis || "—";
person.fsAusstellung = w.ausstellung || "—";
} else if (d.typ === "ws") {
person.wsNr = w.nummer || "—";
person.wsGueltig = d.gueltigBis || "—";
person.wsWaffen = w.waffen || [];
} else if (d.typ === "gk") {
person.gkVersicherung = w.versicherung || "—";
person.gkNr = w.nummer || "—";
person.gkGueltig = d.gueltigBis || "—";
} else if (d.typ === "vk") {
person.telefon = w.telefon || "—";
}
// Kurzinfo für die Listenansicht
let info = d.gueltigBis ? ("gültig bis " + d.gueltigBis) : "";
if (d.typ === "fs" && w.klassen) info = "Klassen " + w.klassen.join(", ");
if (d.typ === "gk") info = w.versicherung || info;
if (d.typ === "vk") info = w.telefon || "keine Nummer";
return {
id: d.typ, // für die Kartendesigns (kartenRenderer)
dbId: d.id, // für Aktionen am Server
boerse: d.boerse || 0,
name: d.name, icon: d.icon, accent: d.accent, info: info,
};
});
// Börsen aufbauen: so viele, wie der Spieler Börsen-Items besitzt
const anzahl = Math.max(1, z.boersen || 1);
boersen = [];
for (let i = 0; i < anzahl; i++) {
boersen.push({
name: "Börse " + (i + 1),
docs: dokumente.filter(d => d.boerse === i).map(d => d.id),
});
}
if (aktiveBoerse >= boersen.length) aktiveBoerse = 0;
if (!akt().docs.includes(aktiv)) aktiv = akt().docs[0] || null;
spielerNah = (z.spieler || []).map(sp => ({
id: sp.id, bekannt: false, name: "Unbekannt", farbe: "#3a3f4d",
}));
render();
if (z.hinweis) toast(z.hinweis);
}
window.addEventListener("message", (ereignis) => {
const n = ereignis.data || {};
if (n.aktion === "oeffnen") {
document.body.style.display = "";
istEigeneBoerseOffen = true;
setzeZustand(n);
}
else if (n.aktion === "zustand") {
setzeZustand(n);
}
else if (n.aktion === "hinweis") {
toast(n.text || "");
}
else if (n.aktion === "fehler") {
toast(n.text || "Nicht möglich");
}
else if (n.aktion === "vorgezeigt") {
// Jemand zeigt uns ein Dokument — als Vollbild einblenden
zeigeVorgelegtes(n.dokument);
}
else if (n.aktion === "schliessen") {
istEigeneBoerseOffen = false;
document.body.style.display = "none";
schliesseOverlay();
}
});
// Merkt sich, ob die eigene Börse offen ist (für das Schließen nach dem Vorzeigen)
let istEigeneBoerseOffen = false;
/* Zeigt ein Dokument, das eine ANDERE Person vorlegt.
Wichtig: Die Kartendesigns lesen aus `person`. Für die Anzeige wird
daher kurzzeitig auf die Daten des Zeigenden umgeschaltet und danach
wieder zurück — sonst stünden die eigenen Daten auf fremden Karten. */
function zeigeVorgelegtes(d) {
if (!d) return;
document.body.style.display = "";
const eigene = person; // eigene Daten merken
const w = d.daten || {};
person = {
name: d.inhaber || "Unbekannt",
vorname: (d.inhaber || "").split(" ")[0] || "",
nachname: (d.inhaber || "").split(" ").slice(1).join(" "),
geb: d.geburt || "—",
geschlecht: "—",
gebOrt: w.gebOrt || "Los Santos", nationalitaet: w.nationalitaet || "San Andreas",
groesse: w.groesse || "—", augen: w.augen || "—", adresse: w.adresse || "—",
persoNr: w.nummer || "—", persoGueltig: d.gueltigBis || "—",
fsNr: w.nummer || "—", fsKlassen: w.klassen || ["B"],
fsAusstellung: w.ausstellung || "—", fsGueltig: d.gueltigBis || "—",
wsNr: w.nummer || "—", wsGueltig: d.gueltigBis || "—", wsWaffen: w.waffen || [],
gkVersicherung: w.versicherung || "—", gkNr: w.nummer || "—", gkGueltig: d.gueltigBis || "—",
telefon: w.telefon || "—",
};
const karte = renderKarte(d.typ); // mit fremden Daten rendern
person = eigene; // sofort zurückschalten
$("overlay").innerHTML =
'<div class="present-caption"><div class="pc-h">' + sicher(d.inhaber || "Unbekannt") + ' zeigt: ' + sicher(d.name) + '</div>' +
'<div class="pc-s">Geboren am ' + sicher(d.geburt || "—") + '</div></div>' +
'<div class="present-card">' + karte + '</div>' +
'<button class="btn ghost" id="present-close">Schließen</button>';
$("overlay").classList.add("show");
$("present-close").addEventListener("click", () => {
schliesseOverlay();
if (!istEigeneBoerseOffen) document.body.style.display = "none";
});
}
/* ESC schließt die Börse */
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && IST_FIVEM) nui("schliessen", {});
});
/* =====================================================================
START
===================================================================== */
if (IST_FIVEM) {
document.body.style.display = "none"; // wartet auf "oeffnen"
} else {
render(); // Browser-Ansicht mit den Beispieldaten oben
}
</script>
</body>
</html>

View File

@ -0,0 +1,386 @@
-- =====================================================================
-- Aether Documents — Serverlogik
-- Verwaltet Ausweise, Führerscheine und Waffenscheine. Behoerdliche
-- Dokumente kann ein Spieler sich nicht selbst ausstellen.
-- =====================================================================
local Core = exports['aether-core']
local Inventory = exports['aether-inventory']
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Erzeugt eine Dokumentnummer nach einem Muster
--- @param praefix string Vorsilbe, z. B. 'LSA'
--- @return string
local function ErzeugeNummer(praefix)
return ('%s-%04d-%04d'):format(praefix, math.random(1000, 9999), math.random(1000, 9999))
end
--- Rechnet eine Anzahl Jahre in ein Ablaufdatum um
--- @param jahre number|nil Gueltigkeit in Jahren
--- @return string|nil Datum im DB-Format oder nil
local function GueltigBis(jahre)
if not jahre then return nil end
return os.date('%Y-%m-%d', os.time() + jahre * 365 * 24 * 60 * 60)
end
--- Wie viele Boersen besitzt der Spieler? (jede Boerse ist ein Item)
--- @param quelle number Server-ID
--- @return number
local function AnzahlBoersen(quelle)
local anzahl = Inventory:ZaehleItem(quelle, Dokumente.BOERSE_ITEM) or 0
return math.max(0, anzahl)
end
--- Laedt alle Dokumente eines Charakters und ergaenzt die Personendaten
--- @param charakterId number Charakter-ID
--- @return table Liste von Dokumenten
local function HoleDokumente(charakterId)
local zeilen = MySQL.query.await([[
SELECT id, type, wallet, data, issued_by, issued_at, valid_until, revoked
FROM aether_documents
WHERE `character` = ? AND revoked = 0
ORDER BY id ASC
]], { charakterId }) or {}
local liste = {}
for _, zeile in ipairs(zeilen) do
local daten = {}
if zeile.data then
local erfolg, entschluesselt = pcall(json.decode, zeile.data)
if erfolg and type(entschluesselt) == 'table' then daten = entschluesselt end
end
local typ = Dokumente.Typen[zeile.type]
liste[#liste + 1] = {
id = zeile.id,
typ = zeile.type,
name = typ and typ.name or zeile.type,
icon = typ and typ.icon or '📄',
accent = typ and typ.accent or '#888',
boerse = zeile.wallet,
daten = daten,
aussteller= zeile.issued_by,
gueltigBis= zeile.valid_until and tostring(zeile.valid_until):sub(1, 10) or nil,
}
end
return liste
end
--- Legt die automatisch vergebenen Dokumente an, falls sie fehlen
--- @param charakterId number Charakter-ID
--- @param person table Charakterdaten aus dem Core
local function ErstelleAutomatische(charakterId, person)
for typKennung, typ in pairs(Dokumente.Typen) do
if typ.automatisch then
local vorhanden = MySQL.scalar.await(
'SELECT id FROM aether_documents WHERE `character` = ? AND type = ?',
{ charakterId, typKennung })
if not vorhanden then
-- Typspezifische Daten erzeugen
local daten = {}
if typKennung == 'perso' then
daten = {
nummer = ErzeugeNummer('LSA'),
gebOrt = person.herkunft or 'Los Santos',
groesse = tostring(math.random(160, 195)) .. ' cm',
}
elseif typKennung == 'gk' then
daten = {
versicherung = 'San Andreas Gesundheit',
nummer = ErzeugeNummer('GK'),
}
elseif typKennung == 'vk' then
daten = { telefon = nil } -- wird vom Handy-Modul gefuellt
end
MySQL.insert.await([[
INSERT INTO aether_documents (`character`, type, wallet, data, issued_by, valid_until)
VALUES (?, ?, 0, ?, ?, ?)
]], {
charakterId, typKennung, json.encode(daten),
'Einwohnermeldeamt', GueltigBis(typ.gueltigJahre),
})
end
end
end
end
--- Baut den Zustand fuer die Oberflaeche
--- @param quelle number Server-ID
--- @return table|nil
local function BaueZustand(quelle)
local charakterId = Core:HoleCharakterId(quelle)
if not charakterId then return nil end
local person = Core:HoleSpielerInfo(quelle)
if not person then return nil end
-- Personen in Reichweite (bewusst ohne Namen — RP-Regel)
local hier = GetEntityCoords(GetPlayerPed(quelle))
local nahe = {}
for _, andere in ipairs(GetPlayers()) do
andere = tonumber(andere)
if andere ~= quelle then
local dort = GetEntityCoords(GetPlayerPed(andere))
if #(hier - dort) <= Dokumente.VORZEIG_WEITE then
nahe[#nahe + 1] = { id = andere, bekannt = false }
end
end
end
return {
person = {
vorname = person.vorname, nachname = person.nachname,
geburt = person.geburtstag, geschlecht = person.geschlecht,
},
dokumente = HoleDokumente(charakterId),
boersen = AnzahlBoersen(quelle),
maxSlots = Dokumente.MAX_SLOTS,
spieler = nahe,
}
end
--- Schickt den Zustand an einen Spieler
local function SendeZustand(quelle, hinweis)
local zustand = BaueZustand(quelle)
if not zustand then return end
zustand.hinweis = hinweis
TriggerClientEvent('aether:documents:zustand', quelle, zustand)
end
--- Lehnt eine Aktion ab
local function Ablehnen(quelle, grund)
TriggerClientEvent('aether:documents:fehler', quelle, grund)
end
-- ---------------------------------------------------------------------
-- Beim Laden des Spielers: automatische Dokumente sicherstellen
-- ---------------------------------------------------------------------
AddEventHandler('aether:server:spielerGeladen', function(quelle, spieler)
ErstelleAutomatische(spieler.charakterId, {
herkunft = spieler.herkunft,
})
end)
-- ---------------------------------------------------------------------
-- Boerse oeffnen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:documents:oeffnen', function()
local quelle = source
if not Core:HoleCharakterId(quelle) then return end
if AnzahlBoersen(quelle) < 1 then
Ablehnen(quelle, 'Du hast keine Börse dabei. Es gibt welche im 24/7.')
return
end
local zustand = BaueZustand(quelle)
if zustand then
TriggerClientEvent('aether:documents:zeige', quelle, zustand)
end
end)
-- ---------------------------------------------------------------------
-- Dokument in eine andere Boerse verschieben
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:documents:verschieben', function(dokumentId, zielBoerse)
local quelle = source
local charakterId = Core:HoleCharakterId(quelle)
if not charakterId then return end
if type(dokumentId) ~= 'number' or type(zielBoerse) ~= 'number' then return end
zielBoerse = math.floor(zielBoerse)
if zielBoerse < 0 or zielBoerse >= AnzahlBoersen(quelle) then
Ablehnen(quelle, 'Diese Börse hast du nicht.')
return
end
-- Gehoert das Dokument dem Spieler?
local gehoert = MySQL.scalar.await(
'SELECT id FROM aether_documents WHERE id = ? AND `character` = ? AND revoked = 0',
{ dokumentId, charakterId })
if not gehoert then return end
-- Platz in der Zielboerse pruefen
local belegt = MySQL.scalar.await(
'SELECT COUNT(*) FROM aether_documents WHERE `character` = ? AND wallet = ? AND revoked = 0',
{ charakterId, zielBoerse }) or 0
if belegt >= Dokumente.MAX_SLOTS then
Ablehnen(quelle, 'Diese Börse ist voll.')
return
end
MySQL.update.await('UPDATE aether_documents SET wallet = ? WHERE id = ?', { zielBoerse, dokumentId })
SendeZustand(quelle, 'Dokument verschoben.')
end)
-- ---------------------------------------------------------------------
-- Dokument vorzeigen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:documents:vorzeigen', function(dokumentId, zielQuelle)
local quelle = source
local charakterId = Core:HoleCharakterId(quelle)
if not charakterId or type(dokumentId) ~= 'number' then return end
local dokumente = HoleDokumente(charakterId)
local gezeigt = nil
for _, dokument in ipairs(dokumente) do
if dokument.id == dokumentId then gezeigt = dokument break end
end
if not gezeigt then return end
local person = Core:HoleSpielerInfo(quelle)
gezeigt.inhaber = (person.vorname or '') .. ' ' .. (person.nachname or '')
gezeigt.geburt = person.geburtstag
-- Gezielt einer Person zeigen oder allen in Reichweite
local hier = GetEntityCoords(GetPlayerPed(quelle))
local empfaenger = {}
if type(zielQuelle) == 'number' then
local dort = GetEntityCoords(GetPlayerPed(zielQuelle))
if #(hier - dort) <= Dokumente.VORZEIG_WEITE then
empfaenger[#empfaenger + 1] = zielQuelle
else
Ablehnen(quelle, 'Die Person ist zu weit weg.')
return
end
else
for _, andere in ipairs(GetPlayers()) do
andere = tonumber(andere)
if andere ~= quelle then
local dort = GetEntityCoords(GetPlayerPed(andere))
if #(hier - dort) <= Dokumente.VORZEIG_WEITE then
empfaenger[#empfaenger + 1] = andere
end
end
end
end
if #empfaenger == 0 then
Ablehnen(quelle, 'Niemand in der Nähe.')
return
end
for _, andere in ipairs(empfaenger) do
TriggerClientEvent('aether:documents:gezeigt', andere, gezeigt)
end
TriggerClientEvent('aether:documents:erfolg', quelle,
('%s vorgezeigt (%d Person%s).'):format(gezeigt.name, #empfaenger, #empfaenger == 1 and '' or 'en'))
end)
-- ---------------------------------------------------------------------
-- Dokument verlieren
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:documents:verlieren', function(dokumentId)
local quelle = source
local charakterId = Core:HoleCharakterId(quelle)
if not charakterId or type(dokumentId) ~= 'number' then return end
local geloescht = MySQL.update.await(
'DELETE FROM aether_documents WHERE id = ? AND `character` = ?',
{ dokumentId, charakterId })
if (geloescht or 0) > 0 then
SendeZustand(quelle, 'Dokument verloren.')
end
end)
-- =====================================================================
-- EXPORTS — Schnittstelle fuer andere Module
-- =====================================================================
--- Besitzt der Spieler einen gueltigen Waffenschein?
--- Wird vom Shop (Ammu-Nation) abgefragt.
--- @param quelle number Server-ID
--- @return boolean
exports('HatWaffenschein', function(quelle)
local charakterId = Core:HoleCharakterId(quelle)
if not charakterId then return false end
local treffer = MySQL.scalar.await([[
SELECT id FROM aether_documents
WHERE `character` = ? AND type = 'ws' AND revoked = 0
AND (valid_until IS NULL OR valid_until >= CURDATE())
]], { charakterId })
return treffer ~= nil
end)
--- Besitzt der Spieler ein bestimmtes gueltiges Dokument?
--- @param quelle number Server-ID
--- @param typ string Dokumenttyp
--- @return boolean
exports('HatDokument', function(quelle, typ)
local charakterId = Core:HoleCharakterId(quelle)
if not charakterId or not Dokumente.TypGueltig(typ) then return false end
local treffer = MySQL.scalar.await([[
SELECT id FROM aether_documents
WHERE `character` = ? AND type = ? AND revoked = 0
AND (valid_until IS NULL OR valid_until >= CURDATE())
]], { charakterId, typ })
return treffer ~= nil
end)
--- Stellt einem Charakter ein behoerdliches Dokument aus.
--- Gedacht fuer das Fraktions-CAD (Polizei stellt Waffenschein aus).
--- @param charakterId number Charakter-ID
--- @param typ string Dokumenttyp
--- @param daten table|nil Zusatzdaten (z. B. Fahrklassen, Waffenliste)
--- @param aussteller string|nil Ausstellende Stelle
--- @return boolean Erfolg
exports('StelleAus', function(charakterId, typ, daten, aussteller)
if not Dokumente.TypGueltig(typ) then return false end
local typDef = Dokumente.Typen[typ]
local nummer = ErzeugeNummer(typ == 'ws' and 'WA' or 'DL')
daten = type(daten) == 'table' and daten or {}
daten.nummer = daten.nummer or nummer
-- Vorhandenes Dokument auffrischen, sonst neu anlegen
MySQL.query.await([[
INSERT INTO aether_documents (`character`, type, wallet, data, issued_by, valid_until, revoked)
VALUES (?, ?, 0, ?, ?, ?, 0)
ON DUPLICATE KEY UPDATE
data = VALUES(data), issued_by = VALUES(issued_by),
valid_until = VALUES(valid_until), revoked = 0, issued_at = NOW()
]], {
charakterId, typ, json.encode(daten),
aussteller or 'Behörde', GueltigBis(typDef.gueltigJahre),
})
-- Falls der Spieler online ist, Ansicht auffrischen
local quelle = Core:HoleQuelleNachCharakter(charakterId)
if quelle then
SendeZustand(quelle, ('%s ausgestellt.'):format(typDef.name))
end
Aether.Info(('Dokument %s fuer Charakter #%d ausgestellt.'):format(typ, charakterId))
return true
end)
--- Entzieht ein behoerdliches Dokument
--- @param charakterId number Charakter-ID
--- @param typ string Dokumenttyp
--- @return boolean Erfolg
exports('Entziehe', function(charakterId, typ)
if not Dokumente.TypGueltig(typ) then return false end
MySQL.update.await(
'UPDATE aether_documents SET revoked = 1 WHERE `character` = ? AND type = ?',
{ charakterId, typ })
local quelle = Core:HoleQuelleNachCharakter(charakterId)
if quelle then
SendeZustand(quelle, ('%s wurde entzogen.'):format(Dokumente.Typen[typ].name))
end
Aether.Info(('Dokument %s fuer Charakter #%d entzogen.'):format(typ, charakterId))
return true
end)

View File

@ -0,0 +1,49 @@
-- =====================================================================
-- Aether Documents — Dokumenttypen und Grenzen
-- =====================================================================
Dokumente = Dokumente or {}
-- Wie viele Dokumente passen in eine Boerse?
Dokumente.MAX_SLOTS = 10
-- Reichweite zum Vorzeigen (Meter)
Dokumente.VORZEIG_WEITE = 4.0
-- Item, das als Boerse dient (aus aether-inventory)
Dokumente.BOERSE_ITEM = 'boerse'
-- ---------------------------------------------------------------------
-- Dokumenttypen
-- automatisch = wird bei der Charaktererstellung angelegt
-- behoerde = kann nur von einer Behoerde ausgestellt werden
-- ---------------------------------------------------------------------
Dokumente.Typen = {
perso = {
name = 'Personalausweis', icon = '🪪', accent = '#2f9e73',
automatisch = true, gueltigJahre = 10,
},
gk = {
name = 'Gesundheitskarte', icon = '➕', accent = '#2fb0c4',
automatisch = true, gueltigJahre = 6,
},
vk = {
name = 'Visitenkarte', icon = '💼', accent = '#f5a623',
automatisch = true,
},
fs = {
name = 'Führerschein', icon = '🚗', accent = '#d5638a',
behoerde = true, gueltigJahre = 15,
},
ws = {
name = 'Waffenschein', icon = '🔫', accent = '#e0503f',
behoerde = true, gueltigJahre = 3,
},
}
--- Prueft, ob ein Typ gueltig ist
--- @param typ any Zu pruefender Wert
--- @return boolean
function Dokumente.TypGueltig(typ)
return type(typ) == 'string' and Dokumente.Typen[typ] ~= nil
end

View File

@ -0,0 +1,330 @@
-- =====================================================================
-- Aether Garage — Clientlogik
-- Erzeugt Fahrzeuge erst nach Freigabe durch den Server und meldet
-- beim Einparken den tatsaechlichen Zustand zurueck.
-- =====================================================================
local istOffen = false
local istGeladen = false
local aktuelleGarage = nil -- Garage, an der der Spieler gerade steht
-- Taste E
local TASTE_BENUTZEN = 38
--- Schickt eine Nachricht an die Oberflaeche
local function SendeAnNui(nachricht)
SendNuiMessage(json.encode(nachricht))
end
--- Zeigt eine Meldung oben links im Spiel
local function ZeigeSpielmeldung(text)
if not text or text == '' then return end
BeginTextCommandThefeedPost('STRING')
AddTextComponentSubstringPlayerName(text)
EndTextCommandThefeedPostTicker(false, true)
end
--- Schliesst die Garage
local function Schliessen()
if not istOffen then return end
istOffen = false
SetNuiFocus(false, false)
SendeAnNui({ aktion = 'schliessen' })
pcall(function() exports['aether-hud']:SetzeSichtbar(true) end)
end
--- Wandelt eine Hex-Farbe in RGB-Werte um
--- @param hex string z. B. '#3563a8'
--- @return number, number, number
local function HexZuRgb(hex)
hex = (hex or '#20232a'):gsub('#', '')
return tonumber(hex:sub(1, 2), 16) or 32,
tonumber(hex:sub(3, 4), 16) or 35,
tonumber(hex:sub(5, 6), 16) or 42
end
--- Sucht ein Fahrzeug mit einem bestimmten Kennzeichen in der Naehe
--- @param kennzeichen string Kennzeichen
--- @return number|nil Fahrzeug-Handle
local function FindeFahrzeug(kennzeichen)
local position = GetEntityCoords(PlayerPedId())
for fahrzeug in EnumerateVehicles() do
if DoesEntityExist(fahrzeug) then
local schild = GetVehicleNumberPlateText(fahrzeug)
if schild and schild:gsub('%s', '') == kennzeichen:gsub('%s', '') then
if #(position - GetEntityCoords(fahrzeug)) < 30.0 then
return fahrzeug
end
end
end
end
return nil
end
--- Durchlaeuft alle Fahrzeuge in der Naehe
function EnumerateVehicles()
return coroutine.wrap(function()
local handle, fahrzeug = FindFirstVehicle()
local erfolgreich
repeat
coroutine.yield(fahrzeug)
erfolgreich, fahrzeug = FindNextVehicle(handle)
until not erfolgreich
EndFindVehicle(handle)
end)
end
-- ---------------------------------------------------------------------
-- Karten-Markierungen
-- ---------------------------------------------------------------------
CreateThread(function()
for _, garage in pairs(Garage.Garagen) do
if garage.typ == 'oeffentlich' then
local blip = AddBlipForCoord(garage.position.x, garage.position.y, garage.position.z)
SetBlipSprite(blip, 357)
SetBlipColour(blip, 3)
SetBlipScale(blip, 0.75)
SetBlipAsShortRange(blip, true)
BeginTextCommandSetBlipName('STRING')
AddTextComponentString('Garage ' .. garage.name)
EndTextCommandSetBlipName(blip)
end
end
end)
-- ---------------------------------------------------------------------
-- Ereignisse
-- ---------------------------------------------------------------------
AddEventHandler('aether:client:bereit', function()
istGeladen = true
end)
RegisterNetEvent('aether:garage:zeige', function(zustand)
istOffen = true
SetNuiFocus(true, true)
pcall(function() exports['aether-hud']:SetzeSichtbar(false) end)
zustand.aktion = 'oeffnen'
SendeAnNui(zustand)
end)
RegisterNetEvent('aether:garage:zustand', function(zustand)
if not istOffen then
ZeigeSpielmeldung(zustand.hinweis)
return
end
zustand.aktion = 'zustand'
SendeAnNui(zustand)
end)
RegisterNetEvent('aether:garage:fehler', function(text)
if istOffen then
SendeAnNui({ aktion = 'fehler', text = text })
else
ZeigeSpielmeldung(text)
end
end)
-- ---------------------------------------------------------------------
-- Eigenschaften anwenden, die nur clientseitig gesetzt werden koennen
-- Der Server erzeugt das Fahrzeug und legt die Werte im State Bag ab;
-- hier werden sie uebernommen, sobald das Fahrzeug beim Spieler auftaucht.
-- ---------------------------------------------------------------------
AddStateBagChangeHandler('aether', nil, function(bagName, _, werte)
if type(werte) ~= 'table' then return end
local netId = tonumber(bagName:match('entity:(%d+)'))
if not netId then return end
CreateThread(function()
-- Warten, bis die Entitaet lokal existiert
local versuche = 0
while not NetworkDoesEntityExistWithNetworkId(netId) and versuche < 100 do
Wait(50)
versuche = versuche + 1
end
if not NetworkDoesEntityExistWithNetworkId(netId) then return end
local fahrzeug = NetToVeh(netId)
if not fahrzeug or fahrzeug == 0 or not DoesEntityExist(fahrzeug) then return end
if werte.kennzeichen then
SetVehicleNumberPlateText(fahrzeug, werte.kennzeichen)
end
if werte.farbe then
local r, g, b = HexZuRgb(werte.farbe)
SetVehicleCustomPrimaryColour(fahrzeug, r, g, b)
SetVehicleCustomSecondaryColour(fahrzeug, r, g, b)
end
if werte.tank then SetVehicleFuelLevel(fahrzeug, werte.tank + 0.0) end
if werte.motor then SetVehicleEngineHealth(fahrzeug, werte.motor + 0.0) end
if werte.karosserie then SetVehicleBodyHealth(fahrzeug, werte.karosserie + 0.0) end
end)
end)
--- Server hat das Fahrzeug erzeugt: einsteigen
RegisterNetEvent('aether:garage:einsteigen', function(netId, kennzeichen)
Schliessen()
-- Warten, bis das Fahrzeug beim Spieler angekommen ist
local versuche = 0
while not NetworkDoesEntityExistWithNetworkId(netId) and versuche < 100 do
Wait(50)
versuche = versuche + 1
end
if not NetworkDoesEntityExistWithNetworkId(netId) then
ZeigeSpielmeldung('Fahrzeug konnte nicht geladen werden.')
return
end
local fahrzeug = NetToVeh(netId)
if fahrzeug and DoesEntityExist(fahrzeug) then
SetVehicleEngineOn(fahrzeug, true, true, false)
SetPedIntoVehicle(PlayerPedId(), fahrzeug, -1)
ZeigeSpielmeldung('Fahrzeug ausgeparkt · ' .. kennzeichen)
end
end)
-- ---------------------------------------------------------------------
-- Kilometerzaehler
-- GTA fuehrt keinen Tachostand, deshalb zaehlen wir die gefahrene
-- Strecke je Kennzeichen selbst mit (in Metern).
-- ---------------------------------------------------------------------
local gefahreneMeter = {} -- [kennzeichen] = Meter seit dem Ausparken
local letztePosition = nil -- Position im letzten Durchlauf
local seitMeldung = 0 -- Sekunden seit der letzten Zustandsmeldung
CreateThread(function()
while true do
Wait(1000)
local ped = PlayerPedId()
if istGeladen and IsPedInAnyVehicle(ped, false) then
local fahrzeug = GetVehiclePedIsIn(ped, false)
local position = GetEntityCoords(fahrzeug)
local kennzeichen = (GetVehicleNumberPlateText(fahrzeug) or ''):gsub('%s', '')
if letztePosition and kennzeichen ~= '' then
local strecke = #(position - letztePosition)
-- Sprünge durch Teleport oder Nachladen ignorieren
if strecke < 200.0 then
gefahreneMeter[kennzeichen] = (gefahreneMeter[kennzeichen] or 0) + strecke
end
end
-- Zustand regelmaessig melden, damit er auch bei einem
-- unerwarteten Serverstopp nicht verloren geht
seitMeldung = seitMeldung + 1
if seitMeldung >= 30 and kennzeichen ~= '' then
seitMeldung = 0
TriggerServerEvent('aether:garage:zustandMelden', kennzeichen, {
tank = GetVehicleFuelLevel(fahrzeug),
motor = GetVehicleEngineHealth(fahrzeug),
karosserie = GetVehicleBodyHealth(fahrzeug),
})
end
letztePosition = position
else
letztePosition = nil
end
end
end)
-- ---------------------------------------------------------------------
-- Garage in der Naehe erkennen
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
local pause = 800
aktuelleGarage = nil
if istGeladen and not istOffen then
local position = GetEntityCoords(PlayerPedId())
for garageId, garage in pairs(Garage.Garagen) do
if #(position - garage.position) <= Garage.INTERAKTION_WEITE then
aktuelleGarage = garageId
pause = 0
SetTextFont(4)
SetTextScale(0.34, 0.34)
SetTextColour(255, 255, 255, 215)
SetTextCentre(true)
SetTextOutline()
SetTextEntry('STRING')
AddTextComponentString('[E] Garage ' .. garage.name)
SetDrawOrigin(garage.position.x, garage.position.y, garage.position.z + 0.9, 0)
DrawText(0.0, 0.0)
ClearDrawOrigin()
if IsControlJustReleased(0, TASTE_BENUTZEN) then
TriggerServerEvent('aether:garage:oeffnen', garageId)
end
break
end
end
end
Wait(pause)
end
end)
-- ---------------------------------------------------------------------
-- NUI-Callbacks
-- ---------------------------------------------------------------------
RegisterNUICallback('schliessen', function(_, rueckgabe)
Schliessen()
rueckgabe({ ok = true })
end)
RegisterNUICallback('ausparken', function(daten, rueckgabe)
TriggerServerEvent('aether:garage:ausparken', daten.kennzeichen, daten.standort)
rueckgabe({ ok = true })
end)
RegisterNUICallback('einparken', function(daten, rueckgabe)
-- Tatsaechlichen Zustand des Fahrzeugs ermitteln und mitschicken
local fahrzeug = FindeFahrzeug(daten.kennzeichen)
local zustand = {}
if fahrzeug and DoesEntityExist(fahrzeug) then
local schluessel = (daten.kennzeichen or ''):gsub('%s', '')
zustand.tank = GetVehicleFuelLevel(fahrzeug)
zustand.motor = GetVehicleEngineHealth(fahrzeug)
zustand.karosserie = GetVehicleBodyHealth(fahrzeug)
zustand.meter = math.floor(gefahreneMeter[schluessel] or 0)
gefahreneMeter[schluessel] = 0 -- Zaehler nach dem Einparken zuruecksetzen
else
SendeAnNui({ aktion = 'fehler', text = 'Das Fahrzeug ist nicht in der Nähe.' })
rueckgabe({ ok = false })
return
end
TriggerServerEvent('aether:garage:einparken', daten.kennzeichen, daten.standort, zustand)
rueckgabe({ ok = true })
end)
RegisterNUICallback('ausloesen', function(daten, rueckgabe)
TriggerServerEvent('aether:garage:ausloesen', daten.kennzeichen, daten.standort)
rueckgabe({ ok = true })
end)
RegisterNUICallback('orten', function(daten, rueckgabe)
local fahrzeug = FindeFahrzeug(daten.kennzeichen)
if fahrzeug and DoesEntityExist(fahrzeug) then
local position = GetEntityCoords(fahrzeug)
SetNewWaypoint(position.x, position.y)
SendeAnNui({ aktion = 'hinweis', text = 'Markierung auf der Karte gesetzt.' })
else
SendeAnNui({ aktion = 'fehler', text = 'Das Fahrzeug ist nicht in der Nähe.' })
end
rueckgabe({ ok = true })
end)
-- ---------------------------------------------------------------------
-- Aufraeumen
-- ---------------------------------------------------------------------
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false)
end)

View File

@ -0,0 +1,34 @@
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
name 'aether-garage'
author 'Jerrit Fritzsche'
description 'Garagen: Ein- und Ausparken, Fahrzeugzustand, Kennzeichen'
version '0.1.0'
ui_page 'html/index.html'
files {
'html/index.html',
}
shared_scripts {
'@aether-core/shared/config.lua',
'@aether-core/shared/utils.lua',
'shared/garagen.lua',
}
client_scripts {
'client/main.lua',
}
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/main.lua',
}
dependencies {
'aether-core',
'oxmysql',
}

View File

@ -0,0 +1,330 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
</head>
<body>
<title>Aether — Garage</title>
<style>
:root{
--bg:#080a0f;--fg:#eaf0f8;--muted:#828da3;--surface:rgba(22,26,36,0.72);--surface2:rgba(36,41,55,0.85);
--slot:#11141c;--border:rgba(255,255,255,0.09);--accent:#3aa0ff;--ink:#04182f;--accent-soft:rgba(58,160,255,0.15);
--red:#f0506e;--green:#34d39a;--amber:#f5b23d;
}
*{box-sizing:border-box;} html,body{margin:0;height:100%;}
body{min-height:100%;background:radial-gradient(1100px 700px at 20% 0%,rgba(58,160,255,0.09),transparent 55%),radial-gradient(900px 700px at 90% 100%,rgba(52,211,153,0.06),transparent 55%),var(--bg);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);
display:flex;align-items:center;justify-content:center;padding:26px 16px;overflow-x:hidden;}
.garage{position:relative;width:min(1200px,97vw);aspect-ratio:16/10;border-radius:22px;overflow:hidden;
background:linear-gradient(160deg,rgba(20,23,32,0.92),rgba(10,12,17,0.96));backdrop-filter:blur(20px);
border:1px solid var(--border);box-shadow:0 50px 100px -30px rgba(0,0,0,0.85);display:flex;flex-direction:column;}
.g-head{display:flex;align-items:center;gap:14px;padding:16px 24px;border-bottom:1px solid var(--border);}
.g-head .logo{width:40px;height:40px;border-radius:11px;display:grid;place-items:center;font-size:20px;background:linear-gradient(150deg,var(--accent),#1a5fa8);box-shadow:0 8px 20px var(--accent-soft);}
.g-head h1{font-size:19px;margin:0;font-weight:800;letter-spacing:-0.02em;} .g-head h1 small{display:block;font-size:11.5px;color:var(--muted);font-weight:500;}
.g-head select{margin-left:auto;background:var(--slot);border:1px solid var(--border);color:var(--fg);border-radius:10px;padding:9px 12px;font-family:inherit;font-size:13px;font-weight:600;}
.g-head .count{font-size:12.5px;color:var(--muted);font-weight:600;}
.g-body{flex:1;display:grid;grid-template-columns:300px 1fr;min-height:0;}
.veh-list{border-right:1px solid var(--border);padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:9px;}
.veh-list::-webkit-scrollbar{width:7px;} .veh-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.09);border-radius:4px;}
.veh-card{display:flex;align-items:center;gap:12px;padding:11px;border-radius:14px;background:var(--surface);border:1px solid var(--border);cursor:pointer;transition:.14s;}
.veh-card:hover{border-color:var(--accent);transform:translateX(2px);} .veh-card.active{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent) inset;background:var(--accent-soft);}
.veh-card .thumb{width:66px;height:40px;flex-shrink:0;display:grid;place-items:center;} .veh-card .thumb svg{width:100%;height:100%;}
.veh-card .vn{font-size:14px;font-weight:800;} .veh-card .vm{font-size:11.5px;color:var(--muted);}
.veh-card .vp{margin-top:2px;}
.veh-detail{position:relative;display:grid;grid-template-rows:1fr auto;overflow:hidden;}
.stage{position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;
background:radial-gradient(60% 55% at 50% 55%,rgba(58,160,255,0.08),transparent 70%),repeating-linear-gradient(0deg,rgba(255,255,255,0.015) 0 2px,transparent 2px 46px);}
.stage .floor{position:absolute;bottom:14%;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,rgba(255,255,255,0.14),transparent);}
.stage .bigcar{width:78%;max-width:640px;position:relative;z-index:1;filter:drop-shadow(0 26px 30px rgba(0,0,0,0.5));}
.stage .vname{position:absolute;top:22px;left:28px;} .stage .vname h2{font-size:26px;margin:0;font-weight:800;letter-spacing:-0.02em;} .stage .vname .cls{color:var(--accent);font-weight:700;font-size:13px;}
.stage .statuspill{position:absolute;top:26px;right:28px;}
.detail-foot{border-top:1px solid var(--border);padding:16px 24px;display:grid;grid-template-columns:1.1fr 1fr;gap:22px;align-items:center;}
.cond{display:flex;flex-direction:column;gap:9px;}
.cbar{display:flex;align-items:center;gap:10px;} .cbar .cl{width:88px;font-size:12px;color:var(--muted);font-weight:600;} .cbar .ct{flex:1;height:9px;border-radius:5px;background:var(--slot);overflow:hidden;} .cbar .cf{height:100%;border-radius:5px;} .cbar .cv{width:42px;text-align:right;font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}
.plate-wrap{display:flex;flex-direction:column;align-items:center;gap:10px;}
.plate-note{font-size:10.5px;color:var(--muted);font-weight:600;}
.ausp-hint{font-size:12px;color:var(--amber);font-weight:700;align-self:center;}
.actions{display:flex;gap:9px;align-items:center;grid-column:1/-1;}
.btn{border:none;border-radius:11px;padding:12px 20px;font-size:14px;font-weight:800;cursor:pointer;font-family:inherit;transition:.12s;}
.btn.primary{background:var(--accent);color:var(--ink);} .btn.primary:hover{filter:brightness(1.08);} .btn.ghost{background:var(--surface2);color:var(--fg);border:1px solid var(--border);} .btn.amber{background:var(--amber);color:#3a2600;} .btn:active{transform:scale(0.98);}
.pill{font-size:11px;font-weight:800;padding:4px 11px;border-radius:999px;} .pill.in{background:rgba(52,211,153,0.16);color:var(--green);} .pill.out{background:rgba(245,178,61,0.16);color:var(--amber);} .pill.imp{background:rgba(240,80,110,0.16);color:var(--red);}
/* ============ Kennzeichen (San Andreas) ============ */
.plate{width:230px;aspect-ratio:2.1/1;border-radius:11px;position:relative;padding:6px;
box-shadow:0 8px 20px -6px rgba(0,0,0,0.6),0 0 0 1px rgba(0,0,0,0.3);}
.plate .pin{width:100%;height:100%;border-radius:7px;display:flex;flex-direction:column;align-items:center;justify-content:center;border:2px solid;position:relative;overflow:hidden;}
.plate .ptop{font-size:11px;font-weight:800;letter-spacing:0.14em;line-height:1;margin-top:2px;}
.plate .pnum{font-family:"Arial Narrow","Helvetica Neue",Arial,sans-serif;font-weight:800;font-size:38px;letter-spacing:0.06em;line-height:1;margin-top:2px;}
.plate .screw{position:absolute;width:7px;height:7px;border-radius:50%;background:radial-gradient(circle at 35% 30%,#dfe2e6,#8b9099);box-shadow:0 1px 1px rgba(0,0,0,0.4);}
.plate .screw.tl{top:9px;left:9px;} .plate .screw.tr{top:9px;right:9px;} .plate .screw.bl{bottom:9px;left:9px;} .plate .screw.br{bottom:9px;right:9px;}
/* Standard San Andreas: blau auf creme */
.plate.sa{background:linear-gradient(180deg,#fbfbf5,#e9e9dd);} .plate.sa .pin{border-color:#2a3f7a;background:linear-gradient(180deg,#fdfdf7,#eeeee2);}
.plate.sa .ptop{color:#2a3f7a;} .plate.sa .pnum{color:#243a72;text-shadow:0 1px 0 rgba(255,255,255,0.6);}
/* Gelb */
.plate.yellow{background:linear-gradient(180deg,#ffd23f,#f2b71e);} .plate.yellow .pin{border-color:#1c2a12;background:linear-gradient(180deg,#ffd84f,#f4bd22);}
.plate.yellow .ptop{color:#3a2e08;} .plate.yellow .pnum{color:#20180a;text-shadow:0 1px 0 rgba(255,255,255,0.35);}
/* Schwarz (gelb auf schwarz) */
.plate.black{background:linear-gradient(180deg,#26282c,#141517);} .plate.black .pin{border-color:#e8c860;background:linear-gradient(180deg,#222327,#141517);}
.plate.black .ptop{color:#e8c860;} .plate.black .pnum{color:#f0d878;text-shadow:0 1px 2px rgba(0,0,0,0.6);}
.plate.mini{width:96px;padding:3px;box-shadow:none;} .plate.mini .pin{border-width:1.5px;} .plate.mini .ptop{font-size:5.5px;letter-spacing:0.1em;} .plate.mini .pnum{font-size:15px;} .plate.mini .screw{display:none;}
.toast{position:absolute;bottom:18px;left:50%;transform:translateX(-50%) translateY(16px);background:rgba(12,15,22,0.97);border:1px solid var(--border);color:var(--fg);padding:11px 20px;border-radius:12px;font-size:13.5px;font-weight:600;z-index:40;opacity:0;pointer-events:none;transition:.3s;} .toast.show{opacity:1;transform:translateX(-50%) translateY(0);} .toast.warn{border-color:var(--red);} .toast.good{border-color:var(--green);}
</style>
<div class="garage">
<div class="g-head"><span class="logo">🚗</span><div><h1>Garage<small id="g-loc">Legion Square</small></h1></div>
<select id="g-select"></select><span class="count" id="g-count" style="margin-left:14px"></span></div>
<div class="g-body">
<div class="veh-list" id="veh-list"></div>
<div class="veh-detail">
<div class="stage"><div class="floor"></div><div class="vname" id="v-name"></div><span class="statuspill" id="v-status"></span><div class="bigcar" id="v-car"></div></div>
<div class="detail-foot" id="v-foot"></div>
</div>
</div>
<div class="toast" id="toast"></div>
</div>
<script>
"use strict";
const $=id=>document.getElementById(id);
function sicher(t){const d=document.createElement("div");d.textContent=t==null?"":t;return d.innerHTML;}
let toastTimer=null; function toast(t,typ){const e=$("toast");e.textContent=t;e.className="toast show"+(typ?" "+typ:"");clearTimeout(toastTimer);toastTimer=setTimeout(()=>e.classList.remove("show"),2400);}
function schattiere(hex,f){const n=parseInt(hex.slice(1),16);let r=(n>>16)&255,g=(n>>8)&255,b=n&255;r=Math.max(0,Math.min(255,Math.round(r*f)));g=Math.max(0,Math.min(255,Math.round(g*f)));b=Math.max(0,Math.min(255,Math.round(b*f)));return "#"+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1);}
/* =====================================================================
FAHRZEUGE
===================================================================== */
/* Garagen: öffentlich sichtbar für alle; Haus-Garagen nur mit Zugang (Besitz/berechtigt).
Nicht zugängliche Garagen (zugang:false) erscheinen NICHT in der Auswahl. */
/* Garagen und Fahrzeuge kommen vom Server. Der Server schickt nur
Garagen, zu denen der Spieler wirklich Zugang hat. */
let garagen=[
{id:"legion", name:"Legion Square (öffentlich)", typ:"öffentlich"},
{id:"sandy", name:"Sandy Shores Garage (öffentlich)", typ:"öffentlich"},
{id:"delperro", name:"Del Perro Tiefgarage (öffentlich)",typ:"öffentlich"},
{id:"villa", name:"Villa Vinewood Hills (dein Haus)", typ:"haus", zugang:true},
{id:"apartment",name:"Apartment 4B (Zugang)", typ:"haus", zugang:true},
{id:"privat", name:"Privatgarage Mirror Park", typ:"haus", zugang:false} // kein Zugang -> unsichtbar
];
let fahrzeuge=[
{name:"Sultan RS",klasse:"Sportwagen",typ:"sport",farbe:"#c0392b",kennzeichen:"46EEK572",plateStyle:"sa",tank:78,motor:92,karosserie:85,km:14230,garage:"legion",status:"eingelagert"},
{name:"Bati 801",klasse:"Motorrad",typ:"bike",farbe:"#1c1c1e",kennzeichen:"88KLM214",plateStyle:"black",tank:64,motor:100,karosserie:96,km:5120,garage:"legion",status:"eingelagert"},
{name:"Bison",klasse:"Pickup",typ:"pickup",farbe:"#2f6b4a",kennzeichen:"21PRT903",plateStyle:"sa",tank:41,motor:76,karosserie:63,km:38900,garage:"sandy",status:"draußen"},
{name:"Baller",klasse:"SUV",typ:"suv",farbe:"#20232a",kennzeichen:"77VBN018",plateStyle:"sa",tank:88,motor:98,karosserie:91,km:9800,garage:"delperro",status:"eingelagert"},
{name:"Issi",klasse:"Kleinwagen",typ:"compact",farbe:"#3563a8",kennzeichen:"09HGT456",plateStyle:"yellow",tank:52,motor:83,karosserie:88,km:22140,garage:"legion",status:"beschlagnahmt"},
{name:"Dominator",klasse:"Muscle",typ:"muscle",farbe:"#e0a020",kennzeichen:"33MSC120",plateStyle:"black",tank:70,motor:89,karosserie:79,km:16540,garage:"villa",status:"eingelagert"}
];
// Zugängliche Garagen (öffentlich oder mit Zugang)
const zugaenglich=()=>garagen.filter(g=>g.typ==="öffentlich"||g.zugang===true);
const garageName=id=>{const g=garagen.find(x=>x.id===id);return g?g.name:id;};
let standort="legion"; // vom Server gesetzt // aktueller physischer Standort des Spielers
let aktiveGarage="legion"; // aktuell angezeigte Garage ("alle" = Übersicht)
let auswahl=0; // Index in gefilterter Liste
/* =====================================================================
FAHRZEUG-VORSCHAU (Seitenansicht, parametrisch nach Typ)
===================================================================== */
// Rad mit Reifen und Felge
function rad(cx,cy,r){let s=`<circle cx="${cx}" cy="${cy}" r="${r}" fill="#14161b"/><circle cx="${cx}" cy="${cy}" r="${(r*0.58).toFixed(1)}" fill="#2a2e37"/><circle cx="${cx}" cy="${cy}" r="${(r*0.5).toFixed(1)}" fill="none" stroke="#5a616e" stroke-width="2"/>`;
for(let k=0;k<6;k++){const a=k/6*6.283;s+=`<line x1="${cx}" y1="${cy}" x2="${(cx+Math.cos(a)*r*0.5).toFixed(1)}" y2="${(cy+Math.sin(a)*r*0.5).toFixed(1)}" stroke="#5a616e" stroke-width="2.4"/>`;}
return s+`<circle cx="${cx}" cy="${cy}" r="4" fill="#6b7280"/>`;
}
// Karosserie-Formen je Typ
const carShapes={
sport: {body:"M56 150 C52 126 74 120 104 118 L152 92 C182 78 250 78 288 98 L338 118 C366 122 376 132 376 150 C376 158 372 162 362 162 L70 162 C60 162 56 158 56 150 Z",win:"M164 100 C186 87 246 87 274 100 L296 116 L150 116 Z",wheels:[[116,154,27],[304,154,27]],head:[366,132],tail:[56,134]},
sedan: {body:"M54 148 C50 122 70 116 100 114 L140 96 C150 80 175 74 212 74 C252 74 272 84 284 98 L340 116 C368 120 378 130 378 150 C378 158 374 162 364 162 L68 162 C58 162 54 156 54 148 Z",win:"M148 98 C158 82 182 78 212 78 C248 78 266 86 278 98 L300 114 L146 114 Z",wheels:[[118,154,26],[302,154,26]],head:[368,130],tail:[56,132]},
suv: {body:"M52 146 C50 118 66 110 96 108 L120 78 C126 66 152 60 212 60 C282 60 300 68 308 82 L332 108 C366 112 380 122 380 150 C380 158 376 162 366 162 L66 162 C56 162 52 156 52 146 Z",win:"M126 82 C132 68 156 64 212 64 C272 64 292 70 300 84 L314 106 L120 106 Z",wheels:[[116,156,29],[300,156,29]],head:[370,128],tail:[54,130]},
pickup: {body:"M52 148 C50 122 66 114 92 112 L112 84 C118 72 142 68 178 68 C208 68 222 76 228 88 L238 112 L360 112 C374 114 382 124 382 150 C382 158 378 162 368 162 L66 162 C56 162 52 156 52 148 Z",win:"M118 88 C124 74 144 72 178 72 C204 72 216 78 222 90 L228 110 L114 110 Z",wheels:[[112,156,27],[320,156,27]],head:[372,130],tail:[54,132],bed:[240,238,112]},
compact:{body:"M76 148 C72 124 92 118 118 116 L150 94 C162 80 186 76 216 76 C248 76 264 84 274 98 L316 116 C340 120 348 130 348 150 C348 158 344 162 336 162 L90 162 C80 162 76 156 76 148 Z",win:"M154 98 C164 84 188 80 216 80 C246 80 260 88 270 100 L292 116 L152 116 Z",wheels:[[132,154,24],[300,154,24]],head:[340,132],tail:[76,134]},
muscle: {body:"M50 150 C46 124 68 118 100 116 L156 94 C186 80 252 80 286 98 L344 118 C372 122 384 132 384 150 C384 158 380 162 370 162 L64 162 C54 162 50 158 50 150 Z",win:"M168 100 C190 88 248 88 276 100 L298 116 L156 116 Z",wheels:[[116,154,28],[308,154,28]],head:[374,134],tail:[50,136]}
};
function carSvg(typ,farbe){
const fL=schattiere(farbe,1.24),fD=schattiere(farbe,0.68),uid="cg"+Math.random().toString(36).slice(2,7);
if(typ==="bike")return bikeSvg(farbe,fL,fD,uid);
const s=carShapes[typ]||carShapes.sedan;
return `<svg viewBox="0 0 420 200"><defs><linearGradient id="${uid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${fL}"/><stop offset="52%" stop-color="${farbe}"/><stop offset="100%" stop-color="${fD}"/></linearGradient></defs>
<ellipse cx="212" cy="180" rx="160" ry="10" fill="rgba(0,0,0,0.32)"/>
<path d="${s.body}" fill="url(#${uid})" stroke="${fD}" stroke-width="1"/>
${s.bed?`<path d="M ${s.bed[0]} 112 L ${s.bed[0]} 130 L ${s.bed[1]} 130 L ${s.bed[1]} 112" fill="none" stroke="${fD}" stroke-width="3"/>`:""}
<path d="${s.win}" fill="#111a26" opacity="0.92"/><path d="${s.win}" fill="none" stroke="${schattiere(farbe,0.9)}" stroke-width="2"/>
<rect x="${s.head[0]}" y="${s.head[1]}" width="9" height="12" rx="2" fill="#ffe08a"/>
<rect x="${s.tail[0]}" y="${s.tail[1]}" width="8" height="12" rx="2" fill="#e0503f"/>
<path d="M ${s.wheels[0][0]+20} 162 L ${s.wheels[1][0]-20} 162" stroke="${fD}" stroke-width="1" opacity="0.5"/>
${s.wheels.map(w=>rad(w[0],w[1],w[2])).join("")}
</svg>`;
}
function bikeSvg(farbe,fL,fD,uid){
return `<svg viewBox="0 0 420 200">
<ellipse cx="212" cy="182" rx="140" ry="9" fill="rgba(0,0,0,0.3)"/>
<path d="M132 150 L200 112 L252 110 L300 150" stroke="#2a2e37" stroke-width="9" fill="none" stroke-linecap="round"/>
<path d="M118 150 L150 130" stroke="#5a616e" stroke-width="6" stroke-linecap="round"/>
<path d="M300 112 L308 150" stroke="#5a616e" stroke-width="7" stroke-linecap="round"/>
<path d="M170 120 C186 104 224 104 236 118 L240 130 L162 132 Z" fill="${farbe}" stroke="${fD}" stroke-width="1"/>
<path d="M150 132 L182 126 L184 136 L150 140 Z" fill="#18181a"/>
<path d="M288 100 C312 104 320 128 306 148 L292 142 C302 128 298 112 282 108 Z" fill="${farbe}" stroke="${fD}" stroke-width="1"/>
<circle cx="314" cy="120" r="6" fill="#ffe08a"/>
<path d="M300 104 L322 96" stroke="#2a2e37" stroke-width="5" stroke-linecap="round"/>
${rad(118,150,34)}${rad(304,150,34)}
</svg>`;
}
/* =====================================================================
KENNZEICHEN (San Andreas) — verschiedene Stile
===================================================================== */
function plateHtml(text,style,mini){
return `<div class="plate ${style}${mini?' mini':''}"><div class="pin"><div class="ptop">SAN ANDREAS</div><div class="pnum">${sicher(text)}</div></div>
<span class="screw tl"></span><span class="screw tr"></span><span class="screw bl"></span><span class="screw br"></span></div>`;
}
/* =====================================================================
RENDERN
===================================================================== */
// nur zugängliche Garagen; "alle" = Übersicht aller zugänglichen Fahrzeuge
function gefilterte(){
const ids=zugaenglich().map(g=>g.id);
return fahrzeuge.filter(v=>ids.includes(v.garage)&&(aktiveGarage==="alle"||v.garage===aktiveGarage));
}
function renderGarageSelect(){
const opts=zugaenglich().map(g=>`<option value="${g.id}" ${g.id===aktiveGarage?'selected':''}>${g.id===standort?'📍 ':''}${sicher(g.name)}</option>`).join("");
$("g-select").innerHTML=`<option value="alle" ${aktiveGarage==="alle"?'selected':''}>Alle (Übersicht)</option>`+opts;
$("g-select").value=aktiveGarage;
$("g-select").onchange=()=>{aktiveGarage=$("g-select").value;auswahl=0;render();};
}
function render(){
renderGarageSelect();
const liste=gefilterte();
$("g-loc").textContent="📍 Du bist an: "+garageName(standort);
$("g-count").textContent=liste.length+" Fahrzeug"+(liste.length===1?"":"e")+(aktiveGarage==="alle"?" · alle Garagen":"");
auswahl=Math.max(0,Math.min(auswahl,liste.length-1));
const lc=$("veh-list");
lc.innerHTML=liste.map((v,i)=>`<div class="veh-card ${i===auswahl?'active':''}" data-i="${i}">
<div class="thumb">${carSvg(v.typ,v.farbe)}</div>
<div class="grow"><div class="vn">${sicher(v.name)} ${v.garage===standort?'<span class="pill in" style="font-size:9px;padding:2px 6px">hier</span>':''}</div>
<div class="vm">${sicher(v.klasse)}${aktiveGarage==="alle"?' · '+garageName(v.garage).replace(/\s*\(.*\)/,''):''}</div>
<div class="vp">${plateHtml(v.kennzeichen,v.plateStyle,true)}</div></div></div>`).join("")||'<div style="color:var(--muted);text-align:center;padding:30px">Keine Fahrzeuge in dieser Garage.</div>';
lc.querySelectorAll("[data-i]").forEach(c=>c.addEventListener("click",()=>{auswahl=+c.dataset.i;render();}));
renderDetail(liste[auswahl]);
}
const statusInfo={eingelagert:["Eingelagert","in"],"draußen":["Ausgeparkt","out"],beschlagnahmt:["Beschlagnahmt","imp"]};
function condColor(v){return v>=80?"var(--green)":v>=50?"var(--amber)":"var(--red)";}
function renderDetail(v){
if(!v){$("v-name").innerHTML="";$("v-car").innerHTML="";$("v-status").innerHTML="";$("v-foot").innerHTML='<div style="color:var(--muted)">Kein Fahrzeug ausgewählt.</div>';return;}
const st=statusInfo[v.status], amStandort=v.garage===standort;
$("v-name").innerHTML=`<h2>${sicher(v.name)}</h2><div class="cls">${sicher(v.klasse)} · ${v.km.toLocaleString('de-DE')} km</div>`;
$("v-status").innerHTML=`<span class="pill ${st[1]}">${st[0]}</span>`;
$("v-car").innerHTML=carSvg(v.typ,v.farbe);
const cbar=(l,val)=>`<div class="cbar"><span class="cl">${l}</span><div class="ct"><div class="cf" style="width:${val}%;background:${condColor(val)}"></div></div><span class="cv">${val}%</span></div>`;
let actions="", hint="";
if(v.status==="eingelagert"){
actions=`<button class="btn primary" data-act="aus" ${amStandort?'':'disabled'}>Ausparken</button>`;
if(!amStandort)hint=`🚫 Steht in „${garageName(v.garage).replace(/\s*\(.*\)/,'')}“ — nur dort ausparkbar.`;
}
else if(v.status==="draußen")actions=`<button class="btn ghost" data-act="orten">Orten</button><button class="btn primary" data-act="ein">Einparken (hier)</button>`;
else if(v.status==="beschlagnahmt")actions=`<button class="btn amber" data-act="ausloesen">Auslösen · 500 €</button>`;
$("v-foot").innerHTML=`
<div class="cond">${cbar("Tank",v.tank)}${cbar("Motor",v.motor)}${cbar("Karosserie",v.karosserie)}</div>
<div class="plate-wrap">${plateHtml(v.kennzeichen,v.plateStyle)}<div class="plate-note">Kennzeichenfarbe wird beim Kauf festgelegt</div></div>
<div class="actions">${hint?`<span class="ausp-hint">${hint}</span>`:''}<div style="flex:1"></div>${actions}</div>`;
$("v-foot").querySelectorAll("[data-act]").forEach(b=>b.addEventListener("click",()=>fahrzeugAktion(v,b.dataset.act)));
}
function fahrzeugAktion(v,act){
/* In FiveM entscheidet der Server: Besitz, Standort-Regel, Zugang und
Gebühren werden dort geprüft. Die Oberfläche schickt nur den Wunsch. */
if(IST_FIVEM){
if(act==="aus") nui("ausparken",{kennzeichen:v.kennzeichen,standort:standort});
else if(act==="ein") nui("einparken",{kennzeichen:v.kennzeichen,standort:standort});
else if(act==="ausloesen") nui("ausloesen",{kennzeichen:v.kennzeichen,standort:standort});
else if(act==="orten") nui("orten",{kennzeichen:v.kennzeichen});
return;
}
// Browser-Ansicht: lokal simulieren
if(act==="aus"){ if(v.garage!==standort){toast("Nur an der Garage ausparkbar, wo das Fahrzeug steht","warn");return;} v.status="draußen";toast(v.name+" ausgeparkt · "+v.kennzeichen,"good");}
else if(act==="ein"){v.status="eingelagert";v.garage=standort;toast(v.name+" eingelagert · "+garageName(standort).replace(/\s*\(.*\)/,''),"good");}
else if(act==="orten"){const orte=["Vinewood Blvd","Vespucci Beach","Route 68","Legion Square","Sandy Shores"];toast("📍 "+v.name+" zuletzt: "+orte[Math.floor(Math.random()*orte.length)]);}
else if(act==="ausloesen"){v.status="eingelagert";v.garage=standort;toast(v.name+" für 500 € ausgelöst · "+garageName(standort).replace(/\s*\(.*\)/,''),"good");}
render();
}
/* =====================================================================
NUI-BRÜCKE — Verbindung zum FiveM-Client
===================================================================== */
const IST_FIVEM = (typeof GetParentResourceName === "function");
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-garage";
function nui(aktion, daten){
if(!IST_FIVEM) return Promise.resolve();
return fetch(`https://${RESSOURCE}/${aktion}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(daten || {}),
}).catch(() => {});
}
/* Übernimmt Garagen und Fahrzeuge vom Server.
Der Server schickt Status als in/out/impound — hier auf die
Beschriftungen der Oberfläche abbilden. */
const STATUS_TEXT = { in: "eingelagert", out: "draußen", impound: "beschlagnahmt" };
function setzeZustand(z){
garagen = (z.garagen || []).map(g => ({
id: g.id,
name: g.name + (g.typ === "oeffentlich" ? " (öffentlich)" : " (Zugang)"),
typ: g.typ === "oeffentlich" ? "öffentlich" : "haus",
zugang: true, // der Server schickt nur zugängliche Garagen
}));
fahrzeuge = (z.fahrzeuge || []).map(v => ({
...v,
status: STATUS_TEXT[v.status] || v.status,
}));
standort = z.standort || standort;
if(!garagen.some(g => g.id === aktiveGarage)) aktiveGarage = standort;
render();
if(z.hinweis) toast(z.hinweis, "good");
}
window.addEventListener("message", (ereignis) => {
const n = ereignis.data || {};
if(n.aktion === "oeffnen"){
document.body.style.display = "";
setzeZustand(n);
}
else if(n.aktion === "zustand"){
setzeZustand(n);
}
else if(n.aktion === "hinweis"){
toast(n.text || "", "good");
}
else if(n.aktion === "fehler"){
toast(n.text || "Nicht möglich", "warn");
}
else if(n.aktion === "schliessen"){
document.body.style.display = "none";
}
});
/* ESC schließt die Garage */
document.addEventListener("keydown", (e) => {
if(e.key === "Escape" && IST_FIVEM) nui("schliessen", {});
});
/* =====================================================================
START
===================================================================== */
if(IST_FIVEM){
document.body.style.display = "none"; // wartet auf "oeffnen"
} else {
render(); // Browser-Ansicht mit den Beispieldaten oben
}
</script>
</body>
</html>

View File

@ -0,0 +1,511 @@
-- =====================================================================
-- Aether Garage — Serverlogik
-- Verwaltet Fahrzeugbesitz, Ein- und Ausparken. Der Client darf ein
-- Fahrzeug erst erzeugen, nachdem der Server es freigegeben hat.
-- =====================================================================
local Core = exports['aether-core']
-- Fahrzeuge, die gerade ausgeparkt werden duerfen: [quelle] = kennzeichen
local freigaben = {}
-- Aktuell in der Welt stehende Fahrzeuge: [kennzeichen] = Entity-Handle
-- Sie werden vom Server erzeugt und ueberleben dadurch auch, wenn kein
-- Spieler in der Naehe ist.
local aktiveFahrzeuge = {}
-- ---------------------------------------------------------------------
-- Fahrzeuge in der Welt erzeugen und entfernen
-- ---------------------------------------------------------------------
--- Erzeugt ein Fahrzeug serverseitig an einer Position
--- @param datensatz table Zeile aus aether_vehicles
--- @param x number
--- @param y number
--- @param z number
--- @param heading number
--- @return number|nil Entity-Handle
local function ErzeugeFahrzeug(datensatz, x, y, z, heading)
-- Bereits vorhandenes Fahrzeug mit diesem Kennzeichen zuerst entfernen
local vorhandenes = aktiveFahrzeuge[datensatz.plate]
if vorhandenes and DoesEntityExist(vorhandenes) then
DeleteEntity(vorhandenes)
end
local fahrzeug = CreateVehicle(GetHashKey(datensatz.model), x, y, z, heading or 0.0, true, true)
if not fahrzeug or fahrzeug == 0 then return nil end
-- Warten, bis die Entitaet wirklich existiert
local versuche = 0
while not DoesEntityExist(fahrzeug) and versuche < 50 do
Wait(10)
versuche = versuche + 1
end
if not DoesEntityExist(fahrzeug) then return nil end
local props = {}
if datensatz.props then
local erfolg, entschluesselt = pcall(json.decode, datensatz.props)
if erfolg and type(entschluesselt) == 'table' then props = entschluesselt end
end
-- Alles, was nur clientseitig gesetzt werden kann, ueber den State Bag
-- weitergeben. Der Client wendet es an, sobald das Fahrzeug bei ihm
-- auftaucht (siehe client/main.lua).
Entity(fahrzeug).state:set('aether', {
kennzeichen = datensatz.plate,
farbe = props.farbe,
tank = datensatz.fuel,
motor = datensatz.engine,
karosserie = datensatz.body,
}, true)
aktiveFahrzeuge[datensatz.plate] = fahrzeug
return fahrzeug
end
--- Entfernt ein Fahrzeug aus der Welt
--- @param kennzeichen string Kennzeichen
local function EntferneFahrzeug(kennzeichen)
local fahrzeug = aktiveFahrzeuge[kennzeichen]
if fahrzeug and DoesEntityExist(fahrzeug) then
DeleteEntity(fahrzeug)
end
aktiveFahrzeuge[kennzeichen] = nil
end
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Liefert die Charakter-ID eines Spielers
local function CharakterVon(quelle)
return Core:HoleCharakterId(quelle)
end
--- Prueft, ob ein Spieler Zugang zu einer Garage hat.
--- Haus-Garagen werden spaeter ueber `aether-immobilien` geprueft.
--- @param quelle number Server-ID
--- @param garageId string Kennung der Garage
--- @return boolean
local function HatZugang(quelle, garageId)
local garage = Garage.Garagen[garageId]
if not garage then return false end
if garage.typ == 'oeffentlich' then return true end
-- Optionale Abfrage beim Immobilien-Modul
local erfolg, ergebnis = pcall(function()
return exports['aether-immobilien']:HatGaragenZugang(quelle, garageId)
end)
if erfolg and type(ergebnis) == 'boolean' then return ergebnis end
-- Modul noch nicht vorhanden: Haus-Garagen bleiben verschlossen
return false
end
--- Laedt alle Fahrzeuge eines Charakters
--- @param charakterId number Charakter-ID
--- @return table Liste von Fahrzeugen
local function HoleFahrzeuge(charakterId)
local zeilen = MySQL.query.await([[
SELECT plate, model, props, garage, state, fuel, engine, body, mileage
FROM aether_vehicles WHERE owner = ?
]], { charakterId }) or {}
local liste = {}
for _, zeile in ipairs(zeilen) do
local props = {}
if zeile.props then
local erfolg, entschluesselt = pcall(json.decode, zeile.props)
if erfolg and type(entschluesselt) == 'table' then props = entschluesselt end
end
liste[#liste + 1] = {
kennzeichen = zeile.plate,
modell = zeile.model,
name = props.name or zeile.model,
klasse = props.klasse or 'Fahrzeug',
typ = props.typ or 'sedan',
farbe = props.farbe or '#20232a',
plateStyle = props.plateStyle or 'sa',
garage = zeile.garage,
status = zeile.state, -- in | out | impound
tank = math.floor(zeile.fuel or 0),
motor = math.floor((zeile.engine or 1000) / 10),
karosserie = math.floor((zeile.body or 1000) / 10),
km = zeile.mileage or 0,
}
end
return liste
end
--- Baut den Zustand fuer die Oberflaeche
--- @param quelle number Server-ID
--- @param standort string Garage, an der der Spieler steht
--- @return table|nil
local function BaueZustand(quelle, standort)
local charakterId = CharakterVon(quelle)
if not charakterId then return nil end
-- Nur zugaengliche Garagen anzeigen
local garagen = {}
for garageId, garage in pairs(Garage.Garagen) do
if HatZugang(quelle, garageId) then
garagen[#garagen + 1] = {
id = garageId, name = garage.name, typ = garage.typ,
}
end
end
return {
garagen = garagen,
fahrzeuge = HoleFahrzeuge(charakterId),
standort = standort,
gebuehr = Garage.AUSLOESE_GEBUEHR,
}
end
--- Schickt den Zustand an einen Spieler
local function SendeZustand(quelle, standort, hinweis)
local zustand = BaueZustand(quelle, standort)
if not zustand then return end
zustand.hinweis = hinweis
TriggerClientEvent('aether:garage:zustand', quelle, zustand)
end
--- Lehnt eine Aktion ab
local function Ablehnen(quelle, grund)
TriggerClientEvent('aether:garage:fehler', quelle, grund)
end
--- Liest ein Fahrzeug und prueft den Besitz
--- @param charakterId number Charakter-ID
--- @param kennzeichen string Kennzeichen
--- @return table|nil
local function HoleEigenes(charakterId, kennzeichen)
local treffer = MySQL.query.await(
'SELECT * FROM aether_vehicles WHERE plate = ? AND owner = ?',
{ kennzeichen, charakterId })
return treffer and treffer[1] or nil
end
-- ---------------------------------------------------------------------
-- Ausgeparkte Fahrzeuge beim Serverstart wiederherstellen
-- ---------------------------------------------------------------------
CreateThread(function()
Wait(2000) -- warten, bis Datenbank und Spielwelt bereit sind
local zeilen = MySQL.query.await([[
SELECT * FROM aether_vehicles
WHERE state = 'out' AND pos_x IS NOT NULL
]]) or {}
local wiederhergestellt = 0
for _, zeile in ipairs(zeilen) do
local entitaet = ErzeugeFahrzeug(zeile, zeile.pos_x, zeile.pos_y, zeile.pos_z, zeile.heading)
if entitaet then
wiederhergestellt = wiederhergestellt + 1
else
-- Konnte es nicht erzeugt werden (z. B. Modell entfernt),
-- wandert es zurueck in seine Garage statt verloren zu gehen.
MySQL.update("UPDATE aether_vehicles SET state = 'in' WHERE plate = ?", { zeile.plate })
Aether.Fehler(('Fahrzeug %s konnte nicht gesetzt werden — zurueck in die Garage.')
:format(zeile.plate))
end
Wait(50) -- Erzeugung entzerren
end
if wiederhergestellt > 0 then
Aether.Info(('%d ausgeparkte Fahrzeuge wiederhergestellt.'):format(wiederhergestellt))
end
end)
-- ---------------------------------------------------------------------
-- Position der ausgeparkten Fahrzeuge regelmaessig sichern
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
Wait(30000) -- alle 30 Sekunden
for kennzeichen, fahrzeug in pairs(aktiveFahrzeuge) do
if DoesEntityExist(fahrzeug) then
local position = GetEntityCoords(fahrzeug)
MySQL.update([[
UPDATE aether_vehicles SET pos_x = ?, pos_y = ?, pos_z = ?, heading = ?
WHERE plate = ?
]], { position.x, position.y, position.z, GetEntityHeading(fahrzeug), kennzeichen })
else
-- Entitaet ist verschwunden (z. B. zerstoert): Eintrag aufraeumen
aktiveFahrzeuge[kennzeichen] = nil
end
end
end
end)
--- Beim Stoppen der Ressource die letzten Positionen sichern
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
for kennzeichen, fahrzeug in pairs(aktiveFahrzeuge) do
if DoesEntityExist(fahrzeug) then
local position = GetEntityCoords(fahrzeug)
MySQL.update.await([[
UPDATE aether_vehicles SET pos_x = ?, pos_y = ?, pos_z = ?, heading = ?
WHERE plate = ?
]], { position.x, position.y, position.z, GetEntityHeading(fahrzeug), kennzeichen })
end
end
Aether.Info('Fahrzeugpositionen gesichert.')
end)
-- ---------------------------------------------------------------------
-- Zustand eines fahrenden Fahrzeugs uebernehmen (vom Client gemeldet)
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:garage:zustandMelden', function(kennzeichen, zustand)
if type(kennzeichen) ~= 'string' or type(zustand) ~= 'table' then return end
if not aktiveFahrzeuge[kennzeichen] then return end -- nur bekannte Fahrzeuge
local tank = math.max(0, math.min(100, tonumber(zustand.tank) or 100))
local motor = math.max(0, math.min(1000, tonumber(zustand.motor) or 1000))
local body = math.max(0, math.min(1000, tonumber(zustand.karosserie) or 1000))
MySQL.update('UPDATE aether_vehicles SET fuel = ?, engine = ?, body = ? WHERE plate = ?',
{ tank, motor, body, kennzeichen })
end)
-- ---------------------------------------------------------------------
-- Startfahrzeug fuer neue Charaktere
-- ---------------------------------------------------------------------
AddEventHandler('aether:server:spielerGeladen', function(quelle, spieler)
if not Garage.STARTFAHRZEUG then return end
local vorhanden = MySQL.scalar.await(
'SELECT COUNT(*) FROM aether_vehicles WHERE owner = ?', { spieler.charakterId })
if (vorhanden or 0) > 0 then return end
local kennzeichen = Garage.ErzeugeKennzeichen()
MySQL.insert.await([[
INSERT INTO aether_vehicles (plate, owner, model, props, garage, state)
VALUES (?, ?, ?, ?, ?, 'in')
]], {
kennzeichen, spieler.charakterId, Garage.STARTFAHRZEUG.modell,
json.encode({ name = 'Blista', klasse = 'Kleinwagen', typ = 'compact',
farbe = '#3563a8', plateStyle = 'sa' }),
Garage.STARTFAHRZEUG.garage,
})
Aether.Info(('Startfahrzeug %s fuer Charakter #%d angelegt.')
:format(kennzeichen, spieler.charakterId))
end)
-- ---------------------------------------------------------------------
-- Garage oeffnen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:garage:oeffnen', function(garageId)
local quelle = source
if not CharakterVon(quelle) then return end
if type(garageId) ~= 'string' or not Garage.Garagen[garageId] then return end
if not HatZugang(quelle, garageId) then
Ablehnen(quelle, 'Zu dieser Garage hast du keinen Zugang.')
return
end
-- Entfernungspruefung
local position = GetEntityCoords(GetPlayerPed(quelle))
if #(position - Garage.Garagen[garageId].position) > (Garage.INTERAKTION_WEITE + 3.0) then
Ablehnen(quelle, 'Du bist nicht an dieser Garage.')
return
end
local zustand = BaueZustand(quelle, garageId)
if zustand then
TriggerClientEvent('aether:garage:zeige', quelle, zustand)
end
end)
-- ---------------------------------------------------------------------
-- Ausparken
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:garage:ausparken', function(kennzeichen, standort)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
if type(kennzeichen) ~= 'string' or type(standort) ~= 'string' then return end
local garage = Garage.Garagen[standort]
if not garage or not HatZugang(quelle, standort) then return end
local fahrzeug = HoleEigenes(charakterId, kennzeichen)
if not fahrzeug then Ablehnen(quelle, 'Das ist nicht dein Fahrzeug.') return end
if fahrzeug.state == 'impound' then
Ablehnen(quelle, 'Das Fahrzeug ist beschlagnahmt.')
return
end
if fahrzeug.state == 'out' then
Ablehnen(quelle, 'Das Fahrzeug steht bereits draußen.')
return
end
-- Standort-Regel: nur an der Garage, wo das Fahrzeug wirklich steht
if fahrzeug.garage ~= standort then
local wo = Garage.Garagen[fahrzeug.garage]
Ablehnen(quelle, ('Das Fahrzeug steht in %s.'):format(wo and wo.name or fahrzeug.garage))
return
end
-- Fahrzeug serverseitig erzeugen, damit es unabhaengig vom Spieler
-- (und ueber Neustarts hinweg) in der Welt bleibt
local spawn = garage.spawn
local entitaet = ErzeugeFahrzeug(fahrzeug, spawn.x, spawn.y, spawn.z, spawn.w)
if not entitaet then
Ablehnen(quelle, 'Fahrzeug konnte nicht erzeugt werden.')
return
end
MySQL.update.await([[
UPDATE aether_vehicles
SET state = 'out', pos_x = ?, pos_y = ?, pos_z = ?, heading = ?
WHERE plate = ?
]], { spawn.x, spawn.y, spawn.z, spawn.w, kennzeichen })
freigaben[quelle] = kennzeichen
-- Dem Client sagen, in welches Fahrzeug er einsteigen soll
TriggerClientEvent('aether:garage:einsteigen', quelle,
NetworkGetNetworkIdFromEntity(entitaet), kennzeichen)
end)
-- ---------------------------------------------------------------------
-- Einparken
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:garage:einparken', function(kennzeichen, standort, zustand)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
if type(kennzeichen) ~= 'string' or type(standort) ~= 'string' then return end
local garage = Garage.Garagen[standort]
if not garage or not HatZugang(quelle, standort) then return end
local fahrzeug = HoleEigenes(charakterId, kennzeichen)
if not fahrzeug then Ablehnen(quelle, 'Das ist nicht dein Fahrzeug.') return end
-- Entfernung des Spielers zur Garage pruefen
local position = GetEntityCoords(GetPlayerPed(quelle))
if #(position - garage.position) > Garage.EINPARK_WEITE then
Ablehnen(quelle, 'Du bist zu weit von der Garage entfernt.')
return
end
-- Zustandswerte vom Client uebernehmen, aber begrenzen
zustand = type(zustand) == 'table' and zustand or {}
local tank = math.max(0, math.min(100, tonumber(zustand.tank) or fahrzeug.fuel or 100))
local motor = math.max(0, math.min(1000, tonumber(zustand.motor) or fahrzeug.engine or 1000))
local body = math.max(0, math.min(1000, tonumber(zustand.karosserie) or fahrzeug.body or 1000))
-- Kilometerstand: gefahrene Meter aufaddieren (nie verringern).
-- Nach oben begrenzt, damit manipulierte Werte nichts kaputtmachen.
local meter = math.max(0, math.min(1000000, math.floor(tonumber(zustand.meter) or 0)))
local km = (fahrzeug.mileage or 0) + math.floor(meter / 1000)
MySQL.update.await([[
UPDATE aether_vehicles
SET state = 'in', garage = ?, fuel = ?, engine = ?, body = ?, mileage = ?,
pos_x = NULL, pos_y = NULL, pos_z = NULL, heading = NULL
WHERE plate = ?
]], { standort, tank, motor, body, km, kennzeichen })
-- Fahrzeug aus der Welt nehmen (serverseitig, damit es bei allen verschwindet)
EntferneFahrzeug(kennzeichen)
freigaben[quelle] = nil
SendeZustand(quelle, standort, ('Fahrzeug in %s eingelagert.'):format(garage.name))
end)
-- ---------------------------------------------------------------------
-- Beschlagnahmtes Fahrzeug ausloesen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:garage:ausloesen', function(kennzeichen, standort)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
if type(kennzeichen) ~= 'string' or type(standort) ~= 'string' then return end
if not Garage.Garagen[standort] or not HatZugang(quelle, standort) then return end
local fahrzeug = HoleEigenes(charakterId, kennzeichen)
if not fahrzeug or fahrzeug.state ~= 'impound' then return end
if not Core:EntferneGeld(quelle, 'bargeld', Garage.AUSLOESE_GEBUEHR, 'Fahrzeug ausgelöst') then
Ablehnen(quelle, ('Du brauchst %d € in bar.'):format(Garage.AUSLOESE_GEBUEHR))
return
end
MySQL.update.await(
'UPDATE aether_vehicles SET state = ?, garage = ? WHERE plate = ?',
{ 'in', standort, kennzeichen })
SendeZustand(quelle, standort, ('Fahrzeug für %d € ausgelöst.'):format(Garage.AUSLOESE_GEBUEHR))
end)
-- ---------------------------------------------------------------------
-- Aufraeumen
-- ---------------------------------------------------------------------
AddEventHandler('playerDropped', function()
freigaben[source] = nil
end)
-- =====================================================================
-- EXPORTS
-- =====================================================================
--- Gibt einem Charakter ein Fahrzeug
--- @param charakterId number Charakter-ID
--- @param modell string Spawnname des Modells
--- @param garageId string Garage, in der es steht
--- @param props table|nil Anzeigedaten (Name, Klasse, Farbe ...)
--- @return string|nil Kennzeichen
exports('GebeFahrzeug', function(charakterId, modell, garageId, props)
if type(charakterId) ~= 'number' or type(modell) ~= 'string' then return nil end
local kennzeichen = Garage.ErzeugeKennzeichen()
MySQL.insert.await([[
INSERT INTO aether_vehicles (plate, owner, model, props, garage, state)
VALUES (?, ?, ?, ?, ?, 'in')
]], {
kennzeichen, charakterId, modell,
json.encode(props or { name = modell }),
garageId or 'legion',
})
return kennzeichen
end)
--- Beschlagnahmt ein Fahrzeug (fuer das Polizei-CAD)
--- @param kennzeichen string Kennzeichen
--- @return boolean Erfolg
exports('Beschlagnahmen', function(kennzeichen)
if type(kennzeichen) ~= 'string' then return false end
MySQL.update.await(
"UPDATE aether_vehicles SET state = 'impound' WHERE plate = ?", { kennzeichen })
return true
end)
--- Sucht den Besitzer eines Kennzeichens (fuer die Kennzeichenabfrage im CAD)
--- @param kennzeichen string Kennzeichen
--- @return table|nil { charakterId, vorname, nachname, modell }
exports('HoleHalter', function(kennzeichen)
local treffer = MySQL.query.await([[
SELECT v.owner, v.model, c.firstname, c.lastname
FROM aether_vehicles v
JOIN aether_characters c ON c.id = v.owner
WHERE v.plate = ?
]], { kennzeichen })
if not treffer or not treffer[1] then return nil end
return {
charakterId = treffer[1].owner,
vorname = treffer[1].firstname,
nachname = treffer[1].lastname,
modell = treffer[1].model,
}
end)

View File

@ -0,0 +1,93 @@
-- =====================================================================
-- Aether Garage — Garagen, Standorte und Einstellungen
-- =====================================================================
Garage = Garage or {}
Garage.INTERAKTION_WEITE = 3.0 -- Reichweite zum Oeffnen (Meter)
Garage.EINPARK_WEITE = 12.0 -- Wie nah das Fahrzeug an der Garage sein muss
Garage.AUSLOESE_GEBUEHR = 500 -- Kosten fuer beschlagnahmte Fahrzeuge
-- Bekommt ein neuer Charakter ein Startfahrzeug?
Garage.STARTFAHRZEUG = { modell = 'blista', garage = 'legion' }
-- ---------------------------------------------------------------------
-- Garagen
-- typ 'oeffentlich' = fuer alle zugaenglich
-- typ 'haus' = nur mit Zugang (spaeter ueber aether-immobilien)
-- position = Ort der Bedienung, spawn = wo das Fahrzeug erscheint
-- ---------------------------------------------------------------------
Garage.Garagen = {
legion = {
name = 'Legion Square', typ = 'oeffentlich',
position = vector3(215.9, -810.0, 30.7),
spawn = vector4(228.6, -800.5, 30.6, 158.0),
},
sandy = {
name = 'Sandy Shores', typ = 'oeffentlich',
position = vector3(1735.4, 3710.6, 34.1),
spawn = vector4(1729.6, 3716.3, 34.1, 20.0),
},
delperro = {
name = 'Del Perro', typ = 'oeffentlich',
position = vector3(-1160.0, -736.5, 20.4),
spawn = vector4(-1170.5, -737.7, 19.9, 108.0),
},
paleto = {
name = 'Paleto Bay', typ = 'oeffentlich',
position = vector3(105.9, 6613.9, 32.0),
spawn = vector4(112.6, 6608.9, 31.8, 45.0),
},
-- Haus-Garagen: sichtbar nur mit Zugang
villa = {
name = 'Villa Vinewood Hills', typ = 'haus',
position = vector3(-174.3, 502.1, 137.4),
spawn = vector4(-165.9, 496.3, 137.1, 190.0),
},
}
-- ---------------------------------------------------------------------
-- Fahrzeugklassen fuer die Anzeige
-- Ordnet dem GTA-Klassenindex einen Namen und einen Vorschautyp zu.
-- ---------------------------------------------------------------------
Garage.Klassen = {
[0] = { name = 'Kleinwagen', typ = 'compact' },
[1] = { name = 'Limousine', typ = 'sedan' },
[2] = { name = 'SUV', typ = 'suv' },
[3] = { name = 'Coupé', typ = 'sedan' },
[4] = { name = 'Muscle', typ = 'muscle' },
[5] = { name = 'Sportwagen', typ = 'sport' },
[6] = { name = 'Sportwagen', typ = 'sport' },
[7] = { name = 'Supersport', typ = 'sport' },
[8] = { name = 'Motorrad', typ = 'bike' },
[9] = { name = 'Geländewagen', typ = 'suv' },
[12]= { name = 'Transporter', typ = 'pickup' },
[13]= { name = 'Fahrrad', typ = 'bike' },
[14]= { name = 'Boot', typ = 'sedan' },
[15]= { name = 'Helikopter', typ = 'sedan' },
[16]= { name = 'Flugzeug', typ = 'sedan' },
[17]= { name = 'Service', typ = 'pickup' },
[18]= { name = 'Einsatz', typ = 'suv' },
[19]= { name = 'Militär', typ = 'suv' },
[20]= { name = 'LKW', typ = 'pickup' },
}
--- Liefert Klassenname und Vorschautyp zu einem GTA-Klassenindex
--- @param index number Klassenindex
--- @return string Name, string Vorschautyp
function Garage.Klasse(index)
local eintrag = Garage.Klassen[index or -1] or { name = 'Fahrzeug', typ = 'sedan' }
return eintrag.name, eintrag.typ
end
--- Erzeugt ein Kennzeichen im San-Andreas-Format (8 Zeichen)
--- @return string
function Garage.ErzeugeKennzeichen()
local buchstaben = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
local kennzeichen = ('%02d'):format(math.random(0, 99))
for _ = 1, 3 do
local pos = math.random(#buchstaben)
kennzeichen = kennzeichen .. buchstaben:sub(pos, pos)
end
return kennzeichen .. ('%03d'):format(math.random(0, 999))
end

View File

@ -0,0 +1,77 @@
-- =====================================================================
-- Aether HUD — Fahrzeugdaten und Sicherheitsgurt
-- Liefert Tempo, Gang, Tank und Motorzustand fuer die Tachoanzeige.
-- Der Gurt ist eine reine Skriptmechanik (GTA kennt ihn nicht).
-- =====================================================================
Fahrzeug = {}
-- Ist der Gurt gerade angelegt?
local gurtAngelegt = false
-- Merkt sich, ob der Spieler im letzten Durchlauf im Fahrzeug sass
local warImFahrzeug = false
-- Taste zum Anlegen/Loesen des Gurts (B)
local TASTE_GURT = 29
--- Liefert den aktuellen Gurtzustand
--- @return boolean
function Fahrzeug.HatGurt()
return gurtAngelegt
end
--- Sammelt alle Anzeigewerte des Fahrzeugs, in dem der Spieler sitzt
--- @return table|nil Werte oder nil, wenn der Spieler nicht faehrt
function Fahrzeug.HoleDaten()
local ped = PlayerPedId()
if not IsPedInAnyVehicle(ped, false) then return nil end
local fahrzeug = GetVehiclePedIsIn(ped, false)
if not fahrzeug or fahrzeug == 0 then return nil end
-- Geschwindigkeit von m/s in km/h umrechnen
local tempo = GetEntitySpeed(fahrzeug) * 3.6
return {
imFahrzeug = true,
tempo = math.floor(tempo + 0.5),
tank = math.floor(GetVehicleFuelLevel(fahrzeug) + 0.5),
motor = GetIsVehicleEngineRunning(fahrzeug),
gurt = gurtAngelegt,
}
end
-- ---------------------------------------------------------------------
-- Gurt-Steuerung
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
local ped = PlayerPedId()
local imFahrzeug = IsPedInAnyVehicle(ped, false)
if imFahrzeug then
Wait(0)
-- Gurt per Taste umschalten (nur als Fahrer oder Beifahrer sinnvoll)
if IsControlJustReleased(0, TASTE_GURT) then
gurtAngelegt = not gurtAngelegt
PlaySoundFrontend(-1, 'SELECT', 'HUD_FRONTEND_DEFAULT_SOUNDSET', true)
end
-- Mit Gurt: Herausschleudern bei Unfaellen verhindern
local fahrzeug = GetVehiclePedIsIn(ped, false)
if fahrzeug ~= 0 then
SetPedConfigFlag(ped, 32, not gurtAngelegt) -- 32 = kann durch Scheibe fliegen
end
warImFahrzeug = true
else
-- Beim Aussteigen den Gurt automatisch loesen
if warImFahrzeug then
gurtAngelegt = false
warImFahrzeug = false
end
Wait(500)
end
end
end)

View File

@ -0,0 +1,195 @@
-- =====================================================================
-- Aether HUD — Clientlogik
-- Versorgt die Oberflaeche mit Status, Geld, Voice und Fahrzeugwerten.
-- Es wird nur gesendet, wenn sich ein Wert tatsaechlich geaendert hat.
-- =====================================================================
-- Ist der Spieler fertig geladen? (vorher kein HUD zeigen)
local istGeladen = false
-- Soll das HUD sichtbar sein? (z. B. bei offenen Menues ausblendbar)
local istSichtbar = true
-- Zuletzt gesendete Werte, um unnoetige Nachrichten zu vermeiden
local letzterStatus = {}
local letztesFahrzeug = {}
local letzteVoice = {}
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Schickt eine Nachricht an die Oberflaeche
--- @param nachricht table Zu sendende Daten
local function SendeAnHud(nachricht)
SendNuiMessage(json.encode(nachricht))
end
--- Prueft, ob sich mindestens ein Feld gegenueber dem Zwischenspeicher geaendert hat
--- @param neu table Neue Werte
--- @param alt table Zwischenspeicher (wird aktualisiert)
--- @return boolean true, wenn sich etwas geaendert hat
local function HatSichGeaendert(neu, alt)
local geaendert = false
for schluessel, wert in pairs(neu) do
if alt[schluessel] ~= wert then
alt[schluessel] = wert
geaendert = true
end
end
return geaendert
end
--- Rechnet die Lebensenergie (100–200) in Prozent (0–100) um.
--- GTA zaehlt Leben ab 100; darunter ist der Spieler tot.
--- @param ped number Ped-Handle
--- @return number Prozentwert 0–100
local function LebenInProzent(ped)
local leben = GetEntityHealth(ped)
if leben <= 100 then return 0 end
return Aether.Begrenze((leben - 100) / 100 * 100, 0, 100)
end
-- ---------------------------------------------------------------------
-- Ereignisse vom Core
-- ---------------------------------------------------------------------
--- Spieler ist im Spiel: HUD mit den Startwerten fuellen
AddEventHandler('aether:client:bereit', function(daten)
istGeladen = true
SendeAnHud({
aktion = 'geld',
bargeld = daten.bargeld,
konto = daten.konto,
schwarzgeld = daten.schwarzgeld,
-- Schwarzgeld nur zeigen, wenn welches vorhanden ist
schwarzgeldSichtbar = (daten.schwarzgeld or 0) > 0,
})
SendeAnHud({
aktion = 'status',
leben = LebenInProzent(PlayerPedId()),
hunger = daten.hunger,
durst = daten.durst,
ausdauer = daten.ausdauer,
})
SendeAnHud({ aktion = 'sichtbar', an = true })
end)
--- Geld hat sich geaendert
AddEventHandler('aether:client:geldGeaendert', function(geld)
SendeAnHud({
aktion = 'geld',
bargeld = geld.bargeld,
konto = geld.konto,
schwarzgeld = geld.schwarzgeld,
schwarzgeldSichtbar = (geld.schwarzgeld or 0) > 0,
})
end)
--- Hunger/Durst/Ausdauer haben sich geaendert
AddEventHandler('aether:client:zustandGeaendert', function(zustand)
SendeAnHud({
aktion = 'status',
hunger = zustand.hunger,
durst = zustand.durst,
ausdauer = zustand.ausdauer,
})
end)
-- ---------------------------------------------------------------------
-- Laufende Erfassung: Leben und Ausdauer
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
Wait(500)
if istGeladen then
local ped = PlayerPedId()
-- Ausdauer aus dem Sprint-Vorrat ableiten (100 = ausgeruht)
local ausdauer = 100.0 - GetPlayerSprintStaminaRemaining(PlayerId())
local neu = {
leben = math.floor(LebenInProzent(ped)),
ausdauer = math.floor(Aether.Begrenze(ausdauer, 0, 100)),
}
if HatSichGeaendert(neu, letzterStatus) then
neu.aktion = 'status'
SendeAnHud(neu)
end
end
end
end)
-- ---------------------------------------------------------------------
-- Laufende Erfassung: Voice
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
Wait(200)
if istGeladen then
-- Sprechen erkennen (funktioniert mit dem eingebauten Mumble-Voice)
local redet = NetworkIsPlayerTalking(PlayerId())
-- Reichweite aus der Mumble-Einstellung ableiten
local reichweite = MumbleGetTalkerProximity() or 0.0
local stufe = 1 -- 0 = Fluestern, 1 = Normal, 2 = Rufen
if reichweite <= 3.5 then stufe = 0
elseif reichweite >= 12.0 then stufe = 2 end
local neu = { redet = redet, voiceStufe = stufe }
if HatSichGeaendert(neu, letzteVoice) then
neu.aktion = 'voice'
SendeAnHud(neu)
end
end
end
end)
-- ---------------------------------------------------------------------
-- Laufende Erfassung: Fahrzeug
-- Im Fahrzeug haeufiger abfragen, damit der Tacho fluessig laeuft.
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
local pause = 500
if istGeladen then
local daten = Fahrzeug.HoleDaten()
if daten then
pause = 100 -- fluessige Tachoanzeige
if HatSichGeaendert(daten, letztesFahrzeug) then
daten.aktion = 'fahrzeug'
SendeAnHud(daten)
end
elseif letztesFahrzeug.imFahrzeug ~= false then
-- Gerade ausgestiegen: Fahrzeuganzeige ausblenden
letztesFahrzeug = { imFahrzeug = false }
SendeAnHud({ aktion = 'fahrzeug', imFahrzeug = false, tempo = 0 })
end
end
Wait(pause)
end
end)
-- =====================================================================
-- EXPORTS — andere Module koennen das HUD aus- und einblenden
-- Beispiel: exports['aether-hud']:SetzeSichtbar(false)
-- =====================================================================
--- Blendet das HUD ein oder aus
--- @param an boolean true = sichtbar
exports('SetzeSichtbar', function(an)
istSichtbar = an and true or false
SendeAnHud({ aktion = 'sichtbar', an = istSichtbar })
end)
--- Ist das HUD gerade sichtbar?
--- @return boolean
exports('IstSichtbar', function()
return istSichtbar
end)

View File

@ -0,0 +1,30 @@
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
name 'aether-hud'
author 'Jerrit Fritzsche'
description 'In-Game-HUD: Status, Voice, Geld und Fahrzeuganzeige'
version '0.1.0'
-- Das HUD ist ein dauerhaftes Overlay (kein Fokus, keine Maus)
ui_page 'html/index.html'
files {
'html/index.html',
}
-- Geteilte Dateien des Cores mitladen (eigener Lua-State je Resource)
shared_scripts {
'@aether-core/shared/config.lua',
'@aether-core/shared/utils.lua',
}
client_scripts {
'client/fahrzeug.lua',
'client/main.lua',
}
dependencies {
'aether-core',
}

View File

@ -0,0 +1,484 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Aether — HUD</title>
<style>
:root{
--fg:#f2f0fa; /* Standard-Textfarbe */
--muted:#a09bbd; /* gedämpfter Text */
--panel:rgba(14,12,24,0.62); /* halbtransparente HUD-Fläche */
--panel2:rgba(20,17,34,0.9); /* dichtere Panel-Fläche */
--border:rgba(255,255,255,0.1);/* dezente Rahmen */
--accent:#7c5cf6; /* Aether-Akzent (Violett) */
--accent2:#5b8cff; /* zweiter Akzent (Blau) */
/* Statusfarben je Anzeige */
--health:#34d39a; --armor:#5b8cff; --hunger:#f5a742;
--thirst:#3fc7e0; --stamina:#c3e04a; --voice:#b58cff; --fuel:#f5a742;
}
*{box-sizing:border-box;}
html,body{margin:0;height:100%;overflow:hidden;background:transparent;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);}
/* In FiveM ist das HUD ein reines Overlay: Kulisse und Demo-Panel entfallen. */
body.ingame .scene{display:none;}
body.ingame .demo{display:none;}
/* Sanftes Ein-/Ausblenden des gesamten HUDs (z. B. bei offenen Menüs) */
.hud{transition:opacity .2s;}
body.hud-aus .hud{opacity:0;pointer-events:none;}
/* ============================================================
STILISIERTE SPIELSZENE (nur Kulisse, damit das HUD als Overlay wirkt)
============================================================ */
.scene{position:fixed;inset:0;z-index:0;overflow:hidden;
background:linear-gradient(180deg,#1a1440 0%,#3a2a5e 32%,#7a4a6a 52%,#c98a5a 66%,#e0a86a 74%,#2a2038 74%);}
/* Sonnen-/Lichtschein am Horizont */
.scene .sun{position:absolute;left:50%;top:60%;width:340px;height:340px;transform:translate(-50%,-50%);
border-radius:50%;background:radial-gradient(circle,rgba(255,220,150,0.9),rgba(255,180,120,0.2) 45%,transparent 70%);}
/* Häuser-Silhouette im Vordergrund */
.scene .sky{position:absolute;inset:0;background:radial-gradient(1px 1px at 20% 15%,rgba(255,255,255,0.7),transparent),
radial-gradient(1px 1px at 70% 8%,rgba(255,255,255,0.5),transparent),
radial-gradient(1px 1px at 45% 22%,rgba(255,255,255,0.6),transparent),
radial-gradient(1px 1px at 85% 18%,rgba(255,255,255,0.5),transparent);}
.scene .city{position:absolute;left:0;right:0;bottom:26%;height:34%;
background:
linear-gradient(90deg,transparent 4%,#241a38 4% 9%,transparent 9% 13%,#2c2044 13% 20%,transparent 20% 24%,
#201830 24% 30%,transparent 30% 36%,#2c2044 36% 41%,transparent 41% 47%,#281c40 47% 55%,transparent 55% 60%,
#241a38 60% 67%,transparent 67% 72%,#2c2044 72% 80%,transparent 80% 85%,#201830 85% 92%,transparent 92%);
filter:drop-shadow(0 -2px 0 rgba(124,92,246,0.25));}
.scene .ground{position:absolute;left:0;right:0;bottom:0;height:26%;background:linear-gradient(180deg,#1a1428,#0c0a16);}
.scene .road{position:absolute;left:50%;bottom:0;width:60%;height:26%;transform:translateX(-50%);
background:linear-gradient(180deg,#2a2438,#151020);clip-path:polygon(38% 0,62% 0,100% 100%,0 100%);}
.scene .dash{position:absolute;left:50%;bottom:0;width:4px;height:26%;transform:translateX(-50%);
background:repeating-linear-gradient(180deg,rgba(245,167,66,0.8) 0 24px,transparent 24px 48px);}
/* ============================================================
HUD-OVERLAY-CONTAINER
============================================================ */
.hud{position:fixed;inset:0;z-index:2;pointer-events:none;} /* Klicks gehen durch, außer bei Bedienelementen */
.hud > *{pointer-events:auto;}
/* Voice-Anzeige oben mittig */
.voice{position:absolute;top:20px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:10px;
background:var(--panel);border:1px solid var(--border);border-radius:999px;padding:7px 15px;backdrop-filter:blur(10px);}
.voice .mic{font-size:16px;transition:.15s;}
.voice.talking .mic{color:var(--voice);text-shadow:0 0 12px var(--voice);}
.voice .bars{display:flex;align-items:flex-end;gap:3px;height:16px;}
.voice .bars i{width:4px;background:var(--border);border-radius:2px;transition:.15s;}
.voice .bars i.on{background:var(--voice);}
.voice .range{font-size:11.5px;font-weight:800;color:var(--muted);letter-spacing:0.03em;}
.voice.talking .range{color:var(--fg);}
/* Status-Cluster unten links */
.cluster{position:absolute;left:22px;bottom:22px;display:flex;flex-direction:column;gap:12px;align-items:flex-start;}
.gauges{display:flex;gap:9px;flex-wrap:wrap;max-width:340px;}
.gauge{width:44px;height:44px;position:relative;}
.gauge svg{transform:rotate(-90deg);} /* Startpunkt oben */
.gauge .track{stroke:rgba(255,255,255,0.12);}
.gauge .icon{position:absolute;inset:0;display:grid;place-items:center;font-size:16px;}
.gauge .num{position:absolute;left:50%;bottom:-14px;transform:translateX(-50%);font-size:9.5px;font-weight:800;
color:var(--muted);font-variant-numeric:tabular-nums;}
/* Geld-Anzeige unten rechts */
.money{position:absolute;right:22px;bottom:22px;display:flex;flex-direction:column;gap:7px;align-items:flex-end;}
.money .row{display:flex;align-items:center;gap:9px;background:var(--panel);border:1px solid var(--border);
border-radius:11px;padding:7px 13px;backdrop-filter:blur(10px);min-width:160px;justify-content:flex-end;}
.money .row .mi{font-size:14px;}
.money .row .ml{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:0.07em;font-weight:800;margin-right:auto;}
.money .row .mv{font-size:16px;font-weight:800;font-variant-numeric:tabular-nums;}
.money .cash .mv{color:var(--health);}
.money .bank .mv{color:var(--accent2);}
.money .dirty .mv{color:#f5a742;}
.money .row.flash{animation:moneyFlash .6s ease;}
@keyframes moneyFlash{0%{transform:scale(1);}30%{transform:scale(1.06);border-color:var(--accent);}100%{transform:scale(1);}}
/* Fahrzeug-HUD unten rechts (über der Geld-Anzeige) */
.vehicle{position:absolute;right:22px;bottom:130px;display:none;align-items:flex-end;gap:14px;}
.vehicle.on{display:flex;}
.speedo{width:150px;height:150px;position:relative;}
.speedo svg{transform:rotate(135deg);} /* 270°-Skala, unten offen */
.speedo .big{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;}
.speedo .big .n{font-size:38px;font-weight:900;line-height:1;font-variant-numeric:tabular-nums;}
.speedo .big .u{font-size:11px;color:var(--muted);font-weight:800;letter-spacing:0.1em;margin-top:2px;}
.vinfo{display:flex;flex-direction:column;gap:7px;align-items:flex-end;margin-bottom:8px;}
.vinfo .gear{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:6px 12px;backdrop-filter:blur(10px);
font-size:13px;font-weight:800;}
.vinfo .gear b{color:var(--accent);font-size:16px;}
.warn{display:flex;gap:7px;}
.warn .w{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;font-size:15px;
background:var(--panel);border:1px solid var(--border);backdrop-filter:blur(10px);color:var(--muted);transition:.15s;}
.warn .w.alert{color:#f0506e;border-color:#f0506e;background:rgba(240,80,110,0.14);animation:blink 1s steps(2) infinite;}
.warn .w.ok{color:var(--health);}
@keyframes blink{50%{opacity:0.35;}}
.fuelbar{width:150px;height:6px;border-radius:99px;background:rgba(255,255,255,0.12);overflow:hidden;margin-top:2px;}
.fuelbar i{display:block;height:100%;background:linear-gradient(90deg,#f5a742,#f0d24a);border-radius:99px;transition:width .3s;}
/* ============================================================
DEMO-/DEBUG-PANEL (nur Prototyp — in FiveM entfällt es)
============================================================ */
.demo{position:absolute;top:20px;right:20px;width:280px;background:var(--panel2);border:1px solid var(--border);
border-radius:16px;backdrop-filter:blur(14px);box-shadow:0 24px 50px -20px rgba(0,0,0,0.8);overflow:hidden;}
.demo .dh{display:flex;align-items:center;justify-content:space-between;padding:12px 15px;cursor:pointer;
border-bottom:1px solid var(--border);}
.demo .dh .t{font-size:13px;font-weight:800;} .demo .dh .t small{color:var(--muted);font-weight:600;font-size:10.5px;display:block;margin-top:1px;}
.demo .dh .tog{color:var(--muted);font-size:15px;transition:.2s;}
.demo.collapsed .dh .tog{transform:rotate(-90deg);}
.demo .db{padding:13px 15px;display:flex;flex-direction:column;gap:13px;max-height:76vh;overflow-y:auto;}
.demo.collapsed .db{display:none;}
.demo .grp{display:flex;flex-direction:column;gap:8px;}
.demo .grp > .gl{font-size:9.5px;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;font-weight:800;}
.demo .slider{display:flex;align-items:center;gap:9px;}
.demo .slider label{font-size:11.5px;width:64px;color:var(--fg);font-weight:600;}
.demo .slider input[type=range]{flex:1;accent-color:var(--accent);cursor:pointer;}
.demo .slider .rv{font-size:11px;color:var(--muted);width:30px;text-align:right;font-variant-numeric:tabular-nums;}
.demo .btns{display:flex;flex-wrap:wrap;gap:6px;}
.demo button{border:1px solid var(--border);background:rgba(255,255,255,0.05);color:var(--fg);border-radius:9px;
padding:7px 10px;font-family:inherit;font-size:11.5px;font-weight:700;cursor:pointer;transition:.12s;}
.demo button:hover{border-color:var(--accent);color:#fff;}
.demo button.pri{background:var(--accent);color:#160c2e;border-color:transparent;}
.demo button.on{background:var(--accent-soft,rgba(124,92,246,0.2));border-color:var(--accent);color:#fff;}
.demo .divider{height:1px;background:var(--border);margin:1px 0;}
</style>
</head>
<body>
<!-- Kulisse hinter dem HUD -->
<div class="scene">
<div class="sky"></div><div class="sun"></div><div class="city"></div>
<div class="ground"></div><div class="road"></div><div class="dash"></div>
</div>
<!-- HUD-Overlay -->
<div class="hud">
<!-- Voice-Anzeige -->
<div class="voice" id="voice">
<span class="mic">🎙️</span>
<div class="bars"><i style="height:6px"></i><i style="height:10px"></i><i style="height:14px"></i></div>
<span class="range" id="voiceRange">Normal</span>
</div>
<!-- Status-Cluster -->
<div class="cluster">
<div class="gauges" id="gauges"></div>
</div>
<!-- Fahrzeug-HUD -->
<div class="vehicle" id="vehicle">
<div class="vinfo">
<div class="gear">Gang <b id="gear">N</b></div>
<div class="warn">
<div class="w" id="wSeat" title="Sicherheitsgurt">🎗️</div>
<div class="w ok" id="wEngine" title="Motor">⚙️</div>
</div>
<div class="fuelbar"><i id="fuelFill"></i></div>
</div>
<div class="speedo">
<svg width="150" height="150" viewBox="0 0 150 150">
<circle class="track" cx="75" cy="75" r="62" fill="none" stroke-width="10" stroke-linecap="round"
stroke-dasharray="292 390" pathLength="390"></circle>
<circle id="speedArc" cx="75" cy="75" r="62" fill="none" stroke="url(#sg)" stroke-width="10" stroke-linecap="round"
stroke-dasharray="0 390" pathLength="390"></circle>
<defs><linearGradient id="sg"><stop offset="0" stop-color="#5b8cff"/><stop offset="1" stop-color="#7c5cf6"/></linearGradient></defs>
</svg>
<div class="big"><div class="n" id="speedN">0</div><div class="u">KM/H</div></div>
</div>
</div>
<!-- Geld-Anzeige -->
<div class="money" id="money"></div>
<!-- Demo-Panel -->
<div class="demo" id="demo">
<div class="dh" id="demoHead">
<div class="t">Demo-Steuerung<small>Nur Prototyp — Werte simulieren</small></div>
<div class="tog">▾</div>
</div>
<div class="db">
<div class="grp">
<div class="gl">Status (0–100)</div>
<div class="slider"><label>❤️ Leben</label><input type="range" id="sLeben" min="0" max="100" value="100"><span class="rv" id="rLeben">100</span></div>
<div class="slider"><label>🍔 Hunger</label><input type="range" id="sHunger" min="0" max="100" value="82"><span class="rv" id="rHunger">82</span></div>
<div class="slider"><label>💧 Durst</label><input type="range" id="sDurst" min="0" max="100" value="74"><span class="rv" id="rDurst">74</span></div>
<div class="slider"><label>🫁 Ausdauer</label><input type="range" id="sAusdauer" min="0" max="100" value="100"><span class="rv" id="rAusdauer">100</span></div>
</div>
<div class="divider"></div>
<div class="grp">
<div class="gl">Aktionen</div>
<div class="btns">
<button id="bDamage">Schaden −20</button>
<button id="bHeal">Heilen</button>
<button id="bEat">🍔 Essen</button>
<button id="bDrink">💧 Trinken</button>
<button id="bTalk">🎙️ Sprechen</button>
<button id="bVoice">Voice-Reichweite</button>
</div>
</div>
<div class="divider"></div>
<div class="grp">
<div class="gl">Geld</div>
<div class="btns">
<button id="bCash">Bargeld +500</button>
<button id="bBank">Konto −250</button>
<button id="bDirty">Schwarzgeld an/aus</button>
</div>
</div>
<div class="divider"></div>
<div class="grp">
<div class="gl">Fahrzeug</div>
<div class="btns"><button id="bVehicle" class="pri">🚗 Einsteigen</button></div>
<div class="slider"><label>Tempo</label><input type="range" id="sSpeed" min="0" max="260" value="0"><span class="rv" id="rSpeed">0</span></div>
<div class="slider"><label>Tank</label><input type="range" id="sFuel" min="0" max="100" value="68"><span class="rv" id="rFuel">68</span></div>
<div class="btns">
<button id="bSeat">🎗️ Gurt an/aus</button>
<button id="bEngine" class="on">⚙️ Motor an</button>
</div>
</div>
</div>
</div>
</div>
<script>
"use strict";
/* Kurzer Zugriffshelfer auf Elemente per ID */
const $=id=>document.getElementById(id);
/* Zahl auf einen Bereich begrenzen */
const clamp=(n,a,b)=>Math.max(a,Math.min(b,n));
/* Betrag als Euro formatieren */
const euro=n=>Math.round(n).toLocaleString("de-DE")+" €";
/* =====================================================================
HUD-ZUSTAND (im echten Betrieb per NUI vom Client gesetzt)
Bewusst vom UI getrennt gehalten.
===================================================================== */
const hud={
leben:100, hunger:82, durst:74, ausdauer:100, // Status 0–100
redet:false, // spricht der Spieler gerade?
voiceStufe:1, // 0=Flüstern,1=Normal,2=Rufen
bargeld:2450, konto:18900, schwarzgeld:1200, // Geldwerte
schwarzgeldSichtbar:false, // Schwarzgeld nur bei Bedarf zeigen
// Fahrzeug-bezogene Werte
imFahrzeug:false, tempo:0, tank:68, gurt:false, motor:true,
};
/* Die drei Voice-Reichweiten mit Beschriftung und Anzahl aktiver Balken */
const voiceStufen=[{name:"Flüstern",bars:1},{name:"Normal",bars:2},{name:"Rufen",bars:3}];
/* Definition der Status-Anzeigen (Reihenfolge = Anzeige-Reihenfolge) */
const gaugeDefs=[
{key:"leben", icon:"❤️", farbe:"var(--health)"},
{key:"hunger", icon:"🍔", farbe:"var(--hunger)"},
{key:"durst", icon:"💧", farbe:"var(--thirst)"},
{key:"ausdauer", icon:"🫁", farbe:"var(--stamina)"},
];
const RING_UMFANG=2*Math.PI*17; // Umfang der Status-Ringe (Radius 17)
/* =====================================================================
RENDERING
===================================================================== */
/* Baut die Status-Ringe einmalig auf (SVG-Ring + Icon + Zahl) */
function baueGauges(){
$("gauges").innerHTML=gaugeDefs.map(g=>
`<div class="gauge" title="${g.key}">
<svg width="44" height="44" viewBox="0 0 44 44">
<circle class="track" cx="22" cy="22" r="17" fill="none" stroke-width="4"></circle>
<circle id="arc-${g.key}" cx="22" cy="22" r="17" fill="none" stroke="${g.farbe}" stroke-width="4"
stroke-linecap="round" stroke-dasharray="${RING_UMFANG}" stroke-dashoffset="${RING_UMFANG}"></circle>
</svg>
<div class="icon">${g.icon}</div>
<div class="num" id="num-${g.key}">0</div>
</div>`).join('');
}
/* Aktualisiert die Füllstände aller Status-Ringe aus dem Zustand */
function renderGauges(){
for(const g of gaugeDefs){
const wert=clamp(hud[g.key],0,100);
const arc=$("arc-"+g.key);
arc.style.strokeDashoffset=RING_UMFANG*(1-wert/100); // weniger Wert = mehr Lücke
$("num-"+g.key).textContent=Math.round(wert);
}
}
/* Aktualisiert die Voice-Anzeige (Reichweite + Sprech-Zustand) */
function renderVoice(){
const stufe=voiceStufen[hud.voiceStufe];
$("voiceRange").textContent=stufe.name;
$("voice").classList.toggle("talking",hud.redet);
// Nur so viele Balken hervorheben, wie die Reichweite vorgibt (und nur beim Reden)
$("voice").querySelectorAll(".bars i").forEach((b,i)=>
b.classList.toggle("on",hud.redet&&i<stufe.bars));
}
/* Aktualisiert die Geld-Anzeige (Schwarzgeld optional) */
function renderMoney(){
let html=
`<div class="row cash"><span class="mi">💵</span><span class="ml">Bargeld</span><span class="mv">${euro(hud.bargeld)}</span></div>
<div class="row bank"><span class="mi">🏦</span><span class="ml">Konto</span><span class="mv">${euro(hud.konto)}</span></div>`;
if(hud.schwarzgeldSichtbar)
html+=`<div class="row dirty"><span class="mi">🕶️</span><span class="ml">Schwarzgeld</span><span class="mv">${euro(hud.schwarzgeld)}</span></div>`;
$("money").innerHTML=html;
}
/* Lässt eine Geld-Zeile kurz aufblinken, wenn sich der Wert ändert */
function blinkeGeld(klasse){const el=$("money").querySelector("."+klasse);if(el){el.classList.add("flash");setTimeout(()=>el.classList.remove("flash"),600);}}
/* Aktualisiert das Fahrzeug-HUD (Tacho, Gang, Tank, Warnungen) */
const SPEED_MAX=260; // Skalen-Maximum des Tachos
const SPEED_PATHLEN=390, SPEED_ARC=292; // Pfad-Länge und sichtbarer 270°-Bogen
function renderVehicle(){
$("vehicle").classList.toggle("on",hud.imFahrzeug);
if(!hud.imFahrzeug)return;
// Tacho-Bogen proportional zum Tempo füllen
const anteil=clamp(hud.tempo/SPEED_MAX,0,1);
$("speedArc").setAttribute("stroke-dasharray",(SPEED_ARC*anteil)+" "+SPEED_PATHLEN);
$("speedN").textContent=Math.round(hud.tempo);
// Gang aus dem Tempo ableiten (grobe Simulation)
$("gear").textContent=!hud.motor?"–":hud.tempo<1?"N":Math.min(6,Math.floor(hud.tempo/38)+1);
// Tankanzeige
$("fuelFill").style.width=clamp(hud.tank,0,100)+"%";
// Warnleuchten: Gurt blinkt bei Fahrt ohne Gurt, Motor zeigt Status
$("wSeat").className="w"+(!hud.gurt&&hud.tempo>5?" alert":(hud.gurt?" ok":""));
$("wEngine").className="w"+(hud.motor?" ok":" alert");
}
/* Rendert das komplette HUD neu */
function renderAll(){renderGauges();renderVoice();renderMoney();renderVehicle();}
/* =====================================================================
DEMO-STEUERUNG (nur im Prototyp; in FiveM ersetzt durch echte Events)
===================================================================== */
/* Verknüpft einen Slider mit einem Zustandswert inkl. Zahl-Anzeige */
function bindeSlider(sliderId,anzeigeId,setter){
const s=$(sliderId);
s.addEventListener("input",()=>{$(anzeigeId).textContent=s.value;setter(+s.value);renderAll();});
}
bindeSlider("sLeben","rLeben",v=>hud.leben=v);
bindeSlider("sHunger","rHunger",v=>hud.hunger=v);
bindeSlider("sDurst","rDurst",v=>hud.durst=v);
bindeSlider("sAusdauer","rAusdauer",v=>hud.ausdauer=v);
bindeSlider("sSpeed","rSpeed",v=>hud.tempo=v);
bindeSlider("sFuel","rFuel",v=>hud.tank=v);
/* Synchronisiert die Slider-Positionen mit dem Zustand (nach Aktionen) */
function syncSlider(){
$("sLeben").value=hud.leben; $("rLeben").textContent=Math.round(hud.leben);
$("sHunger").value=hud.hunger; $("rHunger").textContent=Math.round(hud.hunger);
$("sDurst").value=hud.durst; $("rDurst").textContent=Math.round(hud.durst);
$("sAusdauer").value=hud.ausdauer; $("rAusdauer").textContent=Math.round(hud.ausdauer);
$("sSpeed").value=hud.tempo; $("rSpeed").textContent=Math.round(hud.tempo);
$("sFuel").value=hud.tank; $("rFuel").textContent=Math.round(hud.tank);
}
/* Aktions-Buttons */
$("bDamage").addEventListener("click",()=>{
// Schaden geht direkt auf das Leben
hud.leben=clamp(hud.leben-20,0,100);
syncSlider(); renderAll();
});
$("bHeal").addEventListener("click",()=>{hud.leben=100;syncSlider();renderAll();});
$("bEat").addEventListener("click",()=>{hud.hunger=clamp(hud.hunger+25,0,100);syncSlider();renderAll();});
$("bDrink").addEventListener("click",()=>{hud.durst=clamp(hud.durst+25,0,100);syncSlider();renderAll();});
/* „Sprechen" simulieren: Voice kurz aktiv schalten */
let redeTimer=null;
$("bTalk").addEventListener("click",()=>{
hud.redet=true; renderVoice(); clearTimeout(redeTimer);
redeTimer=setTimeout(()=>{hud.redet=false;renderVoice();},1500);
});
/* Voice-Reichweite durchschalten */
$("bVoice").addEventListener("click",()=>{hud.voiceStufe=(hud.voiceStufe+1)%voiceStufen.length;renderVoice();});
/* Geld-Buttons */
$("bCash").addEventListener("click",()=>{hud.bargeld+=500;renderMoney();blinkeGeld("cash");});
$("bBank").addEventListener("click",()=>{hud.konto=Math.max(0,hud.konto-250);renderMoney();blinkeGeld("bank");});
$("bDirty").addEventListener("click",()=>{hud.schwarzgeldSichtbar=!hud.schwarzgeldSichtbar;renderMoney();if(hud.schwarzgeldSichtbar)blinkeGeld("dirty");});
/* Fahrzeug-Buttons */
$("bVehicle").addEventListener("click",()=>{
hud.imFahrzeug=!hud.imFahrzeug;
$("bVehicle").textContent=hud.imFahrzeug?"🚶 Aussteigen":"🚗 Einsteigen";
$("bVehicle").classList.toggle("on",hud.imFahrzeug);
if(!hud.imFahrzeug){hud.tempo=0;syncSlider();} // beim Aussteigen Tempo zurücksetzen
renderAll();
});
$("bSeat").addEventListener("click",()=>{hud.gurt=!hud.gurt;$("bSeat").classList.toggle("on",hud.gurt);renderVehicle();});
$("bEngine").addEventListener("click",()=>{hud.motor=!hud.motor;$("bEngine").classList.toggle("on",hud.motor);$("bEngine").textContent=hud.motor?"⚙️ Motor an":"⚙️ Motor aus";renderVehicle();});
/* Demo-Panel ein-/ausklappen */
$("demoHead").addEventListener("click",()=>$("demo").classList.toggle("collapsed"));
/* =====================================================================
LEBENDIGE SIMULATION (langsamer Verbrauch)
===================================================================== */
/* Hunger/Durst sinken langsam; leerer Magen zieht am Leben; Tank sinkt bei Fahrt */
function tickVerbrauchsWerte(){
hud.hunger=clamp(hud.hunger-1,0,100);
hud.durst=clamp(hud.durst-1.4,0,100);
if(hud.hunger<=0||hud.durst<=0)hud.leben=clamp(hud.leben-2,0,100); // Verhungern/Verdursten
if(hud.imFahrzeug&&hud.motor&&hud.tempo>0)hud.tank=clamp(hud.tank-0.6,0,100);
if(hud.tank<=0)hud.tempo=0; // ohne Sprit kein Vortrieb
syncSlider(); renderAll();
}
/* =====================================================================
NUI-BRÜCKE — Werte kommen vom FiveM-Client
===================================================================== */
/* Läuft das HUD in FiveM oder eigenständig im Browser? */
const IST_FIVEM = (typeof GetParentResourceName === "function");
/* Übernimmt nur die Felder, die tatsächlich mitgeschickt wurden */
function uebernehme(ziel, quelle, felder){
for(const feld of felder){
if(quelle[feld] !== undefined && quelle[feld] !== null) ziel[feld] = quelle[feld];
}
}
window.addEventListener("message", (ereignis) => {
const n = ereignis.data || {};
if(n.aktion === "status"){ // Leben, Hunger, Durst, Ausdauer
uebernehme(hud, n, ["leben","hunger","durst","ausdauer"]);
renderGauges();
}
else if(n.aktion === "geld"){ // Bargeld, Konto, Schwarzgeld
const vorherBar = hud.bargeld, vorherKonto = hud.konto;
uebernehme(hud, n, ["bargeld","konto","schwarzgeld","schwarzgeldSichtbar"]);
renderMoney();
// Geänderte Beträge kurz hervorheben
if(n.bargeld !== undefined && n.bargeld !== vorherBar) blinkeGeld("cash");
if(n.konto !== undefined && n.konto !== vorherKonto) blinkeGeld("bank");
}
else if(n.aktion === "voice"){ // Sprechen und Reichweite
uebernehme(hud, n, ["redet","voiceStufe"]);
renderVoice();
}
else if(n.aktion === "fahrzeug"){ // Tacho, Tank, Gurt, Motor
uebernehme(hud, n, ["imFahrzeug","tempo","tank","gurt","motor"]);
renderVehicle();
}
else if(n.aktion === "sichtbar"){ // HUD ein-/ausblenden
document.body.classList.toggle("hud-aus", n.an === false);
}
});
/* =====================================================================
START
===================================================================== */
baueGauges();
if(IST_FIVEM){
// Im Spiel: Kulisse und Demo-Panel ausblenden, keine Simulation laufen lassen
document.body.classList.add("ingame");
// Startwerte neutral halten, bis der Client die echten Werte schickt
Object.assign(hud, {leben:100, hunger:100, durst:100, ausdauer:100,
bargeld:0, konto:0, schwarzgeld:0, imFahrzeug:false, tempo:0});
renderAll();
} else {
// Eigenständige Browser-Ansicht: Demo-Panel und Verbrauchssimulation aktiv
syncSlider();
renderAll();
setInterval(tickVerbrauchsWerte, 4000);
}
</script>
</body>
</html>

View File

@ -0,0 +1,164 @@
-- =====================================================================
-- Aether Inventory — Sichtbare Boden-Drops
-- Fuer jedes fallengelassene Item steht ein Karton in der Welt.
-- Die Props werden nur lokal erzeugt und nach Entfernung ein-/ausgeblendet.
-- =====================================================================
Drops = {}
-- Bekannte Drops vom Server: [id] = { id, item, menge, x, y, z, prop }
local bekannteDrops = {}
-- Erzeugte Prop-Objekte: [id] = Objekt-Handle
local propObjekte = {}
-- Taste E zum Aufheben
local TASTE_AUFHEBEN = 38
-- ---------------------------------------------------------------------
-- Prop erzeugen und entfernen
-- ---------------------------------------------------------------------
--- Erzeugt den Karton eines Drops in der Welt
--- @param drop table Datensatz vom Server
local function ErzeugeProp(drop)
if propObjekte[drop.id] then return end
local modell = GetHashKey(drop.prop or 'prop_cardbordbox_03a')
RequestModel(modell)
local versuche = 0
while not HasModelLoaded(modell) and versuche < 60 do
Wait(50)
versuche = versuche + 1
end
if not HasModelLoaded(modell) then return end
-- Nur lokal erzeugen: Jeder Client setzt seinen eigenen Karton
local objekt = CreateObject(modell, drop.x, drop.y, drop.z, false, false, false)
PlaceObjectOnGroundProperly(objekt)
FreezeEntityPosition(objekt, true)
SetEntityCollision(objekt, false, true) -- nicht wegschiebbar, aber sichtbar
SetModelAsNoLongerNeeded(modell)
propObjekte[drop.id] = objekt
end
--- Entfernt den Karton eines Drops wieder
--- @param id number Drop-ID
local function EntferneProp(id)
local objekt = propObjekte[id]
if objekt and DoesEntityExist(objekt) then
DeleteObject(objekt)
end
propObjekte[id] = nil
end
--- Entfernt alle erzeugten Kartons (beim Stoppen der Ressource)
function Drops.AlleEntfernen()
for id in pairs(propObjekte) do EntferneProp(id) end
end
-- ---------------------------------------------------------------------
-- Ereignisse vom Server
-- ---------------------------------------------------------------------
--- Vollstaendige Liste beim Einsteigen ins Spiel
RegisterNetEvent('aether:inventory:dropListe', function(liste)
bekannteDrops = {}
for _, drop in pairs(liste or {}) do
bekannteDrops[drop.id] = drop
end
end)
--- Ein neues Item wurde fallengelassen
RegisterNetEvent('aether:inventory:dropHinzu', function(drop)
if not drop or not drop.id then return end
bekannteDrops[drop.id] = drop
end)
--- Ein Item wurde aufgehoben
RegisterNetEvent('aether:inventory:dropWeg', function(id)
bekannteDrops[id] = nil
EntferneProp(id)
end)
-- ---------------------------------------------------------------------
-- Kartons je nach Entfernung ein- und ausblenden
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
Wait(1000)
local position = GetEntityCoords(PlayerPedId())
for id, drop in pairs(bekannteDrops) do
local abstand = #(position - vector3(drop.x, drop.y, drop.z))
if abstand <= Inventar.DROP_SPAWN_WEITE then
ErzeugeProp(drop)
elseif propObjekte[id] then
EntferneProp(id)
end
end
-- Kartons entfernen, deren Drop es nicht mehr gibt
for id in pairs(propObjekte) do
if not bekannteDrops[id] then EntferneProp(id) end
end
end
end)
-- ---------------------------------------------------------------------
-- Aufheben per Taste E am naechsten Karton
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
local pause = 500
local position = GetEntityCoords(PlayerPedId())
-- Naechstgelegenen Drop in Reichweite suchen
local naechsteId, naechsterAbstand, naechsterDrop = nil, Inventar.DROP_HINWEIS, nil
for id, drop in pairs(bekannteDrops) do
local abstand = #(position - vector3(drop.x, drop.y, drop.z))
if abstand < naechsterAbstand then
naechsteId, naechsterAbstand, naechsterDrop = id, abstand, drop
end
end
if naechsteId then
pause = 0 -- fuer die Anzeige jeden Frame laufen
-- Hinweis ueber dem Karton einblenden
local def = Inventar.Items[naechsterDrop.item]
local beschriftung = def and def.name or naechsterDrop.item
if (naechsterDrop.menge or 1) > 1 then
beschriftung = beschriftung .. ' ×' .. naechsterDrop.menge
end
SetTextFont(4)
SetTextScale(0.32, 0.32)
SetTextColour(255, 255, 255, 215)
SetTextCentre(true)
SetTextOutline()
SetTextEntry('STRING')
AddTextComponentString('[E] ' .. beschriftung .. ' aufheben')
SetDrawOrigin(naechsterDrop.x, naechsterDrop.y, naechsterDrop.z + 0.6, 0)
DrawText(0.0, 0.0)
ClearDrawOrigin()
if IsControlJustReleased(0, TASTE_AUFHEBEN) then
TriggerServerEvent('aether:inventory:aufheben', naechsteId)
end
end
Wait(pause)
end
end)
-- ---------------------------------------------------------------------
-- Aufraeumen
-- ---------------------------------------------------------------------
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
Drops.AlleEntfernen()
end)

View File

@ -0,0 +1,151 @@
-- =====================================================================
-- Aether Inventory — Clientlogik
-- Oeffnet die Oberflaeche und reicht Wuensche an den Server weiter.
-- Es wird bewusst nichts lokal entschieden.
-- =====================================================================
-- Ist das Inventar gerade offen?
local istOffen = false
-- Ist der Spieler im Spiel?
local istGeladen = false
-- Taste zum Oeffnen (I)
local TASTE_INVENTAR = 311
--- Schickt eine Nachricht an die Oberflaeche
--- @param nachricht table Daten
local function SendeAnNui(nachricht)
SendNuiMessage(json.encode(nachricht))
end
--- Schliesst das Inventar und gibt die Steuerung zurueck
local function Schliessen()
if not istOffen then return end
istOffen = false
SetNuiFocus(false, false)
SendeAnNui({ aktion = 'schliessen' })
-- HUD wieder einblenden, falls vorhanden
pcall(function() exports['aether-hud']:SetzeSichtbar(true) end)
end
-- ---------------------------------------------------------------------
-- Ereignisse vom Core und Server
-- ---------------------------------------------------------------------
AddEventHandler('aether:client:bereit', function()
istGeladen = true
end)
--- Server schickt den Zustand und moechte, dass wir oeffnen
RegisterNetEvent('aether:inventory:zeige', function(zustand)
istOffen = true
SetNuiFocus(true, true)
-- HUD ausblenden, solange das Inventar offen ist
pcall(function() exports['aether-hud']:SetzeSichtbar(false) end)
zustand.aktion = 'oeffnen'
zustand.items = Inventar.Items -- Registry zum Anzeigen mitschicken
zustand.equipSlots = Inventar.EQUIP_SLOTS
zustand.bereiche = Inventar.BEREICH
SendeAnNui(zustand)
end)
--- Zeigt eine Meldung oben links im Spiel (wenn die Oberflaeche zu ist)
--- @param text string Meldungstext
local function ZeigeSpielmeldung(text)
if not text or text == '' then return end
BeginTextCommandThefeedPost('STRING')
AddTextComponentSubstringPlayerName(text)
EndTextCommandThefeedPostTicker(false, true)
end
--- Server schickt einen aktualisierten Zustand (nach jeder Aktion)
RegisterNetEvent('aether:inventory:zustand', function(zustand)
if not istOffen then
-- Inventar zu (z. B. Aufheben per Taste E): kurz im Spiel melden
ZeigeSpielmeldung(zustand.hinweis)
return
end
zustand.aktion = 'zustand'
SendeAnNui(zustand)
end)
--- Kurze Rueckmeldung (z. B. Ablehnung einer Aktion)
RegisterNetEvent('aether:inventory:hinweis', function(text)
if istOffen then
SendeAnNui({ aktion = 'hinweis', text = text })
else
ZeigeSpielmeldung(text)
end
end)
--- Heilung durch ein benutztes Item
RegisterNetEvent('aether:inventory:heilen', function(menge)
local ped = PlayerPedId()
local leben = GetEntityHealth(ped)
SetEntityHealth(ped, math.min(200, leben + (menge or 0)))
end)
-- ---------------------------------------------------------------------
-- Oeffnen per Taste
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
Wait(0)
if istGeladen and not istOffen and IsControlJustReleased(0, TASTE_INVENTAR) then
-- Nicht oeffnen, waehrend man tot ist oder schwimmt
local ped = PlayerPedId()
if not IsEntityDead(ped) then
TriggerServerEvent('aether:inventory:oeffnen')
end
end
end
end)
-- ---------------------------------------------------------------------
-- NUI-Callbacks — jede Aktion geht ungeprueft an den Server,
-- der sie dann validiert.
-- ---------------------------------------------------------------------
RegisterNUICallback('schliessen', function(_, rueckgabe)
Schliessen()
rueckgabe({ ok = true })
end)
RegisterNUICallback('verschieben', function(daten, rueckgabe)
TriggerServerEvent('aether:inventory:verschieben', daten.von, daten.nach)
rueckgabe({ ok = true })
end)
RegisterNUICallback('splitten', function(daten, rueckgabe)
TriggerServerEvent('aether:inventory:splitten', daten.slot, daten.menge)
rueckgabe({ ok = true })
end)
RegisterNUICallback('fallenlassen', function(daten, rueckgabe)
TriggerServerEvent('aether:inventory:fallenlassen', daten.slot, daten.menge)
rueckgabe({ ok = true })
end)
RegisterNUICallback('aufheben', function(daten, rueckgabe)
TriggerServerEvent('aether:inventory:aufheben', daten.bodenId)
rueckgabe({ ok = true })
end)
RegisterNUICallback('geben', function(daten, rueckgabe)
TriggerServerEvent('aether:inventory:geben', daten.slot, daten.menge, daten.zielId)
rueckgabe({ ok = true })
end)
RegisterNUICallback('benutzen', function(daten, rueckgabe)
TriggerServerEvent('aether:inventory:benutzen', daten.slot)
rueckgabe({ ok = true })
end)
-- ---------------------------------------------------------------------
-- Aufraeumen
-- ---------------------------------------------------------------------
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false)
end)

View File

@ -0,0 +1,38 @@
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
name 'aether-inventory'
author 'Jerrit Fritzsche'
description 'Server-geprüftes Inventar mit Drag & Drop, Gewicht und Boden-Items'
version '0.1.0'
ui_page 'html/index.html'
files {
'html/index.html',
}
-- Item-Registry teilen sich Client und Server.
-- Core-Dateien mitladen, da jede Resource einen eigenen Lua-State hat.
shared_scripts {
'@aether-core/shared/config.lua',
'@aether-core/shared/utils.lua',
'shared/items.lua',
}
client_scripts {
'client/drops.lua',
'client/main.lua',
}
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/inventar.lua',
'server/main.lua',
}
dependencies {
'aether-core',
'oxmysql',
}

View File

@ -0,0 +1,772 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
</head>
<body>
<title>Aether — Inventar</title>
<style>
/* ===================== Design-Tokens ===================== */
:root {
--page-bg: #0a0b10;
--fg: #e9ecf4;
--muted: #838aa0;
--surface: rgba(24,26,36,0.72);
--surface2: rgba(36,39,53,0.8);
--slot: #14161f;
--slot-line: rgba(255,255,255,0.07);
--border: rgba(255,255,255,0.09);
--accent: #f5a623; /* warmer "Loot"-Akzent */
--accent-soft: rgba(245,166,35,0.16);
--red: #f0506e;
--green: #34d39a;
--amber: #f5b23d;
/* Seltenheits-Farben */
--r-common: #8b93a1;
--r-uncommon: #34d39a;
--r-rare: #4aa8ff;
--r-epic: #a56bff;
--r-legendary: #f5b23d;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
min-height: 100%;
background:
radial-gradient(1100px 700px at 12% 8%, rgba(245,166,35,0.08), transparent 55%),
radial-gradient(1000px 800px at 88% 92%, rgba(74,168,255,0.07), transparent 55%),
var(--page-bg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--fg);
display: flex; align-items: center; justify-content: center;
padding: 32px 18px; overflow-x: hidden;
}
/* ===================== Fenster ===================== */
.inv {
position: relative;
width: min(1200px, 96vw);
aspect-ratio: 16 / 10;
background:
radial-gradient(120% 80% at 0% 0%, rgba(245,166,35,0.05), transparent 45%),
linear-gradient(160deg, rgba(18,20,28,0.92), rgba(10,11,16,0.95));
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border); border-radius: 22px;
box-shadow: 0 50px 100px -30px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.03) inset;
display: flex; flex-direction: column; overflow: hidden;
}
.inv-head { display: flex; align-items: center; gap: 18px; padding: 16px 22px; border-bottom: 1px solid var(--border); }
.inv-head .logo { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center;
background: linear-gradient(150deg, #f5a623, #c9791a); color: #1a1206; font-weight: 800; }
.inv-head .title { font-size: 19px; font-weight: 800; letter-spacing: -0.02em; }
.inv-head .title small { display: block; font-size: 11.5px; font-weight: 500; color: var(--muted); letter-spacing: 0; }
.weight { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.weight-bar { width: 200px; height: 9px; border-radius: 5px; background: var(--slot); overflow: hidden; border: 1px solid var(--slot-line); }
.weight-fill { height: 100%; border-radius: 5px; background: var(--accent); transition: width .3s ease, background .3s ease; }
.weight-txt { font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; min-width: 96px; text-align: right; }
.inv-body { flex: 1; display: grid; grid-template-columns: 232px 1fr 300px; gap: 0; min-height: 0; }
.col { padding: 16px 18px; min-height: 0; display: flex; flex-direction: column; }
.col.umgebung { border-right: 1px solid var(--border); }
.col.avatar-col { border-left: 1px solid var(--border); }
.panel-h { font-size: 11.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.07em;
color: var(--muted); margin: 4px 2px 10px; display: flex; align-items: center; gap: 8px; }
.panel-h:not(:first-child) { margin-top: 18px; }
/* ===================== Slots & Items ===================== */
.slots { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; overflow-y: auto; padding-right: 4px; align-content: start; }
.slots::-webkit-scrollbar { width: 7px; } .slots::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
.slot {
aspect-ratio: 1; border-radius: 12px; background: var(--slot); border: 1px solid var(--slot-line);
position: relative; display: grid; place-items: center; transition: border-color .12s, background .12s;
}
.slot.over { border-color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 2px var(--accent-soft); }
.slot .num { position: absolute; top: 5px; left: 7px; font-size: 11px; font-weight: 800; color: var(--muted); }
.item {
width: 100%; height: 100%; border-radius: 12px; position: relative; cursor: grab;
display: grid; place-items: center; background: linear-gradient(160deg, rgba(255,255,255,0.05), rgba(255,255,255,0.01));
border: 1.5px solid var(--rar, var(--r-common));
box-shadow: 0 0 14px -6px var(--rar, transparent) inset;
transition: transform .1s ease, box-shadow .12s;
}
.item:hover { box-shadow: 0 0 0 1px var(--rar), 0 8px 20px -8px rgba(0,0,0,0.6); }
.item.dragging { opacity: 0.35; }
.item .ic { font-size: 30px; line-height: 1; filter: drop-shadow(0 3px 5px rgba(0,0,0,0.5)); }
.item .qty { position: absolute; right: 5px; bottom: 4px; font-size: 12px; font-weight: 800;
background: rgba(8,9,13,0.85); border-radius: 6px; padding: 1px 6px; font-variant-numeric: tabular-nums; }
.item .wt { position: absolute; left: 5px; bottom: 4px; font-size: 9.5px; color: var(--muted); font-weight: 600; font-variant-numeric: tabular-nums; }
.item .asset { position: absolute; top: 5px; right: 6px; width: 8px; height: 8px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 8px var(--accent); }
.rar-common { --rar: var(--r-common); } .rar-uncommon { --rar: var(--r-uncommon); }
.rar-rare { --rar: var(--r-rare); } .rar-epic { --rar: var(--r-epic); } .rar-legendary { --rar: var(--r-legendary); }
/* ===================== Umgebung (links) ===================== */
.player { display: flex; align-items: center; gap: 10px; padding: 9px 11px; border-radius: 12px;
background: var(--surface); border: 1px solid var(--border); margin-bottom: 8px; transition: .12s; }
.player.over { border-color: var(--green); background: rgba(52,211,154,0.12); }
.player .pa { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; color: #fff; flex-shrink: 0; }
.player .pn { font-size: 13.5px; font-weight: 700; }
.player .pr { font-size: 11.5px; color: var(--muted); }
.player .give { margin-left: auto; font-size: 15px; opacity: 0.5; }
.boden { flex: 1; border: 1.5px dashed var(--border); border-radius: 14px; padding: 10px; overflow-y: auto;
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; align-content: start; transition: .12s; min-height: 90px; }
.boden.over { border-color: var(--accent); background: var(--accent-soft); }
.boden .hint { grid-column: 1 / -1; color: var(--muted); font-size: 12px; text-align: center; align-self: center; padding: 18px 8px; line-height: 1.5; }
/* ===================== Avatar (rechts) ===================== */
.avatar-stage { position: relative; flex: 1; min-height: 0; display: grid; place-items: center; perspective: 900px; }
.mannequin { width: 130px; height: 240px; animation: turn 7s ease-in-out infinite; transform-style: preserve-3d; }
@keyframes turn { 0%,100% { transform: rotateY(-13deg); } 50% { transform: rotateY(13deg); } }
@keyframes floaty { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
@media (prefers-reduced-motion: reduce) { .mannequin { animation: none; } }
.platform { position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%); width: 150px; height: 30px;
border-radius: 50%; background: radial-gradient(ellipse, rgba(245,166,35,0.22), transparent 70%); }
.eq-slot { position: absolute; width: 58px; height: 58px; border-radius: 12px; background: var(--slot);
border: 1px solid var(--slot-line); display: grid; place-items: center; transition: .12s; }
.eq-slot.over { border-color: var(--accent); background: var(--accent-soft); }
.eq-slot .hintico { font-size: 22px; opacity: 0.28; }
.eq-slot .eqlabel { position: absolute; bottom: -16px; left: 0; right: 0; text-align: center; font-size: 9.5px;
color: var(--muted); font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
.eq-slot .item { border-radius: 11px; }
.eq-helm { top: 4px; left: 50%; transform: translateX(-50%); }
.eq-schmuck{ top: 40px; right: 6px; }
.eq-brust { top: 40px; left: 6px; }
.eq-hose { top: 116px; left: 6px; }
.eq-schuhe { bottom: 30px; left: 50%; transform: translateX(-50%); }
.hotbar { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
.hot { aspect-ratio: 1; border-radius: 11px; background: var(--slot); border: 1px solid var(--slot-line);
position: relative; display: grid; place-items: center; transition: .12s; }
.hot.over { border-color: var(--accent); background: var(--accent-soft); }
.hot .num { position: absolute; top: 4px; left: 6px; font-size: 10px; font-weight: 800; color: var(--accent); }
/* ===================== Tooltip ===================== */
.tooltip { position: fixed; z-index: 200; pointer-events: none; display: none; max-width: 268px;
background: rgba(12,14,20,0.97); border: 1px solid var(--border); border-radius: 14px; padding: 13px 15px;
box-shadow: 0 20px 50px -12px rgba(0,0,0,0.8); }
.tt-name { font-size: 15px; font-weight: 800; letter-spacing: -0.01em; }
.tt-kat { font-size: 11.5px; color: var(--muted); font-weight: 600; margin-top: 1px; }
.tt-desc { font-size: 12.5px; color: #b9c0cf; line-height: 1.45; margin: 9px 0; }
.tt-row { display: flex; justify-content: space-between; gap: 12px; font-size: 12.5px; padding: 3px 0; }
.tt-row span:first-child { color: var(--muted); }
.tt-row .mono { font-family: ui-monospace, monospace; font-size: 11.5px; letter-spacing: 0.03em; }
.tt-assets { margin-top: 9px; border-top: 1px solid var(--border); padding-top: 9px; }
.tt-assets-h { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); font-weight: 800; margin-bottom: 6px; }
.chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600;
background: var(--surface2); border: 1px solid var(--border); border-radius: 999px; padding: 3px 9px; margin: 0 5px 5px 0; }
.chip::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.tt-fg-common { color: var(--r-common); } .tt-fg-uncommon { color: var(--r-uncommon); }
.tt-fg-rare { color: var(--r-rare); } .tt-fg-epic { color: var(--r-epic); } .tt-fg-legendary { color: var(--r-legendary); }
/* ===================== Rechtsklick-Menü ===================== */
.ctx { position: absolute; z-index: 210; min-width: 168px; background: rgba(14,16,22,0.98);
border: 1px solid var(--border); border-radius: 12px; padding: 5px; display: none;
box-shadow: 0 20px 50px -12px rgba(0,0,0,0.8); }
.ctx.show { display: block; animation: ctxin .12s ease; }
@keyframes ctxin { from { opacity: 0; transform: scale(.96); } to { opacity: 1; transform: none; } }
.ctx-head { padding: 8px 10px 6px; font-size: 12.5px; font-weight: 800; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
.ctx button { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; border: none;
background: none; color: var(--fg); font-family: inherit; font-size: 13.5px; font-weight: 600; padding: 9px 10px;
border-radius: 8px; cursor: pointer; }
.ctx button:hover { background: var(--surface2); }
.ctx button.danger { color: var(--red); }
.ctx button .k { margin-left: auto; font-size: 16px; opacity: 0.7; }
/* ===================== Split-Modal ===================== */
.overlay { position: absolute; inset: 0; z-index: 220; background: rgba(6,7,11,0.62); backdrop-filter: blur(4px);
display: none; align-items: center; justify-content: center; }
.overlay.show { display: flex; }
.modal { width: 360px; max-width: 90%; background: rgba(18,20,28,0.98); border: 1px solid var(--border);
border-radius: 18px; padding: 22px; box-shadow: 0 30px 70px -18px rgba(0,0,0,0.8); }
.modal h3 { margin: 0 0 4px; font-size: 18px; }
.modal p { margin: 0 0 18px; color: var(--muted); font-size: 13px; }
.split-val { text-align: center; font-size: 34px; font-weight: 800; font-variant-numeric: tabular-nums; margin-bottom: 4px; }
.split-sub { text-align: center; color: var(--muted); font-size: 12.5px; margin-bottom: 16px; }
input[type=range] { width: 100%; accent-color: var(--accent); height: 26px; }
.modal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
.btn { border: none; border-radius: 12px; padding: 12px; font-size: 14.5px; font-weight: 700; cursor: pointer; font-family: inherit; }
.btn.primary { background: var(--accent); color: #1a1206; }
.btn.ghost { background: var(--surface2); color: var(--fg); border: 1px solid var(--border); }
.toast { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(18px);
background: rgba(12,14,20,0.97); border: 1px solid var(--border); color: var(--fg); padding: 11px 20px;
border-radius: 13px; font-size: 13.5px; font-weight: 600; z-index: 230; opacity: 0; pointer-events: none;
transition: .3s; box-shadow: 0 14px 34px -10px rgba(0,0,0,0.7); }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.hint-bar { position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%); z-index: 5;
font-size: 12px; color: #6f7789; text-align: center; }
.hint-bar b { color: #9aa2b4; }
</style>
<div class="inv" id="inv">
<div class="inv-head">
<span class="logo">Æ</span>
<div class="title">Inventar<small>Max Weber · Rucksack</small></div>
<div class="weight">
<div class="weight-bar"><div class="weight-fill" id="weight-fill"></div></div>
<div class="weight-txt" id="weight-txt">0 / 30 kg</div>
</div>
</div>
<div class="inv-body">
<!-- Links: Umgebung -->
<div class="col umgebung">
<div class="panel-h">Spieler in der Nähe</div>
<div id="players"></div>
<div class="panel-h">Boden</div>
<div class="boden" id="boden"></div>
</div>
<!-- Mitte: Inventar-Grid -->
<div class="col grid-col">
<div class="panel-h">Tasche <span style="margin-left:auto;font-weight:600;text-transform:none;letter-spacing:0" id="slots-count"></span></div>
<div class="slots" id="slots"></div>
</div>
<!-- Rechts: Avatar + Ausrüstung + Hotbar -->
<div class="col avatar-col">
<div class="panel-h">Charakter &amp; Ausrüstung</div>
<div class="avatar-stage">
<div class="platform"></div>
<svg class="mannequin" viewBox="0 0 130 240" fill="none">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#3a4152"/><stop offset="55%" stop-color="#262b38"/><stop offset="100%" stop-color="#171a24"/>
</linearGradient>
</defs>
<ellipse cx="65" cy="34" rx="19" ry="21" fill="url(#body)" stroke="rgba(255,255,255,0.08)"/>
<path d="M46 66 q19 -10 38 0 l6 60 q-25 12 -50 0 Z" fill="url(#body)" stroke="rgba(255,255,255,0.08)"/>
<path d="M46 68 q-14 6 -18 46 l12 5 q6 -34 12 -44 Z" fill="url(#body)" stroke="rgba(255,255,255,0.06)"/>
<path d="M84 68 q14 6 18 46 l-12 5 q-6 -34 -12 -44 Z" fill="url(#body)" stroke="rgba(255,255,255,0.06)"/>
<path d="M50 128 q15 8 30 0 l-2 82 l-11 0 l-3 -60 l-3 60 l-11 0 Z" fill="url(#body)" stroke="rgba(255,255,255,0.07)"/>
</svg>
<div class="eq-slot eq-helm" data-eq="helm"><span class="eqlabel">Helm</span></div>
<div class="eq-slot eq-schmuck" data-eq="schmuck"><span class="eqlabel">Schmuck</span></div>
<div class="eq-slot eq-brust" data-eq="brust"><span class="eqlabel">Brust</span></div>
<div class="eq-slot eq-hose" data-eq="hose"><span class="eqlabel">Hose</span></div>
<div class="eq-slot eq-schuhe" data-eq="schuhe"><span class="eqlabel">Schuhe</span></div>
</div>
<div class="panel-h">Schnellzugriff (Hotbar)</div>
<div class="hotbar" id="hotbar"></div>
</div>
</div>
<!-- Overlays -->
<div class="ctx" id="ctx"></div>
<div class="overlay" id="overlay"></div>
<div class="toast" id="toast"></div>
</div>
<div class="hint-bar">
<b>Ziehen</b> zum Verschieben · auf <b>Boden</b> = fallenlassen · auf <b>Spieler</b> = geben · <b>Rechtsklick</b> für Menü &amp; Splitten · <b>Hover</b> für Details
</div>
<div class="tooltip" id="tooltip"></div>
<script>
"use strict";
/* =====================================================================
ITEM-DEFINITIONEN (Registry) — Name, Icon, Kategorie, Gewicht, Seltenheit …
komponenten = fest verbaute Assets (z. B. Handy → SIM + Akku)
waffe = besitzt Seriennummer & Aufsätze (Attachments)
slot = ausrüstbar in Ausrüstungs-Slot; verbrauchbar = wird bei Nutzung -1
===================================================================== */
/* Item-Registry. In FiveM wird sie vom Server überschrieben, damit Server und
Oberfläche garantiert dieselben Werte verwenden. */
let itemDefs = {
pistole: { name: "Pistole P99", icon: "🔫", kat: "Waffe", gewicht: 1.2, rar: "epic", waffe: true,
beschreibung: "Halbautomatische 9mm-Pistole. Registrierte Feuerwaffe." },
munition9: { name: "9mm Munition", icon: "🧨", kat: "Munition", gewicht: 0.01, rar: "common", stapelbar: true,
beschreibung: "Standard-Patronen für 9mm-Waffen." },
handy: { name: "Smartphone", icon: "📱", kat: "Elektronik", gewicht: 0.3, rar: "rare",
komponenten: ["SIM-Karte", "Akku"], nutzbar: true,
beschreibung: "Modernes Smartphone. Enthält verbaute Komponenten." },
wasser: { name: "Wasserflasche", icon: "🥤", kat: "Nahrung", gewicht: 0.5, rar: "common", stapelbar: true, verbrauchbar: true, nutzbar: true,
beschreibung: "Löscht den Durst. +25 Durst." },
sandwich: { name: "Sandwich", icon: "🥪", kat: "Nahrung", gewicht: 0.2, rar: "common", stapelbar: true, verbrauchbar: true, nutzbar: true,
beschreibung: "Stillt den Hunger. +30 Hunger." },
verband: { name: "Verband", icon: "🩹", kat: "Medizin", gewicht: 0.1, rar: "uncommon", stapelbar: true, verbrauchbar: true, nutzbar: true,
beschreibung: "Stoppt Blutungen und heilt leichte Wunden." },
tablette: { name: "Schmerztabletten", icon: "💊", kat: "Medizin", gewicht: 0.02, rar: "common", stapelbar: true, verbrauchbar: true, nutzbar: true,
beschreibung: "Lindert Schmerzen kurzfristig." },
funk: { name: "Funkgerät", icon: "📻", kat: "Elektronik", gewicht: 0.4, rar: "uncommon", nutzbar: true,
beschreibung: "Kurzstrecken-Funk für die Kommunikation." },
dietrich: { name: "Dietrich", icon: "🗝️", kat: "Werkzeug", gewicht: 0.05, rar: "uncommon", stapelbar: true,
beschreibung: "Zum Öffnen einfacher Schlösser. Kann brechen." },
reparaturkit:{ name: "Reparaturkit", icon: "🧰", kat: "Werkzeug", gewicht: 2.0, rar: "uncommon", nutzbar: true,
beschreibung: "Repariert Fahrzeuge notdürftig." },
bargeld: { name: "Bargeld", icon: "💵", kat: "Wertsache", gewicht: 0, rar: "legendary", stapelbar: true,
beschreibung: "Sauberes Bargeld." },
goldkette: { name: "Goldkette", icon: "📿", kat: "Schmuck", gewicht: 0.15, rar: "epic", slot: "schmuck",
beschreibung: "Massive Goldkette. Ein echter Hingucker." },
helm: { name: "Ballistikhelm", icon: "⛑️", kat: "Ausrüstung", gewicht: 1.0, rar: "uncommon", slot: "helm",
beschreibung: "Schützt den Kopf vor Treffern." },
weste: { name: "Schutzweste", icon: "🦺", kat: "Ausrüstung", gewicht: 3.5, rar: "rare", slot: "brust",
beschreibung: "Kugelsichere Weste. Reduziert Schaden am Oberkörper." },
jeans: { name: "Jeans", icon: "👖", kat: "Kleidung", gewicht: 0.6, rar: "common", slot: "hose",
beschreibung: "Robuste Jeans." },
sneaker: { name: "Sneaker", icon: "👟", kat: "Kleidung", gewicht: 0.8, rar: "common", slot: "schuhe",
beschreibung: "Bequeme Sneaker." }
};
// Icon-Hinweise für leere Ausrüstungs-Slots
const slotHinweis = { helm: "⛑️", brust: "🦺", hose: "👖", schuhe: "👟", schmuck: "💍" };
const slotNamen = { helm: "Helm", brust: "Brust", hose: "Hose", schuhe: "Schuhe", schmuck: "Schmuck" };
const rarNamen = { common: "Gewöhnlich", uncommon: "Ungewöhnlich", rare: "Selten", epic: "Episch", legendary: "Legendär" };
let MAX_GEWICHT = 30; // kg — kommt vom Server
const SLOTS = 25; // Größe der Tasche
/* =====================================================================
ZUSTAND — die verschiedenen "Orte", an denen Items liegen können
===================================================================== */
/* Alle Behälter werden vom Server befüllt — die Oberfläche hält nur eine
Anzeigekopie und entscheidet selbst nichts. */
let inventar = new Array(SLOTS).fill(null);
let ausruestung = { helm: null, brust: null, hose: null, schuhe: null, schmuck: null };
let hotbar = new Array(5).fill(null);
let boden = []; // [{ bodenId, def, menge, meta }]
let spielerNah = []; // [{ id, bekannt }]
let gewichtVomServer = 0;
// Slotnummern der Ausrüstungsplätze (kommen vom Server)
let EQUIP_SLOTS = { helm: 200, brust: 201, hose: 202, schuhe: 203, schmuck: 204 };
const HOTBAR_BASIS = 100;
/* Anzeige-Daten eines Spielers in der Nähe.
WICHTIG (RP): Der Server schickt bewusst keine Namen mit — im Rollenspiel
kennt man eine Person erst nach Ausweis oder Visitenkarte. */
function spielerAnzeige(sp) {
return sp.bekannt
? { label: sp.name, sub: sp.rolle || "", av: (sp.name || "?").substring(0, 1), farbe: "#4aa8ff" }
: { label: "Unbekannt", sub: "ID #" + sp.id, av: "?", farbe: "#3a3f4d" };
}
/* =====================================================================
HILFSFUNKTIONEN
===================================================================== */
const $ = id => document.getElementById(id);
function sicher(t) { const d = document.createElement("div"); d.textContent = t; return d.innerHTML; }
// Gewicht deutsch als kg
function kg(x) { return x.toLocaleString("de-DE", { minimumFractionDigits: x < 10 ? 1 : 0, maximumFractionDigits: 2 }) + " kg"; }
let toastTimer = null;
function toast(text) {
const t = $("toast"); t.textContent = text; t.classList.add("show");
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 2000);
}
/* =====================================================================
ORTE LESEN / SCHREIBEN (inv | equip | hotbar | boden)
===================================================================== */
function leseOrt(o) {
if (o.typ === "inv") return inventar[o.i];
if (o.typ === "equip") return ausruestung[o.key];
if (o.typ === "hotbar") return hotbar[o.i];
if (o.typ === "boden") return boden[o.i];
return null;
}
/* Schreibende Helfer gibt es bewusst nicht mehr: Der Zustand wird
ausschließlich vom Server gesetzt (siehe setzeZustand). */
function gleicherOrt(a, b) { return a.typ === b.typ && a.i === b.i && a.key === b.key; }
// erster freier Tasche-Slot (für Splitten)
function freierSlot() { return inventar.findIndex(s => s === null); }
/* =====================================================================
VERSCHIEBEN (Drag & Drop-Kernlogik)
===================================================================== */
let zieht = null; // aktuell gezogener Ort-Deskriptor
/* Rechnet einen Ort-Deskriptor in die Slotnummer des Servers um.
Tasche 0–24, Hotbar 100–104, Ausrüstung 200–204. */
function ortZuSlot(o) {
if (o.typ === "inv") return o.i;
if (o.typ === "hotbar") return HOTBAR_BASIS + o.i;
if (o.typ === "equip") return EQUIP_SLOTS[o.key];
return null;
}
/* Verschieben: Die Oberfläche entscheidet nichts selbst, sondern schickt den
Wunsch an den Server. Dieser prüft und antwortet mit dem neuen Zustand. */
function verschiebe(quelle, ziel) {
if (gleicherOrt(quelle, ziel)) return;
const a = leseOrt(quelle);
if (!a) return;
// Vom Boden in die Tasche ziehen = aufheben
if (quelle.typ === "boden") {
nui("aufheben", { bodenId: a.bodenId });
return;
}
// Auf den Boden ziehen = fallenlassen
if (ziel.typ === "boden") {
nui("fallenlassen", { slot: ortZuSlot(quelle), menge: a.menge });
return;
}
// Auf einen Spieler ziehen = geben
if (ziel.typ === "spieler") {
nui("geben", { slot: ortZuSlot(quelle), menge: a.menge, zielId: ziel.id });
return;
}
const vonSlot = ortZuSlot(quelle), nachSlot = ortZuSlot(ziel);
if (vonSlot === null || nachSlot === null) return;
nui("verschieben", { von: vonSlot, nach: nachSlot });
}
/* =====================================================================
RENDERN
===================================================================== */
function render() {
// Gewicht: in FiveM rechnet der Server, im Browser rechnen wir selbst
let gewicht = gewichtVomServer;
if (!IST_FIVEM) {
gewicht = 0;
const zaehle = it => { if (it) gewicht += it.def.gewicht * it.menge; };
inventar.forEach(zaehle); Object.values(ausruestung).forEach(zaehle); hotbar.forEach(zaehle);
}
const proz = Math.min(100, gewicht / MAX_GEWICHT * 100);
const fill = $("weight-fill");
fill.style.width = proz + "%";
fill.style.background = proz > 90 ? "var(--red)" : (proz > 70 ? "var(--amber)" : "var(--accent)");
$("weight-txt").textContent = gewicht.toLocaleString("de-DE", { maximumFractionDigits: 1 }) + " / " + MAX_GEWICHT + " kg";
$("slots-count").textContent = inventar.filter(Boolean).length + " / " + SLOTS;
// Tasche
const slots = $("slots"); slots.innerHTML = "";
for (let i = 0; i < SLOTS; i++) {
const slot = document.createElement("div");
slot.className = "slot";
dropZiel(slot, { typ: "inv", i });
if (inventar[i]) slot.appendChild(baueItem(inventar[i], { typ: "inv", i }));
slots.appendChild(slot);
}
// Ausrüstung (Slots sind persistent -> Drop wird einmalig in initDrop() verdrahtet)
document.querySelectorAll(".eq-slot").forEach(el => {
const key = el.dataset.eq;
el.querySelectorAll(".item").forEach(n => n.remove());
const alt = el.querySelector(".hintico"); if (alt) alt.remove();
if (ausruestung[key]) el.insertBefore(baueItem(ausruestung[key], { typ: "equip", key }), el.firstChild);
else { const h = document.createElement("span"); h.className = "hintico"; h.textContent = slotHinweis[key]; el.insertBefore(h, el.firstChild); }
});
// Hotbar
const hb = $("hotbar"); hb.innerHTML = "";
for (let i = 0; i < 5; i++) {
const h = document.createElement("div"); h.className = "hot";
h.innerHTML = '<span class="num">' + (i + 1) + "</span>";
dropZiel(h, { typ: "hotbar", i });
if (hotbar[i]) h.appendChild(baueItem(hotbar[i], { typ: "hotbar", i }));
hb.appendChild(h);
}
// Boden (Container ist persistent -> Drop wird einmalig in initDrop() verdrahtet)
const bd = $("boden"); bd.innerHTML = "";
if (boden.length === 0) bd.innerHTML = '<div class="hint">Items hierher ziehen,<br>um sie fallenzulassen</div>';
else boden.forEach((it, i) => bd.appendChild(baueItem(it, { typ: "boden", i })));
// Spieler (anonymisiert, solange unbekannt)
const pl = $("players"); pl.innerHTML = "";
spielerNah.forEach(sp => {
const a = spielerAnzeige(sp);
const row = document.createElement("div"); row.className = "player";
row.innerHTML = '<span class="pa" style="background:' + a.farbe + '">' + a.av + '</span>' +
'<div><div class="pn">' + sicher(a.label) + '</div><div class="pr">' + sicher(a.sub) + '</div></div>' +
'<span class="give">🤝</span>';
dropZiel(row, { typ: "spieler", id: sp.id });
pl.appendChild(row);
});
}
// Drop-Ziel verdrahten
function dropZiel(el, ziel) {
el.addEventListener("dragover", e => { e.preventDefault(); el.classList.add("over"); });
el.addEventListener("dragleave", () => el.classList.remove("over"));
el.addEventListener("drop", e => {
e.preventDefault(); el.classList.remove("over");
if (zieht) { verschiebe(zieht, ziel); zieht = null; }
});
}
// Ein Item-Element (draggable, mit Hover-Tooltip & Rechtsklick) bauen
function baueItem(item, ort) {
const d = item.def;
const el = document.createElement("div");
el.className = "item rar-" + (d.rar || "common");
el.draggable = true;
const hatAssets = (d.komponenten && d.komponenten.length) || (item.meta && item.meta.attachments && item.meta.attachments.length);
el.innerHTML =
'<span class="ic">' + d.icon + "</span>" +
(item.menge > 1 ? '<span class="qty">' + item.menge + "</span>" : "") +
'<span class="wt">' + kg(d.gewicht * item.menge) + "</span>" +
(hatAssets ? '<span class="asset" title="Enthält Komponenten"></span>' : "");
el.addEventListener("dragstart", e => { zieht = ort; el.classList.add("dragging"); versteckeTooltip(); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", d.name); });
el.addEventListener("dragend", () => el.classList.remove("dragging"));
// Doppelklick: benutzen (bzw. Ausrüstung anlegen)
el.addEventListener("dblclick", () => {
versteckeTooltip();
if (d.nutzbar) benutze(item, ort);
else if (d.slot && ort.typ !== "equip") verschiebe(ort, { typ: "equip", key: d.slot });
else toast(d.name + " lässt sich nicht benutzen");
});
el.addEventListener("mouseenter", e => zeigeTooltip(item, e));
el.addEventListener("mousemove", bewegeTooltip);
el.addEventListener("mouseleave", versteckeTooltip);
el.addEventListener("contextmenu", e => { e.preventDefault(); versteckeTooltip(); zeigeKontext(item, ort, e); });
return el;
}
/* =====================================================================
TOOLTIP (alle Details + Gewicht + Asset-Komponenten / Waffen-Infos)
===================================================================== */
const tooltip = $("tooltip");
function zeigeTooltip(item, e) {
const d = item.def;
let h = '<div class="tt-name tt-fg-' + (d.rar || "common") + '">' + sicher(d.name) + "</div>";
h += '<div class="tt-kat">' + sicher(d.kat) + " · " + (rarNamen[d.rar] || "Gewöhnlich") + "</div>";
h += '<div class="tt-desc">' + sicher(d.beschreibung) + "</div>";
h += '<div class="tt-row"><span>Gewicht</span><span>' + kg(d.gewicht) + " / Stück</span></div>";
if (item.menge > 1) h += '<div class="tt-row"><span>Gesamt (' + item.menge + "×)</span><span>" + kg(d.gewicht * item.menge) + "</span></div>";
// Assets / verbaute Komponenten (nur was da ist, kein Status)
if (d.komponenten && d.komponenten.length)
h += '<div class="tt-assets"><div class="tt-assets-h">Verbaute Komponenten</div>' + d.komponenten.map(k => '<span class="chip">' + sicher(k) + "</span>").join("") + "</div>";
// Waffen-Infos: Seriennummer + Aufsätze
if (d.waffe) {
h += '<div class="tt-row"><span>Seriennummer</span><span class="mono">' + sicher(item.meta && item.meta.serie ? item.meta.serie : "unbekannt") + "</span></div>";
if (item.meta && item.meta.attachments && item.meta.attachments.length)
h += '<div class="tt-assets"><div class="tt-assets-h">Aufsätze</div>' + item.meta.attachments.map(a => '<span class="chip">' + sicher(a) + "</span>").join("") + "</div>";
}
tooltip.innerHTML = h; tooltip.style.display = "block"; bewegeTooltip(e);
}
function bewegeTooltip(e) {
const pad = 16, w = tooltip.offsetWidth, hgt = tooltip.offsetHeight;
let x = e.clientX + pad, y = e.clientY + pad;
if (x + w > window.innerWidth - 8) x = e.clientX - w - pad;
if (y + hgt > window.innerHeight - 8) y = e.clientY - hgt - pad;
tooltip.style.left = x + "px"; tooltip.style.top = y + "px";
}
function versteckeTooltip() { tooltip.style.display = "none"; }
/* =====================================================================
RECHTSKLICK-MENÜ (Benutzen, Ausrüsten, Splitten, Geben, Fallenlassen)
===================================================================== */
const ctx = $("ctx");
function zeigeKontext(item, ort, e) {
const d = item.def;
const aktionen = [];
if (d.nutzbar) aktionen.push({ label: "Benutzen", icon: "▶", fn: () => benutze(item, ort) });
if (d.slot) aktionen.push({ label: "Ausrüsten", icon: "🛡", fn: () => verschiebe(ort, { typ: "equip", key: d.slot }) });
if (ort.typ === "equip") aktionen.push({ label: "Ablegen", icon: "↩", fn: () => ablegen(ort) });
if (item.menge > 1) aktionen.push({ label: "Splitten…", icon: "⇔", fn: () => oeffneSplit(item, ort) });
aktionen.push({ label: "Geben…", icon: "🤝", fn: () => oeffneGeben(item, ort) });
aktionen.push({ label: "Fallenlassen", icon: "🗑", danger: true, fn: () => verschiebe(ort, { typ: "boden" }) });
ctx.innerHTML = '<div class="ctx-head">' + sicher(d.name) + "</div>" +
aktionen.map((a, i) => '<button data-i="' + i + '"' + (a.danger ? ' class="danger"' : "") + ">" +
sicher(a.label) + '<span class="k">' + a.icon + "</span></button>").join("");
// Position relativ zum Inventar-Fenster
const box = $("inv").getBoundingClientRect();
let x = e.clientX - box.left, y = e.clientY - box.top;
ctx.classList.add("show");
x = Math.min(x, box.width - ctx.offsetWidth - 8);
y = Math.min(y, box.height - ctx.offsetHeight - 8);
ctx.style.left = x + "px"; ctx.style.top = y + "px";
ctx.querySelectorAll("button").forEach(b => b.addEventListener("click", () => {
schliesseKontext(); aktionen[+b.dataset.i].fn();
}));
}
function schliesseKontext() { ctx.classList.remove("show"); }
document.addEventListener("click", schliesseKontext);
document.addEventListener("scroll", schliesseKontext, true);
/* =====================================================================
AKTIONEN
===================================================================== */
// Item benutzen — Wirkung entscheidet der Server
function benutze(item, ort) {
nui("benutzen", { slot: ortZuSlot(ort) });
}
// Ausrüstung ablegen -> in ersten freien Tasche-Slot
function ablegen(ort) {
const frei = freierSlot();
if (frei < 0) { toast("Kein Platz in der Tasche"); return; }
verschiebe(ort, { typ: "inv", i: frei });
}
// Splitten-Modal
function oeffneSplit(item, ort) {
if (item.menge < 2) return;
const mitte = Math.floor(item.menge / 2);
$("overlay").innerHTML =
'<div class="modal"><h3>Stapel teilen</h3><p>' + sicher(item.def.name) + " · " + item.menge + " Stück</p>" +
'<div class="split-val" id="sv">' + mitte + "</div>" +
'<div class="split-sub" id="ss">' + mitte + " abtrennen · " + (item.menge - mitte) + " bleibt</div>" +
'<input type="range" id="sr" min="1" max="' + (item.menge - 1) + '" value="' + mitte + '">' +
'<div class="modal-actions"><button class="btn ghost" id="sc">Abbrechen</button>' +
'<button class="btn primary" id="sok">Teilen</button></div></div>';
$("overlay").classList.add("show");
const sr = $("sr");
sr.addEventListener("input", () => {
$("sv").textContent = sr.value;
$("ss").textContent = sr.value + " abtrennen · " + (item.menge - sr.value) + " bleibt";
});
$("sc").addEventListener("click", schliesseOverlay);
$("sok").addEventListener("click", () => {
// Teilen übernimmt der Server (prüft freien Platz selbst)
nui("splitten", { slot: ortZuSlot(ort), menge: parseInt(sr.value, 10) });
schliesseOverlay();
});
}
// Geben-Modal (Spieler in der Nähe auswählen)
function oeffneGeben(item, ort) {
$("overlay").innerHTML =
'<div class="modal"><h3>Item geben</h3><p>' + sicher(item.def.name) + (item.menge > 1 ? " ×" + item.menge : "") + " an wen?</p>" +
'<div id="glist">' + spielerNah.map((sp, i) => { const a = spielerAnzeige(sp); return
'<button class="btn ghost" data-g="' + i + '" style="width:100%;display:flex;align-items:center;gap:10px;justify-content:flex-start;margin-bottom:8px">' +
'<span class="pa" style="width:28px;height:28px;background:' + a.farbe + '">' + a.av + "</span>" +
sicher(a.label) + " · " + sicher(a.sub) + "</button>"; }).join("") + "</div>" +
'<button class="btn ghost" id="gc" style="width:100%">Abbrechen</button></div>';
$("overlay").classList.add("show");
$("gc").addEventListener("click", schliesseOverlay);
$("glist").querySelectorAll("[data-g]").forEach(b => b.addEventListener("click", () => {
const sp = spielerNah[+b.dataset.g];
schliesseOverlay();
verschiebe(ort, { typ: "spieler", id: sp.id });
}));
}
function schliesseOverlay() { $("overlay").classList.remove("show"); $("overlay").innerHTML = ""; }
$("overlay").addEventListener("click", e => { if (e.target === $("overlay")) schliesseOverlay(); });
/* =====================================================================
START
===================================================================== */
// Drop-Ziele der persistenten Elemente (Ausrüstung + Boden) einmalig verdrahten
function initDrop() {
document.querySelectorAll(".eq-slot").forEach(el => dropZiel(el, { typ: "equip", key: el.dataset.eq }));
dropZiel($("boden"), { typ: "boden" });
}
/* =====================================================================
NUI-BRÜCKE — Verbindung zum FiveM-Client
===================================================================== */
const IST_FIVEM = (typeof GetParentResourceName === "function");
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-inventory";
/* Schickt eine Aktion an den Client; der reicht sie an den Server weiter. */
function nui(aktion, daten) {
if (!IST_FIVEM) { toast("(Browser-Ansicht — keine Serververbindung)"); return Promise.resolve(); }
return fetch(`https://${RESSOURCE}/${aktion}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(daten || {}),
}).catch(() => {});
}
/* Wandelt einen Servereintrag in ein Anzeige-Item um */
function baueEintrag(eintrag) {
const def = itemDefs[eintrag.item];
if (!def) return null; // unbekanntes Item überspringen
return { def, menge: eintrag.menge, meta: eintrag.meta || null, kennung: eintrag.item };
}
/* Übernimmt den kompletten Zustand vom Server */
function setzeZustand(daten) {
if (daten.items) itemDefs = daten.items;
if (daten.equipSlots) EQUIP_SLOTS = daten.equipSlots;
if (daten.maxGewicht) MAX_GEWICHT = daten.maxGewicht;
gewichtVomServer = daten.gewicht || 0;
// Behälter leeren und aus der flachen Slot-Tabelle neu aufbauen
inventar = new Array(SLOTS).fill(null);
hotbar = new Array(5).fill(null);
ausruestung = { helm: null, brust: null, hose: null, schuhe: null, schmuck: null };
const roh = daten.inventar || {};
for (const schluessel of Object.keys(roh)) {
const slot = parseInt(schluessel, 10);
const item = baueEintrag(roh[schluessel]);
if (!item) continue;
if (slot >= 200) {
// Ausrüstung: Slotnummer zurück auf den Schlüssel abbilden
const key = Object.keys(EQUIP_SLOTS).find(k => EQUIP_SLOTS[k] === slot);
if (key) ausruestung[key] = item;
} else if (slot >= HOTBAR_BASIS) {
hotbar[slot - HOTBAR_BASIS] = item;
} else if (slot >= 0 && slot < SLOTS) {
inventar[slot] = item;
}
}
// Boden-Items (jedes mit seiner Server-ID zum Aufheben)
boden = (daten.boden || []).map(b => {
const item = baueEintrag(b);
if (item) item.bodenId = b.id;
return item;
}).filter(Boolean);
spielerNah = daten.spieler || [];
render();
if (daten.hinweis) toast(daten.hinweis);
}
/* Nachrichten vom Client */
window.addEventListener("message", (ereignis) => {
const n = ereignis.data || {};
if (n.aktion === "oeffnen") {
document.body.style.display = "";
setzeZustand(n);
} else if (n.aktion === "zustand") {
setzeZustand(n);
} else if (n.aktion === "hinweis") {
toast(n.text || "");
} else if (n.aktion === "schliessen") {
document.body.style.display = "none";
schliesseOverlay(); schliesseKontext();
}
});
/* ESC schließt das Inventar */
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && IST_FIVEM) nui("schliessen", {});
});
/* =====================================================================
START
===================================================================== */
initDrop();
if (IST_FIVEM) {
document.body.style.display = "none"; // wartet auf "oeffnen"
render();
} else {
// Eigenständige Browser-Ansicht: Beispieldaten zum Prüfen des Layouts
const bsp = (id, menge, meta) => ({ def: itemDefs[id], menge, meta: meta || null, kennung: id });
inventar[0] = bsp("pistole", 1, { serie: "P99-8842-LS", attachments: ["Schalldämpfer", "Laservisier"] });
inventar[1] = bsp("munition9", 64);
inventar[2] = bsp("handy", 1);
inventar[3] = bsp("wasser", 3);
inventar[4] = bsp("sandwich", 2);
inventar[5] = bsp("verband", 5);
inventar[10] = bsp("bargeld", 1450);
ausruestung.brust = bsp("weste", 1);
ausruestung.hose = bsp("jeans", 1);
ausruestung.schuhe = bsp("sneaker", 1);
spielerNah = [{ id: 4821, bekannt: false }, { id: 1093, bekannt: false }];
render();
}
</script>
</body>
</html>

View File

@ -0,0 +1,369 @@
-- =====================================================================
-- Aether Inventory — Datenschicht
-- Haelt die Inventare aktiver Spieler im Speicher und schreibt sie
-- in die Datenbank zurueck. Enthaelt keine Ereignisse — nur Logik.
-- =====================================================================
Lager = {}
-- Inventare aktiver Charaktere: [charakterId] = { [slot] = {item, menge, meta} }
local inventare = {}
-- Items am Boden: [bodenId] = { id, item, menge, meta, x, y, z, prop }
-- Die IDs kommen aus der Datenbank, damit Drops Neustarts ueberleben.
local bodenItems = {}
-- ---------------------------------------------------------------------
-- Laden und Speichern
-- ---------------------------------------------------------------------
--- Laedt das Inventar eines Charakters aus der Datenbank
--- @param charakterId number Charakter-ID
--- @return table Inventar (slot -> Eintrag)
function Lager.Laden(charakterId)
local zeilen = MySQL.query.await(
'SELECT slot, item, amount, metadata FROM aether_inventory WHERE owner_type = ? AND owner_id = ?',
{ 'character', tostring(charakterId) }) or {}
local inventar = {}
for _, zeile in ipairs(zeilen) do
-- Unbekannte Items ueberspringen (z. B. nach Entfernen aus der Registry)
if Inventar.Items[zeile.item] then
local meta = nil
if zeile.metadata then
local erfolg, entschluesselt = pcall(json.decode, zeile.metadata)
if erfolg then meta = entschluesselt end
end
inventar[zeile.slot] = { item = zeile.item, menge = zeile.amount, meta = meta }
end
end
inventare[charakterId] = inventar
return inventar
end
--- Schreibt das Inventar eines Charakters komplett zurueck
--- @param charakterId number Charakter-ID
function Lager.Speichern(charakterId)
local inventar = inventare[charakterId]
if not inventar then return end
-- Einfach und sicher: alten Stand loeschen, aktuellen neu schreiben
MySQL.query.await('DELETE FROM aether_inventory WHERE owner_type = ? AND owner_id = ?',
{ 'character', tostring(charakterId) })
local werte = {}
for slot, eintrag in pairs(inventar) do
werte[#werte + 1] = {
'character', tostring(charakterId), slot, eintrag.item, eintrag.menge,
eintrag.meta and json.encode(eintrag.meta) or nil,
}
end
if #werte > 0 then
MySQL.prepare.await(
'INSERT INTO aether_inventory (owner_type, owner_id, slot, item, amount, metadata) VALUES (?, ?, ?, ?, ?, ?)',
werte)
end
end
--- Entfernt ein Inventar aus dem Speicher (nach dem Speichern)
--- @param charakterId number Charakter-ID
function Lager.Entladen(charakterId)
inventare[charakterId] = nil
end
--- Legt die Startausruestung fuer einen neuen Charakter an
--- @param charakterId number Charakter-ID
function Lager.ErstelleStart(charakterId)
local inventar = {}
for _, eintrag in ipairs(Inventar.StartItems) do
inventar[eintrag.slot] = { item = eintrag.item, menge = eintrag.menge, meta = nil }
end
inventare[charakterId] = inventar
Lager.Speichern(charakterId)
end
-- ---------------------------------------------------------------------
-- Zugriff
-- ---------------------------------------------------------------------
--- Liefert das Inventar eines Charakters (oder nil)
--- @param charakterId number Charakter-ID
--- @return table|nil
function Lager.Hole(charakterId)
return inventare[charakterId]
end
--- Berechnet das getragene Gesamtgewicht
--- @param charakterId number Charakter-ID
--- @return number Gewicht in Kilogramm
function Lager.Gewicht(charakterId)
local inventar = inventare[charakterId]
if not inventar then return 0 end
local gewicht = 0
for _, eintrag in pairs(inventar) do
local def = Inventar.Items[eintrag.item]
if def then gewicht = gewicht + (def.gewicht or 0) * eintrag.menge end
end
return gewicht
end
--- Sucht den ersten freien Platz in der Tasche
--- @param charakterId number Charakter-ID
--- @return number|nil Slotnummer oder nil, wenn voll
function Lager.FreierSlot(charakterId)
local inventar = inventare[charakterId]
if not inventar then return nil end
for slot = Inventar.BEREICH.tasche.von, Inventar.BEREICH.tasche.bis do
if not inventar[slot] then return slot end
end
return nil
end
--- Prueft, ob eine zusaetzliche Menge noch getragen werden kann
--- @param charakterId number Charakter-ID
--- @param kennung string Item-Kennung
--- @param menge number Stueckzahl
--- @return boolean
function Lager.PasstNoch(charakterId, kennung, menge)
local def = Inventar.Items[kennung]
if not def then return false end
local zusatz = (def.gewicht or 0) * menge
return (Lager.Gewicht(charakterId) + zusatz) <= Inventar.MAX_GEWICHT
end
-- ---------------------------------------------------------------------
-- Aenderungen am Inventar
-- ---------------------------------------------------------------------
--- Fuegt ein Item hinzu (stapelt, wenn moeglich)
--- @param charakterId number Charakter-ID
--- @param kennung string Item-Kennung
--- @param menge number Stueckzahl
--- @param meta table|nil Zusatzdaten
--- @return boolean Erfolg, string|nil Fehlergrund
function Lager.Hinzufuegen(charakterId, kennung, menge, meta)
local inventar = inventare[charakterId]
local def = Inventar.Items[kennung]
if not inventar or not def then return false, 'Unbekanntes Item' end
if not Lager.PasstNoch(charakterId, kennung, menge) then return false, 'Zu schwer' end
-- Stapelbare Items ohne Zusatzdaten auf vorhandene Stapel legen
if def.stapelbar and not meta then
for slot = Inventar.BEREICH.tasche.von, Inventar.BEREICH.tasche.bis do
local eintrag = inventar[slot]
if eintrag and eintrag.item == kennung and not eintrag.meta then
eintrag.menge = eintrag.menge + menge
return true
end
end
end
local frei = Lager.FreierSlot(charakterId)
if not frei then return false, 'Kein Platz frei' end
inventar[frei] = { item = kennung, menge = menge, meta = meta }
return true
end
--- Entfernt eine Menge aus einem bestimmten Slot
--- @param charakterId number Charakter-ID
--- @param slot number Slotnummer
--- @param menge number Stueckzahl
--- @return table|nil Entfernter Eintrag {item, menge, meta} oder nil
function Lager.EntferneAusSlot(charakterId, slot, menge)
local inventar = inventare[charakterId]
if not inventar then return nil end
local eintrag = inventar[slot]
if not eintrag then return nil end
menge = math.min(menge or eintrag.menge, eintrag.menge)
if menge <= 0 then return nil end
local entfernt = { item = eintrag.item, menge = menge, meta = eintrag.meta }
if menge >= eintrag.menge then
inventar[slot] = nil
else
eintrag.menge = eintrag.menge - menge
end
return entfernt
end
--- Verschiebt oder tauscht zwei Slots (inkl. Stapeln)
--- @param charakterId number Charakter-ID
--- @param vonSlot number Quelle
--- @param nachSlot number Ziel
--- @return boolean Erfolg, string|nil Fehlergrund
function Lager.Verschieben(charakterId, vonSlot, nachSlot)
local inventar = inventare[charakterId]
if not inventar or vonSlot == nachSlot then return false end
local quelle = inventar[vonSlot]
if not quelle then return false, 'Kein Item' end
local quellDef = Inventar.Items[quelle.item]
local ziel = inventar[nachSlot]
-- Ausruestungs-Slot: nur passende Items zulassen
local zielSchluessel = Inventar.EquipSchluessel(nachSlot)
if zielSchluessel and quellDef.slot ~= zielSchluessel then
return false, 'Passt nicht in diesen Ausrüstungsplatz'
end
-- Beim Tauschen muss auch das Zielitem in die Quelle passen
local quellSchluessel = Inventar.EquipSchluessel(vonSlot)
if ziel and quellSchluessel then
local zielDef = Inventar.Items[ziel.item]
if zielDef.slot ~= quellSchluessel then
return false, 'Tauschen nicht möglich'
end
end
-- Stapeln, wenn identisches stapelbares Item ohne Zusatzdaten
if ziel and ziel.item == quelle.item and quellDef.stapelbar
and not quelle.meta and not ziel.meta then
ziel.menge = ziel.menge + quelle.menge
inventar[vonSlot] = nil
return true
end
-- Verschieben oder Tauschen
inventar[nachSlot] = quelle
inventar[vonSlot] = ziel -- nil, wenn das Ziel leer war
return true
end
--- Teilt einen Stapel auf einen freien Taschenplatz auf
--- @param charakterId number Charakter-ID
--- @param slot number Quellslot
--- @param menge number Abzuspaltende Menge
--- @return boolean Erfolg, string|nil Fehlergrund
function Lager.Splitten(charakterId, slot, menge)
local inventar = inventare[charakterId]
if not inventar then return false end
local eintrag = inventar[slot]
if not eintrag then return false, 'Kein Item' end
if menge < 1 or menge >= eintrag.menge then return false, 'Ungültige Menge' end
local frei = Lager.FreierSlot(charakterId)
if not frei then return false, 'Kein Platz frei' end
eintrag.menge = eintrag.menge - menge
inventar[frei] = { item = eintrag.item, menge = menge, meta = eintrag.meta }
return true
end
-- ---------------------------------------------------------------------
-- Items am Boden
-- ---------------------------------------------------------------------
--- Laedt alle fallengelassenen Items aus der Datenbank (beim Serverstart)
--- und raeumt dabei zu alte Drops auf.
--- @return table Alle geladenen Drops
function Lager.LadeBoden()
-- Alte Drops entfernen, damit die Welt nicht zumuellt
if Inventar.DROP_MAX_STUNDEN and Inventar.DROP_MAX_STUNDEN > 0 then
MySQL.query.await(
'DELETE FROM aether_drops WHERE created_at < DATE_SUB(NOW(), INTERVAL ? HOUR)',
{ Inventar.DROP_MAX_STUNDEN })
end
local zeilen = MySQL.query.await(
'SELECT id, item, amount, metadata, x, y, z, prop FROM aether_drops') or {}
bodenItems = {}
for _, zeile in ipairs(zeilen) do
-- Items, die es nicht mehr gibt, werden uebersprungen
if Inventar.Items[zeile.item] then
local meta = nil
if zeile.metadata then
local erfolg, entschluesselt = pcall(json.decode, zeile.metadata)
if erfolg then meta = entschluesselt end
end
bodenItems[zeile.id] = {
id = zeile.id, item = zeile.item, menge = zeile.amount, meta = meta,
x = zeile.x, y = zeile.y, z = zeile.z, prop = zeile.prop,
}
end
end
return bodenItems
end
--- Liefert alle bekannten Boden-Items (fuer die Uebertragung an Clients)
--- @return table
function Lager.AlleBodenItems()
return bodenItems
end
--- Legt ein Item an einer Position auf den Boden und speichert es dauerhaft
--- @param eintrag table {item, menge, meta}
--- @param koordinaten table {x, y, z}
--- @return table|nil Datensatz mit id und prop
function Lager.AufBodenLegen(eintrag, koordinaten)
-- Zufaelligen Karton waehlen, damit alle Drops einheitlich aussehen
local prop = Inventar.DROP_PROPS[math.random(#Inventar.DROP_PROPS)]
local id = MySQL.insert.await([[
INSERT INTO aether_drops (item, amount, metadata, x, y, z, prop)
VALUES (?, ?, ?, ?, ?, ?, ?)
]], {
eintrag.item, eintrag.menge,
eintrag.meta and json.encode(eintrag.meta) or nil,
koordinaten.x, koordinaten.y, koordinaten.z, prop,
})
if not id then return nil end
local datensatz = {
id = id, item = eintrag.item, menge = eintrag.menge, meta = eintrag.meta,
x = koordinaten.x, y = koordinaten.y, z = koordinaten.z, prop = prop,
}
bodenItems[id] = datensatz
return datensatz
end
--- Nimmt ein Item vom Boden (und loescht es dauerhaft)
--- @param bodenId number Boden-ID
--- @return table|nil Eintrag oder nil
function Lager.VomBodenNehmen(bodenId)
local eintrag = bodenItems[bodenId]
if not eintrag then return nil end
bodenItems[bodenId] = nil
MySQL.query('DELETE FROM aether_drops WHERE id = ?', { bodenId })
return eintrag
end
--- Liefert ein Item am Boden, ohne es zu entfernen
--- @param bodenId number Boden-ID
--- @return table|nil
function Lager.HoleBodenItem(bodenId)
return bodenItems[bodenId]
end
--- Liefert alle Boden-Items in Reichweite einer Position
--- @param koordinaten table {x, y, z}
--- @param weite number Sichtweite in Metern
--- @return table Liste mit {id, item, menge, meta}
function Lager.BodenInDerNaehe(koordinaten, weite)
local liste = {}
for id, eintrag in pairs(bodenItems) do
local dx = eintrag.x - koordinaten.x
local dy = eintrag.y - koordinaten.y
local dz = eintrag.z - koordinaten.z
if (dx * dx + dy * dy + dz * dz) <= (weite * weite) then
liste[#liste + 1] = {
id = id, item = eintrag.item, menge = eintrag.menge, meta = eintrag.meta,
}
end
end
return liste
end

View File

@ -0,0 +1,403 @@
-- =====================================================================
-- Aether Inventory — Serverlogik
-- Jede Bewegung wird hier geprueft. Der Client sagt nur, was er moechte;
-- entschieden wird ausschliesslich auf dem Server.
-- =====================================================================
local Core = exports['aether-core']
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Liefert die Charakter-ID eines Spielers
--- @param quelle number Server-ID
--- @return number|nil
local function CharakterVon(quelle)
return Core:HoleCharakterId(quelle)
end
--- Prueft, ob eine Slotnummer ueberhaupt gueltig ist
--- @param slot any Zu pruefender Wert
--- @return boolean
local function SlotGueltig(slot)
if type(slot) ~= 'number' or slot ~= math.floor(slot) then return false end
return Inventar.BereichVon(slot) ~= nil
end
--- Baut den Zustand, den die Oberflaeche zum Anzeigen braucht
--- @param quelle number Server-ID
--- @param charakterId number Charakter-ID
--- @return table
local function BaueZustand(quelle, charakterId)
local koordinaten = GetEntityCoords(GetPlayerPed(quelle))
local position = { x = koordinaten.x, y = koordinaten.y, z = koordinaten.z }
-- Spieler in Reichweite (anonym, solange nicht bekannt)
local nahe = {}
for _, andereQuelle in ipairs(GetPlayers()) do
andereQuelle = tonumber(andereQuelle)
if andereQuelle ~= quelle then
local andererPed = GetPlayerPed(andereQuelle)
local andereKoordinaten = GetEntityCoords(andererPed)
if #(koordinaten - andereKoordinaten) <= Inventar.GEBEN_WEITE then
nahe[#nahe + 1] = {
id = andereQuelle,
-- Namen bewusst nicht mitschicken: Im Rollenspiel kennt man
-- eine Person erst nach Ausweis/Visitenkarte.
bekannt = false,
}
end
end
end
return {
inventar = Lager.Hole(charakterId) or {},
gewicht = Lager.Gewicht(charakterId),
maxGewicht = Inventar.MAX_GEWICHT,
boden = Lager.BodenInDerNaehe(position, Inventar.BODEN_SICHT),
spieler = nahe,
}
end
--- Schickt den aktuellen Zustand an einen Spieler
--- @param quelle number Server-ID
--- @param hinweis string|nil Kurze Rueckmeldung
local function SendeZustand(quelle, hinweis)
local charakterId = CharakterVon(quelle)
if not charakterId then return end
local zustand = BaueZustand(quelle, charakterId)
zustand.hinweis = hinweis
TriggerClientEvent('aether:inventory:zustand', quelle, zustand)
end
--- Meldet dem Spieler, dass eine Aktion nicht moeglich war
--- @param quelle number Server-ID
--- @param grund string Begruendung
local function Ablehnen(quelle, grund)
TriggerClientEvent('aether:inventory:hinweis', quelle, grund or 'Nicht möglich')
end
-- ---------------------------------------------------------------------
-- Fallengelassene Items beim Serverstart laden
-- ---------------------------------------------------------------------
CreateThread(function()
Wait(1000) -- kurz warten, bis die Datenbankverbindung steht
local drops = Lager.LadeBoden()
local anzahl = 0
for _ in pairs(drops) do anzahl = anzahl + 1 end
Aether.Info(('%d fallengelassene Items geladen.'):format(anzahl))
end)
-- ---------------------------------------------------------------------
-- Laden und Speichern im Spielerlebenszyklus
-- ---------------------------------------------------------------------
--- Spieler wurde geladen: Inventar aus der Datenbank holen
AddEventHandler('aether:server:spielerGeladen', function(quelle, spieler)
local charakterId = spieler.charakterId
local vorhanden = MySQL.scalar.await(
'SELECT COUNT(*) FROM aether_inventory WHERE owner_type = ? AND owner_id = ?',
{ 'character', tostring(charakterId) })
if (vorhanden or 0) == 0 then
-- Frischer Charakter: Startausruestung anlegen
Lager.ErstelleStart(charakterId)
Aether.Info(('Startausruestung fuer Charakter #%d angelegt.'):format(charakterId))
else
Lager.Laden(charakterId)
end
-- Alle bekannten Boden-Items schicken, damit der Client die Kartons setzt
TriggerClientEvent('aether:inventory:dropListe', quelle, Lager.AlleBodenItems())
end)
--- Spieler hat den Server verlassen: Inventar sichern
AddEventHandler('aether:server:spielerEntfernt', function(quelle, spieler)
Lager.Speichern(spieler.charakterId)
Lager.Entladen(spieler.charakterId)
end)
--- Beim Stoppen der Ressource alle Inventare sichern
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
for _, quelle in ipairs(GetPlayers()) do
local charakterId = CharakterVon(tonumber(quelle))
if charakterId then Lager.Speichern(charakterId) end
end
end)
-- ---------------------------------------------------------------------
-- Oeffnen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:inventory:oeffnen', function()
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
-- Sicherstellen, dass das Inventar geladen ist
if not Lager.Hole(charakterId) then Lager.Laden(charakterId) end
TriggerClientEvent('aether:inventory:zeige', quelle, BaueZustand(quelle, charakterId))
end)
-- ---------------------------------------------------------------------
-- Verschieben / Tauschen / Stapeln
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:inventory:verschieben', function(vonSlot, nachSlot)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
if not SlotGueltig(vonSlot) or not SlotGueltig(nachSlot) then return end
local erfolg, grund = Lager.Verschieben(charakterId, vonSlot, nachSlot)
if not erfolg and grund then Ablehnen(quelle, grund) end
SendeZustand(quelle)
end)
-- ---------------------------------------------------------------------
-- Stapel teilen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:inventory:splitten', function(slot, menge)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
if not SlotGueltig(slot) or type(menge) ~= 'number' then return end
local erfolg, grund = Lager.Splitten(charakterId, slot, math.floor(menge))
if not erfolg and grund then Ablehnen(quelle, grund) end
SendeZustand(quelle)
end)
-- ---------------------------------------------------------------------
-- Fallenlassen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:inventory:fallenlassen', function(slot, menge)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
if not SlotGueltig(slot) then return end
local entfernt = Lager.EntferneAusSlot(charakterId, slot, math.floor(menge or 0))
if not entfernt then Ablehnen(quelle, 'Kein Item') return end
local koordinaten = GetEntityCoords(GetPlayerPed(quelle))
local datensatz = Lager.AufBodenLegen(entfernt,
{ x = koordinaten.x, y = koordinaten.y, z = koordinaten.z })
if not datensatz then
-- Speichern fehlgeschlagen: Item zurueckgeben, damit es nicht verschwindet
Lager.Hinzufuegen(charakterId, entfernt.item, entfernt.menge, entfernt.meta)
Ablehnen(quelle, 'Fallenlassen fehlgeschlagen')
SendeZustand(quelle)
return
end
-- Alle Clients erzeugen den Karton an dieser Stelle
TriggerClientEvent('aether:inventory:dropHinzu', -1, datensatz)
local def = Inventar.Items[entfernt.item]
SendeZustand(quelle, (def and def.name or entfernt.item) .. ' fallengelassen')
end)
-- ---------------------------------------------------------------------
-- Aufheben
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:inventory:aufheben', function(bodenId)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId or type(bodenId) ~= 'number' then return end
local amBoden = Lager.HoleBodenItem(bodenId)
if not amBoden then Ablehnen(quelle, 'Item ist nicht mehr da') return end
-- Entfernungspruefung: niemand darf quer über die Karte aufheben
local koordinaten = GetEntityCoords(GetPlayerPed(quelle))
local abstand = #(koordinaten - vector3(amBoden.x, amBoden.y, amBoden.z))
if abstand > Inventar.AUFHEB_WEITE then
Ablehnen(quelle, 'Zu weit entfernt')
return
end
if not Lager.PasstNoch(charakterId, amBoden.item, amBoden.menge) then
Ablehnen(quelle, 'Zu schwer')
return
end
-- Erst jetzt vom Boden nehmen, damit es bei Ablehnung liegen bleibt
local genommen = Lager.VomBodenNehmen(bodenId)
if not genommen then Ablehnen(quelle, 'Item ist nicht mehr da') return end
-- Karton bei allen Clients entfernen
TriggerClientEvent('aether:inventory:dropWeg', -1, bodenId)
local erfolg, grund = Lager.Hinzufuegen(charakterId, genommen.item, genommen.menge, genommen.meta)
if not erfolg then
-- Zurueck auf den Boden legen, damit nichts verloren geht
local neu = Lager.AufBodenLegen(genommen, { x = amBoden.x, y = amBoden.y, z = amBoden.z })
if neu then TriggerClientEvent('aether:inventory:dropHinzu', -1, neu) end
Ablehnen(quelle, grund or 'Aufheben nicht möglich')
return
end
local def = Inventar.Items[genommen.item]
SendeZustand(quelle, (def and def.name or genommen.item) .. ' aufgehoben')
end)
-- ---------------------------------------------------------------------
-- Geben
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:inventory:geben', function(slot, menge, zielQuelle)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
if not SlotGueltig(slot) or type(zielQuelle) ~= 'number' then return end
if zielQuelle == quelle then return end
local zielCharakter = CharakterVon(zielQuelle)
if not zielCharakter then Ablehnen(quelle, 'Person nicht erreichbar') return end
-- Entfernungspruefung
local hier = GetEntityCoords(GetPlayerPed(quelle))
local dort = GetEntityCoords(GetPlayerPed(zielQuelle))
if #(hier - dort) > Inventar.GEBEN_WEITE then
Ablehnen(quelle, 'Zu weit entfernt')
return
end
-- Erst pruefen, ob der Empfaenger es tragen kann
local inventar = Lager.Hole(charakterId)
local eintrag = inventar and inventar[slot]
if not eintrag then Ablehnen(quelle, 'Kein Item') return end
local abgabe = math.min(math.floor(menge or eintrag.menge), eintrag.menge)
if abgabe < 1 then return end
if not Lager.PasstNoch(zielCharakter, eintrag.item, abgabe) then
Ablehnen(quelle, 'Die Person kann das nicht tragen')
return
end
local entfernt = Lager.EntferneAusSlot(charakterId, slot, abgabe)
if not entfernt then return end
local erfolg = Lager.Hinzufuegen(zielCharakter, entfernt.item, entfernt.menge, entfernt.meta)
if not erfolg then
-- Rueckgabe an den Absender, damit nichts verschwindet
Lager.Hinzufuegen(charakterId, entfernt.item, entfernt.menge, entfernt.meta)
Ablehnen(quelle, 'Übergabe fehlgeschlagen')
SendeZustand(quelle)
return
end
local def = Inventar.Items[entfernt.item]
local bezeichnung = def and def.name or entfernt.item
SendeZustand(quelle, bezeichnung .. ' übergeben')
TriggerClientEvent('aether:inventory:hinweis', zielQuelle,
('Du hast %s ×%d erhalten'):format(bezeichnung, entfernt.menge))
SendeZustand(zielQuelle)
end)
-- ---------------------------------------------------------------------
-- Benutzen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:inventory:benutzen', function(slot)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId or not SlotGueltig(slot) then return end
local inventar = Lager.Hole(charakterId)
local eintrag = inventar and inventar[slot]
if not eintrag then return end
local def = Inventar.Items[eintrag.item]
if not def or not def.nutzbar then Ablehnen(quelle, 'Das lässt sich nicht benutzen') return end
-- Wirkung anwenden
if def.wirkung then
if def.wirkung.art == 'leben' then
TriggerClientEvent('aether:inventory:heilen', quelle, def.wirkung.menge)
else
Core:AendereZustand(quelle, def.wirkung.art, def.wirkung.menge)
end
end
-- Verbrauchbare Items aufbrauchen
if def.verbrauchbar then
Lager.EntferneAusSlot(charakterId, slot, 1)
end
-- Andere Module informieren (z. B. Handy oeffnen)
TriggerEvent('aether:inventory:itemBenutzt', quelle, eintrag.item, eintrag.meta)
TriggerClientEvent('aether:inventory:itemBenutzt', quelle, eintrag.item, eintrag.meta)
SendeZustand(quelle, def.name .. ' benutzt')
end)
-- =====================================================================
-- EXPORTS — Schnittstelle fuer andere Module (Shop, Jobs, Dokumente ...)
-- =====================================================================
--- Gibt einem Spieler ein Item
exports('GebeItem', function(quelle, kennung, menge, meta)
local charakterId = CharakterVon(quelle)
if not charakterId then return false end
local erfolg = Lager.Hinzufuegen(charakterId, kennung, menge or 1, meta)
if erfolg then SendeZustand(quelle) end
return erfolg
end)
--- Prueft, ob ein Spieler eine zusaetzliche Menge noch tragen kann
--- @param quelle number Server-ID
--- @param kennung string Item-Kennung
--- @param menge number Stueckzahl
--- @return boolean
exports('PasstNoch', function(quelle, kennung, menge)
local charakterId = CharakterVon(quelle)
if not charakterId then return false end
return Lager.PasstNoch(charakterId, kennung, menge or 1)
end)
--- Zaehlt, wie viele Stueck eines Items ein Spieler besitzt
exports('ZaehleItem', function(quelle, kennung)
local charakterId = CharakterVon(quelle)
local inventar = charakterId and Lager.Hole(charakterId)
if not inventar then return 0 end
local summe = 0
for _, eintrag in pairs(inventar) do
if eintrag.item == kennung then summe = summe + eintrag.menge end
end
return summe
end)
--- Entfernt eine Menge eines Items (ueber beliebige Slots verteilt)
exports('EntferneItem', function(quelle, kennung, menge)
local charakterId = CharakterVon(quelle)
local inventar = charakterId and Lager.Hole(charakterId)
if not inventar then return false end
-- Erst pruefen, ob genug vorhanden ist
local vorhanden = 0
for _, eintrag in pairs(inventar) do
if eintrag.item == kennung then vorhanden = vorhanden + eintrag.menge end
end
if vorhanden < menge then return false end
-- Dann abziehen
local offen = menge
for slot, eintrag in pairs(inventar) do
if offen <= 0 then break end
if eintrag.item == kennung then
local abzug = math.min(offen, eintrag.menge)
Lager.EntferneAusSlot(charakterId, slot, abzug)
offen = offen - abzug
end
end
SendeZustand(quelle)
return true
end)

View File

@ -0,0 +1,264 @@
-- =====================================================================
-- Aether Inventory — Item-Registry und Slot-Aufteilung
-- Wird auf Client und Server geladen. Der Server rechnet damit
-- (Gewicht, Stapelbarkeit), der Client zeigt sie nur an.
-- =====================================================================
Inventar = Inventar or {}
-- ---------------------------------------------------------------------
-- Grenzen
-- ---------------------------------------------------------------------
Inventar.MAX_GEWICHT = 30.0 -- Traglast in Kilogramm
Inventar.TASCHE_SLOTS = 25 -- Plaetze in der Tasche
Inventar.HOTBAR_SLOTS = 5 -- Plaetze in der Schnellleiste
Inventar.AUFHEB_WEITE = 3.0 -- Reichweite zum Aufheben (Meter)
Inventar.GEBEN_WEITE = 3.0 -- Reichweite zum Geben (Meter)
Inventar.BODEN_SICHT = 15.0 -- Sichtweite fuer Items in der Inventarliste (Meter)
-- ---------------------------------------------------------------------
-- Fallengelassene Items (sichtbare Kartons in der Welt)
-- ---------------------------------------------------------------------
-- Einheitliche Props: Beim Fallenlassen wird zufaellig einer davon gewaehlt
Inventar.DROP_PROPS = {
'prop_cardbordbox_03a',
'prop_cardbordbox_04a',
'prop_cardbordbox_05a',
}
Inventar.DROP_SPAWN_WEITE = 30.0 -- Ab dieser Entfernung wird der Karton erzeugt
Inventar.DROP_HINWEIS = 1.8 -- Entfernung, ab der „E zum Aufheben" erscheint
Inventar.DROP_MAX_STUNDEN = 48 -- Nach dieser Zeit werden Drops aufgeraeumt (0 = nie)
-- ---------------------------------------------------------------------
-- Slot-Aufteilung
-- Alles liegt in einer Tabelle; die Slotnummer bestimmt den Bereich.
-- ---------------------------------------------------------------------
Inventar.BEREICH = {
tasche = { von = 0, bis = 24 }, -- 0–24
hotbar = { von = 100, bis = 104 }, -- 100–104
equip = { von = 200, bis = 204 }, -- 200–204
}
-- Zuordnung der Ausruestungs-Slots zu ihren Slotnummern
Inventar.EQUIP_SLOTS = {
helm = 200,
brust = 201,
hose = 202,
schuhe = 203,
schmuck = 204,
}
--- Liefert den Bereichsnamen zu einer Slotnummer
--- @param slot number Slotnummer
--- @return string|nil 'tasche' | 'hotbar' | 'equip'
function Inventar.BereichVon(slot)
for name, bereich in pairs(Inventar.BEREICH) do
if slot >= bereich.von and slot <= bereich.bis then return name end
end
return nil
end
--- Liefert den Ausruestungs-Schluessel zu einer Slotnummer (z. B. 'brust')
--- @param slot number Slotnummer
--- @return string|nil
function Inventar.EquipSchluessel(slot)
for schluessel, nummer in pairs(Inventar.EQUIP_SLOTS) do
if nummer == slot then return schluessel end
end
return nil
end
-- ---------------------------------------------------------------------
-- Item-Registry
-- gewicht = Kilogramm pro Stueck
-- stapelbar = mehrere Stueck in einem Slot
-- slot = passt nur in diesen Ausruestungs-Slot
-- nutzbar = kann benutzt werden
-- wirkung = was beim Benutzen passiert (vom Server ausgewertet)
-- ---------------------------------------------------------------------
Inventar.Items = {
pistole = {
name = 'Pistole P99', icon = '🔫', kat = 'Waffe', gewicht = 1.2, rar = 'epic',
waffe = true, beschreibung = 'Halbautomatische 9mm-Pistole. Registrierte Feuerwaffe.',
},
munition9 = {
name = '9mm Munition', icon = '🧨', kat = 'Munition', gewicht = 0.01, rar = 'common',
stapelbar = true, beschreibung = 'Standard-Patronen für 9mm-Waffen.',
},
handy = {
name = 'Smartphone', icon = '📱', kat = 'Elektronik', gewicht = 0.3, rar = 'rare',
komponenten = { 'SIM-Karte', 'Akku' }, nutzbar = true,
beschreibung = 'Modernes Smartphone. Enthält verbaute Komponenten.',
},
wasser = {
name = 'Wasserflasche', icon = '🥤', kat = 'Nahrung', gewicht = 0.5, rar = 'common',
stapelbar = true, verbrauchbar = true, nutzbar = true,
wirkung = { art = 'durst', menge = 25 },
beschreibung = 'Löscht den Durst. +25 Durst.',
},
sandwich = {
name = 'Sandwich', icon = '🥪', kat = 'Nahrung', gewicht = 0.2, rar = 'common',
stapelbar = true, verbrauchbar = true, nutzbar = true,
wirkung = { art = 'hunger', menge = 30 },
beschreibung = 'Stillt den Hunger. +30 Hunger.',
},
verband = {
name = 'Verband', icon = '🩹', kat = 'Medizin', gewicht = 0.1, rar = 'uncommon',
stapelbar = true, verbrauchbar = true, nutzbar = true,
wirkung = { art = 'leben', menge = 25 },
beschreibung = 'Stoppt Blutungen und heilt leichte Wunden.',
},
tablette = {
name = 'Schmerztabletten', icon = '💊', kat = 'Medizin', gewicht = 0.02, rar = 'common',
stapelbar = true, verbrauchbar = true, nutzbar = true,
wirkung = { art = 'leben', menge = 10 },
beschreibung = 'Lindert Schmerzen kurzfristig.',
},
funk = {
name = 'Funkgerät', icon = '📻', kat = 'Elektronik', gewicht = 0.4, rar = 'uncommon',
nutzbar = true, beschreibung = 'Kurzstrecken-Funk für die Kommunikation.',
},
dietrich = {
name = 'Dietrich', icon = '🗝️', kat = 'Werkzeug', gewicht = 0.05, rar = 'uncommon',
stapelbar = true, beschreibung = 'Zum Öffnen einfacher Schlösser. Kann brechen.',
},
reparaturkit = {
name = 'Reparaturkit', icon = '🧰', kat = 'Werkzeug', gewicht = 2.0, rar = 'uncommon',
nutzbar = true, beschreibung = 'Repariert Fahrzeuge notdürftig.',
},
bargeld = {
name = 'Bargeld', icon = '💵', kat = 'Wertsache', gewicht = 0, rar = 'legendary',
stapelbar = true, beschreibung = 'Sauberes Bargeld.',
},
goldkette = {
name = 'Goldkette', icon = '📿', kat = 'Schmuck', gewicht = 0.15, rar = 'epic',
slot = 'schmuck', beschreibung = 'Massive Goldkette. Ein echter Hingucker.',
},
helm = {
name = 'Ballistikhelm', icon = '⛑️', kat = 'Ausrüstung', gewicht = 1.0, rar = 'uncommon',
slot = 'helm', beschreibung = 'Schützt den Kopf vor Treffern.',
},
weste = {
name = 'Schutzweste', icon = '🦺', kat = 'Ausrüstung', gewicht = 3.5, rar = 'rare',
slot = 'brust', beschreibung = 'Kugelsichere Weste. Reduziert Schaden am Oberkörper.',
},
jeans = {
name = 'Jeans', icon = '👖', kat = 'Kleidung', gewicht = 0.6, rar = 'common',
slot = 'hose', beschreibung = 'Robuste Jeans.',
},
sneaker = {
name = 'Sneaker', icon = '👟', kat = 'Kleidung', gewicht = 0.8, rar = 'common',
slot = 'schuhe', beschreibung = 'Bequeme Sneaker.',
},
-- --- Weitere Waren aus dem Laden -------------------------------------
cola = {
name = 'Cola', icon = '🥫', kat = 'Nahrung', gewicht = 0.4, rar = 'common',
stapelbar = true, verbrauchbar = true, nutzbar = true,
wirkung = { art = 'durst', menge = 20 },
beschreibung = 'Eiskalt, koffeinhaltig.',
},
energy = {
name = 'Energydrink', icon = '⚡', kat = 'Nahrung', gewicht = 0.4, rar = 'common',
stapelbar = true, verbrauchbar = true, nutzbar = true,
wirkung = { art = 'ausdauer', menge = 40 },
beschreibung = 'Gibt Extra-Ausdauer.',
},
kaffee = {
name = 'Kaffee to go', icon = '☕', kat = 'Nahrung', gewicht = 0.3, rar = 'common',
stapelbar = true, verbrauchbar = true, nutzbar = true,
wirkung = { art = 'ausdauer', menge = 25 },
beschreibung = 'Wach werden.',
},
chips = {
name = 'Chips', icon = '🍟', kat = 'Nahrung', gewicht = 0.2, rar = 'common',
stapelbar = true, verbrauchbar = true, nutzbar = true,
wirkung = { art = 'hunger', menge = 15 },
beschreibung = 'Knuspriger Snack.',
},
schoko = {
name = 'Schokoriegel', icon = '🍫', kat = 'Nahrung', gewicht = 0.1, rar = 'common',
stapelbar = true, verbrauchbar = true, nutzbar = true,
wirkung = { art = 'hunger', menge = 12 },
beschreibung = 'Süße Energie.',
},
burger = {
name = 'Burger', icon = '🍔', kat = 'Nahrung', gewicht = 0.3, rar = 'common',
stapelbar = true, verbrauchbar = true, nutzbar = true,
wirkung = { art = 'hunger', menge = 45 },
beschreibung = 'Deftige Mahlzeit.',
},
medkit = {
name = 'Erste-Hilfe-Kit', icon = '🧳', kat = 'Medizin', gewicht = 1.5, rar = 'rare',
stapelbar = true, verbrauchbar = true, nutzbar = true,
wirkung = { art = 'leben', menge = 60 },
beschreibung = 'Behandelt schwere Wunden.',
},
kabel = {
name = 'Kabelbinder', icon = '🔗', kat = 'Werkzeug', gewicht = 0.1, rar = 'common',
stapelbar = true, beschreibung = 'Vielseitig einsetzbar.',
},
lampe = {
name = 'Taschenlampe', icon = '🔦', kat = 'Werkzeug', gewicht = 0.3, rar = 'common',
nutzbar = true, beschreibung = 'Leuchtet den Weg.',
},
sim = {
name = 'SIM-Karte', icon = '📶', kat = 'Elektronik', gewicht = 0.01, rar = 'common',
stapelbar = true, beschreibung = 'Wird fürs Handy benötigt.',
},
akku = {
name = 'Akku', icon = '🔋', kat = 'Elektronik', gewicht = 0.1, rar = 'common',
stapelbar = true, beschreibung = 'Ersatz-Akku fürs Handy.',
},
boerse = {
name = 'Börse', icon = '👛', kat = 'Zubehör', gewicht = 0.2, rar = 'uncommon',
beschreibung = 'Fasst bis zu 10 Dokumente.',
},
rucksack = {
name = 'Rucksack', icon = '🎒', kat = 'Zubehör', gewicht = 0.6, rar = 'uncommon',
beschreibung = 'Erhöht die Tragekapazität.',
},
-- --- Waffen und Zubehoer (Ammu-Nation) -------------------------------
smg = {
name = 'MP5 (MPX)', icon = '🔫', kat = 'Waffe', gewicht = 3.0, rar = 'legendary',
waffe = true, beschreibung = 'Maschinenpistole. Registrierte Feuerwaffe.',
},
flinte = {
name = 'Schrotflinte 870', icon = '🔫', kat = 'Waffe', gewicht = 3.6, rar = 'epic',
waffe = true, beschreibung = 'Pump-Action Shotgun. Registrierte Feuerwaffe.',
},
schrot12 = {
name = 'Schrot 12/70', icon = '🧨', kat = 'Munition', gewicht = 0.04, rar = 'common',
stapelbar = true, beschreibung = 'Schrotpatronen für Flinten.',
},
schalldaempfer = {
name = 'Schalldämpfer', icon = '🔇', kat = 'Aufsatz', gewicht = 0.2, rar = 'rare',
beschreibung = 'Dämpft den Mündungsknall.',
},
waffenlampe = {
name = 'Waffenlampe', icon = '🔦', kat = 'Aufsatz', gewicht = 0.15, rar = 'uncommon',
beschreibung = 'Taktische Beleuchtung.',
},
laservisier = {
name = 'Laservisier', icon = '🎯', kat = 'Aufsatz', gewicht = 0.15, rar = 'uncommon',
beschreibung = 'Bessere Zielerfassung.',
},
}
-- Startausruestung eines frisch erstellten Charakters
Inventar.StartItems = {
{ item = 'handy', menge = 1, slot = 0 },
{ item = 'wasser', menge = 2, slot = 1 },
{ item = 'sandwich', menge = 1, slot = 2 },
{ item = 'jeans', menge = 1, slot = Inventar.EQUIP_SLOTS.hose },
{ item = 'sneaker', menge = 1, slot = Inventar.EQUIP_SLOTS.schuhe },
}
--- Liefert die Definition eines Items
--- @param kennung string Item-Kennung
--- @return table|nil
function Inventar.HoleItem(kennung)
return Inventar.Items[kennung]
end

View File

@ -0,0 +1,344 @@
-- =====================================================================
-- Aether Jobs — Clientlogik
-- Die Arbeit findet in der Welt statt: Marker, Route, Animation.
-- Der Server prueft Ort und Dauer, bevor er bezahlt.
-- =====================================================================
local istOffen = false
local istGeladen = false
-- Aktuelle Schicht (vom Server gespiegelt)
local schicht = nil -- { jobId, schrittIdx, auftrag, schritte, farbe, name }
local arbeitetGerade = false -- laeuft gerade eine Handlung?
local zielBlip = nil -- Markierung des aktuellen Arbeitsorts
local TASTE_BENUTZEN = 38 -- E
local function SendeAnNui(nachricht)
SendNuiMessage(json.encode(nachricht))
end
local function ZeigeSpielmeldung(text)
if not text or text == '' then return end
BeginTextCommandThefeedPost('STRING')
AddTextComponentSubstringPlayerName(text)
EndTextCommandThefeedPostTicker(false, true)
end
local function Schliessen()
if not istOffen then return end
istOffen = false
SetNuiFocus(false, false)
SendeAnNui({ aktion = 'schliessen' })
TriggerServerEvent('aether:jobs:schliessen')
pcall(function() exports['aether-hud']:SetzeSichtbar(true) end)
end
-- ---------------------------------------------------------------------
-- Wegpunkt zum aktuellen Arbeitsort
-- ---------------------------------------------------------------------
--- Liefert den Ort des aktuellen Arbeitsschritts
--- @return table|nil { position, name }
local function AktuellerOrt()
if not schicht or not schicht.auftrag or not schicht.auftrag.orte then return nil end
return schicht.auftrag.orte[schicht.schrittIdx + 1]
end
--- Setzt die Kartenmarkierung auf den aktuellen Arbeitsort
local function AktualisiereZielBlip()
if zielBlip then
RemoveBlip(zielBlip)
zielBlip = nil
end
local ort = AktuellerOrt()
if not ort then return end
zielBlip = AddBlipForCoord(ort.position.x, ort.position.y, ort.position.z)
SetBlipSprite(zielBlip, 1)
SetBlipColour(zielBlip, 5)
SetBlipScale(zielBlip, 0.9)
SetBlipRoute(zielBlip, true) -- Route auf der Karte anzeigen
SetBlipRouteColour(zielBlip, 5)
BeginTextCommandSetBlipName('STRING')
AddTextComponentString('Arbeitsort: ' .. (ort.name or ''))
EndTextCommandSetBlipName(zielBlip)
end
--- Entfernt die Zielmarkierung
local function EntferneZielBlip()
if zielBlip then
RemoveBlip(zielBlip)
zielBlip = nil
end
end
-- ---------------------------------------------------------------------
-- Arbeitshandlung ausfuehren
-- ---------------------------------------------------------------------
--- Spielt die Handlung ab und meldet sie danach dem Server
--- @param schrittDef table Schrittdefinition
local function FuehreHandlungAus(schrittDef)
if arbeitetGerade then return end
arbeitetGerade = true
local ped = PlayerPedId()
local dauer = schrittDef.dauer or 4000
-- Passendes Szenario abspielen, damit die Handlung sichtbar ist
if schrittDef.szenario then
TaskStartScenarioInPlace(ped, schrittDef.szenario, 0, true)
end
FreezeEntityPosition(ped, true)
-- Fortschrittsbalken anzeigen
local start = GetGameTimer()
CreateThread(function()
while arbeitetGerade do
local vergangen = GetGameTimer() - start
local anteil = math.min(1.0, vergangen / dauer)
-- Balken am unteren Bildschirmrand
DrawRect(0.5, 0.86, 0.24, 0.028, 0, 0, 0, 170)
DrawRect(0.38 + 0.12 * anteil, 0.86, 0.24 * anteil, 0.028, 90, 200, 120, 220)
SetTextFont(4)
SetTextScale(0.34, 0.34)
SetTextColour(255, 255, 255, 230)
SetTextCentre(true)
SetTextEntry('STRING')
AddTextComponentString(schrittDef.text or 'Arbeite …')
SetTextOutline()
DrawText(0.5, 0.895)
Wait(0)
end
end)
Wait(dauer)
ClearPedTasks(ped)
FreezeEntityPosition(ped, false)
arbeitetGerade = false
-- Erst jetzt meldet der Client den Schritt; der Server prueft Ort und Zeit
TriggerServerEvent('aether:jobs:schritt')
end
-- ---------------------------------------------------------------------
-- Ereignisse vom Server
-- ---------------------------------------------------------------------
AddEventHandler('aether:client:bereit', function()
istGeladen = true
end)
RegisterNetEvent('aether:jobs:zeige', function(zustand)
istOffen = true
SetNuiFocus(true, true)
pcall(function() exports['aether-hud']:SetzeSichtbar(false) end)
zustand.aktion = 'oeffnen'
SendeAnNui(zustand)
end)
RegisterNetEvent('aether:jobs:zustand', function(zustand)
-- Schicht lokal spiegeln, damit Marker und Anzeige stimmen
if zustand.aktiv then
local job = nil
for _, j in ipairs(zustand.jobs or {}) do
if j.id == zustand.aktiv.jobId then job = j break end
end
schicht = {
jobId = zustand.aktiv.jobId,
schrittIdx = zustand.aktiv.schrittIdx,
auftrag = zustand.aktiv.aktuell,
schritte = job and job.schritte or {},
name = job and job.name or '',
}
AktualisiereZielBlip()
else
schicht = nil
EntferneZielBlip()
end
if istOffen then
zustand.aktion = 'zustand'
SendeAnNui(zustand)
elseif zustand.hinweis then
ZeigeSpielmeldung(zustand.hinweis)
end
end)
RegisterNetEvent('aether:jobs:fehler', function(text)
if istOffen then
SendeAnNui({ aktion = 'fehler', text = text })
else
ZeigeSpielmeldung(text)
end
end)
--- Dienstfahrzeug steht bereit
RegisterNetEvent('aether:jobs:fahrzeugBereit', function(netId, spawn)
Schliessen()
local versuche = 0
while not NetworkDoesEntityExistWithNetworkId(netId) and versuche < 100 do
Wait(50)
versuche = versuche + 1
end
if not NetworkDoesEntityExistWithNetworkId(netId) then return end
local fahrzeug = NetToVeh(netId)
if fahrzeug and DoesEntityExist(fahrzeug) then
SetVehicleNumberPlateText(fahrzeug, 'DIENST')
ZeigeSpielmeldung('~g~Dienstfahrzeug~s~ steht bereit — folge der Markierung.')
-- Wegpunkt zum Fahrzeug setzen, damit man es findet
SetNewWaypoint(spawn.x, spawn.y)
end
end)
-- ---------------------------------------------------------------------
-- Jobcenter in der Naehe erkennen
-- ---------------------------------------------------------------------
CreateThread(function()
local blip = AddBlipForCoord(Jobs.JOBCENTER.x, Jobs.JOBCENTER.y, Jobs.JOBCENTER.z)
SetBlipSprite(blip, 407)
SetBlipColour(blip, 5)
SetBlipScale(blip, 0.8)
SetBlipAsShortRange(blip, true)
BeginTextCommandSetBlipName('STRING')
AddTextComponentString('Jobcenter')
EndTextCommandSetBlipName(blip)
while true do
local pause = 800
if istGeladen and not istOffen and not schicht then
local position = GetEntityCoords(PlayerPedId())
if #(position - Jobs.JOBCENTER) <= Jobs.INTERAKTION_WEITE then
pause = 0
SetTextFont(4)
SetTextScale(0.34, 0.34)
SetTextColour(255, 255, 255, 215)
SetTextCentre(true)
SetTextOutline()
SetTextEntry('STRING')
AddTextComponentString('[E] Jobcenter')
SetDrawOrigin(Jobs.JOBCENTER.x, Jobs.JOBCENTER.y, Jobs.JOBCENTER.z + 0.9, 0)
DrawText(0.0, 0.0)
ClearDrawOrigin()
if IsControlJustReleased(0, TASTE_BENUTZEN) then
TriggerServerEvent('aether:jobs:oeffnen')
end
end
end
Wait(pause)
end
end)
-- ---------------------------------------------------------------------
-- Arbeitsort: Marker, Aufgabenanzeige und Aktion
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
local pause = 500
if schicht and not istOffen and not arbeitetGerade then
local ort = AktuellerOrt()
local schrittDef = schicht.schritte[schicht.schrittIdx + 1]
if ort and schrittDef then
pause = 0
local position = GetEntityCoords(PlayerPedId())
local ziel = vector3(ort.position.x, ort.position.y, ort.position.z)
local abstand = #(position - ziel)
-- Aufgabenanzeige oben links
SetTextFont(4)
SetTextScale(0.36, 0.36)
SetTextColour(255, 255, 255, 220)
SetTextEntry('STRING')
AddTextComponentString(('~y~%s~s~ %s'):format(schicht.name, schrittDef.text))
SetTextOutline()
DrawText(0.012, 0.62)
SetTextFont(4)
SetTextScale(0.31, 0.31)
SetTextColour(200, 200, 210, 190)
SetTextEntry('STRING')
AddTextComponentString(('Ziel: %s · %d m'):format(ort.name or '', math.floor(abstand)))
SetTextOutline()
DrawText(0.012, 0.655)
-- Marker am Arbeitsort, sobald er in Sichtweite ist
if abstand < 60.0 then
DrawMarker(1, ziel.x, ziel.y, ziel.z - 0.95, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
1.6, 1.6, 0.7, 90, 200, 120, 120, false, false, 2, false, nil, nil, false)
end
-- In Reichweite: Aktion anbieten
if abstand <= Jobs.ORT_RADIUS then
SetTextFont(4)
SetTextScale(0.34, 0.34)
SetTextColour(255, 255, 255, 220)
SetTextCentre(true)
SetTextOutline()
SetTextEntry('STRING')
AddTextComponentString('[E] ' .. (schrittDef.btn or 'Arbeiten'))
SetDrawOrigin(ziel.x, ziel.y, ziel.z + 0.9, 0)
DrawText(0.0, 0.0)
ClearDrawOrigin()
if IsControlJustReleased(0, TASTE_BENUTZEN) then
FuehreHandlungAus(schrittDef)
end
end
end
end
Wait(pause)
end
end)
--- Taste J: Arbeitsansicht oeffnen
RegisterCommand('job', function()
if istGeladen and not istOffen then
TriggerServerEvent('aether:jobs:oeffnen')
end
end, false)
RegisterKeyMapping('job', 'Jobcenter / Arbeitsansicht öffnen', 'keyboard', 'J')
-- ---------------------------------------------------------------------
-- NUI-Callbacks
-- ---------------------------------------------------------------------
RegisterNUICallback('schliessen', function(_, rueckgabe)
Schliessen()
rueckgabe({ ok = true })
end)
RegisterNUICallback('antreten', function(daten, rueckgabe)
TriggerServerEvent('aether:jobs:antreten', tostring(daten.jobId or ''))
Schliessen() -- gearbeitet wird in der Welt, nicht im Menue
rueckgabe({ ok = true })
end)
RegisterNUICallback('feierabend', function(_, rueckgabe)
TriggerServerEvent('aether:jobs:feierabend')
rueckgabe({ ok = true })
end)
-- ---------------------------------------------------------------------
-- Aufraeumen
-- ---------------------------------------------------------------------
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false)
EntferneZielBlip()
ClearPedTasks(PlayerPedId())
FreezeEntityPosition(PlayerPedId(), false)
end)

View File

@ -0,0 +1,34 @@
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
name 'aether-jobs'
author 'Jerrit Fritzsche'
description 'Jobcenter mit acht Jobs, Level/XP und Auftrags-Loop'
version '0.1.0'
ui_page 'html/index.html'
files {
'html/index.html',
}
shared_scripts {
'@aether-core/shared/config.lua',
'@aether-core/shared/utils.lua',
'shared/jobs.lua',
}
client_scripts {
'client/main.lua',
}
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/main.lua',
}
dependencies {
'aether-core',
'oxmysql',
}

View File

@ -0,0 +1,340 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
</head>
<body>
<title>Aether — Jobs</title>
<style>
:root{
--bg:#080a0f;--fg:#e9ecf4;--muted:#828a9e;--surface:rgba(22,26,36,0.72);--surface2:rgba(34,39,52,0.85);
--slot:#111520;--border:rgba(255,255,255,0.09);--accent:#4aa8ff;--ink:#04162f;--accent-soft:rgba(74,168,255,0.16);
--green:#34d39a;--amber:#f5b23d;--red:#f0506e;
}
*{box-sizing:border-box;} html,body{margin:0;height:100%;}
body{min-height:100%;background:radial-gradient(1100px 700px at 15% 0%,var(--accent-soft),transparent 55%),radial-gradient(900px 700px at 90% 100%,rgba(52,211,153,0.05),transparent 55%),var(--bg);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);
display:flex;align-items:center;justify-content:center;padding:22px 14px;overflow-x:hidden;transition:background .4s;}
.app{position:relative;width:min(1200px,98vw);aspect-ratio:16/10;border-radius:20px;overflow:hidden;
background:linear-gradient(160deg,rgba(18,22,30,0.92),rgba(9,11,16,0.96));backdrop-filter:blur(20px);border:1px solid var(--border);
box-shadow:0 50px 100px -30px rgba(0,0,0,0.85);display:grid;grid-template-columns:210px 1fr;grid-template-rows:auto 1fr;grid-template-areas:"brand top" "nav main";}
.brand{grid-area:brand;padding:14px 16px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
.brand .bl{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;font-size:19px;background:linear-gradient(150deg,var(--accent),#1f5fb0);}
.brand .bn{font-size:16px;font-weight:800;letter-spacing:-0.02em;} .brand .bn small{display:block;font-size:10px;color:var(--muted);font-weight:500;}
.top{grid-area:top;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:14px;padding:0 18px;}
.top .status{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:700;} .status .dot{width:9px;height:9px;border-radius:50%;background:var(--muted);} .status .dot.on{background:var(--green);box-shadow:0 0 8px var(--green);}
.top .money{margin-left:auto;display:flex;gap:10px;} .money-chip{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:7px 13px;text-align:right;} .money-chip .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;font-weight:800;} .money-chip .v{font-size:14px;font-weight:800;font-variant-numeric:tabular-nums;}
.nav{grid-area:nav;border-right:1px solid var(--border);padding:12px 9px;display:flex;flex-direction:column;gap:2px;overflow-y:auto;}
.nav button{display:flex;align-items:center;gap:10px;padding:9px 11px;border-radius:9px;border:none;background:none;color:var(--muted);font-family:inherit;font-size:13.5px;font-weight:700;cursor:pointer;text-align:left;transition:.12s;}
.nav button .ni{font-size:15px;width:18px;text-align:center;} .nav button:hover{background:var(--surface);color:var(--fg);} .nav button.active{background:var(--accent-soft);color:var(--accent);} .nav button:disabled{opacity:.4;cursor:not-allowed;}
.main{grid-area:main;overflow-y:auto;padding:20px 24px;} .main::-webkit-scrollbar,.nav::-webkit-scrollbar{width:7px;} .main::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.09);border-radius:4px;}
.phead{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;margin-bottom:16px;} .phead h2{font-size:21px;margin:0;font-weight:800;letter-spacing:-0.02em;} .phead p{margin:2px 0 0;color:var(--muted);font-size:12.5px;}
.jobgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px;}
.jobcard{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:16px;transition:.12s;position:relative;overflow:hidden;}
.jobcard.aktiv{border-color:var(--green);box-shadow:0 0 0 1px var(--green) inset;}
.jobcard .ico{width:48px;height:48px;border-radius:12px;display:grid;place-items:center;font-size:26px;margin-bottom:10px;}
.jobcard .jn{font-size:16px;font-weight:800;} .jobcard .jk{font-size:11.5px;color:var(--muted);} .jobcard .jd{font-size:12.5px;color:#b9c0cf;margin:8px 0 10px;line-height:1.4;min-height:34px;}
.jobcard .lvl{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted);margin-bottom:10px;}
.lvlbar{flex:1;height:6px;border-radius:4px;background:var(--slot);overflow:hidden;} .lvlbar>i{display:block;height:100%;border-radius:4px;background:var(--accent);}
.btn{border:none;border-radius:11px;padding:11px 16px;font-size:14px;font-weight:800;cursor:pointer;font-family:inherit;transition:.12s;width:100%;} .btn.primary{background:var(--accent);color:#fff;} .btn.primary:hover{filter:brightness(1.08);} .btn.ghost{background:var(--surface2);color:var(--fg);border:1px solid var(--border);} .btn.green{background:var(--green);color:#06231a;} .btn.red{background:var(--red);color:#2a0910;} .btn:disabled{opacity:.4;cursor:not-allowed;} .btn:active{transform:scale(0.99);} .btn.wide{padding:15px;font-size:15px;}
.card{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:18px;}
.list-h{font-size:10.5px;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);font-weight:800;margin:0 0 10px;}
.kpi .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;font-weight:800;} .kpi .v{font-size:24px;font-weight:800;font-variant-numeric:tabular-nums;margin-top:3px;}
.pill{font-size:10px;font-weight:800;padding:3px 9px;border-radius:999px;display:inline-block;} .pill.g{background:rgba(52,211,153,0.16);color:var(--green);} .pill.a{background:rgba(245,178,61,0.16);color:var(--amber);} .pill.ac{background:var(--accent-soft);color:var(--accent);}
/* Arbeits-Ansicht */
.work-head{display:flex;align-items:center;gap:16px;margin-bottom:16px;}
.work-head .ico{width:64px;height:64px;border-radius:16px;display:grid;place-items:center;font-size:34px;flex-shrink:0;}
.work-head .wt{font-size:22px;font-weight:800;} .work-head .wl{font-size:12.5px;color:var(--muted);}
.xpbar{height:8px;border-radius:5px;background:var(--slot);overflow:hidden;margin-top:6px;width:220px;} .xpbar>i{display:block;height:100%;border-radius:5px;background:var(--accent);transition:width .4s;}
.task{background:linear-gradient(150deg,var(--accent-soft),transparent);border:1px solid var(--border);border-radius:16px;padding:22px;text-align:center;}
.task .auf{font-size:12px;color:var(--muted);font-weight:700;letter-spacing:0.04em;text-transform:uppercase;}
.task .ziel{font-size:20px;font-weight:800;margin:6px 0 3px;} .task .schritt{font-size:14px;color:#c2cbd8;margin-bottom:16px;}
.task .lohn{display:inline-block;background:var(--slot);border:1px solid var(--border);border-radius:999px;padding:5px 14px;font-weight:800;color:var(--green);margin-bottom:16px;}
.workprog{height:12px;border-radius:7px;background:var(--slot);overflow:hidden;margin:0 auto 14px;max-width:420px;} .workprog>i{display:block;height:100%;width:0;border-radius:7px;background:var(--accent);} .workprog>i.fill{animation:fill 1s linear forwards;} @keyframes fill{to{width:100%;}}
.stepbar{display:flex;gap:6px;justify-content:center;margin-bottom:16px;} .stepbar .sd{width:26px;height:6px;border-radius:3px;background:var(--slot);} .stepbar .sd.done{background:var(--green);} .stepbar .sd.cur{background:var(--accent);}
.toast{position:absolute;bottom:16px;left:50%;transform:translateX(-50%) translateY(16px);background:rgba(12,15,22,0.97);border:1px solid var(--border);color:var(--fg);padding:11px 20px;border-radius:12px;font-size:13.5px;font-weight:600;z-index:50;opacity:0;pointer-events:none;transition:.3s;} .toast.show{opacity:1;transform:translateX(-50%) translateY(0);} .toast.good{border-color:var(--green);} .toast.warn{border-color:var(--red);} .toast.level{border-color:var(--amber);}
.empty{text-align:center;color:var(--muted);padding:44px 10px;} .empty .big{font-size:40px;margin-bottom:8px;}
/* Hinweis, dass die Arbeit in der Welt stattfindet */
.ortshinweis{background:rgba(255,255,255,0.05);border:1px dashed var(--border);border-radius:12px;
padding:14px 16px;font-size:13.5px;line-height:1.6;color:var(--muted);margin-top:4px;}
.ortshinweis b{color:var(--fg);}
.ortshinweis .ortname{margin-top:6px;font-size:12.5px;color:var(--accent);font-weight:700;}
</style>
<div class="app">
<div class="brand"><span class="bl">💼</span><div class="bn">Aether Jobs<small>Arbeitsvermittlung</small></div></div>
<div class="top" id="top"></div>
<div class="nav" id="nav"></div>
<div class="main" id="main"></div>
<div class="toast" id="toast"></div>
</div>
<script>
"use strict";
const $=id=>document.getElementById(id);
function sicher(t){const d=document.createElement("div");d.textContent=t==null?"":t;return d.innerHTML;}
const euro=n=>Math.round(n).toLocaleString("de-DE")+" €";
const rnd=(a,b)=>a+Math.floor(Math.random()*(b-a+1));
const pick=a=>a[Math.floor(Math.random()*a.length)];
let toastTimer=null; function toast(t,typ){const e=$("toast");e.textContent=t;e.className="toast show"+(typ?" "+typ:"");clearTimeout(toastTimer);toastTimer=setTimeout(()=>e.classList.remove("show"),2600);}
/* =====================================================================
SPIELER & FORTSCHRITT
===================================================================== */
let spieler={bargeld:0,konto:0}; // vom Server befuellt
let XP_PRO_LEVEL=120;
const orte=["Legion Square","Vinewood Blvd","Vespucci Beach","Grove Street","Sandy Shores","Del Perro","Mirror Park","Downtown","Rockford Hills","Paleto Bay"];
const fische=[{n:"alter Stiefel",w:5},{n:"Hering",w:55},{n:"Makrele",w:85},{n:"Wolfsbarsch",w:130},{n:"Lachs",w:190},{n:"Thunfisch",w:340}];
const erze=[{n:"Kohle",w:45},{n:"Eisenerz",w:80},{n:"Kupfererz",w:120},{n:"Silbererz",w:220},{n:"Goldnugget",w:480}];
/* =====================================================================
JOBS — jeder mit Schritten und Auftrags-Generator
===================================================================== */
let jobs=[
{id:"liefer",name:"Lieferdienst",icon:"📦",kat:"Service",farbe:"#4aa8ff",beschreibung:"Pakete abholen und im Stadtgebiet ausliefern.",
schritte:[{text:"Fahre zum Depot und lade das Paket ein.",btn:"📦 Paket abholen"},{text:"Liefere das Paket beim Kunden aus.",btn:"✅ Zustellen"}],
auftrag:l=>({ziel:"Zustellung: "+pick(orte),geld:Math.round(rnd(120,180)*(1+l*0.05)),xp:30})},
{id:"muell",name:"Müllabfuhr",icon:"🗑️",kat:"Service",farbe:"#7c8b52",beschreibung:"Mülltonnen entlang der Route leeren.",
schritte:[{text:"Sammle die Mülltonnen entlang der Straße ein.",btn:"🗑️ Tonnen leeren"},{text:"Entlade am Wertstoffhof.",btn:"♻️ Entladen"}],
auftrag:l=>({ziel:"Route: "+pick(orte),geld:Math.round(rnd(90,140)*(1+l*0.05)),xp:26})},
{id:"taxi",name:"Taxi",icon:"🚕",kat:"Service",farbe:"#f5b23d",beschreibung:"Fahrgäste abholen und ans Ziel bringen.",
schritte:[{text:"Hole den Fahrgast am Standort ab.",btn:"🧍 Fahrgast abholen"},{text:"Bringe den Fahrgast sicher ans Ziel.",btn:"🏁 Zum Ziel fahren"}],
auftrag:l=>{const d=rnd(2,12);return {ziel:pick(orte)+" → "+pick(orte),geld:Math.round((80+d*14)*(1+l*0.05)),xp:22,info:d+" km Fahrt"};}},
{id:"trucker",name:"Trucker",icon:"🚛",kat:"Fracht",farbe:"#e0503f",beschreibung:"Große Frachten über Land ausliefern — Top-Bezahlung.",
schritte:[{text:"Beladung am Lagerhaus vorbereiten.",btn:"📦 Anhänger beladen"},{text:"Fahre die Fracht zum Zielort.",btn:"🛣️ Ausliefern"}],
auftrag:l=>({ziel:"Fracht nach "+pick(orte),geld:Math.round(rnd(420,680)*(1+l*0.05)),xp:45})},
{id:"tankwart",name:"Tankwart",icon:"⛽",kat:"Service",farbe:"#34d39a",beschreibung:"Zapfsäulen bedienen und kassieren.",
schritte:[{text:"Bediene die Zapfsäule für den Kunden.",btn:"⛽ Betanken"},{text:"Kassiere an der Kasse.",btn:"💳 Kassieren"}],
auftrag:l=>({ziel:"Tankstelle "+pick(orte),geld:Math.round(rnd(100,160)*(1+l*0.05)),xp:24})},
{id:"angeln",name:"Angeln",icon:"🎣",kat:"Freizeit",farbe:"#4ac1d4",beschreibung:"Angel auswerfen und den Fang verkaufen.",
schritte:[{text:"Wirf die Angel aus und warte auf einen Biss.",btn:"🎣 Auswerfen"}],
auftrag:l=>{const f=pick(fische);return {ziel:"Am Pier",geld:Math.round(f.w*(1+l*0.06)),xp:18,fang:f.n};}},
{id:"holz",name:"Holzfäller",icon:"🪓",kat:"Rohstoff",farbe:"#8a6a34",beschreibung:"Bäume fällen und Holz zum Sägewerk bringen.",
schritte:[{text:"Fälle den markierten Baum.",btn:"🪓 Baum fällen"},{text:"Bringe die Stämme zum Sägewerk.",btn:"🪵 Verkaufen"}],
auftrag:l=>{const n=rnd(3,8);return {ziel:"Wald bei Paleto",geld:Math.round(n*35*(1+l*0.05)),xp:28,fang:n+" Stämme"};}},
{id:"bergbau",name:"Bergbau",icon:"⛏️",kat:"Rohstoff",farbe:"#9aa0b5",beschreibung:"Erz abbauen und einschmelzen.",
schritte:[{text:"Baue das Erz in der Mine ab.",btn:"⛏️ Erz abbauen"},{text:"Schmelze das Erz ein und verkaufe.",btn:"🔥 Einschmelzen"}],
auftrag:l=>{const e=pick(erze),n=rnd(2,6);return {ziel:"Mine Sandy Shores",geld:Math.round(e.w*n*0.5*(1+l*0.05)),xp:30,fang:n+"× "+e.n};}}
];
const jobById=id=>jobs.find(j=>j.id===id);
// Fortschritt je Job
let fortschritt={};
jobs.forEach(j=>fortschritt[j.id]={level:1,xp:0});
fortschritt.liefer.level=4; fortschritt.liefer.xp=60; fortschritt.angeln.level=2; fortschritt.taxi.level=3;
let sektion="jobcenter";
let aktiv=null; // {jobId, schrittIdx, aktuell:{...}, verdient, auftraege, startTs}
let arbeitet=false; // gerade ein Schritt in Bearbeitung
let schrittTimer=null, schichtTimer=null;
const aktJob=()=>aktiv?jobById(aktiv.jobId):null;
/* =====================================================================
SHELL
===================================================================== */
function geldAnzeige(){$("m-cash").textContent=euro(spieler.bargeld);$("m-bank").textContent=euro(spieler.konto);}
function renderTop(){
const j=aktJob();
$("top").innerHTML=`<div class="status"><span class="dot ${aktiv?'on':''}"></span>${aktiv?'Im Dienst · '+j.name:'Außer Dienst'}</div>
<div class="money"><div class="money-chip"><div class="l">Bargeld</div><div class="v" id="m-cash"></div></div><div class="money-chip"><div class="l">Konto</div><div class="v" id="m-bank"></div></div></div>`;
geldAnzeige();
}
function renderNav(){
const items=[{id:"jobcenter",label:"Jobcenter",icon:"🏢"},{id:"arbeit",label:"Meine Arbeit",icon:"🛠️",dis:!aktiv},{id:"statistik",label:"Statistik",icon:"📊"}];
$("nav").innerHTML=items.map(n=>`<button data-s="${n.id}" class="${n.id===sektion?'active':''}" ${n.dis?'disabled':''}><span class="ni">${n.icon}</span>${n.label}</button>`).join('');
$("nav").querySelectorAll("[data-s]").forEach(b=>b.addEventListener("click",()=>{sektion=b.dataset.s;renderNav();renderSektion();}));
}
function setAccent(farbe){document.documentElement.style.setProperty("--accent",farbe);document.documentElement.style.setProperty("--accent-soft","color-mix(in srgb, "+farbe+" 16%, transparent)");}
function renderSektion(){clearInterval(schichtTimer);(sektionen[sektion]||sektionen.jobcenter)();}
function refresh(){renderTop();renderNav();renderSektion();}
const sektionen={};
/* =====================================================================
JOBCENTER
===================================================================== */
sektionen.jobcenter=()=>{
setAccent("#4aa8ff");
$("main").innerHTML=`<div class="phead"><div><h2>Jobcenter</h2><p>Wähle einen Job und tritt den Dienst an</p></div></div>
<div class="jobgrid">${jobs.map(j=>{const f=fortschritt[j.id],istAktiv=aktiv&&aktiv.jobId===j.id;
return `<div class="jobcard ${istAktiv?'aktiv':''}"><div class="ico" style="background:${j.farbe}22;color:${j.farbe}">${j.icon}</div>
<div class="jn">${sicher(j.name)} ${istAktiv?'<span class="pill g">im Dienst</span>':''}</div><div class="jk">${sicher(j.kat)}</div>
<div class="jd">${sicher(j.beschreibung)}</div>
<div class="lvl">Level ${f.level}<div class="lvlbar"><i style="width:${Math.round(f.xp/XP_PRO_LEVEL*100)}%;background:${j.farbe}"></i></div></div>
${istAktiv?`<button class="btn ghost" data-open="${j.id}">Zur Arbeit ›</button>`:`<button class="btn primary" data-start="${j.id}" ${aktiv?'disabled':''} style="background:${j.farbe}">${aktiv?'Erst abmelden':'Job antreten'}</button>`}</div>`;}).join('')}</div>
${aktiv?'<div class="card" style="margin-top:14px;color:var(--muted);font-size:12.5px">Du bist aktuell bei <b style="color:var(--fg)">'+sicher(aktJob().name)+'</b> im Dienst. Melde dich unter „Meine Arbeit" ab, um den Job zu wechseln.</div>':''}`;
$("main").querySelectorAll("[data-start]").forEach(b=>b.addEventListener("click",()=>antreten(b.dataset.start)));
$("main").querySelectorAll("[data-open]").forEach(b=>b.addEventListener("click",()=>{sektion="arbeit";renderNav();renderSektion();}));
};
function antreten(id){
if(IST_FIVEM){ nui("antreten",{jobId:id}); return; }
const j=jobById(id);
aktiv={jobId:id,schrittIdx:0,aktuell:j.auftrag(fortschritt[id].level),verdient:0,auftraege:0,startTs:Date.now()};
sektion="arbeit"; toast("Dienst angetreten: "+j.name,"good"); refresh();
}
/* =====================================================================
ARBEIT (Auftrags-Loop)
===================================================================== */
function dienstzeit(){const s=Math.floor((Date.now()-aktiv.startTs)/1000),m=Math.floor(s/60);return m+" min "+String(s%60).padStart(2,"0")+" s";}
sektionen.arbeit=()=>{
if(!aktiv){sektion="jobcenter";return sektionen.jobcenter();}
const j=aktJob(),f=fortschritt[j.id],a=aktiv,s=j.schritte[a.schrittIdx];
setAccent(j.farbe);
$("main").innerHTML=`<div class="work-head"><div class="ico" style="background:${j.farbe}22;color:${j.farbe}">${j.icon}</div>
<div style="flex:1"><div class="wt">${sicher(j.name)}</div><div class="wl">Level ${f.level} · ${f.xp}/${XP_PRO_LEVEL} XP</div><div class="xpbar"><i style="width:${Math.round(f.xp/XP_PRO_LEVEL*100)}%;background:${j.farbe}"></i></div></div>
<button class="btn red" id="feierabend" style="width:auto">Feierabend</button></div>
<div class="grid" style="display:grid;grid-template-columns:1.5fr 1fr;gap:14px;align-items:start">
<div class="task"><div class="auf">Auftrag #${a.auftraege+1}</div><div class="ziel">${sicher(a.aktuell.ziel)}</div>
<div class="schritt">${a.aktuell.info?'<span style="color:var(--muted)">'+sicher(a.aktuell.info)+'</span> · ':''}${sicher(s.text)}</div>
<div class="lohn">Lohn: ${euro(a.aktuell.geld)}${a.aktuell.fang?' · '+sicher(a.aktuell.fang):''}</div>
<div class="stepbar">${j.schritte.map((_,i)=>`<div class="sd ${i<a.schrittIdx?'done':i===a.schrittIdx?'cur':''}"></div>`).join('')}</div>
${arbeitet?'<div class="workprog"><i class="fill"></i></div>':''}
${IST_FIVEM
? '<div class="ortshinweis">📍 Fahre zum markierten Ort auf der Karte und drücke dort <b>E</b>:<br><b>'+sicher(s.btn)+'</b>'+(aktuellerOrtName()?'<div class="ortname">Ziel: '+sicher(aktuellerOrtName())+'</div>':'')+'</div>'
: '<button class="btn wide primary" id="do-step" '+(arbeitet?'disabled':'')+' style="background:'+j.farbe+'">'+(arbeitet?'⏳ Arbeite…':sicher(s.btn))+'</button>'}</div>
<div class="card"><div class="list-h">Schicht</div>
<div style="display:flex;flex-direction:column;gap:2px">
<div class="kpi"><div class="l">Verdient (Schicht)</div><div class="v" style="color:var(--green)">${euro(a.verdient)}</div></div>
<div class="kpi" style="margin-top:10px"><div class="l">Aufträge erledigt</div><div class="v">${a.auftraege}</div></div>
<div class="kpi" style="margin-top:10px"><div class="l">Dienstzeit</div><div class="v" id="dz" style="font-size:18px">${dienstzeit()}</div></div></div></div></div>`;
$("feierabend").addEventListener("click",feierabend);
if(!IST_FIVEM) $("do-step").addEventListener("click",macheSchritt);
clearInterval(schichtTimer); schichtTimer=setInterval(()=>{const el=$("dz");if(el&&aktiv)el.textContent=dienstzeit();else clearInterval(schichtTimer);},1000);
};
/* Nur für die eigenständige Browser-Ansicht.
In FiveM wird ausschließlich am echten Arbeitsort per E gearbeitet —
das Menü kann keinen Schritt auslösen. */
function macheSchritt(){
if(arbeitet||!aktiv||IST_FIVEM)return;
const j=aktJob(),a=aktiv;
arbeitet=true; sektionen.arbeit(); // rendert Fortschrittsbalken
clearTimeout(schrittTimer);
schrittTimer=setTimeout(()=>{
arbeitet=false;
if(a.schrittIdx<j.schritte.length-1){ a.schrittIdx++; sektionen.arbeit(); return; }
// Auftrag abgeschlossen -> Belohnung
const r=a.aktuell; spieler.bargeld+=r.geld; a.verdient+=r.geld; a.auftraege++;
const f=fortschritt[j.id]; f.xp+=r.xp; let up=false;
while(f.xp>=XP_PRO_LEVEL){ f.xp-=XP_PRO_LEVEL; f.level++; up=true; }
a.schrittIdx=0; a.aktuell=j.auftrag(f.level);
toast((r.fang?"🎉 "+r.fang+" · ":"✅ Auftrag erledigt · ")+euro(r.geld)+" verdient","good");
if(up)setTimeout(()=>toast("⭐ Level "+f.level+" bei "+j.name+"!","level"),700);
renderTop(); sektionen.arbeit();
},1000);
}
function feierabend(){
if(IST_FIVEM){ nui("feierabend",{}); return; }
const j=aktJob(),a=aktiv;
clearTimeout(schrittTimer); clearInterval(schichtTimer); arbeitet=false;
toast("Feierabend bei "+j.name+" · "+a.auftraege+" Aufträge · "+euro(a.verdient),"good");
aktiv=null; sektion="jobcenter"; refresh();
}
/* =====================================================================
STATISTIK
===================================================================== */
sektionen.statistik=()=>{
setAccent("#4aa8ff");
const gesamtLevel=jobs.reduce((s,j)=>s+fortschritt[j.id].level,0);
$("main").innerHTML=`<div class="phead"><div><h2>Statistik</h2><p>Dein Fortschritt in allen Jobs</p></div></div>
<div class="grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:14px">
<div class="card kpi"><div class="l">Aktueller Job</div><div class="v" style="font-size:18px">${aktiv?sicher(aktJob().name):'—'}</div></div>
<div class="card kpi"><div class="l">Level gesamt</div><div class="v">${gesamtLevel}</div></div>
<div class="card kpi"><div class="l">Bargeld</div><div class="v">${euro(spieler.bargeld)}</div></div></div>
<div class="card"><div class="list-h">Level je Job</div>${jobs.map(j=>{const f=fortschritt[j.id];return `<div style="display:flex;align-items:center;gap:12px;padding:8px 0;border-bottom:1px solid var(--border)"><span style="font-size:22px;width:30px;text-align:center">${j.icon}</span><div style="flex:1"><div style="font-weight:700;font-size:13.5px">${sicher(j.name)} <span class="pill ac">Lvl ${f.level}</span></div><div class="lvlbar" style="margin-top:5px"><i style="width:${Math.round(f.xp/XP_PRO_LEVEL*100)}%;background:${j.farbe}"></i></div></div><span style="color:var(--muted);font-size:12px">${f.xp}/${XP_PRO_LEVEL} XP</span></div>`;}).join('')}</div>`;
};
/* =====================================================================
NUI-BRÜCKE — Verbindung zum FiveM-Client
===================================================================== */
const IST_FIVEM = (typeof GetParentResourceName === "function");
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-jobs";
let SCHRITT_DAUER = 4000; // vom Server vorgegeben
function nui(aktion, daten){
if(!IST_FIVEM) return Promise.resolve();
return fetch(`https://${RESSOURCE}/${aktion}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(daten || {}),
}).catch(() => {});
}
/* Name des Orts, an dem der aktuelle Arbeitsschritt stattfindet */
function aktuellerOrtName(){
if(!aktiv || !aktiv.aktuell || !aktiv.aktuell.orte) return null;
const ort = aktiv.aktuell.orte[aktiv.schrittIdx];
return ort ? ort.name : null;
}
/* Übernimmt Jobs, Fortschritt und laufende Schicht vom Server */
function setzeZustand(z){
if(z.jobs) jobs = z.jobs;
if(z.xpProLevel) XP_PRO_LEVEL = z.xpProLevel;
if(z.schrittDauer) SCHRITT_DAUER = z.schrittDauer;
if(z.fortschritt) fortschritt = z.fortschritt;
if(z.geld){ spieler.bargeld = z.geld.bargeld || 0; spieler.konto = z.geld.konto || 0; }
if(z.aktiv){
/* startTs aus der vom Server gemeldeten Dienstzeit zurückrechnen,
damit der Timer in der Oberfläche weiterläuft. */
aktiv = {
jobId: z.aktiv.jobId,
schrittIdx: z.aktiv.schrittIdx,
aktuell: z.aktiv.aktuell,
verdient: z.aktiv.verdient,
auftraege: z.aktiv.auftraege,
startTs: Date.now() - (z.aktiv.dienstzeit || 0) * 1000,
};
if(sektion !== "arbeit" && sektion !== "statistik") sektion = "arbeit";
} else {
aktiv = null;
if(sektion === "arbeit") sektion = "jobcenter";
}
arbeitet = false;
refresh();
if(z.hinweis) toast(z.hinweis, "good");
}
window.addEventListener("message", (ereignis) => {
const n = ereignis.data || {};
if(n.aktion === "oeffnen"){
document.body.style.display = "";
setzeZustand(n);
}
else if(n.aktion === "zustand"){
setzeZustand(n);
}
else if(n.aktion === "fehler"){
arbeitet = false;
toast(n.text || "Nicht möglich", "warn");
refresh();
}
else if(n.aktion === "schliessen"){
document.body.style.display = "none";
}
});
/* ESC schließt das Jobcenter */
document.addEventListener("keydown", (e) => {
if(e.key === "Escape" && IST_FIVEM) nui("schliessen", {});
});
/* In FiveM zunächst verstecken — die Seite wartet auf "oeffnen" */
if(IST_FIVEM) document.body.style.display = "none";
/* START */
if(!IST_FIVEM) refresh();
</script>
</body>
</html>

View File

@ -0,0 +1,344 @@
-- =====================================================================
-- Aether Jobs — Serverlogik
-- Der Server vergibt die Auftraege, bestimmt den Lohn und prueft die
-- Zeit je Arbeitsschritt. Der Client kann nichts davon beeinflussen.
-- =====================================================================
local Core = exports['aether-core']
-- Laufende Schichten: [quelle] = {
-- jobId, auftrag, schrittIdx, schrittSeit, dienstBeginn, verdient, auftraege, fahrzeug
-- }
local schichten = {}
-- ---------------------------------------------------------------------
-- Dienstfahrzeuge
-- ---------------------------------------------------------------------
--- Stellt das Dienstfahrzeug eines Jobs bereit
--- @param quelle number Server-ID
--- @param job table Jobdefinition
--- @return number|nil Entity-Handle
local function StelleFahrzeug(quelle, job)
if not job.fahrzeug then return nil end
local spawn = job.fahrzeug.spawn
local fahrzeug = CreateVehicle(GetHashKey(job.fahrzeug.modell),
spawn.x, spawn.y, spawn.z, spawn.w, true, true)
local versuche = 0
while not DoesEntityExist(fahrzeug) and versuche < 50 do
Wait(10)
versuche = versuche + 1
end
if not DoesEntityExist(fahrzeug) then return nil end
-- Kennzeichen als Dienstfahrzeug markieren
Entity(fahrzeug).state:set('aetherJob', {
job = job.id, besitzer = quelle,
}, true)
return fahrzeug
end
--- Entfernt das Dienstfahrzeug einer Schicht
local function EntferneFahrzeug(schicht)
if schicht and schicht.fahrzeug and DoesEntityExist(schicht.fahrzeug) then
DeleteEntity(schicht.fahrzeug)
end
end
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
local function CharakterVon(quelle)
return Core:HoleCharakterId(quelle)
end
--- Laedt den Fortschritt aller Jobs eines Charakters
--- @param charakterId number Charakter-ID
--- @return table [jobId] = { level, xp, verdient, auftraege }
local function HoleFortschritt(charakterId)
local zeilen = MySQL.query.await(
'SELECT job, level, xp, verdient, auftraege FROM aether_jobs WHERE `character` = ?',
{ charakterId }) or {}
local fortschritt = {}
-- Standardwerte fuer alle bekannten Jobs
for _, job in ipairs(Jobs.Liste) do
fortschritt[job.id] = { level = 1, xp = 0, verdient = 0, auftraege = 0 }
end
for _, zeile in ipairs(zeilen) do
if fortschritt[zeile.job] then
fortschritt[zeile.job] = {
level = zeile.level, xp = zeile.xp,
verdient = zeile.verdient, auftraege = zeile.auftraege,
}
end
end
return fortschritt
end
--- Schreibt den Fortschritt eines Jobs zurueck
local function SpeichereFortschritt(charakterId, jobId, werte)
MySQL.query([[
INSERT INTO aether_jobs (`character`, job, level, xp, verdient, auftraege)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
level = VALUES(level), xp = VALUES(xp),
verdient = VALUES(verdient), auftraege = VALUES(auftraege)
]], { charakterId, jobId, werte.level, werte.xp, werte.verdient, werte.auftraege })
end
--- Baut den Zustand fuer die Oberflaeche
local function BaueZustand(quelle)
local charakterId = CharakterVon(quelle)
if not charakterId then return nil end
local schicht = schichten[quelle]
local aktiv = nil
if schicht then
local job = Jobs.Hole(schicht.jobId)
aktiv = {
jobId = schicht.jobId,
schrittIdx = schicht.schrittIdx,
aktuell = schicht.auftrag,
verdient = schicht.verdient,
auftraege = schicht.auftraege,
dienstzeit = math.floor((os.time() - schicht.dienstBeginn)),
schritte = job and job.schritte or {},
}
end
local person = Core:HoleSpielerInfo(quelle)
return {
jobs = Jobs.Liste,
fortschritt = HoleFortschritt(charakterId),
aktiv = aktiv,
xpProLevel = Jobs.XP_PRO_LEVEL,
schrittDauer= Jobs.SCHRITT_DAUER,
geld = {
bargeld = person and person.bargeld or 0,
konto = person and person.konto or 0,
},
}
end
local function SendeZustand(quelle, hinweis)
local zustand = BaueZustand(quelle)
if not zustand then return end
zustand.hinweis = hinweis
TriggerClientEvent('aether:jobs:zustand', quelle, zustand)
end
local function Ablehnen(quelle, grund)
TriggerClientEvent('aether:jobs:fehler', quelle, grund)
end
-- ---------------------------------------------------------------------
-- Jobcenter oeffnen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:jobs:oeffnen', function()
local quelle = source
if not CharakterVon(quelle) then return end
-- Am Jobcenter oder bereits im Dienst (dann von ueberall einsehbar)
if not schichten[quelle] then
local position = GetEntityCoords(GetPlayerPed(quelle))
if #(position - Jobs.JOBCENTER) > (Jobs.INTERAKTION_WEITE + 3.0) then
Ablehnen(quelle, 'Du bist nicht am Jobcenter.')
return
end
end
local zustand = BaueZustand(quelle)
if zustand then
TriggerClientEvent('aether:jobs:zeige', quelle, zustand)
end
end)
-- ---------------------------------------------------------------------
-- Dienst antreten
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:jobs:antreten', function(jobId)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId or type(jobId) ~= 'string' then return end
local job = Jobs.Hole(jobId)
if not job then return end
if schichten[quelle] then
Ablehnen(quelle, 'Du bist bereits im Dienst. Mach erst Feierabend.')
return
end
local fortschritt = HoleFortschritt(charakterId)
local level = fortschritt[jobId].level
schichten[quelle] = {
jobId = jobId,
auftrag = Jobs.ErzeugeAuftrag(jobId, level),
schrittIdx = 0,
schrittSeit = 0,
dienstBeginn = os.time(),
verdient = 0,
auftraege = 0,
fahrzeug = nil,
}
-- Dienstfahrzeug bereitstellen, sofern der Job eines vorsieht
if job.fahrzeug then
schichten[quelle].fahrzeug = StelleFahrzeug(quelle, job)
if schichten[quelle].fahrzeug then
TriggerClientEvent('aether:jobs:fahrzeugBereit', quelle,
NetworkGetNetworkIdFromEntity(schichten[quelle].fahrzeug),
job.fahrzeug.spawn)
end
end
SendeZustand(quelle, 'Dienst angetreten: ' .. job.name)
end)
-- ---------------------------------------------------------------------
-- Arbeitsschritt ausfuehren
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:jobs:schritt', function()
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
local schicht = schichten[quelle]
if not schicht then Ablehnen(quelle, 'Du bist nicht im Dienst.') return end
local job = Jobs.Hole(schicht.jobId)
if not job then return end
local schrittDef = job.schritte[schicht.schrittIdx + 1]
if not schrittDef then return end
-- ---- Ortspruefung: Der Spieler muss wirklich dort stehen ----
local ziel = schicht.auftrag.orte and schicht.auftrag.orte[schicht.schrittIdx + 1]
if not ziel then return end
local position = GetEntityCoords(GetPlayerPed(quelle))
local abstand = #(position - vector3(ziel.position.x, ziel.position.y, ziel.position.z))
if abstand > (Jobs.ORT_RADIUS + 2.0) then
Ablehnen(quelle, ('Du bist nicht am Arbeitsort (%s).'):format(ziel.name))
return
end
-- ---- Zeitpruefung: Die Handlung braucht ihre vorgesehene Dauer ----
local dauer = schrittDef.dauer or 4000
local jetzt = GetGameTimer()
if schicht.schrittSeit > 0 and (jetzt - schicht.schrittSeit) < (dauer - 500) then
Ablehnen(quelle, 'Zu schnell — arbeite den Schritt ab.')
return
end
schicht.schrittSeit = jetzt
schicht.schrittIdx = schicht.schrittIdx + 1
-- Noch nicht der letzte Schritt? Dann nur weiterzaehlen.
if schicht.schrittIdx < #job.schritte then
SendeZustand(quelle)
return
end
-- ---- Auftrag abgeschlossen: bezahlen und Erfahrung gutschreiben ----
local auftrag = schicht.auftrag
local fortschritt = HoleFortschritt(charakterId)
local werte = fortschritt[schicht.jobId]
-- Lohn kommt aus dem serverseitig erzeugten Auftrag
Core:FuegeGeldHinzu(quelle, 'bargeld', auftrag.geld, 'Lohn ' .. job.name)
werte.xp = werte.xp + auftrag.xp
werte.verdient = werte.verdient + auftrag.geld
werte.auftraege = werte.auftraege + 1
local aufgestiegen = false
while werte.xp >= Jobs.XP_PRO_LEVEL and werte.level < Jobs.MAX_LEVEL do
werte.xp = werte.xp - Jobs.XP_PRO_LEVEL
werte.level = werte.level + 1
aufgestiegen = true
end
-- Auf Hoechstlevel keine Erfahrung mehr ansammeln
if werte.level >= Jobs.MAX_LEVEL then werte.xp = 0 end
SpeichereFortschritt(charakterId, schicht.jobId, werte)
schicht.verdient = schicht.verdient + auftrag.geld
schicht.auftraege = schicht.auftraege + 1
schicht.schrittIdx = 0
schicht.auftrag = Jobs.ErzeugeAuftrag(schicht.jobId, werte.level)
local hinweis = ('Auftrag erledigt · +%d €'):format(auftrag.geld)
if aufgestiegen then
hinweis = hinweis .. (' · Level %d erreicht!'):format(werte.level)
end
SendeZustand(quelle, hinweis)
end)
-- ---------------------------------------------------------------------
-- Feierabend
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:jobs:feierabend', function()
local quelle = source
local schicht = schichten[quelle]
if not schicht then return end
local job = Jobs.Hole(schicht.jobId)
local dauer = math.floor((os.time() - schicht.dienstBeginn) / 60)
EntferneFahrzeug(schicht)
schichten[quelle] = nil
SendeZustand(quelle, ('Feierabend · %d € in %d Minuten verdient.')
:format(schicht.verdient, dauer))
if job then
Aether.Debug(('%s hat den Dienst als %s beendet (%d €).')
:format(GetPlayerName(quelle), job.name, schicht.verdient))
end
end)
-- ---------------------------------------------------------------------
-- Aufraeumen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:jobs:schliessen', function()
-- Die Schicht laeuft weiter, nur die Oberflaeche wird geschlossen
end)
AddEventHandler('playerDropped', function()
EntferneFahrzeug(schichten[source])
schichten[source] = nil
end)
--- Beim Stoppen der Ressource alle Dienstfahrzeuge entfernen
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
for _, schicht in pairs(schichten) do
EntferneFahrzeug(schicht)
end
end)
-- =====================================================================
-- EXPORTS
-- =====================================================================
--- Liefert den aktuellen Job eines Spielers (oder nil)
exports('HoleAktivenJob', function(quelle)
local schicht = schichten[quelle]
return schicht and schicht.jobId or nil
end)
--- Liefert das Level eines Spielers in einem Job
exports('HoleLevel', function(quelle, jobId)
local charakterId = CharakterVon(quelle)
if not charakterId then return 0 end
local fortschritt = HoleFortschritt(charakterId)
return fortschritt[jobId] and fortschritt[jobId].level or 1
end)

View File

@ -0,0 +1,271 @@
-- =====================================================================
-- Aether Jobs — Jobdefinitionen, Arbeitsorte und Auftragsgenerator
--
-- Jeder Arbeitsschritt hat einen ECHTEN Ort in der Welt. Der Spieler
-- muss hinfahren; der Server prueft die Entfernung, bevor er bezahlt.
-- =====================================================================
Jobs = Jobs or {}
Jobs.XP_PRO_LEVEL = 120 -- Erfahrung bis zum naechsten Level
Jobs.MAX_LEVEL = 20 -- Hoechstlevel je Job
-- Wie nah man am Arbeitsort stehen muss (Meter)
Jobs.ORT_RADIUS = 4.0
-- Reichweite zum Jobcenter (Meter)
Jobs.INTERAKTION_WEITE = 2.0
-- Standort des Jobcenters (Innenstadt)
Jobs.JOBCENTER = vector3(-265.0, -963.5, 31.2)
-- ---------------------------------------------------------------------
-- Abgabeorte im Stadtgebiet
-- Werden fuer Zustellungen, Taxifahrten und Frachten zufaellig gewaehlt.
-- ---------------------------------------------------------------------
Jobs.Abgabeorte = {
{ name = 'Legion Square', pos = vector3(195.9, -933.2, 30.7) },
{ name = 'Vinewood Blvd', pos = vector3(297.4, 180.6, 104.4) },
{ name = 'Vespucci Beach', pos = vector3(-1233.6, -1490.3, 4.4) },
{ name = 'Grove Street', pos = vector3(105.8, -1940.0, 21.3) },
{ name = 'Sandy Shores', pos = vector3(1961.0, 3740.5, 32.3) },
{ name = 'Del Perro', pos = vector3(-1305.6, -394.2, 36.7) },
{ name = 'Mirror Park', pos = vector3(1136.5, -982.3, 46.0) },
{ name = 'Downtown', pos = vector3(-247.5, -883.4, 31.2) },
{ name = 'Rockford Hills', pos = vector3(-1108.5, -455.2, 36.2) },
{ name = 'Paleto Bay', pos = vector3(-127.0, 6386.0, 31.5) },
}
-- Zufallsdaten fuer Fang und Erz
Jobs.Fische = {
{ n = 'alter Stiefel', w = 5 }, { n = 'Hering', w = 55 }, { n = 'Makrele', w = 85 },
{ n = 'Wolfsbarsch', w = 130 }, { n = 'Lachs', w = 190 }, { n = 'Thunfisch', w = 340 },
}
Jobs.Erze = {
{ n = 'Kohle', w = 45 }, { n = 'Eisenerz', w = 80 }, { n = 'Kupfererz', w = 120 },
{ n = 'Silbererz', w = 220 }, { n = 'Goldnugget', w = 480 },
}
-- ---------------------------------------------------------------------
-- Jobs
--
-- Je Schritt:
-- text Aufgabenbeschreibung
-- btn Beschriftung der Aktion
-- ort fester vector3 ODER 'zufaellig' (aus Jobs.Abgabeorte)
-- ortName Anzeigename bei festem Ort
-- dauer Wie lange die Handlung dauert (ms)
-- szenario GTA-Szenario, das dabei gespielt wird (optional)
--
-- fahrzeug: Dienstfahrzeug, das beim Antreten gestellt wird
-- ---------------------------------------------------------------------
Jobs.Liste = {
{
id = 'liefer', name = 'Lieferdienst', icon = '📦', kat = 'Service', farbe = '#4aa8ff',
beschreibung = 'Pakete am Depot laden und im Stadtgebiet ausliefern.',
lohnVon = 120, lohnBis = 180, xp = 30,
fahrzeug = { modell = 'boxville2', spawn = vector4(-424.9, -2789.5, 6.0, 90.0) },
schritte = {
{ text = 'Fahre zum Depot und lade das Paket ein.', btn = '📦 Paket laden',
ort = vector3(-419.5, -2792.3, 6.0), ortName = 'Depot Terminal',
dauer = 5000, szenario = 'PROP_HUMAN_BUM_BIN' },
{ text = 'Liefere das Paket beim Kunden aus.', btn = '✅ Zustellen',
ort = 'zufaellig', dauer = 4000, szenario = 'WORLD_HUMAN_CLIPBOARD' },
},
},
{
id = 'muell', name = 'Müllabfuhr', icon = '🗑️', kat = 'Service', farbe = '#7bc96f',
beschreibung = 'Mülltonnen einsammeln und am Wertstoffhof entladen.',
lohnVon = 90, lohnBis = 140, xp = 26,
fahrzeug = { modell = 'trash', spawn = vector4(-343.1, -1560.2, 25.5, 270.0) },
schritte = {
{ text = 'Fahre die Route ab und sammle die Tonnen ein.', btn = '🗑️ Tonnen leeren',
ort = 'zufaellig', dauer = 6000, szenario = 'PROP_HUMAN_BUM_BIN' },
{ text = 'Entlade am Wertstoffhof.', btn = '♻️ Entladen',
ort = vector3(-351.6, -1546.1, 25.2), ortName = 'Wertstoffhof',
dauer = 5000, szenario = 'WORLD_HUMAN_CLIPBOARD' },
},
},
{
id = 'taxi', name = 'Taxi', icon = '🚕', kat = 'Transport', farbe = '#facc15',
beschreibung = 'Fahrgäste abholen und ans Ziel bringen.',
lohnVon = 80, lohnBis = 250, xp = 22,
fahrzeug = { modell = 'taxi', spawn = vector4(895.8, -179.1, 74.7, 240.0) },
schritte = {
{ text = 'Hole den Fahrgast ab.', btn = '🧍 Fahrgast aufnehmen',
ort = 'zufaellig', dauer = 3000 },
{ text = 'Bringe den Fahrgast ans Ziel.', btn = '🏁 Fahrgast absetzen',
ort = 'zufaellig', dauer = 3000 },
},
},
{
id = 'trucker', name = 'Trucker', icon = '🚛', kat = 'Transport', farbe = '#f0684a',
beschreibung = 'Fracht über Land transportieren — gut bezahlt.',
lohnVon = 420, lohnBis = 680, xp = 45,
fahrzeug = { modell = 'mule3', spawn = vector4(151.6, -3211.0, 5.9, 90.0) },
schritte = {
{ text = 'Beladung am Lagerhaus vorbereiten.', btn = '📦 Beladen',
ort = vector3(158.7, -3212.5, 5.9), ortName = 'Hafenlager',
dauer = 8000, szenario = 'WORLD_HUMAN_CLIPBOARD' },
{ text = 'Fahre die Fracht zum Zielort.', btn = '🛣️ Ausliefern',
ort = 'zufaellig', dauer = 6000, szenario = 'WORLD_HUMAN_CLIPBOARD' },
},
},
{
id = 'tankwart', name = 'Tankwart', icon = '⛽', kat = 'Service', farbe = '#f5a742',
beschreibung = 'Kunden an der Zapfsäule bedienen und abkassieren.',
lohnVon = 100, lohnBis = 160, xp = 24,
schritte = {
{ text = 'Bediene die Zapfsäule für den Kunden.', btn = '⛽ Betanken',
ort = vector3(265.1, -1261.3, 29.3), ortName = 'Tankstelle Strawberry',
dauer = 6000, szenario = 'WORLD_HUMAN_VEHICLE_MECHANIC' },
{ text = 'Kassiere an der Kasse.', btn = '💳 Kassieren',
ort = vector3(263.9, -1256.4, 29.6), ortName = 'Kasse',
dauer = 3000, szenario = 'WORLD_HUMAN_CLIPBOARD' },
},
},
{
id = 'angeln', name = 'Angeln', icon = '🎣', kat = 'Natur', farbe = '#3fc7e0',
beschreibung = 'Am Pier angeln und den Fang am Stand verkaufen.',
lohnVon = 5, lohnBis = 340, xp = 18,
schritte = {
{ text = 'Wirf die Angel aus und warte auf einen Biss.', btn = '🎣 Angeln',
ort = vector3(-1850.1, -1245.9, 8.6), ortName = 'Pier Vespucci',
dauer = 12000, szenario = 'WORLD_HUMAN_STAND_FISHING' },
{ text = 'Verkaufe den Fang am Fischstand.', btn = '💰 Verkaufen',
ort = vector3(-1817.5, -1193.6, 14.3), ortName = 'Fischstand',
dauer = 3000, szenario = 'WORLD_HUMAN_CLIPBOARD' },
},
},
{
id = 'holz', name = 'Holzfäller', icon = '🪓', kat = 'Natur', farbe = '#a3703f',
beschreibung = 'Bäume fällen und die Stämme zum Sägewerk bringen.',
lohnVon = 105, lohnBis = 280, xp = 28,
schritte = {
{ text = 'Fälle den markierten Baum.', btn = '🪓 Baum fällen',
ort = vector3(-552.6, 5324.9, 73.6), ortName = 'Wald bei Paleto',
dauer = 10000, szenario = 'WORLD_HUMAN_HAMMERING' },
{ text = 'Bringe die Stämme zum Sägewerk.', btn = '🪵 Abliefern',
ort = vector3(-501.3, 5305.5, 79.5), ortName = 'Sägewerk',
dauer = 5000, szenario = 'WORLD_HUMAN_CLIPBOARD' },
},
},
{
id = 'bergbau', name = 'Bergbau', icon = '⛏️', kat = 'Natur', farbe = '#9a9aa0',
beschreibung = 'Erz in der Mine abbauen, einschmelzen und verkaufen.',
lohnVon = 45, lohnBis = 480, xp = 30,
schritte = {
{ text = 'Baue das Erz in der Mine ab.', btn = '⛏️ Erz abbauen',
ort = vector3(2949.6, 2792.2, 40.9), ortName = 'Mine Sandy Shores',
dauer = 12000, szenario = 'WORLD_HUMAN_HAMMERING' },
{ text = 'Schmelze das Erz ein und verkaufe es.', btn = '🔥 Einschmelzen',
ort = vector3(1109.4, -2007.9, 31.0), ortName = 'Schmelze Cypress Flats',
dauer = 8000, szenario = 'WORLD_HUMAN_WELDING' },
},
},
}
--- Liefert die Definition eines Jobs
--- @param jobId string Job-Kennung
--- @return table|nil
function Jobs.Hole(jobId)
for _, job in ipairs(Jobs.Liste) do
if job.id == jobId then return job end
end
return nil
end
local function zufall(a, b) return math.random(a, b) end
local function waehle(liste) return liste[math.random(#liste)] end
--- Loest die Orte aller Schritte eines Auftrags zu echten Koordinaten auf.
--- Wird nur serverseitig aufgerufen.
--- @param job table Jobdefinition
--- @return table Liste { position = {x,y,z}, name = ... } je Schritt
local function LoeseOrteAuf(job)
local orte = {}
local bereitsGewaehlt = nil
for _, schritt in ipairs(job.schritte) do
if schritt.ort == 'zufaellig' then
-- Nicht zweimal denselben Ort im selben Auftrag verwenden
local ziel
repeat ziel = waehle(Jobs.Abgabeorte)
until not bereitsGewaehlt or ziel.name ~= bereitsGewaehlt or #Jobs.Abgabeorte < 2
bereitsGewaehlt = ziel.name
orte[#orte + 1] = {
position = { x = ziel.pos.x, y = ziel.pos.y, z = ziel.pos.z },
name = ziel.name,
}
else
orte[#orte + 1] = {
position = { x = schritt.ort.x, y = schritt.ort.y, z = schritt.ort.z },
name = schritt.ortName or 'Arbeitsort',
}
end
end
return orte
end
--- Erzeugt einen Auftrag fuer einen Job.
--- Wird ausschliesslich serverseitig aufgerufen — Lohn und Orte duerfen
--- nie vom Client kommen.
--- @param jobId string Job-Kennung
--- @param level number Aktuelles Level des Spielers
--- @return table|nil Auftrag { ziel, geld, xp, info?, fang?, orte }
function Jobs.ErzeugeAuftrag(jobId, level)
local job = Jobs.Hole(jobId)
if not job then return nil end
local bonus = 1 + (math.max(1, level) - 1) * 0.05
local orte = LoeseOrteAuf(job)
local auftrag
if jobId == 'taxi' then
auftrag = {
ziel = orte[1].name .. ' → ' .. orte[2].name,
geld = math.floor(zufall(80, 250) * bonus + 0.5),
xp = job.xp,
}
elseif jobId == 'angeln' then
local fisch = waehle(Jobs.Fische)
auftrag = {
ziel = 'Pier Vespucci',
geld = math.floor(fisch.w * (1 + (level - 1) * 0.06) + 0.5),
xp = job.xp,
fang = fisch.n,
}
elseif jobId == 'holz' then
local anzahl = zufall(3, 8)
auftrag = {
ziel = 'Wald bei Paleto',
geld = math.floor(anzahl * 35 * bonus + 0.5),
xp = job.xp,
fang = anzahl .. ' Stämme',
}
elseif jobId == 'bergbau' then
local erz = waehle(Jobs.Erze)
local anzahl = zufall(2, 6)
auftrag = {
ziel = 'Mine Sandy Shores',
geld = math.floor(erz.w * anzahl * 0.5 * bonus + 0.5),
xp = job.xp,
fang = anzahl .. '× ' .. erz.n,
}
else
local vorsatz = {
liefer = 'Zustellung: ',
muell = 'Route: ',
trucker = 'Fracht nach ',
tankwart = 'Schicht an der ',
}
-- Beim Standardauftrag benennt der letzte Ort das Ziel
auftrag = {
ziel = (vorsatz[jobId] or '') .. orte[#orte].name,
geld = math.floor(zufall(job.lohnVon, job.lohnBis) * bonus + 0.5),
xp = job.xp,
}
end
auftrag.orte = orte
return auftrag
end

View File

@ -0,0 +1,19 @@
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
name 'aether-loading'
author 'Jerrit Fritzsche'
description 'Ladebildschirm des Aether-Servers'
version '0.1.0'
-- Diese Datei wird beim Verbinden als Ladebildschirm angezeigt
loadscreen 'html/index.html'
-- Der Ladebildschirm bleibt sichtbar, bis der Core ihn per
-- ShutdownLoadingScreen() beendet (nachdem der Charakter gespawnt ist)
loadscreen_manual_shutdown 'yes'
files {
'html/index.html',
}

View File

@ -0,0 +1,380 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Aether — Ladebildschirm</title>
<style>
:root{
--bg:#07070c; /* Grundhintergrund (sehr dunkel) */
--fg:#ece9f6; /* Standard-Textfarbe */
--muted:#8b86a6; /* gedämpfter Text (Labels/Sekundär) */
--surface:rgba(24,22,38,0.72); /* Panel-Hintergrund */
--surface2:rgba(38,34,60,0.85); /* hellere Panel-Fläche */
--border:rgba(255,255,255,0.08); /* dezente Rahmen */
--accent:#7c5cf6; /* Aether-Akzent (Violett) */
--accent2:#5b8cff; /* zweiter Akzent (Blau) für Verläufe */
--accent-soft:rgba(124,92,246,0.16); /* Akzent als Fläche */
--good:#34d39a; /* Erfolg/Bereit */
--ink:#160c2e; /* Text auf hellem Akzent */
}
*{box-sizing:border-box;}
html,body{margin:0;height:100%;}
body{
height:100%;background:var(--bg);color:var(--fg);overflow:hidden;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
/* Vollflächiger Partikel-/Aurora-Hintergrund */
#bg{position:fixed;inset:0;z-index:0;}
.aurora{position:fixed;inset:-20% ;z-index:0;filter:blur(60px);opacity:0.55;pointer-events:none;
background:
radial-gradient(40% 40% at 20% 30%,rgba(124,92,246,0.55),transparent 60%),
radial-gradient(35% 45% at 80% 25%,rgba(91,140,255,0.45),transparent 60%),
radial-gradient(45% 40% at 60% 85%,rgba(124,92,246,0.35),transparent 60%);
animation:auroraMove 18s ease-in-out infinite alternate;}
@keyframes auroraMove{from{transform:translate3d(-2%,-1%,0) scale(1);}to{transform:translate3d(3%,2%,0) scale(1.08);}}
/* Dunkler Vignetten-Verlauf für Lesbarkeit über dem Hintergrund */
.vignette{position:fixed;inset:0;z-index:1;pointer-events:none;
background:radial-gradient(120% 90% at 50% 40%,transparent 30%,rgba(4,4,9,0.75) 100%);}
/* Zentrales Layout-Raster: Kopf · Mitte · Fuß */
.screen{position:relative;z-index:2;height:100%;display:grid;
grid-template-rows:auto 1fr auto;padding:34px clamp(24px,5vw,72px);gap:18px;}
/* Kopfzeile: Marke links, Server-Infos rechts */
.top{display:flex;align-items:center;justify-content:space-between;gap:20px;}
.brand{display:flex;align-items:center;gap:14px;}
.brand .mark{width:52px;height:52px;border-radius:15px;display:grid;place-items:center;font-size:26px;
background:linear-gradient(150deg,var(--accent),var(--accent2));box-shadow:0 12px 30px -8px rgba(124,92,246,0.6);}
.brand .name{font-size:24px;font-weight:800;letter-spacing:-0.02em;line-height:1;}
.brand .name span{background:linear-gradient(90deg,var(--accent),var(--accent2));-webkit-background-clip:text;background-clip:text;color:transparent;}
.brand .name small{display:block;font-size:11px;color:var(--muted);font-weight:600;margin-top:5px;letter-spacing:0.14em;text-transform:uppercase;}
.serverinfo{display:flex;gap:10px;}
.stat{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:9px 14px;text-align:right;backdrop-filter:blur(8px);}
.stat .l{font-size:9.5px;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;font-weight:800;}
.stat .v{font-size:16px;font-weight:800;font-variant-numeric:tabular-nums;}
.stat .v .on{color:var(--good);}
/* Mittelbereich: großer Titel + rotierendes Info-Karussell */
.middle{display:grid;grid-template-columns:1.1fr 0.9fr;align-items:center;gap:40px;}
.hero .kicker{color:var(--accent);font-weight:800;letter-spacing:0.18em;text-transform:uppercase;font-size:12px;margin-bottom:14px;}
.hero h1{font-size:clamp(46px,7vw,86px);margin:0;line-height:0.95;font-weight:900;letter-spacing:-0.03em;
background:linear-gradient(120deg,#fff 20%,var(--accent) 60%,var(--accent2));-webkit-background-clip:text;background-clip:text;color:transparent;}
.hero .sub{color:var(--muted);font-size:16px;margin-top:16px;max-width:460px;line-height:1.5;}
/* Karussell für News / Regeln / Tipps */
.carousel{background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:24px;backdrop-filter:blur(10px);min-height:196px;
box-shadow:0 30px 60px -30px rgba(0,0,0,0.8);display:flex;flex-direction:column;}
.carousel .tag{display:inline-flex;align-items:center;gap:7px;font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:0.06em;
color:var(--accent);background:var(--accent-soft);padding:5px 11px;border-radius:999px;align-self:flex-start;margin-bottom:14px;}
.carousel h3{margin:0 0 8px;font-size:20px;font-weight:800;letter-spacing:-0.01em;}
.carousel p{margin:0;color:var(--muted);font-size:14px;line-height:1.55;}
.carousel .slide{animation:fadeUp .5s ease;flex:1;}
@keyframes fadeUp{from{opacity:0;transform:translateY(10px);}to{opacity:1;transform:none;}}
.dots{display:flex;gap:7px;margin-top:16px;}
.dots i{width:7px;height:7px;border-radius:50%;background:var(--border);transition:.25s;cursor:pointer;}
.dots i.active{background:var(--accent);width:22px;border-radius:4px;}
/* Fußbereich: Ladefortschritt + Musik-Player */
.bottom{display:flex;flex-direction:column;gap:14px;}
.loadrow{display:flex;align-items:center;gap:16px;}
.loadinfo{flex:1;min-width:0;}
.loadhead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:9px;}
.loadhead .status{font-size:13.5px;font-weight:600;color:var(--fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.loadhead .status .file{color:var(--muted);font-weight:500;}
.loadhead .pct{font-size:15px;font-weight:800;font-variant-numeric:tabular-nums;color:var(--accent);}
.bar{height:8px;border-radius:99px;background:rgba(255,255,255,0.07);overflow:hidden;position:relative;}
.bar .fill{height:100%;width:0;border-radius:99px;background:linear-gradient(90deg,var(--accent),var(--accent2));
box-shadow:0 0 16px rgba(124,92,246,0.7);transition:width .35s cubic-bezier(.4,0,.2,1);position:relative;}
/* Wandernder Glanz auf dem Balken */
.bar .fill::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,0.4),transparent);
transform:translateX(-100%);animation:sheen 1.6s linear infinite;}
@keyframes sheen{to{transform:translateX(100%);}}
/* Musik-Player (WebAudio, ohne externe Datei) */
.player{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);
border-radius:14px;padding:9px 12px 9px 9px;backdrop-filter:blur(8px);min-width:270px;}
.player .cover{width:40px;height:40px;border-radius:10px;flex:none;display:grid;place-items:center;font-size:19px;
background:linear-gradient(150deg,var(--accent),var(--accent2));position:relative;overflow:hidden;}
.player .cover.playing::before{content:"";position:absolute;inset:0;background:radial-gradient(circle,rgba(255,255,255,0.35),transparent 70%);animation:pulse 1.4s ease-in-out infinite;}
@keyframes pulse{50%{opacity:0.4;}}
.player .meta{min-width:0;flex:1;}
.player .meta .t{font-size:12.5px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.player .meta .a{font-size:10.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.pbtn{width:34px;height:34px;flex:none;border-radius:50%;border:1px solid var(--border);background:var(--surface2);color:var(--fg);
cursor:pointer;display:grid;place-items:center;font-size:14px;transition:.12s;}
.pbtn:hover{border-color:var(--accent);color:var(--accent);}
.pbtn.play{background:var(--accent);color:var(--ink);border-color:transparent;}
.vol{width:66px;accent-color:var(--accent);cursor:pointer;}
/* „Bereit"-Zustand, wenn das Laden 100 % erreicht */
.ready-glow{animation:readyPulse 1.6s ease-in-out infinite;}
@keyframes readyPulse{50%{text-shadow:0 0 22px rgba(52,211,153,0.7);}}
/* Kompakteres Layout auf schmalen Fenstern */
@media (max-width:900px){
.middle{grid-template-columns:1fr;gap:22px;}
.serverinfo .stat:nth-child(3){display:none;}
.loadrow{flex-direction:column;align-items:stretch;}
.player{min-width:0;}
}
</style>
</head>
<body>
<canvas id="bg"></canvas>
<div class="aurora"></div>
<div class="vignette"></div>
<div class="screen">
<!-- Kopfzeile: Marke + Server-Statistiken -->
<header class="top">
<div class="brand">
<span class="mark">◆</span>
<div class="name"><span>AETHER</span><small>Los Santos Roleplay</small></div>
</div>
<div class="serverinfo" id="serverinfo"></div>
</header>
<!-- Mittelbereich: Titel + rotierendes Karussell -->
<main class="middle">
<div class="hero">
<div class="kicker">Willkommen zurück</div>
<h1>Deine<br>Geschichte<br>beginnt.</h1>
<p class="sub">Steig ein in das Leben von Los Santos — dein Charakter, deine Entscheidungen, deine Regeln. Bitte warte, während die Welt geladen wird.</p>
</div>
<div class="carousel" id="carousel"></div>
</main>
<!-- Fußbereich: Fortschrittsbalken + Musik-Player -->
<footer class="bottom">
<div class="loadrow">
<div class="loadinfo">
<div class="loadhead">
<div class="status" id="status">Verbinde mit dem Server…</div>
<div class="pct" id="pct">0%</div>
</div>
<div class="bar"><div class="fill" id="fill"></div></div>
</div>
<div class="player" id="player">
<div class="cover" id="cover">♪</div>
<div class="meta">
<div class="t" id="trackTitle">Aether Ambient</div>
<div class="a" id="trackArtist">Lobby · Loop</div>
</div>
<button class="pbtn play" id="playBtn" title="Wiedergabe / Pause">▶</button>
<button class="pbtn" id="nextBtn" title="Nächster Titel">⏭</button>
<input class="vol" id="vol" type="range" min="0" max="100" value="45" title="Lautstärke">
</div>
</div>
</footer>
</div>
<script>
"use strict";
/* Kurzer Zugriffshelfer auf Elemente per ID */
const $=id=>document.getElementById(id);
/* =====================================================================
SERVER-DATEN (im echten Betrieb vom Server geliefert / per NUI gesetzt)
Bewusst vom UI getrennt gehalten.
===================================================================== */
const server={
spielerAktuell:184, // aktuell verbundene Spieler
spielerMax:256, // Slot-Limit des Servers
voiceKanal:"TeamSpeak/PMA-Voice", // genutztes Voice-System
discord:"discord.gg/aether", // Community-Link (nur Anzeige)
};
/* Karussell-Inhalte: Nachrichten, Regeln und Spieltipps im Wechsel */
const karussell=[
{tag:"📰 News", titel:"Neues Immobilien-Update", text:"Ab sofort lassen sich Wohnungen möbeln und Hausschlüssel beim Schlüsseldienst nachmachen. Schau im Markt (AetherImmos) vorbei."},
{tag:"📜 Regel", titel:"Kein RDM / VDM", text:"Töten oder Anfahren ohne Rollenspiel-Grund ist verboten. Jede Gewalt braucht eine nachvollziehbare Vorgeschichte."},
{tag:"💡 Tipp", titel:"Ausweis dabeihaben", text:"Öffne deine Börse und zeig Behörden bei Kontrollen deinen Personalausweis — ohne Papiere wird's teuer."},
{tag:"📜 Regel", titel:"Bleib in der Rolle", text:"Metagaming und Powergaming sind untersagt. Wissen aus Discord oder Stream gehört nicht in die Spielwelt."},
{tag:"💡 Tipp", titel:"Jobs bringen Startgeld", text:"Im Jobcenter findest du 8 legale Jobs. Mit steigendem Level steigt auch dein Lohn — ideal für den Einstieg."},
{tag:"📰 News", titel:"Aether Casino eröffnet", text:"Slots, Blackjack, Roulette, Glücksrad & Münzwurf — teste dein Glück, aber spiel mit Verantwortung."},
];
/* Musik-Titel für den Player (synthetisch erzeugt, ohne externe Datei) */
const playlist=[
{titel:"Aether Ambient", artist:"Lobby · Loop", akkord:[220.00,277.18,329.63]}, // A-Dur-artiges Pad
{titel:"Neon Nights", artist:"Downtown · Loop", akkord:[196.00,246.94,293.66]}, // G-Dur
{titel:"Midnight Drive", artist:"Vinewood · Loop", akkord:[174.61,220.00,261.63]}, // F-Dur
];
/* =====================================================================
SERVER-INFO & KARUSSELL RENDERN
===================================================================== */
/* Baut die Statistik-Kacheln in der Kopfzeile auf */
function renderServerInfo(){
$("serverinfo").innerHTML=
`<div class="stat"><div class="l">Spieler online</div><div class="v"><span class="on">${server.spielerAktuell}</span> / ${server.spielerMax}</div></div>
<div class="stat"><div class="l">Voice</div><div class="v" style="font-size:12.5px">${server.voiceKanal}</div></div>
<div class="stat"><div class="l">Discord</div><div class="v" style="font-size:12.5px">${server.discord}</div></div>`;
}
let slideIndex=0; // aktuell sichtbare Karussell-Folie
let slideTimer=null; // Timer für den automatischen Wechsel
/* Zeigt eine bestimmte Karussell-Folie und baut die Punkt-Navigation */
function zeigeSlide(i){
slideIndex=(i+karussell.length)%karussell.length;
const k=karussell[slideIndex];
$("carousel").innerHTML=
`<div class="slide"><span class="tag">${k.tag}</span><h3>${k.titel}</h3><p>${k.text}</p></div>
<div class="dots">${karussell.map((_,j)=>`<i class="${j===slideIndex?'active':''}" data-dot="${j}"></i>`).join('')}</div>`;
// Klick auf einen Punkt springt zur jeweiligen Folie
$("carousel").querySelectorAll("[data-dot]").forEach(d=>d.addEventListener("click",()=>{starteSlideAuto(+d.dataset.dot);}));
}
/* Startet den automatischen Folienwechsel (alle 5 s) ab einem Index */
function starteSlideAuto(i){
zeigeSlide(i);
clearInterval(slideTimer);
slideTimer=setInterval(()=>zeigeSlide(slideIndex+1),5000);
}
/* =====================================================================
LADEFORTSCHRITT (Simulation; in FiveM von echten Lade-Events getrieben)
===================================================================== */
/* Ressourcen-Namen, die als "wird geladen"-Status durchlaufen */
const ressourcen=["aether-core","aether-phone","aether-inventory","aether-bank","aether-hud",
"aether-character","aether-garage","aether-jobs","aether-police","aether-ems",
"aether-clothing","aether-immobilien","aether-documents","aether-shop","aether-admin","map-lospm"];
let fortschritt=0; // aktueller Fortschritt in Prozent (0–100)
let resIndex=0; // Index der gerade "geladenen" Ressource
/* Erhöht den Fortschritt schrittweise und aktualisiert Balken + Status */
function ladeSchritt(){
// Zufällige, gegen Ende kleinere Sprünge für einen realistischen Verlauf
const rest=100-fortschritt;
const zuwachs=Math.max(0.6,Math.min(rest,(Math.random()*9+2)*(rest/100)+0.6));
fortschritt=Math.min(100,fortschritt+zuwachs);
$("fill").style.width=fortschritt+"%";
$("pct").textContent=Math.round(fortschritt)+"%";
// Status-Text an die aktuelle Ressource koppeln
const zielIndex=Math.min(ressourcen.length-1,Math.floor(fortschritt/100*ressourcen.length));
if(zielIndex!==resIndex){resIndex=zielIndex;
$("status").innerHTML=`Lade Ressource <span class="file">${ressourcen[resIndex]}</span>`;}
if(fortschritt>=100){ladenFertig();return;}
// Nächster Schritt nach zufälliger kurzer Pause
setTimeout(ladeSchritt,Math.random()*260+120);
}
/* Wird aufgerufen, sobald der Fortschritt 100 % erreicht */
function ladenFertig(){
$("status").innerHTML=`<span class="ready-glow" style="color:var(--good);font-weight:800">✓ Bereit — willkommen in Los Santos</span>`;
$("pct").style.color="var(--good)";
$("fill").style.background="linear-gradient(90deg,var(--good),var(--accent2))";
}
/* =====================================================================
MUSIK-PLAYER (WebAudio — sanftes Ambient-Pad, kein externes Audio)
===================================================================== */
let audioCtx=null; // Web-Audio-Kontext (erst bei Nutzer-Interaktion erlaubt)
let masterGain=null; // Haupt-Lautstärke-Regler
let aktiveOsz=[]; // aktuell klingende Oszillatoren des Akkords
let spielt=false; // läuft die Wiedergabe gerade?
let trackIndex=0; // aktueller Titel aus der Playlist
/* Erzeugt für den aktuellen Titel ein weiches Akkord-Pad */
function starteTon(){
if(!audioCtx){
audioCtx=new (window.AudioContext||window.webkitAudioContext)();
masterGain=audioCtx.createGain();
masterGain.gain.value=(+$("vol").value/100)*0.18; // insgesamt dezent halten
masterGain.connect(audioCtx.destination);
}
stoppeOsz();
// Für jede Frequenz des Akkords einen sanften Sinus-Oszillator anlegen
playlist[trackIndex].akkord.forEach((freq,i)=>{
const osz=audioCtx.createOscillator();
const g=audioCtx.createGain();
osz.type="sine";
osz.frequency.value=freq;
// Leichtes Verstimmen für einen volleren, wärmeren Klang
osz.detune.value=(i-1)*4;
g.gain.value=0;
osz.connect(g); g.connect(masterGain);
osz.start();
// Weich einblenden
g.gain.linearRampToValueAtTime(0.33,audioCtx.currentTime+1.2);
// Langsames Schweben der Lautstärke (LFO) für lebendiges Pad
const lfo=audioCtx.createOscillator(), lfoG=audioCtx.createGain();
lfo.frequency.value=0.06+i*0.02; lfoG.gain.value=0.12;
lfo.connect(lfoG); lfoG.connect(g.gain); lfo.start();
aktiveOsz.push(osz,lfo);
});
}
/* Stoppt und verwirft alle laufenden Oszillatoren */
function stoppeOsz(){aktiveOsz.forEach(o=>{try{o.stop();}catch(e){}});aktiveOsz=[];}
/* Schaltet zwischen Wiedergabe und Pause um */
function togglePlay(){
spielt=!spielt;
if(spielt){starteTon();$("playBtn").textContent="⏸";$("cover").classList.add("playing");}
else {stoppeOsz();$("playBtn").textContent="▶";$("cover").classList.remove("playing");}
}
/* Wechselt zum nächsten Titel der Playlist */
function naechsterTitel(){
trackIndex=(trackIndex+1)%playlist.length;
$("trackTitle").textContent=playlist[trackIndex].titel;
$("trackArtist").textContent=playlist[trackIndex].artist;
if(spielt)starteTon(); // bei laufender Wiedergabe direkt neu anspielen
}
/* Player-Bedienelemente verknüpfen */
$("playBtn").addEventListener("click",togglePlay);
$("nextBtn").addEventListener("click",naechsterTitel);
$("vol").addEventListener("input",()=>{if(masterGain)masterGain.gain.value=(+$("vol").value/100)*0.18;});
/* =====================================================================
START
===================================================================== */
renderServerInfo();
starteSlideAuto(0);
// Kurze Verzögerung, damit der leere Balken sichtbar ist, bevor er wächst
setTimeout(ladeSchritt,600);
/* Hintergrund-Partikel: langsam aufsteigende Lichtpunkte auf dem Canvas */
const cv=$("bg"), ctx=cv.getContext("2d");
let partikel=[]; // Liste aller Partikel mit Position/Geschwindigkeit
/* Passt die Canvas-Größe an das Fenster an und erzeugt neue Partikel */
function initCanvas(){
cv.width=innerWidth; cv.height=innerHeight;
partikel=Array.from({length:Math.round(innerWidth/22)},()=>neuerPartikel(true));
}
/* Erzeugt einen einzelnen Partikel (optional an zufälliger Höhe) */
function neuerPartikel(zufallY){
return {
x:Math.random()*cv.width,
y:zufallY?Math.random()*cv.height:cv.height+10,
r:Math.random()*1.8+0.4, // Radius
v:Math.random()*0.4+0.15, // Aufstiegsgeschwindigkeit
a:Math.random()*0.4+0.15, // Deckkraft
drift:(Math.random()-0.5)*0.3, // seitliches Driften
};
}
/* Zeichnet einen Animationsschritt aller Partikel */
function zeichne(){
ctx.clearRect(0,0,cv.width,cv.height);
for(const p of partikel){
p.y-=p.v; p.x+=p.drift;
if(p.y<-10)Object.assign(p,neuerPartikel(false)); // oben raus → unten neu
ctx.beginPath();
ctx.arc(p.x,p.y,p.r,0,Math.PI*2);
ctx.fillStyle=`rgba(150,130,255,${p.a})`;
ctx.fill();
}
requestAnimationFrame(zeichne);
}
addEventListener("resize",initCanvas);
initCanvas(); zeichne();
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More