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:
parent
0c370eeab8
commit
e4b82dd685
21
.env.example
Normal file
21
.env.example
Normal 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
9
.gitignore
vendored
Normal 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
682
README.md
Normal 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
977
admin/index.html
Normal 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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- & 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
366
casino/index.html
Normal 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
227
clothing/index.html
Normal 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 & 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 & 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
68
docker-compose.yml
Normal 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
48
docker/README.md
Normal 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
88
docker/migrate.sh
Normal 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
381
factions/index.html
Normal 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 & Rechte</h2><p>${F().raenge.length} Ränge · Gehälter & 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 & 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 & 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
424
fuel/index.html
Normal 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
296
immobilien/index.html
Normal 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- & 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
404
minigames/index.html
Normal 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
294
radial/index.html
Normal 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>
|
||||
87
resources/[aether]/aether-admin/README.md
Normal file
87
resources/[aether]/aether-admin/README.md
Normal 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.
|
||||
50
resources/[aether]/aether-admin/client/kamera.lua
Normal file
50
resources/[aether]/aether-admin/client/kamera.lua
Normal 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)
|
||||
282
resources/[aether]/aether-admin/client/main.lua
Normal file
282
resources/[aether]/aether-admin/client/main.lua
Normal 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)
|
||||
62
resources/[aether]/aether-admin/fxmanifest.lua
Normal file
62
resources/[aether]/aether-admin/fxmanifest.lua
Normal 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',
|
||||
}
|
||||
99
resources/[aether]/aether-admin/html/css/app.css
Normal file
99
resources/[aether]/aether-admin/html/css/app.css
Normal 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;}
|
||||
28
resources/[aether]/aether-admin/html/index.html
Normal file
28
resources/[aether]/aether-admin/html/index.html
Normal 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>
|
||||
202
resources/[aether]/aether-admin/html/js/app.js
Normal file
202
resources/[aether]/aether-admin/html/js/app.js
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||||
488
resources/[aether]/aether-admin/html/js/sections.js
Normal file
488
resources/[aether]/aether-admin/html/js/sections.js
Normal 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"); } });
|
||||
});
|
||||
})();
|
||||
247
resources/[aether]/aether-admin/html/js/webrtc.js
Normal file
247
resources/[aether]/aether-admin/html/js/webrtc.js
Normal 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) };
|
||||
})();
|
||||
79
resources/[aether]/aether-admin/media-server/README.md
Normal file
79
resources/[aether]/aether-admin/media-server/README.md
Normal 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".
|
||||
18
resources/[aether]/aether-admin/media-server/package.json
Normal file
18
resources/[aether]/aether-admin/media-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
198
resources/[aether]/aether-admin/media-server/server.js
Normal file
198
resources/[aether]/aether-admin/media-server/server.js
Normal 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,
|
||||
};
|
||||
}
|
||||
95
resources/[aether]/aether-admin/server/auth.lua
Normal file
95
resources/[aether]/aether-admin/server/auth.lua
Normal 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)
|
||||
150
resources/[aether]/aether-admin/server/db.lua
Normal file
150
resources/[aether]/aether-admin/server/db.lua
Normal 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
|
||||
218
resources/[aether]/aether-admin/server/kamera.lua
Normal file
218
resources/[aether]/aether-admin/server/kamera.lua
Normal 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)
|
||||
89
resources/[aether]/aether-admin/server/kern.lua
Normal file
89
resources/[aether]/aether-admin/server/kern.lua
Normal 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
|
||||
69
resources/[aether]/aether-admin/server/log.lua
Normal file
69
resources/[aether]/aether-admin/server/log.lua
Normal 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
|
||||
80
resources/[aether]/aether-admin/server/main.lua
Normal file
80
resources/[aether]/aether-admin/server/main.lua
Normal 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)
|
||||
32
resources/[aether]/aether-admin/server/module/broadcast.lua
Normal file
32
resources/[aether]/aether-admin/server/module/broadcast.lua
Normal 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)
|
||||
59
resources/[aether]/aether-admin/server/module/dashboard.lua
Normal file
59
resources/[aether]/aether-admin/server/module/dashboard.lua
Normal 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)
|
||||
85
resources/[aether]/aether-admin/server/module/fahrzeuge.lua
Normal file
85
resources/[aether]/aether-admin/server/module/fahrzeuge.lua
Normal 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)
|
||||
88
resources/[aether]/aether-admin/server/module/fraktionen.lua
Normal file
88
resources/[aether]/aether-admin/server/module/fraktionen.lua
Normal 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)
|
||||
51
resources/[aether]/aether-admin/server/module/immobilien.lua
Normal file
51
resources/[aether]/aether-admin/server/module/immobilien.lua
Normal 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)
|
||||
17
resources/[aether]/aether-admin/server/module/logs.lua
Normal file
17
resources/[aether]/aether-admin/server/module/logs.lua
Normal 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)
|
||||
59
resources/[aether]/aether-admin/server/module/moderation.lua
Normal file
59
resources/[aether]/aether-admin/server/module/moderation.lua
Normal 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)
|
||||
114
resources/[aether]/aether-admin/server/module/reports.lua
Normal file
114
resources/[aether]/aether-admin/server/module/reports.lua
Normal 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)
|
||||
68
resources/[aether]/aether-admin/server/module/ressourcen.lua
Normal file
68
resources/[aether]/aether-admin/server/module/ressourcen.lua
Normal 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)
|
||||
48
resources/[aether]/aether-admin/server/module/settings.lua
Normal file
48
resources/[aether]/aether-admin/server/module/settings.lua
Normal 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)
|
||||
360
resources/[aether]/aether-admin/server/module/spieler.lua
Normal file
360
resources/[aether]/aether-admin/server/module/spieler.lua
Normal 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
|
||||
77
resources/[aether]/aether-admin/server/module/staat.lua
Normal file
77
resources/[aether]/aether-admin/server/module/staat.lua
Normal 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)
|
||||
77
resources/[aether]/aether-admin/server/module/team.lua
Normal file
77
resources/[aether]/aether-admin/server/module/team.lua
Normal 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)
|
||||
87
resources/[aether]/aether-admin/server/module/welt.lua
Normal file
87
resources/[aether]/aether-admin/server/module/welt.lua
Normal 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)
|
||||
31
resources/[aether]/aether-admin/server/module/weltkarte.lua
Normal file
31
resources/[aether]/aether-admin/server/module/weltkarte.lua
Normal 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)
|
||||
65
resources/[aether]/aether-admin/server/module/wirtschaft.lua
Normal file
65
resources/[aether]/aether-admin/server/module/wirtschaft.lua
Normal 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)
|
||||
193
resources/[aether]/aether-admin/shared/config.lua
Normal file
193
resources/[aether]/aether-admin/shared/config.lua
Normal 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
|
||||
180
resources/[aether]/aether-bank/client/main.lua
Normal file
180
resources/[aether]/aether-bank/client/main.lua
Normal 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)
|
||||
34
resources/[aether]/aether-bank/fxmanifest.lua
Normal file
34
resources/[aether]/aether-bank/fxmanifest.lua
Normal 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',
|
||||
}
|
||||
938
resources/[aether]/aether-bank/html/index.html
Normal file
938
resources/[aether]/aether-bank/html/index.html
Normal 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>
|
||||
295
resources/[aether]/aether-bank/server/main.lua
Normal file
295
resources/[aether]/aether-bank/server/main.lua
Normal 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)
|
||||
132
resources/[aether]/aether-character/client/aussehen.lua
Normal file
132
resources/[aether]/aether-character/client/aussehen.lua
Normal 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
|
||||
157
resources/[aether]/aether-character/client/main.lua
Normal file
157
resources/[aether]/aether-character/client/main.lua
Normal 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)
|
||||
189
resources/[aether]/aether-character/client/vorschau.lua
Normal file
189
resources/[aether]/aether-character/client/vorschau.lua
Normal 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
|
||||
39
resources/[aether]/aether-character/fxmanifest.lua
Normal file
39
resources/[aether]/aether-character/fxmanifest.lua
Normal 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',
|
||||
}
|
||||
519
resources/[aether]/aether-character/html/index.html
Normal file
519
resources/[aether]/aether-character/html/index.html
Normal 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 & 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>
|
||||
221
resources/[aether]/aether-character/server/main.lua
Normal file
221
resources/[aether]/aether-character/server/main.lua
Normal 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)
|
||||
165
resources/[aether]/aether-core/client/main.lua
Normal file
165
resources/[aether]/aether-core/client/main.lua
Normal 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)
|
||||
76
resources/[aether]/aether-core/client/nui.lua
Normal file
76
resources/[aether]/aether-core/client/nui.lua
Normal 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)
|
||||
32
resources/[aether]/aether-core/fxmanifest.lua
Normal file
32
resources/[aether]/aether-core/fxmanifest.lua
Normal 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',
|
||||
}
|
||||
131
resources/[aether]/aether-core/server/database.lua
Normal file
131
resources/[aether]/aether-core/server/database.lua
Normal 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
|
||||
305
resources/[aether]/aether-core/server/main.lua
Normal file
305
resources/[aether]/aether-core/server/main.lua
Normal 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)
|
||||
204
resources/[aether]/aether-core/server/player.lua
Normal file
204
resources/[aether]/aether-core/server/player.lua
Normal 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
|
||||
78
resources/[aether]/aether-core/shared/config.lua
Normal file
78
resources/[aether]/aether-core/shared/config.lua
Normal 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,
|
||||
}
|
||||
97
resources/[aether]/aether-core/shared/utils.lua
Normal file
97
resources/[aether]/aether-core/shared/utils.lua
Normal 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
|
||||
187
resources/[aether]/aether-death/client/main.lua
Normal file
187
resources/[aether]/aether-death/client/main.lua
Normal 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)
|
||||
34
resources/[aether]/aether-death/fxmanifest.lua
Normal file
34
resources/[aether]/aether-death/fxmanifest.lua
Normal 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',
|
||||
}
|
||||
405
resources/[aether]/aether-death/html/index.html
Normal file
405
resources/[aether]/aether-death/html/index.html
Normal 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>
|
||||
249
resources/[aether]/aether-death/server/main.lua
Normal file
249
resources/[aether]/aether-death/server/main.lua
Normal 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)
|
||||
39
resources/[aether]/aether-death/shared/config.lua
Normal file
39
resources/[aether]/aether-death/shared/config.lua
Normal 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
|
||||
129
resources/[aether]/aether-documents/client/main.lua
Normal file
129
resources/[aether]/aether-documents/client/main.lua
Normal 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)
|
||||
36
resources/[aether]/aether-documents/fxmanifest.lua
Normal file
36
resources/[aether]/aether-documents/fxmanifest.lua
Normal 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',
|
||||
}
|
||||
709
resources/[aether]/aether-documents/html/index.html
Normal file
709
resources/[aether]/aether-documents/html/index.html
Normal 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 & 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 & 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, "") + '<<<<<<<<<<<br>9603140M3403147SAA<<WEBER<<MAX<<<<<<<<</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>
|
||||
386
resources/[aether]/aether-documents/server/main.lua
Normal file
386
resources/[aether]/aether-documents/server/main.lua
Normal 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)
|
||||
49
resources/[aether]/aether-documents/shared/config.lua
Normal file
49
resources/[aether]/aether-documents/shared/config.lua
Normal 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
|
||||
330
resources/[aether]/aether-garage/client/main.lua
Normal file
330
resources/[aether]/aether-garage/client/main.lua
Normal 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)
|
||||
34
resources/[aether]/aether-garage/fxmanifest.lua
Normal file
34
resources/[aether]/aether-garage/fxmanifest.lua
Normal 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',
|
||||
}
|
||||
330
resources/[aether]/aether-garage/html/index.html
Normal file
330
resources/[aether]/aether-garage/html/index.html
Normal 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>
|
||||
511
resources/[aether]/aether-garage/server/main.lua
Normal file
511
resources/[aether]/aether-garage/server/main.lua
Normal 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)
|
||||
93
resources/[aether]/aether-garage/shared/garagen.lua
Normal file
93
resources/[aether]/aether-garage/shared/garagen.lua
Normal 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
|
||||
77
resources/[aether]/aether-hud/client/fahrzeug.lua
Normal file
77
resources/[aether]/aether-hud/client/fahrzeug.lua
Normal 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)
|
||||
195
resources/[aether]/aether-hud/client/main.lua
Normal file
195
resources/[aether]/aether-hud/client/main.lua
Normal 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)
|
||||
30
resources/[aether]/aether-hud/fxmanifest.lua
Normal file
30
resources/[aether]/aether-hud/fxmanifest.lua
Normal 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',
|
||||
}
|
||||
484
resources/[aether]/aether-hud/html/index.html
Normal file
484
resources/[aether]/aether-hud/html/index.html
Normal 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>
|
||||
164
resources/[aether]/aether-inventory/client/drops.lua
Normal file
164
resources/[aether]/aether-inventory/client/drops.lua
Normal 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)
|
||||
151
resources/[aether]/aether-inventory/client/main.lua
Normal file
151
resources/[aether]/aether-inventory/client/main.lua
Normal 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)
|
||||
38
resources/[aether]/aether-inventory/fxmanifest.lua
Normal file
38
resources/[aether]/aether-inventory/fxmanifest.lua
Normal 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',
|
||||
}
|
||||
772
resources/[aether]/aether-inventory/html/index.html
Normal file
772
resources/[aether]/aether-inventory/html/index.html
Normal 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 & 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ü & 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>
|
||||
369
resources/[aether]/aether-inventory/server/inventar.lua
Normal file
369
resources/[aether]/aether-inventory/server/inventar.lua
Normal 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
|
||||
403
resources/[aether]/aether-inventory/server/main.lua
Normal file
403
resources/[aether]/aether-inventory/server/main.lua
Normal 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)
|
||||
264
resources/[aether]/aether-inventory/shared/items.lua
Normal file
264
resources/[aether]/aether-inventory/shared/items.lua
Normal 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
|
||||
344
resources/[aether]/aether-jobs/client/main.lua
Normal file
344
resources/[aether]/aether-jobs/client/main.lua
Normal 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)
|
||||
34
resources/[aether]/aether-jobs/fxmanifest.lua
Normal file
34
resources/[aether]/aether-jobs/fxmanifest.lua
Normal 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',
|
||||
}
|
||||
340
resources/[aether]/aether-jobs/html/index.html
Normal file
340
resources/[aether]/aether-jobs/html/index.html
Normal 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>
|
||||
344
resources/[aether]/aether-jobs/server/main.lua
Normal file
344
resources/[aether]/aether-jobs/server/main.lua
Normal 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)
|
||||
271
resources/[aether]/aether-jobs/shared/jobs.lua
Normal file
271
resources/[aether]/aether-jobs/shared/jobs.lua
Normal 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
|
||||
19
resources/[aether]/aether-loading/fxmanifest.lua
Normal file
19
resources/[aether]/aether-loading/fxmanifest.lua
Normal 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',
|
||||
}
|
||||
380
resources/[aether]/aether-loading/html/index.html
Normal file
380
resources/[aether]/aether-loading/html/index.html
Normal 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
Loading…
Reference in New Issue
Block a user