first commit
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
|||||||
|
# Runtime
|
||||||
|
cache/
|
||||||
|
crashes/
|
||||||
|
logs/
|
||||||
|
txData/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
server.local.cfg
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Temporary release work
|
||||||
|
release-build/
|
||||||
|
tmp/
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.4.3
|
||||||
|
|
||||||
|
- Apartment- und Warehouse-IPLs werden nicht mehr global entfernt oder übereinander geladen.
|
||||||
|
- `devx_ipl` stellt Fokus, HD-Area und Kollision nach Interiorwechseln wieder her.
|
||||||
|
- Bankvorbereitungen nutzen eine No-IPL-Garage mit Missionsprops, Fahrzeugen und NPCs.
|
||||||
|
- Apartmentflur auf stabile No-IPL-Szene umgestellt.
|
||||||
|
- Freie Wohnungen werden im Flur nicht mehr als bewohnt dargestellt.
|
||||||
|
- Nur verkaufte Wohnungen erhalten Türschild, Klingeln und Klopfen.
|
||||||
|
- Eigentümer gelöschter Charaktere werden automatisch aus der Belegung entfernt.
|
||||||
|
- Apartment-Einladung über Spielerliste und Annahme/Ablehnung per E/X.
|
||||||
|
- Gast wird erst nach Annahme in die Wohnung teleportiert.
|
||||||
|
- Welt-Streaming wird beim Verlassen von Mission, Vorschau und Gebäude repariert.
|
||||||
|
|
||||||
|
## 0.4.2
|
||||||
|
|
||||||
|
- Apartment-IPLs werden nicht mehr global und übereinander geladen.
|
||||||
|
- Der gemeinsame Flur nutzt nur noch eine exklusive, exakt positionierte IPL-Variante.
|
||||||
|
- Interior-Streaming akzeptiert bei GTA-Online-IPLs auch den zuverlässigen IPL-/Scene-Status, wenn FiveM keine Interior-ID liefert.
|
||||||
|
- Kollisionsprüfung und Diagnoseausgaben für Lobby, Flur und Apartments erweitert.
|
||||||
|
|
||||||
|
## 0.4.1
|
||||||
|
|
||||||
|
- Mission-, Casino- und Apartment-IPLs werden durch `devx_ipl` zuverlässig vorgeladen
|
||||||
|
- Warehouse-Vorbereitungsmissionen nutzen das eingerichtete Import/Export-Lager und Planungscutscenes
|
||||||
|
- Apartmentvorschau verwendet feste Kamerapunkte innerhalb der vier tatsächlichen Interior-Tiers
|
||||||
|
- Lobby/Etagenflure auf einen stabil geladenen Innenraumbereich verschoben
|
||||||
|
- genau ein Apartment pro Charakter; nach Kauf wird am passenden Gebäude direkt „Apartment betreten“ angeboten
|
||||||
|
- Whiskey-, Bier-, Schlaf- und Bong-Interaktionen in den Apartments
|
||||||
|
- Smartphone-Taste M ist ein echter Ein/Aus-Schalter ohne zweites Release-Event
|
||||||
|
- Casino mit Ambient-NPCs, originalem Glücksrad ohne Duplikat, Chips, Kasse, Slots, Roulette und Blackjack
|
||||||
|
- Vanilla-Unicorn-Beziehungssystem mit nicht-expliziter VIP-Lounge, Zufriedenheitsbalken, Telefonkontakt und Apartmenteinladung
|
||||||
|
- Fahrzeugradio auf Non-Stop-Pop FM oder Aus beschränkt
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
- Intro-Kamera folgt dem Spieler; Fahrzeug wartet bereits am Straßenrand
|
||||||
|
- globale Spielerkarte mit serverseitig synchronisierten Blips
|
||||||
|
- Schutz gegen übrig gebliebene Routing Buckets und neuer `/world`-Befehl
|
||||||
|
- Heist-Vorbereitungen in sichere Warehouse-Missionsinstanzen verschoben
|
||||||
|
- physisch animiertes Casino-Glücksrad
|
||||||
|
- Smartphone-Keymapping und M-Tasten-Fallback repariert
|
||||||
|
- mehrstöckige Apartmenthäuser mit Lobby, Fluren und Aufzug
|
||||||
|
- gemeinsame Etagen- und Wohnungsinstanzen für mehrere Spieler
|
||||||
|
- Klingeln, Klopfen, Besucherfreigabe und Einladungen
|
||||||
|
- drehende Wohnungsvorschau mit Pfeilen und Kaufleiste
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
- Charakterkopf und Freemode-Aussehen korrigiert
|
||||||
|
- Charakterdatum normalisiert und Löschdialog neu gebaut
|
||||||
|
- Flughafen-Intro als animierte Ankunftssequenz überarbeitet
|
||||||
|
- Fahrerfahrzeug auf Straßennavigation umgestellt
|
||||||
|
- dauerhaftes Geld-HUD entfernt und Untertitel bereinigt
|
||||||
|
- Smartphone auf Taste M mit Prop, Kontakten und vollständiger Bank-App
|
||||||
|
- eindeutige Telefonnummern pro Charakter
|
||||||
|
- eigenes DevX-Admininterface mit Geld, Waffen, Teleport, Kick und Ban
|
||||||
|
- persistente Bans
|
||||||
|
- Bankraub mit drei Vorbereitungsmissionen
|
||||||
|
- Casinozugang und tägliches Glücksrad
|
||||||
|
|
||||||
|
## 0.2.0
|
||||||
|
|
||||||
|
- klassische Charakterauswahl und 3D-Vorschau
|
||||||
|
- erste Flughafen-Begrüßungsfahrt
|
||||||
|
|
||||||
|
## 0.1.5
|
||||||
|
|
||||||
|
- Client-Callbackhelper in das Resource-Packfile aufgenommen
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
Copyright (c) 2026 Colin-Joel / DevX Studios
|
||||||
|
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This source code may be used and modified by the repository owner for their
|
||||||
|
own FiveM servers. Redistribution, resale, sublicensing, publication of
|
||||||
|
modified copies, or removal of copyright notices requires prior written
|
||||||
|
permission from the copyright holder.
|
||||||
|
|
||||||
|
Third-party dependencies retain their respective licenses.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# DevX RP Framework v0.4.3
|
||||||
|
|
||||||
|
Eigenständiges FiveM-RP-Framework für txAdmin ohne ESX, QBCore oder QBox.
|
||||||
|
`oxmysql` wird ausschließlich als Datenbanktreiber verwendet.
|
||||||
|
|
||||||
|
## Schwerpunkt v0.4.3
|
||||||
|
|
||||||
|
- entfernt globale Apartment-/Warehouse-IPL-Wechsel, die Weltteile oder
|
||||||
|
Kollisionen nach einem Interiorwechsel beschädigen konnten
|
||||||
|
- neuer Streaming-Guard in `devx_ipl` und `/fixworld`
|
||||||
|
- Bankraub-Vorbereitungen in einem immer verfügbaren GTA-Online-Garageninterior
|
||||||
|
mit Missionsdekoration, Fahrzeugen, NPCs und Planungsdialog
|
||||||
|
- Apartmentflure in einer stabilen virtuellen Korridorszene statt in einem
|
||||||
|
fehlerhaften Apartment-IPL
|
||||||
|
- nur tatsächlich bewohnte Wohnungen bekommen Namensschild und Türaktion
|
||||||
|
- verwaiste Eigentümer von gelöschten Charakteren werden automatisch bereinigt
|
||||||
|
- pro Charakter weiterhin maximal ein Apartment
|
||||||
|
- Eigentümer können an der Wohnungstür mit `G` eine Spielerliste öffnen
|
||||||
|
- eingeladene Spieler müssen die Einladung mit `E` annehmen; erst danach erfolgt
|
||||||
|
der Teleport in die gemeinsame Wohnungsinstanz
|
||||||
|
- Klingeln und Klopfen bleiben für Bewohner anderer Apartments erhalten
|
||||||
|
|
||||||
|
## Update eines bestehenden Servers
|
||||||
|
|
||||||
|
1. Server und FiveM stoppen.
|
||||||
|
2. `release/devx-rp-hotfix-v0.4.3.zip` im Server-Datenordner entpacken.
|
||||||
|
3. Vorhandene Dateien überschreiben.
|
||||||
|
4. Startreihenfolge prüfen: `devx_ipl` vor `devx_housing` und `devx_crime`.
|
||||||
|
5. Server und FiveM vollständig neu starten.
|
||||||
|
|
||||||
|
Eine Schemaänderung ist nicht erforderlich. Die optionale Datei
|
||||||
|
`sql/migrations/v0.4.3.sql` bereinigt ausschließlich Eigentümerzeilen, deren
|
||||||
|
Charakter fehlt oder bereits gelöscht wurde.
|
||||||
|
|
||||||
|
## Gitea / txAdmin
|
||||||
|
|
||||||
|
Repository-Inhalt in den `main`-Branch hochladen. Danach ein Gitea-Release mit
|
||||||
|
Tag `v0.4.3` erstellen und `release/devx-rp-server-v0.4.3.zip` als Asset
|
||||||
|
anhängen. txAdmin erhält die Raw-URL der `recipe.yaml`.
|
||||||
|
|
||||||
|
## Wichtige Befehle
|
||||||
|
|
||||||
|
- `/fixworld` – Weltfokus und Kollision neu aufbauen
|
||||||
|
- `/world` – in die öffentliche Routing-Welt zurückkehren
|
||||||
|
- `/phone` – Smartphone umschalten
|
||||||
|
- `/admin` – DevX-Administration, nur mit `devx.admin`
|
||||||
|
- `/inviteapartment` – in der eigenen Wohnung Spielerliste öffnen
|
||||||
|
- `/acceptapartment` – Fallback für eine offene Einladung
|
||||||
|
|
||||||
|
## Entwicklungsstand
|
||||||
|
|
||||||
|
Dies ist ein eigener Test- und Entwicklungsstand. Syntax, JavaScript, YAML und
|
||||||
|
Archive werden beim Build geprüft. Ein vollständiger GTA/FiveM-Praxistest mit
|
||||||
|
allen Game-Builds, Interiors und NPC-Pfaden ist außerhalb eines laufenden
|
||||||
|
FXServers nicht möglich.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Architektur
|
||||||
|
|
||||||
|
## Ressourcen
|
||||||
|
|
||||||
|
### devx_core
|
||||||
|
Serverautoritative Spieler-Sessions, Identifier, Geld, Callbacks und
|
||||||
|
Positionsspeicherung. Andere DevX-Ressourcen greifen ausschließlich über
|
||||||
|
Exports und validierte Serverevents auf Sitzungsdaten zu.
|
||||||
|
|
||||||
|
### devx_characters
|
||||||
|
Charakterliste, Erstellung, Auswahl und manueller Spawn. Der Spieler wird
|
||||||
|
nicht vom Basic-Gamemode automatisch gespawnt.
|
||||||
|
|
||||||
|
### devx_banking
|
||||||
|
Konten, Kontonummern, Überweisungen und Transaktionshistorie. Geldoperationen
|
||||||
|
werden serialisiert, damit zwei gleichzeitige Buchungen ein Konto nicht
|
||||||
|
überziehen.
|
||||||
|
|
||||||
|
### devx_housing
|
||||||
|
Kaufbare Objekte. Mehrere Wohnungen können denselben GTA-Innenraum benutzen,
|
||||||
|
weil jeder Eigentümer beim Betreten in einen eigenen OneSync-Routing-Bucket
|
||||||
|
verschoben wird.
|
||||||
|
|
||||||
|
### devx_hud
|
||||||
|
Eigene kleine NUI für Charaktername, Bargeld und Kontostand. Die Minimap wird
|
||||||
|
nach dem Spawn dauerhaft eingeblendet.
|
||||||
|
|
||||||
|
### devx_phone
|
||||||
|
Eigene, originale NUI. In v0.1 zeigt sie Charakter- und Bankdaten. Kontakte,
|
||||||
|
Nachrichten und Anrufe sind für spätere Versionen vorbereitet.
|
||||||
|
|
||||||
|
## Vertrauensgrenzen
|
||||||
|
|
||||||
|
- Der Client darf niemals Kontostände, Eigentum oder Charakterbesitz festlegen.
|
||||||
|
- Jeder Charakterzugriff wird gegen die FiveM-`license:` des Spielers geprüft.
|
||||||
|
- Beträge werden auf Ganzzahlen, Positivität und Obergrenzen geprüft.
|
||||||
|
- Wohnungskäufe und Bankbuchungen besitzen serverseitige Sperren.
|
||||||
|
- NUI ist nur Darstellung; alle fachlichen Entscheidungen trifft der Server.
|
||||||
|
|
||||||
|
## Erweiterungsprinzip
|
||||||
|
|
||||||
|
Neue Systeme erhalten eine eigene Ressource und verwenden Core-Exports.
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
local ok = exports.devx_core:RemoveCash(source, 100, 'Kauf')
|
||||||
|
```
|
||||||
|
|
||||||
|
Für Datenbankzugriffe stets Platzhalter verwenden; niemals SQL-Strings mit
|
||||||
|
ungeprüften Clientwerten zusammensetzen.
|
||||||
|
|
||||||
|
|
||||||
|
## Charaktervorschau und Begrüßungssequenz
|
||||||
|
|
||||||
|
`devx_characters` erzeugt für die Auswahl einen lokalen, nicht synchronisierten
|
||||||
|
Vorschau-Ped und eine Skriptkamera. Der echte Spieler bleibt währenddessen
|
||||||
|
unsichtbar und eingefroren. Neue Charaktere starten einmalig am Flughafen.
|
||||||
|
Fahrer, Fahrzeug, Dialoge und Fahrt laufen clientseitig; der erfolgreiche
|
||||||
|
Abschluss wird serverseitig im Charakterfeld `metadata` gespeichert.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Gitea und txAdmin – DevX RP v0.4.3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "DevX RP framework v0.4.3"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
In Gitea ein Release mit Tag `v0.4.3` erstellen und
|
||||||
|
`release/devx-rp-server-v0.4.3.zip` als Asset anhängen.
|
||||||
|
|
||||||
|
In txAdmin die Raw-URL zur `recipe.yaml` verwenden:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://git.devx-studios.de/c.scupin/devx-rp/raw/branch/main/recipe.yaml
|
||||||
|
```
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
## v0.2 – Charakter und Einstieg
|
||||||
|
- [x] klassische Charakterauswahl und 3D-Vorschau
|
||||||
|
- [x] einmalige Begrüßungsfahrt mit NPC
|
||||||
|
- [ ] Aussehen-/Kleidungseditor
|
||||||
|
- [ ] Hunger, Durst und Gesundheit
|
||||||
|
- [ ] Inventar, Items und Shops
|
||||||
|
- [ ] vollständiges Todes-/Respawn-System
|
||||||
|
- [ ] Ausweis, Führerschein und Dokumente
|
||||||
|
|
||||||
|
## v0.3 – Fahrzeuge und Wohnungen
|
||||||
|
- Fahrzeugbesitz, Schlüssel und Garagen
|
||||||
|
- Mietwohnungen und Nebenkosten
|
||||||
|
- Wohnungslager, Möbel und Gäste
|
||||||
|
- mehrere Apartment-Innenraumtypen
|
||||||
|
- Makler- und Verwaltungsoberfläche
|
||||||
|
|
||||||
|
## v0.4 – Smartphone
|
||||||
|
- Telefonnummern
|
||||||
|
- Kontakte und Chatnachrichten
|
||||||
|
- Banking-App
|
||||||
|
- Firmen-App
|
||||||
|
- native Telefonanimation und Prop
|
||||||
|
- optionale Sprach-/Anrufintegration
|
||||||
|
|
||||||
|
## v0.5 – Wirtschaft und Jobs
|
||||||
|
- eigenes Jobsystem
|
||||||
|
- IT-Systemhaus-Karriere
|
||||||
|
- Firmenkonten, Angestellte und Gehälter
|
||||||
|
- Finanzamt, Steuern, Rechnungen und Verträge
|
||||||
|
- legale und illegale Aufträge
|
||||||
|
|
||||||
|
## Vor öffentlichem Betrieb
|
||||||
|
- Integrationstests
|
||||||
|
- Migrationen mit Versionsnummern
|
||||||
|
- Rate-Limits und Audit-Log
|
||||||
|
- Backups und Wiederherstellungstest
|
||||||
|
- Performanceprofiling mit vielen Spielern
|
||||||
|
- Datenschutz- und Regelwerksprüfung
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
$engine: 3
|
||||||
|
$onesync: on
|
||||||
|
|
||||||
|
name: DevX RP
|
||||||
|
version: 0.4.3
|
||||||
|
author: c.scupin
|
||||||
|
description: Eigenständiges RP-Framework ohne ESX, QBCore oder QBox.
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- action: connect_database
|
||||||
|
|
||||||
|
# Offizielle CFX-Grundressourcen
|
||||||
|
- action: download_github
|
||||||
|
src: https://github.com/citizenfx/cfx-server-data
|
||||||
|
ref: master
|
||||||
|
subpath: resources
|
||||||
|
dest: ./resources
|
||||||
|
|
||||||
|
# Das Basic-Gamemode würde automatisch spawnen und wird deshalb entfernt.
|
||||||
|
- action: remove_path
|
||||||
|
path: ./resources/[gamemodes]/basic-gamemode
|
||||||
|
|
||||||
|
# Reiner MariaDB/MySQL-Treiber, kein RP-Framework.
|
||||||
|
- action: download_file
|
||||||
|
url: https://github.com/overextended/oxmysql/releases/latest/download/oxmysql.zip
|
||||||
|
path: ./tmp/oxmysql.zip
|
||||||
|
|
||||||
|
- action: unzip
|
||||||
|
src: ./tmp/oxmysql.zip
|
||||||
|
dest: ./resources/[standalone]
|
||||||
|
|
||||||
|
# Eigenes DevX-RP-Release aus Gitea.
|
||||||
|
- action: download_file
|
||||||
|
url: https://git.devx-studios.de/c.scupin/devx-rp/releases/download/v0.4.3/devx-rp-server-v0.4.3.zip
|
||||||
|
path: ./tmp/devx-rp-server.zip
|
||||||
|
|
||||||
|
- action: unzip
|
||||||
|
src: ./tmp/devx-rp-server.zip
|
||||||
|
dest: ./
|
||||||
|
|
||||||
|
- action: query_database
|
||||||
|
file: ./sql/schema.sql
|
||||||
|
|
||||||
|
- action: replace_string
|
||||||
|
mode: all_vars
|
||||||
|
file: ./server.cfg
|
||||||
|
|
||||||
|
- action: remove_path
|
||||||
|
path: ./tmp
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
DevX RP v0.4.3 – Streaming-, Missions- und Apartment-Reparatur
|
||||||
|
|
||||||
|
1. FXServer in txAdmin vollständig stoppen.
|
||||||
|
2. Eine Sicherung von server.cfg, resources/[devx] und der Datenbank erstellen.
|
||||||
|
3. devx-rp-hotfix-v0.4.3.zip im Server-Datenordner entpacken.
|
||||||
|
4. Vorhandene Dateien überschreiben.
|
||||||
|
5. Prüfen, dass devx_ipl vor devx_crime und devx_housing startet.
|
||||||
|
6. Server und FiveM vollständig neu starten.
|
||||||
|
|
||||||
|
Keine Pflichtmigration: devx_housing bereinigt beim Start verwaiste
|
||||||
|
Apartment-Eigentümer. Optional kann sql/migrations/v0.4.3.sql einmal manuell
|
||||||
|
importiert werden.
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
- Keine Apartment-/Warehouse-IPLs aus älteren DevX-Versionen zusätzlich laden.
|
||||||
|
- /fixworld baut Weltfokus und Kollision neu auf.
|
||||||
|
- Im Apartment an der Tür: E verlassen, G Spieler auswählen/einladen.
|
||||||
|
- Der Gast wird erst nach E-Annahme der Einladung teleportiert.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
|
|||||||
|
# DevX RP v0.4.3 – relevante Reihenfolge
|
||||||
|
ensure oxmysql
|
||||||
|
ensure devx_ipl
|
||||||
|
ensure devx_core
|
||||||
|
ensure devx_characters
|
||||||
|
ensure devx_hud
|
||||||
|
ensure devx_players
|
||||||
|
ensure devx_banking
|
||||||
|
ensure devx_housing
|
||||||
|
ensure devx_phone
|
||||||
|
ensure devx_admin
|
||||||
|
ensure devx_crime
|
||||||
|
ensure devx_casino
|
||||||
|
ensure devx_stripclub
|
||||||
|
ensure devx_radio
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
local open = false
|
||||||
|
|
||||||
|
local function setOpen(state)
|
||||||
|
open = state
|
||||||
|
SetNuiFocus(state, state)
|
||||||
|
SendNUIMessage({ action = state and 'open' or 'close' })
|
||||||
|
if state then
|
||||||
|
DevXTriggerCallback('admin:bootstrap', {}, function(result)
|
||||||
|
if not result.ok then
|
||||||
|
open = false
|
||||||
|
SetNuiFocus(false, false)
|
||||||
|
SendNUIMessage({ action = 'close' })
|
||||||
|
exports.devx_core:Notify(result.error or 'Kein Adminzugriff.')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
SendNUIMessage({ action = 'state', players = result.players, weapons = result.weapons })
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterCommand('admin', function()
|
||||||
|
setOpen(not open)
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
RegisterNUICallback('close', function(_, cb)
|
||||||
|
setOpen(false)
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('action', function(data, cb)
|
||||||
|
DevXTriggerCallback('admin:action', data, function(result)
|
||||||
|
if result.players then SendNUIMessage({ action = 'players', players = result.players }) end
|
||||||
|
cb(result)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_admin:client:giveWeapon', function(weapon, ammo)
|
||||||
|
GiveWeaponToPed(PlayerPedId(), GetHashKey(weapon), tonumber(ammo) or 250, false, true)
|
||||||
|
exports.devx_core:Notify(('Adminwaffe erhalten: %s'):format(weapon))
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_admin:client:heal', function()
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
SetEntityHealth(ped, GetEntityMaxHealth(ped))
|
||||||
|
SetPedArmour(ped, 100)
|
||||||
|
ClearPedBloodDamage(ped)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_admin:client:freeze', function(state)
|
||||||
|
FreezeEntityPosition(PlayerPedId(), state == true)
|
||||||
|
exports.devx_core:Notify(state and 'Du wurdest eingefroren.' or 'Du wurdest freigegeben.')
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_admin:client:teleport', function(coords)
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
SetEntityCoordsNoOffset(ped, coords.x, coords.y, coords.z, false, false, false)
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('onResourceStop', function(resourceName)
|
||||||
|
if resourceName == GetCurrentResourceName() then SetNuiFocus(false, false) end
|
||||||
|
end)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
fx_version 'cerulean'
|
||||||
|
game 'gta5'
|
||||||
|
|
||||||
|
author 'Colin-Joel / DevX Studios'
|
||||||
|
description 'DevX RP ACE-secured administration interface'
|
||||||
|
version '0.4.3'
|
||||||
|
lua54 'yes'
|
||||||
|
|
||||||
|
shared_script '@devx_core/shared/config.lua'
|
||||||
|
ui_page 'web/index.html'
|
||||||
|
files { 'web/index.html', 'web/style.css', 'web/app.js' }
|
||||||
|
client_scripts { '@devx_core/client/callbacks.lua', 'client/main.lua' }
|
||||||
|
server_scripts { '@oxmysql/lib/MySQL.lua', '@devx_core/server/callbacks.lua', 'server/main.lua' }
|
||||||
|
dependencies { 'devx_core', 'devx_banking' }
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
local frozenPlayers = {}
|
||||||
|
|
||||||
|
local function isAdmin(source)
|
||||||
|
return source == 0 or IsPlayerAceAllowed(source, 'devx.admin')
|
||||||
|
end
|
||||||
|
|
||||||
|
local function cleanReason(value)
|
||||||
|
if type(value) ~= 'string' then return 'Keine Begründung angegeben.' end
|
||||||
|
value = value:gsub('^%s+', ''):gsub('%s+$', '')
|
||||||
|
if value == '' then return 'Keine Begründung angegeben.' end
|
||||||
|
return value:sub(1, 255)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function playerRows()
|
||||||
|
local rows = {}
|
||||||
|
for _, id in ipairs(GetPlayers()) do
|
||||||
|
local source = tonumber(id)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
rows[#rows + 1] = {
|
||||||
|
source = source,
|
||||||
|
connectionName = GetPlayerName(source) or ('Spieler %s'):format(source),
|
||||||
|
fullName = player and player.fullName or 'Kein Charakter geladen',
|
||||||
|
characterId = player and player.characterId or nil,
|
||||||
|
phoneNumber = player and player.phoneNumber or nil,
|
||||||
|
cash = player and player.cash or 0,
|
||||||
|
bank = player and player.bank or 0,
|
||||||
|
rpXp = player and player.rpXp or 0,
|
||||||
|
frozen = frozenPlayers[source] == true
|
||||||
|
}
|
||||||
|
end
|
||||||
|
table.sort(rows, function(a, b) return a.source < b.source end)
|
||||||
|
return rows
|
||||||
|
end
|
||||||
|
|
||||||
|
DevXRegisterCallback('admin:bootstrap', function(source)
|
||||||
|
if not isAdmin(source) then return { ok = false, error = 'Keine Administratorberechtigung.' } end
|
||||||
|
|
||||||
|
local weapons = {}
|
||||||
|
for name, label in pairs(DevXConfig.AdminWeapons or {}) do
|
||||||
|
weapons[#weapons + 1] = { name = name, label = label }
|
||||||
|
end
|
||||||
|
table.sort(weapons, function(a, b) return a.label < b.label end)
|
||||||
|
|
||||||
|
return { ok = true, players = playerRows(), weapons = weapons }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('admin:action', function(source, payload)
|
||||||
|
if not isAdmin(source) then return { ok = false, error = 'Keine Administratorberechtigung.' } end
|
||||||
|
|
||||||
|
local action = tostring(payload.action or '')
|
||||||
|
local target = tonumber(payload.target)
|
||||||
|
local targetPlayer = target and exports.devx_core:GetPlayer(target) or nil
|
||||||
|
|
||||||
|
if action == 'refresh' then return { ok = true, players = playerRows() } end
|
||||||
|
if not target or not GetPlayerName(target) then return { ok = false, error = 'Spieler ist nicht mehr online.' } end
|
||||||
|
|
||||||
|
if action == 'setcash' then
|
||||||
|
local amount = math.floor(tonumber(payload.amount) or -1)
|
||||||
|
if amount < 0 or amount > DevXConfig.MaxMoneyOperation or not targetPlayer then
|
||||||
|
return { ok = false, error = 'Ungültiger Bargeldbetrag.' }
|
||||||
|
end
|
||||||
|
if not exports.devx_core:SetCash(target, amount, ('Admin %s'):format(source)) then
|
||||||
|
return { ok = false, error = 'Bargeld konnte nicht geändert werden.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif action == 'setbank' then
|
||||||
|
local amount = math.floor(tonumber(payload.amount) or -1)
|
||||||
|
if amount < 0 or amount > DevXConfig.MaxMoneyOperation or not targetPlayer then
|
||||||
|
return { ok = false, error = 'Ungültiger Bankbetrag.' }
|
||||||
|
end
|
||||||
|
local success, errorMessage = exports.devx_banking:SetPlayerBalance(target, amount, ('Admin %s'):format(source))
|
||||||
|
if not success then return { ok = false, error = errorMessage } end
|
||||||
|
|
||||||
|
elseif action == 'giveweapon' then
|
||||||
|
local weapon = tostring(payload.weapon or ''):upper()
|
||||||
|
if not DevXConfig.AdminWeapons[weapon] then return { ok = false, error = 'Waffe ist nicht freigegeben.' } end
|
||||||
|
TriggerClientEvent('devx_admin:client:giveWeapon', target, weapon, 250)
|
||||||
|
|
||||||
|
elseif action == 'heal' then
|
||||||
|
TriggerClientEvent('devx_admin:client:heal', target)
|
||||||
|
|
||||||
|
elseif action == 'freeze' then
|
||||||
|
frozenPlayers[target] = not frozenPlayers[target]
|
||||||
|
TriggerClientEvent('devx_admin:client:freeze', target, frozenPlayers[target])
|
||||||
|
|
||||||
|
elseif action == 'goto' then
|
||||||
|
local ped = GetPlayerPed(target)
|
||||||
|
if ped <= 0 then return { ok = false, error = 'Zielposition ist nicht verfügbar.' } end
|
||||||
|
local coords = GetEntityCoords(ped)
|
||||||
|
TriggerClientEvent('devx_admin:client:teleport', source, { x = coords.x, y = coords.y, z = coords.z + 1.0 })
|
||||||
|
|
||||||
|
elseif action == 'bring' then
|
||||||
|
local ped = GetPlayerPed(source)
|
||||||
|
if ped <= 0 then return { ok = false, error = 'Adminposition ist nicht verfügbar.' } end
|
||||||
|
local coords = GetEntityCoords(ped)
|
||||||
|
TriggerClientEvent('devx_admin:client:teleport', target, { x = coords.x, y = coords.y, z = coords.z + 1.0 })
|
||||||
|
|
||||||
|
elseif action == 'setphone' then
|
||||||
|
local number = tostring(payload.phoneNumber or ''):gsub('%s+', '')
|
||||||
|
if not targetPlayer or not number:match('^%d%d%d%-%d%d%d%d+$') or #number > 16 then
|
||||||
|
return { ok = false, error = 'Telefonnummer muss wie 555-1234 aussehen.' }
|
||||||
|
end
|
||||||
|
local duplicate = MySQL.scalar.await('SELECT id FROM devx_characters WHERE phone_number = ? AND id <> ? LIMIT 1', {
|
||||||
|
number, targetPlayer.characterId
|
||||||
|
})
|
||||||
|
if duplicate then return { ok = false, error = 'Telefonnummer ist bereits vergeben.' } end
|
||||||
|
MySQL.update.await('UPDATE devx_characters SET phone_number = ? WHERE id = ?', { number, targetPlayer.characterId })
|
||||||
|
targetPlayer.phoneNumber = number
|
||||||
|
TriggerClientEvent('devx_core:client:playerData', target, targetPlayer)
|
||||||
|
|
||||||
|
elseif action == 'kick' then
|
||||||
|
local reason = cleanReason(payload.reason)
|
||||||
|
DropPlayer(target, ('Du wurdest von einem Administrator getrennt.\nGrund: %s'):format(reason))
|
||||||
|
|
||||||
|
elseif action == 'ban' then
|
||||||
|
local reason = cleanReason(payload.reason)
|
||||||
|
local minutes = math.max(0, math.floor(tonumber(payload.minutes) or 0))
|
||||||
|
local license = exports.devx_core:GetLicense(target)
|
||||||
|
if not license then return { ok = false, error = 'Spielerlizenz konnte nicht ermittelt werden.' } end
|
||||||
|
|
||||||
|
local expiresAt = nil
|
||||||
|
if minutes > 0 then
|
||||||
|
expiresAt = os.date('!%Y-%m-%d %H:%M:%S', os.time() + minutes * 60)
|
||||||
|
end
|
||||||
|
|
||||||
|
MySQL.insert.await([[
|
||||||
|
INSERT INTO devx_bans (license, player_name, reason, banned_by, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
]], {
|
||||||
|
license,
|
||||||
|
targetPlayer and targetPlayer.fullName or GetPlayerName(target),
|
||||||
|
reason,
|
||||||
|
GetPlayerName(source) or ('Admin %s'):format(source),
|
||||||
|
expiresAt
|
||||||
|
})
|
||||||
|
|
||||||
|
DropPlayer(target, ('Du wurdest von DevX-City gesperrt.\nGrund: %s'):format(reason))
|
||||||
|
else
|
||||||
|
return { ok = false, error = 'Unbekannte Adminaktion.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
return { ok = true, players = playerRows() }
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('playerDropped', function()
|
||||||
|
frozenPlayers[source] = nil
|
||||||
|
end)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const resource=typeof GetParentResourceName==='function'?GetParentResourceName():'devx_admin';
|
||||||
|
const app=document.querySelector('#app'),playersEl=document.querySelector('#players'),editor=document.querySelector('#editor'),errorEl=document.querySelector('#error');
|
||||||
|
let players=[],weapons=[],selectedId=null;
|
||||||
|
async function post(event,data={}){const r=await fetch(`https://${resource}/${event}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});return r.json()}
|
||||||
|
function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||||
|
function error(t=''){errorEl.textContent=t;errorEl.classList.toggle('hidden',!t)}
|
||||||
|
function selected(){return players.find(p=>Number(p.source)===Number(selectedId))}
|
||||||
|
function renderPlayers(){const q=document.querySelector('#search').value.toLowerCase();playersEl.innerHTML='';players.filter(p=>`${p.source} ${p.fullName} ${p.connectionName}`.toLowerCase().includes(q)).forEach(p=>{const b=document.createElement('button');b.className=`player ${Number(p.source)===Number(selectedId)?'active':''}`;b.innerHTML=`<span>${p.source}</span><div><strong>${esc(p.fullName)}</strong><small>${esc(p.connectionName)}</small></div>`;b.onclick=()=>{selectedId=p.source;renderPlayers();renderEditor()};playersEl.appendChild(b)})}
|
||||||
|
function action(action,data={}){error('');return post('action',{action,target:selectedId,...data}).then(r=>{if(!r.ok)error(r.error||'Aktion fehlgeschlagen.');if(r.players){players=r.players;renderPlayers();renderEditor()}return r})}
|
||||||
|
function renderEditor(){const p=selected();if(!p){editor.className='editor empty';editor.innerHTML='<div><h2>Spieler auswählen</h2><p>Wähle links einen Online-Spieler aus.</p></div>';return}editor.className='editor';editor.innerHTML=`
|
||||||
|
<div class="editor-header"><div><h2>${esc(p.fullName)}</h2><p>Server-ID ${p.source} · Charakter ${p.characterId??'–'} · ${esc(p.phoneNumber||'keine Nummer')}</p></div><div class="badges"><span class="badge">${p.cash} $ bar</span><span class="badge">${p.bank} $ Bank</span><span class="badge">${p.rpXp} XP</span></div></div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><h3>Geld bearbeiten</h3><div class="form-row"><input id="cash" type="number" min="0" value="${p.cash}"><button class="primary" data-act="setcash">Bargeld setzen</button></div><div class="form-row"><input id="bank" type="number" min="0" value="${p.bank}"><button class="primary" data-act="setbank">Bank setzen</button></div></div>
|
||||||
|
<div class="card"><h3>Identität</h3><div class="form-row"><input id="phone" value="${esc(p.phoneNumber||'')}"><button class="primary" data-act="setphone">Nummer setzen</button></div></div>
|
||||||
|
<div class="card"><h3>Waffe geben</h3><div class="form-row"><select id="weapon">${weapons.map(w=>`<option value="${w.name}">${esc(w.label)}</option>`).join('')}</select><button class="primary" data-act="giveweapon">Geben</button></div></div>
|
||||||
|
<div class="card"><h3>Spieleraktionen</h3><div class="button-grid"><button data-act="heal">Heilen</button><button data-act="freeze">${p.frozen?'Freigeben':'Einfrieren'}</button><button data-act="goto">Zu Spieler</button><button data-act="bring">Herholen</button></div></div>
|
||||||
|
<div class="card punish"><h3>Verwarnung / Trennung</h3><div class="punish-fields"><input id="reason" class="full-input" placeholder="Begründung"><input id="minutes" class="full-input" type="number" min="0" placeholder="Minuten (0 = permanent)"></div><div class="button-grid"><button class="danger" data-act="kick">Kicken</button><button class="danger" data-act="ban">Bannen</button></div></div>
|
||||||
|
</div>`;
|
||||||
|
editor.querySelectorAll('[data-act]').forEach(b=>b.onclick=()=>{const a=b.dataset.act;if(a==='setcash')action(a,{amount:document.querySelector('#cash').value});else if(a==='setbank')action(a,{amount:document.querySelector('#bank').value});else if(a==='setphone')action(a,{phoneNumber:document.querySelector('#phone').value});else if(a==='giveweapon')action(a,{weapon:document.querySelector('#weapon').value});else if(a==='kick'||a==='ban')action(a,{reason:document.querySelector('#reason').value,minutes:document.querySelector('#minutes').value});else action(a)})}
|
||||||
|
document.querySelector('#search').oninput=renderPlayers;document.querySelector('#refresh').onclick=()=>action('refresh');document.querySelector('#close').onclick=()=>post('close');document.addEventListener('keyup',e=>{if(e.key==='Escape')post('close')});
|
||||||
|
window.addEventListener('message',e=>{const d=e.data||{};if(d.action==='open')app.classList.remove('hidden');if(d.action==='close')app.classList.add('hidden');if(d.action==='state'){players=d.players||[];weapons=d.weapons||[];selectedId=players[0]?.source??null;renderPlayers();renderEditor()}if(d.action==='players'){players=d.players||[];renderPlayers();renderEditor()}});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html><html lang="de"><head><meta charset="utf-8"><link rel="stylesheet" href="style.css"></head>
|
||||||
|
<body><main id="app" class="hidden">
|
||||||
|
<section class="admin-shell">
|
||||||
|
<header><div><small>DEVX-CITY</small><h1>Administration</h1></div><div class="header-actions"><button id="refresh">Aktualisieren</button><button id="close">×</button></div></header>
|
||||||
|
<div id="error" class="error hidden"></div>
|
||||||
|
<div class="layout">
|
||||||
|
<aside><input id="search" placeholder="Spieler suchen"><div id="players" class="players"></div></aside>
|
||||||
|
<section id="editor" class="editor empty"><h2>Spieler auswählen</h2><p>Wähle links einen Online-Spieler aus.</p></section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main><script src="app.js"></script></body></html>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
:root{font-family:Inter,system-ui,sans-serif;color:#f6f7fb;--line:rgba(255,255,255,.1);--muted:#929baa;--accent:#5e82f4}
|
||||||
|
*{box-sizing:border-box}body{margin:0;background:transparent;overflow:hidden}.hidden{display:none!important}button,input,select{font:inherit}
|
||||||
|
#app{position:fixed;inset:0;display:grid;place-items:center;background:rgba(2,4,8,.62)}
|
||||||
|
.admin-shell{width:min(1180px,94vw);height:min(760px,90vh);padding:25px;border:1px solid var(--line);border-radius:16px;background:#0d1119;box-shadow:0 35px 110px rgba(0,0,0,.62)}
|
||||||
|
header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}header small{color:#84a2ff;letter-spacing:.2em;font-weight:850}h1{margin:4px 0 0}.header-actions{display:flex;gap:8px}button{border:0;border-radius:8px;padding:10px 13px;color:white;background:rgba(255,255,255,.08);cursor:pointer}#close{font-size:22px}.layout{height:calc(100% - 78px);display:grid;grid-template-columns:320px 1fr;gap:18px}aside,.editor{border:1px solid var(--line);border-radius:12px;background:#111721;padding:15px;overflow:auto}#search{width:100%;padding:11px;border:1px solid var(--line);border-radius:8px;background:#090d13;color:white;outline:0}.players{display:grid;gap:7px;margin-top:12px}.player{width:100%;display:grid;grid-template-columns:42px 1fr;gap:10px;text-align:left;padding:11px;background:rgba(255,255,255,.04);border:1px solid transparent}.player.active{border-color:#6689f4;background:rgba(72,106,215,.18)}.player span{display:grid;place-items:center;width:38px;height:38px;border-radius:9px;background:#27375d;font-weight:850}.player strong{display:block}.player small{color:var(--muted)}.editor.empty{display:grid;place-items:center;text-align:center;color:var(--muted)}.editor-header{display:flex;justify-content:space-between;align-items:flex-start}.editor-header h2{margin:0}.editor-header p{margin:5px 0;color:var(--muted)}.badges{display:flex;gap:7px}.badge{padding:7px 9px;border-radius:7px;background:rgba(255,255,255,.06);font-size:11px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:13px;margin-top:20px}.card{padding:15px;border:1px solid var(--line);border-radius:10px;background:rgba(255,255,255,.025)}.card h3{margin:0 0 12px;font-size:14px}.form-row{display:grid;grid-template-columns:1fr auto;gap:7px;margin-top:8px}.form-row input,.form-row select,.full-input{width:100%;padding:10px;border:1px solid var(--line);border-radius:8px;background:#090d13;color:white}.primary{background:#557af0}.danger{background:rgba(197,62,81,.23);color:#ffabb5}.button-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}.error{margin-bottom:12px;padding:10px;border-radius:8px;background:rgba(184,48,67,.25);color:#ffadb7}.punish{grid-column:1/-1}.punish-fields{display:grid;grid-template-columns:1fr 140px;gap:8px;margin-bottom:8px}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
local open = false
|
||||||
|
local bankLocations = {
|
||||||
|
vector3(149.77, -1040.72, 29.37),
|
||||||
|
vector3(-1212.98, -330.84, 37.78),
|
||||||
|
vector3(-2962.58, 482.63, 15.70),
|
||||||
|
vector3(314.19, -278.62, 54.17)
|
||||||
|
}
|
||||||
|
|
||||||
|
local function setOpen(state)
|
||||||
|
open = state
|
||||||
|
SetNuiFocus(state, state)
|
||||||
|
SendNUIMessage({ action = state and 'open' or 'close' })
|
||||||
|
if state then
|
||||||
|
DevXTriggerCallback('banking:overview', {}, function(result)
|
||||||
|
SendNUIMessage({
|
||||||
|
action = result.ok and 'overview' or 'error',
|
||||||
|
overview = result.overview,
|
||||||
|
message = result.error
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterCommand('bank', function()
|
||||||
|
if exports.devx_core:IsPlayerLoaded() then setOpen(not open) end
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
RegisterNUICallback('close', function(_, cb)
|
||||||
|
setOpen(false)
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('refresh', function(_, cb)
|
||||||
|
DevXTriggerCallback('banking:overview', {}, cb)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('transfer', function(data, cb)
|
||||||
|
DevXTriggerCallback('banking:transfer', data, cb)
|
||||||
|
end)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
for _, location in ipairs(bankLocations) do
|
||||||
|
local blip = AddBlipForCoord(location.x, location.y, location.z)
|
||||||
|
SetBlipSprite(blip, 108)
|
||||||
|
SetBlipScale(blip, 0.65)
|
||||||
|
SetBlipColour(blip, 2)
|
||||||
|
SetBlipAsShortRange(blip, true)
|
||||||
|
BeginTextCommandSetBlipName('STRING')
|
||||||
|
AddTextComponentString('Bank')
|
||||||
|
EndTextCommandSetBlipName(blip)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while true do
|
||||||
|
local sleep = 1000
|
||||||
|
if exports.devx_core:IsPlayerLoaded() then
|
||||||
|
local coords = GetEntityCoords(PlayerPedId())
|
||||||
|
for _, location in ipairs(bankLocations) do
|
||||||
|
local distance = #(coords - location)
|
||||||
|
if distance < 15.0 then
|
||||||
|
sleep = 0
|
||||||
|
DrawMarker(
|
||||||
|
1, location.x, location.y, location.z - 1.0,
|
||||||
|
0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
|
||||||
|
1.1, 1.1, 0.35, 70, 120, 255, 120,
|
||||||
|
false, false, 2, false, nil, nil, false
|
||||||
|
)
|
||||||
|
if distance < 1.5 then
|
||||||
|
BeginTextCommandDisplayHelp('STRING')
|
||||||
|
AddTextComponentSubstringPlayerName('Drücke ~INPUT_CONTEXT~, um die Bank zu öffnen.')
|
||||||
|
EndTextCommandDisplayHelp(0, false, true, -1)
|
||||||
|
if IsControlJustReleased(0, 38) then setOpen(true) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Wait(sleep)
|
||||||
|
end
|
||||||
|
end)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
fx_version 'cerulean'
|
||||||
|
game 'gta5'
|
||||||
|
|
||||||
|
author 'Colin-Joel / DevX Studios'
|
||||||
|
description 'DevX RP banking'
|
||||||
|
version '0.4.3'
|
||||||
|
|
||||||
|
lua54 'yes'
|
||||||
|
|
||||||
|
shared_script '@devx_core/shared/config.lua'
|
||||||
|
|
||||||
|
ui_page 'web/index.html'
|
||||||
|
|
||||||
|
files {
|
||||||
|
'web/index.html',
|
||||||
|
'web/style.css',
|
||||||
|
'web/app.js'
|
||||||
|
}
|
||||||
|
|
||||||
|
client_scripts {
|
||||||
|
'@devx_core/client/callbacks.lua',
|
||||||
|
'client/main.lua'
|
||||||
|
}
|
||||||
|
|
||||||
|
server_scripts {
|
||||||
|
'@oxmysql/lib/MySQL.lua',
|
||||||
|
'@devx_core/server/callbacks.lua',
|
||||||
|
'server/main.lua'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependency 'devx_core'
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
local accountLocks = {}
|
||||||
|
|
||||||
|
local function money(value, allowZero)
|
||||||
|
local amount = tonumber(value)
|
||||||
|
if not amount then return nil end
|
||||||
|
amount = math.floor(amount)
|
||||||
|
if amount < (allowZero and 0 or 1) or amount > DevXConfig.MaxMoneyOperation then return nil end
|
||||||
|
return amount
|
||||||
|
end
|
||||||
|
|
||||||
|
local function withLocks(accountIds, callback)
|
||||||
|
local unique = {}
|
||||||
|
for _, id in ipairs(accountIds) do
|
||||||
|
id = tonumber(id)
|
||||||
|
if id and not unique[id] then unique[id] = true end
|
||||||
|
end
|
||||||
|
|
||||||
|
for id in pairs(unique) do
|
||||||
|
if accountLocks[id] then return false, 'Ein beteiligtes Konto verarbeitet gerade eine andere Buchung.' end
|
||||||
|
end
|
||||||
|
for id in pairs(unique) do accountLocks[id] = true end
|
||||||
|
|
||||||
|
local ok, resultA, resultB = pcall(callback)
|
||||||
|
for id in pairs(unique) do accountLocks[id] = nil end
|
||||||
|
|
||||||
|
if not ok then
|
||||||
|
print(('[devx_banking] Buchungsfehler: %s'):format(tostring(resultA)))
|
||||||
|
return false, 'Interner Buchungsfehler.'
|
||||||
|
end
|
||||||
|
return resultA, resultB
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getOverview(characterId)
|
||||||
|
local account = MySQL.single.await([[
|
||||||
|
SELECT id, account_number AS accountNumber, account_name AS accountName,
|
||||||
|
account_type AS accountType, balance
|
||||||
|
FROM devx_bank_accounts
|
||||||
|
WHERE character_id = ? AND account_type = 'personal'
|
||||||
|
LIMIT 1
|
||||||
|
]], { characterId })
|
||||||
|
if not account then return nil end
|
||||||
|
|
||||||
|
account.id = tonumber(account.id)
|
||||||
|
account.balance = tonumber(account.balance) or 0
|
||||||
|
|
||||||
|
local transactions = MySQL.query.await([[
|
||||||
|
SELECT id, direction, amount, balance_after AS balanceAfter,
|
||||||
|
counterparty, reference, created_at AS createdAt
|
||||||
|
FROM devx_bank_transactions
|
||||||
|
WHERE account_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 25
|
||||||
|
]], { account.id }) or {}
|
||||||
|
|
||||||
|
for _, transaction in ipairs(transactions) do
|
||||||
|
transaction.id = tonumber(transaction.id)
|
||||||
|
transaction.amount = tonumber(transaction.amount) or 0
|
||||||
|
transaction.balanceAfter = tonumber(transaction.balanceAfter) or 0
|
||||||
|
end
|
||||||
|
|
||||||
|
return { account = account, transactions = transactions }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function addTransaction(accountId, direction, amount, balanceAfter, counterparty, reference)
|
||||||
|
return MySQL.insert.await([[
|
||||||
|
INSERT INTO devx_bank_transactions
|
||||||
|
(account_id, direction, amount, balance_after, counterparty, reference)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
]], { accountId, direction, amount, balanceAfter, counterparty, reference })
|
||||||
|
end
|
||||||
|
|
||||||
|
local function chargeCharacter(characterId, amount, reference, counterparty)
|
||||||
|
amount = money(amount)
|
||||||
|
if not amount then return false, 'Ungültiger Betrag.' end
|
||||||
|
|
||||||
|
local account = MySQL.single.await([[
|
||||||
|
SELECT id, balance FROM devx_bank_accounts
|
||||||
|
WHERE character_id = ? AND account_type = 'personal' LIMIT 1
|
||||||
|
]], { characterId })
|
||||||
|
if not account then return false, 'Kein Bankkonto gefunden.' end
|
||||||
|
|
||||||
|
return withLocks({ account.id }, function()
|
||||||
|
account = MySQL.single.await('SELECT id, balance FROM devx_bank_accounts WHERE id = ? LIMIT 1', { account.id })
|
||||||
|
if not account or tonumber(account.balance) < amount then return false, 'Das Kontoguthaben reicht nicht aus.' end
|
||||||
|
|
||||||
|
local newBalance = tonumber(account.balance) - amount
|
||||||
|
local changed = MySQL.update.await('UPDATE devx_bank_accounts SET balance = ? WHERE id = ?', { newBalance, account.id })
|
||||||
|
if not changed or changed < 1 then return false, 'Belastung fehlgeschlagen.' end
|
||||||
|
|
||||||
|
addTransaction(account.id, 'debit', amount, newBalance, counterparty or 'Unbekannt', reference or 'Zahlung')
|
||||||
|
return true, newBalance
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function creditCharacter(characterId, amount, reference, counterparty)
|
||||||
|
amount = money(amount)
|
||||||
|
if not amount then return false, 'Ungültiger Betrag.' end
|
||||||
|
|
||||||
|
local account = MySQL.single.await([[
|
||||||
|
SELECT id, balance FROM devx_bank_accounts
|
||||||
|
WHERE character_id = ? AND account_type = 'personal' LIMIT 1
|
||||||
|
]], { characterId })
|
||||||
|
if not account then return false, 'Kein Bankkonto gefunden.' end
|
||||||
|
|
||||||
|
return withLocks({ account.id }, function()
|
||||||
|
account = MySQL.single.await('SELECT id, balance FROM devx_bank_accounts WHERE id = ? LIMIT 1', { account.id })
|
||||||
|
if not account then return false, 'Kein Bankkonto gefunden.' end
|
||||||
|
|
||||||
|
local newBalance = tonumber(account.balance) + amount
|
||||||
|
local changed = MySQL.update.await('UPDATE devx_bank_accounts SET balance = ? WHERE id = ?', { newBalance, account.id })
|
||||||
|
if not changed or changed < 1 then return false, 'Gutschrift fehlgeschlagen.' end
|
||||||
|
|
||||||
|
addTransaction(account.id, 'credit', amount, newBalance, counterparty or 'Unbekannt', reference or 'Gutschrift')
|
||||||
|
return true, newBalance
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setPlayerBalance(source, amount, reason)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
amount = money(amount, true)
|
||||||
|
if not player or not player.accountId or amount == nil then return false, 'Ungültige Anfrage.' end
|
||||||
|
|
||||||
|
return withLocks({ player.accountId }, function()
|
||||||
|
local changed = MySQL.update.await('UPDATE devx_bank_accounts SET balance = ? WHERE id = ?', { amount, player.accountId })
|
||||||
|
if not changed or changed < 1 then return false, 'Kontostand konnte nicht geändert werden.' end
|
||||||
|
addTransaction(player.accountId, 'credit', 0, amount, 'Administration', reason or 'Kontostand gesetzt')
|
||||||
|
exports.devx_core:SetBankCache(source, amount)
|
||||||
|
return true, amount
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function transferForSource(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
local amount = money(payload.amount)
|
||||||
|
local destinationNumber = type(payload.destination) == 'string' and payload.destination:gsub('%s+', ''):upper() or nil
|
||||||
|
local reference = type(payload.reference) == 'string' and payload.reference:sub(1, 140) or 'Überweisung'
|
||||||
|
|
||||||
|
if not player or not player.accountId then return false, 'Kein Charakter aktiv.' end
|
||||||
|
if not amount or not destinationNumber or #destinationNumber < 4 then return false, 'Betrag oder Empfängerkonto ist ungültig.' end
|
||||||
|
if destinationNumber == player.accountNumber then return false, 'Du kannst nicht auf dasselbe Konto überweisen.' end
|
||||||
|
|
||||||
|
local destination = MySQL.single.await([[
|
||||||
|
SELECT id, account_number, account_name, balance
|
||||||
|
FROM devx_bank_accounts WHERE account_number = ? LIMIT 1
|
||||||
|
]], { destinationNumber })
|
||||||
|
if not destination then return false, 'Empfängerkonto wurde nicht gefunden.' end
|
||||||
|
|
||||||
|
return withLocks({ player.accountId, destination.id }, function()
|
||||||
|
local sourceAccount = MySQL.single.await([[
|
||||||
|
SELECT id, account_number, account_name, balance
|
||||||
|
FROM devx_bank_accounts WHERE id = ? AND character_id = ? LIMIT 1
|
||||||
|
]], { player.accountId, player.characterId })
|
||||||
|
destination = MySQL.single.await([[
|
||||||
|
SELECT id, account_number, account_name, balance
|
||||||
|
FROM devx_bank_accounts WHERE id = ? LIMIT 1
|
||||||
|
]], { destination.id })
|
||||||
|
|
||||||
|
if not sourceAccount or not destination then return false, 'Ein Konto wurde nicht gefunden.' end
|
||||||
|
if tonumber(sourceAccount.balance) < amount then return false, 'Das Kontoguthaben reicht nicht aus.' end
|
||||||
|
|
||||||
|
local sourceBalance = tonumber(sourceAccount.balance) - amount
|
||||||
|
local destinationBalance = tonumber(destination.balance) + amount
|
||||||
|
local transactionOk = MySQL.transaction.await({
|
||||||
|
{
|
||||||
|
query = 'UPDATE devx_bank_accounts SET balance = ? WHERE id = ?',
|
||||||
|
values = { sourceBalance, sourceAccount.id }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = 'UPDATE devx_bank_accounts SET balance = ? WHERE id = ?',
|
||||||
|
values = { destinationBalance, destination.id }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = [[INSERT INTO devx_bank_transactions
|
||||||
|
(account_id, direction, amount, balance_after, counterparty, reference)
|
||||||
|
VALUES (?, 'debit', ?, ?, ?, ?)]],
|
||||||
|
values = { sourceAccount.id, amount, sourceBalance, destination.account_name, reference }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = [[INSERT INTO devx_bank_transactions
|
||||||
|
(account_id, direction, amount, balance_after, counterparty, reference)
|
||||||
|
VALUES (?, 'credit', ?, ?, ?, ?)]],
|
||||||
|
values = { destination.id, amount, destinationBalance, sourceAccount.account_name, reference }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if not transactionOk then return false, 'Überweisung fehlgeschlagen.' end
|
||||||
|
exports.devx_core:SetBankCache(source, sourceBalance)
|
||||||
|
return true, sourceBalance
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
exports('GetOverview', getOverview)
|
||||||
|
exports('ChargeCharacter', chargeCharacter)
|
||||||
|
exports('CreditCharacter', creditCharacter)
|
||||||
|
exports('SetPlayerBalance', setPlayerBalance)
|
||||||
|
exports('TransferForSource', transferForSource)
|
||||||
|
|
||||||
|
DevXRegisterCallback('banking:overview', function(source)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||||
|
local overview = getOverview(player.characterId)
|
||||||
|
if not overview then return { ok = false, error = 'Kein Bankkonto gefunden.' } end
|
||||||
|
exports.devx_core:SetBankCache(source, overview.account.balance)
|
||||||
|
return { ok = true, overview = overview }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('banking:transfer', function(source, payload)
|
||||||
|
local success, result = transferForSource(source, payload)
|
||||||
|
return success and { ok = true, balance = result } or { ok = false, error = result }
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterCommand('setbank', function(source, args)
|
||||||
|
if source ~= 0 and not IsPlayerAceAllowed(source, 'devx.admin') then return end
|
||||||
|
local target = tonumber(args[1])
|
||||||
|
local amount = money(args[2], true)
|
||||||
|
if not target or amount == nil then return end
|
||||||
|
setPlayerBalance(target, amount, ('Admin %s'):format(source))
|
||||||
|
end, false)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
const resource = typeof GetParentResourceName === 'function'
|
||||||
|
? GetParentResourceName()
|
||||||
|
: 'devx_banking';
|
||||||
|
const app = document.querySelector('#app');
|
||||||
|
const form = document.querySelector('#transferForm');
|
||||||
|
const transactions = document.querySelector('#transactions');
|
||||||
|
const errorEl = document.querySelector('#error');
|
||||||
|
const format = new Intl.NumberFormat('de-DE');
|
||||||
|
|
||||||
|
async function post(event, data = {}) {
|
||||||
|
const response = await fetch(`https://${resource}/${event}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(text = '') {
|
||||||
|
errorEl.textContent = text;
|
||||||
|
errorEl.classList.toggle('hidden', !text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(overview) {
|
||||||
|
const account = overview.account;
|
||||||
|
document.querySelector('#accountName').textContent = account.accountName;
|
||||||
|
document.querySelector('#accountNumber').textContent = account.accountNumber;
|
||||||
|
document.querySelector('#balance').textContent = `${format.format(account.balance)} $`;
|
||||||
|
|
||||||
|
transactions.innerHTML = '';
|
||||||
|
for (const entry of overview.transactions || []) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'transaction';
|
||||||
|
const sign = entry.direction === 'credit' ? '+' : '−';
|
||||||
|
row.innerHTML = `
|
||||||
|
<div><strong>${escapeHtml(entry.reference)}</strong><br>
|
||||||
|
<small>${escapeHtml(entry.counterparty || '')} · ${escapeHtml(String(entry.createdAt))}</small></div>
|
||||||
|
<strong class="${entry.direction}">${sign}${format.format(entry.amount)} $</strong>`;
|
||||||
|
transactions.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replace(/[&<>"']/g, char => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
}[char]));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector('#close').addEventListener('click', () => post('close'));
|
||||||
|
document.addEventListener('keyup', event => {
|
||||||
|
if (event.key === 'Escape') post('close');
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
showError('');
|
||||||
|
const result = await post('transfer', Object.fromEntries(new FormData(form)));
|
||||||
|
if (!result.ok) {
|
||||||
|
showError(result.error || 'Überweisung fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.reset();
|
||||||
|
const refreshed = await post('refresh');
|
||||||
|
if (refreshed.ok) render(refreshed.overview);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('message', event => {
|
||||||
|
const data = event.data;
|
||||||
|
if (data.action === 'open') app.classList.remove('hidden');
|
||||||
|
if (data.action === 'close') app.classList.add('hidden');
|
||||||
|
if (data.action === 'overview') render(data.overview);
|
||||||
|
if (data.action === 'error') showError(data.message);
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="app" class="hidden">
|
||||||
|
<section class="bank">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<p>LOS SANTOS FINANCIAL</p>
|
||||||
|
<h1>Banking</h1>
|
||||||
|
</div>
|
||||||
|
<button id="close" aria-label="Schließen">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="error" class="error hidden"></div>
|
||||||
|
|
||||||
|
<section class="account">
|
||||||
|
<div><span>KONTO</span><strong id="accountName">–</strong></div>
|
||||||
|
<div><span>KONTONUMMER</span><strong id="accountNumber">–</strong></div>
|
||||||
|
<div class="balance"><span>VERFÜGBAR</span><strong id="balance">0 $</strong></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="columns">
|
||||||
|
<form id="transferForm">
|
||||||
|
<h2>Überweisung</h2>
|
||||||
|
<label>Empfängerkonto<input name="destination" placeholder="LS0000000000" required></label>
|
||||||
|
<label>Betrag<input name="amount" type="number" min="1" step="1" required></label>
|
||||||
|
<label>Verwendungszweck<input name="reference" maxlength="140" value="Überweisung"></label>
|
||||||
|
<button type="submit">Überweisen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Letzte Buchungen</h2>
|
||||||
|
<div id="transactions" class="transactions"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
:root { font-family: Inter, Arial, sans-serif; color: #f7f8fb; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; overflow: hidden; background: transparent; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
#app {
|
||||||
|
min-height: 100vh; display: grid; place-items: center;
|
||||||
|
background: rgba(2,5,9,.55); backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
.bank {
|
||||||
|
width: min(980px, 92vw); max-height: 86vh; overflow: auto;
|
||||||
|
background: #10151e; border: 1px solid rgba(255,255,255,.1);
|
||||||
|
border-radius: 22px; padding: 28px; box-shadow: 0 30px 100px rgba(0,0,0,.55);
|
||||||
|
}
|
||||||
|
header { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||||
|
header p, .account span { color: #8f9aae; font-size: 11px; letter-spacing: .15em; }
|
||||||
|
h1 { margin: 4px 0 22px; font-size: 38px; }
|
||||||
|
h2 { font-size: 18px; }
|
||||||
|
#close { background: transparent; color: white; border: 0; font-size: 32px; cursor: pointer; }
|
||||||
|
.account {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;
|
||||||
|
background: linear-gradient(135deg, #2857dd, #1e3e98);
|
||||||
|
border-radius: 16px; padding: 20px;
|
||||||
|
}
|
||||||
|
.account div { display: grid; gap: 7px; }
|
||||||
|
.account .balance strong { font-size: 28px; }
|
||||||
|
.columns { display: grid; grid-template-columns: .8fr 1.2fr; gap: 24px; margin-top: 22px; }
|
||||||
|
form { display: grid; gap: 12px; align-content: start; }
|
||||||
|
label { display: grid; gap: 6px; color: #aeb7c7; font-size: 13px; }
|
||||||
|
input {
|
||||||
|
border: 1px solid rgba(255,255,255,.11); background: #0a0e15;
|
||||||
|
color: white; border-radius: 10px; padding: 12px;
|
||||||
|
}
|
||||||
|
button[type=submit] {
|
||||||
|
border: 0; background: #416cf5; color: white; font-weight: 800;
|
||||||
|
border-radius: 10px; padding: 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.transaction {
|
||||||
|
display: grid; grid-template-columns: 1fr auto; gap: 6px;
|
||||||
|
padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,.08);
|
||||||
|
}
|
||||||
|
.transaction small { color: #8f9aae; }
|
||||||
|
.credit { color: #6ee7a5; }
|
||||||
|
.debit { color: #ff8592; }
|
||||||
|
.error { background: rgba(180,45,65,.28); padding: 12px; border-radius: 10px; margin-bottom: 14px; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.account, .columns { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
local wheelBusy = false
|
||||||
|
local casinoPeds = {}
|
||||||
|
local uiOpen = false
|
||||||
|
|
||||||
|
local function notify(message) exports.devx_core:Notify(message) end
|
||||||
|
local function help(text)
|
||||||
|
BeginTextCommandDisplayHelp('STRING'); AddTextComponentSubstringPlayerName(text); EndTextCommandDisplayHelp(0, false, true, -1)
|
||||||
|
end
|
||||||
|
local function loadModel(name)
|
||||||
|
local hash = GetHashKey(name); RequestModel(hash)
|
||||||
|
local deadline = GetGameTimer() + 6000
|
||||||
|
while not HasModelLoaded(hash) and GetGameTimer() < deadline do Wait(0) end
|
||||||
|
return HasModelLoaded(hash) and hash or nil
|
||||||
|
end
|
||||||
|
local function ensureCasinoInterior()
|
||||||
|
for _, ipl in ipairs(DevXCasinoConfig.Ipls or {}) do RequestIpl(ipl) end
|
||||||
|
local p = DevXCasinoConfig.InteriorEntry
|
||||||
|
SetFocusPosAndVel(p.x, p.y, p.z, 0.0, 0.0, 0.0)
|
||||||
|
RequestCollisionAtCoord(p.x, p.y, p.z)
|
||||||
|
NewLoadSceneStartSphere(p.x, p.y, p.z, 150.0, 0)
|
||||||
|
local deadline = GetGameTimer() + 9000
|
||||||
|
while not IsNewLoadSceneLoaded() and GetGameTimer() < deadline do Wait(0) end
|
||||||
|
NewLoadSceneStop(); ClearFocus()
|
||||||
|
end
|
||||||
|
local function teleport(position)
|
||||||
|
DoScreenFadeOut(250); while not IsScreenFadedOut() do Wait(0) end
|
||||||
|
local ped = PlayerPedId(); FreezeEntityPosition(ped, true)
|
||||||
|
RequestCollisionAtCoord(position.x, position.y, position.z)
|
||||||
|
SetEntityCoordsNoOffset(ped, position.x, position.y, position.z, false, false, false)
|
||||||
|
SetEntityHeading(ped, position.w or position.heading or 0.0)
|
||||||
|
local deadline = GetGameTimer() + 5000
|
||||||
|
while not HasCollisionLoadedAroundEntity(ped) and GetGameTimer() < deadline do RequestCollisionAtCoord(position.x, position.y, position.z); Wait(0) end
|
||||||
|
FreezeEntityPosition(ped, false); Wait(150); DoScreenFadeIn(350)
|
||||||
|
end
|
||||||
|
local function spawnCasinoPeds()
|
||||||
|
for _, ped in ipairs(casinoPeds) do if DoesEntityExist(ped) then DeleteEntity(ped) end end
|
||||||
|
casinoPeds = {}
|
||||||
|
for _, definition in ipairs(DevXCasinoConfig.Npcs or {}) do
|
||||||
|
local model = loadModel(definition.model)
|
||||||
|
if model then
|
||||||
|
local c = definition.coords
|
||||||
|
local ped = CreatePed(4, model, c.x, c.y, c.z, c.w, false, false)
|
||||||
|
SetEntityInvincible(ped, true); SetBlockingOfNonTemporaryEvents(ped, true); FreezeEntityPosition(ped, true)
|
||||||
|
TaskStartScenarioInPlace(ped, definition.scenario or 'WORLD_HUMAN_STAND_MOBILE', 0, true)
|
||||||
|
casinoPeds[#casinoPeds + 1] = ped; SetModelAsNoLongerNeeded(model)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local function getWheelObject()
|
||||||
|
for _, modelName in ipairs(DevXCasinoConfig.WheelModels or {}) do
|
||||||
|
local object = GetClosestObjectOfType(DevXCasinoConfig.Wheel.x, DevXCasinoConfig.Wheel.y, DevXCasinoConfig.Wheel.z, 3.0, GetHashKey(modelName), false, false, false)
|
||||||
|
if object and object ~= 0 and DoesEntityExist(object) then return object end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local function animateWheel(result)
|
||||||
|
local wheel = getWheelObject()
|
||||||
|
if not wheel then notify('Das originale Casino-Glücksrad ist noch nicht geladen. Betritt das Casino erneut.'); return false end
|
||||||
|
local duration, start = 6200, GetGameTimer()
|
||||||
|
local base = GetEntityRotation(wheel, 2).y
|
||||||
|
local segment = 360.0 / tonumber(result.totalSegments or 8)
|
||||||
|
local target = 1440.0 + (360.0 - ((tonumber(result.segment or 0) * segment) + segment * .5))
|
||||||
|
FreezeEntityPosition(wheel, true)
|
||||||
|
while GetGameTimer() - start < duration do
|
||||||
|
local progress = (GetGameTimer() - start) / duration
|
||||||
|
local eased = 1.0 - ((1.0 - progress) ^ 4)
|
||||||
|
SetEntityRotation(wheel, 0.0, (base + target * eased) % 360.0, 0.0, 2, true)
|
||||||
|
help('Das Glücksrad dreht sich …'); Wait(0)
|
||||||
|
end
|
||||||
|
notify(('Glücksrad: %s gewonnen!'):format(result.reward and result.reward.label or 'Gewinn'))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local function spinWheel()
|
||||||
|
if #(GetEntityCoords(PlayerPedId()) - DevXCasinoConfig.Wheel) > 3.0 then
|
||||||
|
notify('Du musst direkt am Glücksrad stehen.')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if wheelBusy then return end
|
||||||
|
wheelBusy = true
|
||||||
|
DevXTriggerCallback('casino:spin', {}, function(result)
|
||||||
|
if not result.ok then notify(result.error or 'Das Glücksrad konnte nicht gedreht werden.'); wheelBusy = false; return end
|
||||||
|
CreateThread(function() animateWheel(result); wheelBusy = false end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
local function openUi(mode)
|
||||||
|
uiOpen = true; SetNuiFocus(true, true)
|
||||||
|
DevXTriggerCallback('casino:status', {}, function(status)
|
||||||
|
SendNUIMessage({ action = 'open', mode = mode, status = status })
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
local function closeUi()
|
||||||
|
uiOpen = false; SetNuiFocus(false, false); SendNUIMessage({ action = 'close' })
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_casino:client:notify', notify)
|
||||||
|
RegisterCommand('dailywheel', spinWheel, false)
|
||||||
|
RegisterNUICallback('close', function(_, cb) closeUi(); cb({ ok = true }) end)
|
||||||
|
RegisterNUICallback('refresh', function(_, cb) DevXTriggerCallback('casino:status', {}, cb) end)
|
||||||
|
RegisterNUICallback('exchange', function(data, cb) DevXTriggerCallback('casino:exchange', data, cb) end)
|
||||||
|
RegisterNUICallback('slots', function(data, cb) DevXTriggerCallback('casino:slots', data, cb) end)
|
||||||
|
RegisterNUICallback('roulette', function(data, cb) DevXTriggerCallback('casino:roulette', data, cb) end)
|
||||||
|
RegisterNUICallback('blackjackStart', function(data, cb) DevXTriggerCallback('casino:blackjackStart', data, cb) end)
|
||||||
|
RegisterNUICallback('blackjackAction', function(data, cb) DevXTriggerCallback('casino:blackjackAction', data, cb) end)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
ensureCasinoInterior(); Wait(1000); spawnCasinoPeds()
|
||||||
|
local blip = AddBlipForCoord(DevXCasinoConfig.ExteriorEntry.x, DevXCasinoConfig.ExteriorEntry.y, DevXCasinoConfig.ExteriorEntry.z)
|
||||||
|
SetBlipSprite(blip, 679); SetBlipScale(blip, .8); SetBlipColour(blip, 27); SetBlipAsShortRange(blip, true)
|
||||||
|
BeginTextCommandSetBlipName('STRING'); AddTextComponentString('Diamond Casino'); EndTextCommandSetBlipName(blip)
|
||||||
|
end)
|
||||||
|
|
||||||
|
local points = {
|
||||||
|
{ key = 'cashier', position = DevXCasinoConfig.Cashier, label = '~INPUT_CONTEXT~ Casino-Kasse und Chips', mode = 'cashier' },
|
||||||
|
{ key = 'slots', position = DevXCasinoConfig.Slots, label = '~INPUT_CONTEXT~ Spielautomaten', mode = 'slots' },
|
||||||
|
{ key = 'roulette', position = DevXCasinoConfig.Roulette, label = '~INPUT_CONTEXT~ Roulette', mode = 'roulette' },
|
||||||
|
{ key = 'blackjack', position = DevXCasinoConfig.Blackjack, label = '~INPUT_CONTEXT~ Blackjack', mode = 'blackjack' }
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while true do
|
||||||
|
local sleep = 700
|
||||||
|
if exports.devx_core:IsPlayerLoaded() and not uiOpen then
|
||||||
|
local coords = GetEntityCoords(PlayerPedId())
|
||||||
|
local exterior = vector3(DevXCasinoConfig.ExteriorEntry.x, DevXCasinoConfig.ExteriorEntry.y, DevXCasinoConfig.ExteriorEntry.z)
|
||||||
|
local interiorExit = vector3(DevXCasinoConfig.InteriorExit.x, DevXCasinoConfig.InteriorExit.y, DevXCasinoConfig.InteriorExit.z)
|
||||||
|
if #(coords - exterior) < 12.0 then
|
||||||
|
sleep = 0; DrawMarker(1, exterior.x, exterior.y, exterior.z - 1.0,0,0,0,0,0,0,1.1,1.1,.3,190,110,255,130,false,false,2,false,nil,nil,false)
|
||||||
|
if #(coords - exterior) < 1.5 then help('~INPUT_CONTEXT~ Casino betreten'); if IsControlJustReleased(0,38) then ensureCasinoInterior(); teleport(DevXCasinoConfig.InteriorEntry); spawnCasinoPeds() end end
|
||||||
|
elseif #(coords - interiorExit) < 8.0 then
|
||||||
|
sleep = 0; DrawMarker(1, interiorExit.x, interiorExit.y, interiorExit.z - 1.0,0,0,0,0,0,0,1.0,1.0,.25,190,110,255,130,false,false,2,false,nil,nil,false)
|
||||||
|
if #(coords - interiorExit) < 1.5 then help('~INPUT_CONTEXT~ Casino verlassen'); if IsControlJustReleased(0,38) then teleport(DevXCasinoConfig.ExteriorEntry) end end
|
||||||
|
else
|
||||||
|
local wheelDistance = #(coords - DevXCasinoConfig.Wheel)
|
||||||
|
if wheelDistance < 10.0 then
|
||||||
|
sleep = 0; DrawMarker(2, DevXCasinoConfig.Wheel.x,DevXCasinoConfig.Wheel.y,DevXCasinoConfig.Wheel.z+.15,0,0,0,0,180,0,.25,.25,.25,255,190,70,150,false,false,2,false,nil,nil,false)
|
||||||
|
if wheelDistance < 1.8 then help(wheelBusy and 'Das Glücksrad dreht sich …' or '~INPUT_CONTEXT~ täglichen Glücksrad-Dreh starten'); if not wheelBusy and IsControlJustReleased(0,38) then spinWheel() end end
|
||||||
|
end
|
||||||
|
for _, point in ipairs(points) do
|
||||||
|
local distance = #(coords - point.position)
|
||||||
|
if distance < 8.0 then
|
||||||
|
sleep = 0; DrawMarker(2,point.position.x,point.position.y,point.position.z+.15,0,0,0,0,180,0,.22,.22,.22,210,160,70,145,false,false,2,false,nil,nil,false)
|
||||||
|
if distance < 1.6 then help(point.label); if IsControlJustReleased(0,38) then openUi(point.mode) end end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Wait(sleep)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('onResourceStop', function(resource)
|
||||||
|
if resource ~= GetCurrentResourceName() then return end
|
||||||
|
closeUi()
|
||||||
|
for _, ped in ipairs(casinoPeds) do if DoesEntityExist(ped) then DeleteEntity(ped) end end
|
||||||
|
end)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
fx_version 'cerulean'
|
||||||
|
game 'gta5'
|
||||||
|
|
||||||
|
author 'Colin-Joel / DevX Studios'
|
||||||
|
description 'DevX RP Diamond Casino with NPC ambience, lucky wheel, chips, slots, roulette and blackjack'
|
||||||
|
version '0.4.3'
|
||||||
|
lua54 'yes'
|
||||||
|
shared_script 'shared/config.lua'
|
||||||
|
ui_page 'web/index.html'
|
||||||
|
files { 'web/index.html', 'web/style.css', 'web/app.js' }
|
||||||
|
client_scripts { '@devx_core/client/callbacks.lua', 'client/main.lua' }
|
||||||
|
server_scripts { '@oxmysql/lib/MySQL.lua', '@devx_core/server/callbacks.lua', 'server/main.lua' }
|
||||||
|
dependencies { 'devx_core', 'devx_banking', 'devx_ipl' }
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
local spinLocks = {}
|
||||||
|
local gameLocks = {}
|
||||||
|
local blackjackSessions = {}
|
||||||
|
|
||||||
|
local rewards = {
|
||||||
|
{ type = 'cash', amount = 500, label = '500 $ Bargeld' },
|
||||||
|
{ type = 'xp', amount = 25, label = '25 RP-XP' },
|
||||||
|
{ type = 'bank', amount = 1000, label = '1.000 $ Bankguthaben' },
|
||||||
|
{ type = 'cash', amount = 750, label = '750 $ Bargeld' },
|
||||||
|
{ type = 'xp', amount = 50, label = '50 RP-XP' },
|
||||||
|
{ type = 'bank', amount = 1500, label = '1.500 $ Bankguthaben' },
|
||||||
|
{ type = 'cash', amount = 1250, label = '1.250 $ Bargeld' },
|
||||||
|
{ type = 'xp', amount = 100, label = '100 RP-XP' }
|
||||||
|
}
|
||||||
|
|
||||||
|
MySQL.ready(function()
|
||||||
|
MySQL.query.await([[
|
||||||
|
CREATE TABLE IF NOT EXISTS devx_casino_daily (
|
||||||
|
character_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
last_spin DATETIME NULL,
|
||||||
|
total_spins INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (character_id),
|
||||||
|
CONSTRAINT fk_devx_casino_character FOREIGN KEY (character_id)
|
||||||
|
REFERENCES devx_characters (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
]])
|
||||||
|
MySQL.query.await([[
|
||||||
|
CREATE TABLE IF NOT EXISTS devx_casino_wallets (
|
||||||
|
character_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
chips BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (character_id),
|
||||||
|
CONSTRAINT fk_devx_casino_wallet_character FOREIGN KEY (character_id)
|
||||||
|
REFERENCES devx_characters (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
]])
|
||||||
|
end)
|
||||||
|
|
||||||
|
local function amount(value, min, max)
|
||||||
|
local n = math.floor(tonumber(value) or 0)
|
||||||
|
if n < (min or 1) or n > (max or 100000) then return nil end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
local function wallet(characterId)
|
||||||
|
MySQL.query.await('INSERT IGNORE INTO devx_casino_wallets (character_id, chips) VALUES (?, 0)', { characterId })
|
||||||
|
return tonumber(MySQL.scalar.await('SELECT chips FROM devx_casino_wallets WHERE character_id = ?', { characterId })) or 0
|
||||||
|
end
|
||||||
|
local function addChips(characterId, chips)
|
||||||
|
MySQL.query.await([[
|
||||||
|
INSERT INTO devx_casino_wallets (character_id, chips) VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE chips = chips + VALUES(chips)
|
||||||
|
]], { characterId, chips })
|
||||||
|
return wallet(characterId)
|
||||||
|
end
|
||||||
|
local function removeChips(characterId, chips)
|
||||||
|
local changed = MySQL.update.await([[
|
||||||
|
UPDATE devx_casino_wallets SET chips = chips - ? WHERE character_id = ? AND chips >= ?
|
||||||
|
]], { chips, characterId, chips })
|
||||||
|
return changed and changed > 0, wallet(characterId)
|
||||||
|
end
|
||||||
|
local function spinStatus(characterId)
|
||||||
|
local row = MySQL.single.await([[
|
||||||
|
SELECT UNIX_TIMESTAMP(last_spin) AS lastSpin, total_spins AS totalSpins
|
||||||
|
FROM devx_casino_daily WHERE character_id = ? LIMIT 1
|
||||||
|
]], { characterId })
|
||||||
|
local lastSpin = row and tonumber(row.lastSpin) or 0
|
||||||
|
local nextSpin = lastSpin + DevXCasinoConfig.CooldownSeconds
|
||||||
|
return { available = os.time() >= nextSpin, remainingSeconds = math.max(0, nextSpin - os.time()), totalSpins = row and tonumber(row.totalSpins) or 0 }
|
||||||
|
end
|
||||||
|
local function card()
|
||||||
|
local rank = math.random(1, 13)
|
||||||
|
local labels = { 'A','2','3','4','5','6','7','8','9','10','J','Q','K' }
|
||||||
|
return { rank = rank, label = labels[rank] }
|
||||||
|
end
|
||||||
|
local function handValue(cards)
|
||||||
|
local total, aces = 0, 0
|
||||||
|
for _, item in ipairs(cards) do
|
||||||
|
if item.rank == 1 then total = total + 11; aces = aces + 1
|
||||||
|
elseif item.rank >= 10 then total = total + 10
|
||||||
|
else total = total + item.rank end
|
||||||
|
end
|
||||||
|
while total > 21 and aces > 0 do total = total - 10; aces = aces - 1 end
|
||||||
|
return total
|
||||||
|
end
|
||||||
|
local function blackjackPayload(session, reveal, result)
|
||||||
|
local dealer = {}
|
||||||
|
for index, item in ipairs(session.dealer) do
|
||||||
|
dealer[index] = (reveal or index == 1) and item or { label = '?' }
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
ok = true, active = result == nil, result = result,
|
||||||
|
playerCards = session.player, dealerCards = dealer,
|
||||||
|
playerValue = handValue(session.player), dealerValue = reveal and handValue(session.dealer) or nil,
|
||||||
|
chips = wallet(session.characterId)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
local function settleBlackjack(source, session, outcome)
|
||||||
|
local result = outcome
|
||||||
|
if outcome == 'win' then addChips(session.characterId, session.bet * 2)
|
||||||
|
elseif outcome == 'blackjack' then addChips(session.characterId, math.floor(session.bet * 2.5))
|
||||||
|
elseif outcome == 'push' then addChips(session.characterId, session.bet) end
|
||||||
|
blackjackSessions[source] = nil
|
||||||
|
return blackjackPayload(session, true, result)
|
||||||
|
end
|
||||||
|
|
||||||
|
DevXRegisterCallback('casino:status', function(source)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||||
|
local status = spinStatus(player.characterId)
|
||||||
|
status.ok = true; status.chips = wallet(player.characterId)
|
||||||
|
return status
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('casino:exchange', function(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
local value = amount(payload.amount, 100, 100000)
|
||||||
|
local direction = payload.direction
|
||||||
|
if not player or not value or (direction ~= 'buy' and direction ~= 'sell') then return { ok = false, error = 'Ungültige Wechselanfrage.' } end
|
||||||
|
if gameLocks[source] then return { ok = false, error = 'Eine Casino-Buchung läuft bereits.' } end
|
||||||
|
gameLocks[source] = true
|
||||||
|
local response
|
||||||
|
if direction == 'buy' then
|
||||||
|
local paid, balance = exports.devx_banking:ChargeCharacter(player.characterId, value, 'Casino-Chips', 'Diamond Casino')
|
||||||
|
if paid then exports.devx_core:SetBankCache(source, balance); response = { ok = true, chips = addChips(player.characterId, value) }
|
||||||
|
else response = { ok = false, error = balance } end
|
||||||
|
else
|
||||||
|
local removed = removeChips(player.characterId, value)
|
||||||
|
if removed then
|
||||||
|
local credited, balance = exports.devx_banking:CreditCharacter(player.characterId, value, 'Casino-Chips eingelöst', 'Diamond Casino')
|
||||||
|
if credited then exports.devx_core:SetBankCache(source, balance); response = { ok = true, chips = wallet(player.characterId) }
|
||||||
|
else addChips(player.characterId, value); response = { ok = false, error = balance } end
|
||||||
|
else response = { ok = false, error = 'Du besitzt nicht genügend Chips.' } end
|
||||||
|
end
|
||||||
|
gameLocks[source] = nil
|
||||||
|
return response
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('casino:spin', function(source)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||||
|
if spinLocks[source] then return { ok = false, error = 'Dein Glücksrad-Dreh läuft bereits.' } end
|
||||||
|
spinLocks[source] = true
|
||||||
|
local status = spinStatus(player.characterId)
|
||||||
|
if not status.available then spinLocks[source] = nil; return { ok = false, error = ('Nächster Dreh in %s Stunden.'):format(math.ceil(status.remainingSeconds / 3600)) } end
|
||||||
|
|
||||||
|
local index = math.random(1, #rewards); local reward = rewards[index]; local success = false
|
||||||
|
if reward.type == 'cash' then success = exports.devx_core:AddCash(source, reward.amount, 'Casino-Glücksrad') == true
|
||||||
|
elseif reward.type == 'bank' then
|
||||||
|
local credited, newBalance = exports.devx_banking:CreditCharacter(player.characterId, reward.amount, 'Casino-Glücksrad', 'Diamond Casino')
|
||||||
|
success = credited == true; if success then exports.devx_core:SetBankCache(source, newBalance) end
|
||||||
|
elseif reward.type == 'xp' then success = exports.devx_core:AddMetadataNumber(source, 'rpXp', reward.amount) == true end
|
||||||
|
if not success then spinLocks[source] = nil; return { ok = false, error = 'Gewinn konnte nicht gutgeschrieben werden.' } end
|
||||||
|
|
||||||
|
MySQL.query.await([[
|
||||||
|
INSERT INTO devx_casino_daily (character_id, last_spin, total_spins) VALUES (?, NOW(), 1)
|
||||||
|
ON DUPLICATE KEY UPDATE last_spin = NOW(), total_spins = total_spins + 1
|
||||||
|
]], { player.characterId })
|
||||||
|
spinLocks[source] = nil
|
||||||
|
return { ok = true, reward = reward, segment = index - 1, totalSegments = #rewards, nextSpinSeconds = DevXCasinoConfig.CooldownSeconds }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('casino:slots', function(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source); local bet = amount(payload.bet, 100, 5000)
|
||||||
|
if not player or not bet then return { ok = false, error = 'Ungültiger Einsatz.' } end
|
||||||
|
local removed = removeChips(player.characterId, bet)
|
||||||
|
if not removed then return { ok = false, error = 'Nicht genügend Chips.' } end
|
||||||
|
local symbols = { '7', 'BAR', 'KIRSCHE', 'DIAMANT', 'GLOCKE', 'ZITRONE' }
|
||||||
|
local roll = { symbols[math.random(#symbols)], symbols[math.random(#symbols)], symbols[math.random(#symbols)] }
|
||||||
|
local payout = 0
|
||||||
|
if roll[1] == roll[2] and roll[2] == roll[3] then payout = bet * (roll[1] == '7' and 12 or 7)
|
||||||
|
elseif roll[1] == roll[2] or roll[2] == roll[3] or roll[1] == roll[3] then payout = bet * 2 end
|
||||||
|
if payout > 0 then addChips(player.characterId, payout) end
|
||||||
|
return { ok = true, symbols = roll, payout = payout, chips = wallet(player.characterId) }
|
||||||
|
end)
|
||||||
|
|
||||||
|
local redNumbers = { [1]=true,[3]=true,[5]=true,[7]=true,[9]=true,[12]=true,[14]=true,[16]=true,[18]=true,[19]=true,[21]=true,[23]=true,[25]=true,[27]=true,[30]=true,[32]=true,[34]=true,[36]=true }
|
||||||
|
DevXRegisterCallback('casino:roulette', function(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source); local bet = amount(payload.bet, 100, 10000); local choice = payload.choice
|
||||||
|
if not player or not bet or (choice ~= 'red' and choice ~= 'black' and choice ~= 'green') then return { ok = false, error = 'Ungültiger Einsatz.' } end
|
||||||
|
local removed = removeChips(player.characterId, bet); if not removed then return { ok = false, error = 'Nicht genügend Chips.' } end
|
||||||
|
local number = math.random(0, 36); local color = number == 0 and 'green' or (redNumbers[number] and 'red' or 'black')
|
||||||
|
local payout = color == choice and bet * (color == 'green' and 14 or 2) or 0
|
||||||
|
if payout > 0 then addChips(player.characterId, payout) end
|
||||||
|
return { ok = true, number = number, color = color, payout = payout, chips = wallet(player.characterId) }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('casino:blackjackStart', function(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source); local bet = amount(payload.bet, 100, 10000)
|
||||||
|
if not player or not bet then return { ok = false, error = 'Ungültiger Einsatz.' } end
|
||||||
|
if blackjackSessions[source] then return { ok = false, error = 'Du spielst bereits eine Runde.' } end
|
||||||
|
local removed = removeChips(player.characterId, bet); if not removed then return { ok = false, error = 'Nicht genügend Chips.' } end
|
||||||
|
local session = { characterId = player.characterId, bet = bet, player = { card(), card() }, dealer = { card(), card() } }
|
||||||
|
blackjackSessions[source] = session
|
||||||
|
local pv, dv = handValue(session.player), handValue(session.dealer)
|
||||||
|
if pv == 21 and dv == 21 then return settleBlackjack(source, session, 'push') end
|
||||||
|
if pv == 21 then return settleBlackjack(source, session, 'blackjack') end
|
||||||
|
if dv == 21 then return settleBlackjack(source, session, 'lose') end
|
||||||
|
return blackjackPayload(session, false, nil)
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('casino:blackjackAction', function(source, payload)
|
||||||
|
local session = blackjackSessions[source]
|
||||||
|
if not session then return { ok = false, error = 'Keine aktive Blackjack-Runde.' } end
|
||||||
|
if payload.action == 'hit' then
|
||||||
|
session.player[#session.player + 1] = card()
|
||||||
|
local value = handValue(session.player)
|
||||||
|
if value > 21 then return settleBlackjack(source, session, 'bust') end
|
||||||
|
if value == 21 then payload.action = 'stand' else return blackjackPayload(session, false, nil) end
|
||||||
|
end
|
||||||
|
if payload.action == 'stand' then
|
||||||
|
while handValue(session.dealer) < 17 do session.dealer[#session.dealer + 1] = card() end
|
||||||
|
local pv, dv = handValue(session.player), handValue(session.dealer)
|
||||||
|
if dv > 21 or pv > dv then return settleBlackjack(source, session, 'win')
|
||||||
|
elseif pv == dv then return settleBlackjack(source, session, 'push')
|
||||||
|
else return settleBlackjack(source, session, 'lose') end
|
||||||
|
end
|
||||||
|
return { ok = false, error = 'Ungültige Aktion.' }
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterCommand('resetwheel', function(source, args)
|
||||||
|
if source ~= 0 and not IsPlayerAceAllowed(source, 'devx.admin') then return end
|
||||||
|
local target = tonumber(args[1]) or source
|
||||||
|
local player = exports.devx_core:GetPlayer(target)
|
||||||
|
if not player then return end
|
||||||
|
MySQL.query.await('DELETE FROM devx_casino_daily WHERE character_id = ?', { player.characterId })
|
||||||
|
TriggerClientEvent('devx_casino:client:notify', target, 'Dein täglicher Glücksrad-Dreh wurde zurückgesetzt.')
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
AddEventHandler('playerDropped', function()
|
||||||
|
spinLocks[source] = nil; gameLocks[source] = nil; blackjackSessions[source] = nil
|
||||||
|
end)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
DevXCasinoConfig = {
|
||||||
|
ExteriorEntry = vector4(935.72, 46.86, 81.10, 146.0),
|
||||||
|
InteriorEntry = vector4(1089.70, 206.12, -49.00, 350.0),
|
||||||
|
InteriorExit = vector4(1085.83, 214.41, -49.20, 130.0),
|
||||||
|
Wheel = vector3(1111.12, 229.84, -49.64),
|
||||||
|
WheelModels = { 'vw_prop_vw_luckywheel_02a', 'vw_prop_vw_luckywheel_01a' },
|
||||||
|
Cashier = vector3(1117.48, 219.63, -49.44),
|
||||||
|
Slots = vector3(1114.65, 235.85, -49.64),
|
||||||
|
Roulette = vector3(1143.82, 264.58, -51.84),
|
||||||
|
Blackjack = vector3(1148.76, 269.20, -51.84),
|
||||||
|
CooldownSeconds = 86400,
|
||||||
|
Ipls = {
|
||||||
|
'hei_dlc_windows_casino', 'hei_dlc_casino_aircon', 'vw_dlc_casino_door',
|
||||||
|
'hei_dlc_casino_door', 'vw_casino_main', 'vw_casino_garage', 'vw_casino_carpark'
|
||||||
|
},
|
||||||
|
Npcs = {
|
||||||
|
{ model = 's_f_y_casino_01', coords = vector4(1117.52, 220.48, -49.44, 90.0), scenario = 'WORLD_HUMAN_CLIPBOARD' },
|
||||||
|
{ model = 's_m_y_casino_01', coords = vector4(1144.10, 264.25, -51.84, 180.0), scenario = 'WORLD_HUMAN_STAND_IMPATIENT' },
|
||||||
|
{ model = 's_m_y_casino_01', coords = vector4(1148.95, 268.82, -51.84, 180.0), scenario = 'WORLD_HUMAN_STAND_IMPATIENT' },
|
||||||
|
{ model = 'a_f_y_business_04', coords = vector4(1116.15, 233.75, -49.64, 110.0), scenario = 'WORLD_HUMAN_STAND_MOBILE' },
|
||||||
|
{ model = 'a_m_y_business_03', coords = vector4(1107.65, 229.25, -49.64, 280.0), scenario = 'WORLD_HUMAN_DRINKING' },
|
||||||
|
{ model = 'a_f_y_bevhills_02', coords = vector4(1126.85, 241.30, -49.64, 205.0), scenario = 'WORLD_HUMAN_PARTYING' },
|
||||||
|
{ model = 'a_m_m_bevhills_02', coords = vector4(1136.25, 250.90, -51.04, 50.0), scenario = 'WORLD_HUMAN_STAND_MOBILE' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const resource=typeof GetParentResourceName==='function'?GetParentResourceName():'devx_casino';const app=document.querySelector('#app'),chips=document.querySelector('#chips'),message=document.querySelector('#message');const views=[...document.querySelectorAll('.view')];let blackjackActive=false;
|
||||||
|
async function post(name,data={}){try{const r=await fetch(`https://${resource}/${name}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});return await r.json()}catch{return{ok:false,error:'Keine Antwort vom Casino.'}}}
|
||||||
|
function money(v){return new Intl.NumberFormat('de-DE').format(Number(v)||0)}function setMessage(t){message.textContent=t||''}function setChips(v){chips.textContent=money(v)}function show(id){views.forEach(v=>v.classList.toggle('hidden',v.id!==id));document.querySelector('#title').textContent={cashier:'Casino-Kasse',slots:'Spielautomaten',roulette:'Roulette',blackjack:'Blackjack'}[id]||'Casino'}
|
||||||
|
async function refresh(){const r=await post('refresh');if(r.ok)setChips(r.chips);else setMessage(r.error)}
|
||||||
|
document.querySelector('#close').onclick=()=>post('close');document.addEventListener('keyup',e=>{if(e.key==='Escape')post('close')});document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>show(b.dataset.view));
|
||||||
|
async function exchange(direction){const amount=document.querySelector('#exchangeAmount').value;const r=await post('exchange',{direction,amount});setMessage(r.ok?'Buchung abgeschlossen.':r.error);if(r.ok)setChips(r.chips)}document.querySelector('#buyChips').onclick=()=>exchange('buy');document.querySelector('#sellChips').onclick=()=>exchange('sell');
|
||||||
|
document.querySelector('#slotPlay').onclick=async()=>{setMessage('Walzen drehen …');const r=await post('slots',{bet:document.querySelector('#slotBet').value});if(!r.ok){setMessage(r.error);return}document.querySelectorAll('#reels b').forEach((el,i)=>el.textContent=r.symbols[i]);setChips(r.chips);setMessage(r.payout>0?`Gewinn: ${money(r.payout)} Chips`:'Leider kein Gewinn.')};
|
||||||
|
document.querySelectorAll('[data-color]').forEach(b=>b.onclick=async()=>{const r=await post('roulette',{choice:b.dataset.color,bet:document.querySelector('#rouletteBet').value});if(!r.ok){setMessage(r.error);return}const ball=document.querySelector('#rouletteResult');ball.textContent=r.number;ball.style.background=r.color==='red'?'#9d2b37':r.color==='black'?'#17191e':'#26804a';setChips(r.chips);setMessage(r.payout>0?`Gewinn: ${money(r.payout)} Chips`:`${r.number} · ${r.color}`)});
|
||||||
|
function cards(list){return (list||[]).map(c=>c.label).join(' ')}function renderBlackjack(r){document.querySelector('#dealerCards').textContent=cards(r.dealerCards);document.querySelector('#playerCards').textContent=cards(r.playerCards);document.querySelector('#playerValue').textContent=r.playerValue?`${r.playerValue} Punkte`:'';setChips(r.chips);blackjackActive=Boolean(r.active);document.querySelector('#blackjackActions').classList.toggle('hidden',!blackjackActive);document.querySelector('#blackjackStart').disabled=blackjackActive;if(r.result)setMessage({win:'Gewonnen!',blackjack:'Blackjack!',push:'Unentschieden.',lose:'Dealer gewinnt.',bust:'Überkauft.'}[r.result]||r.result)}
|
||||||
|
document.querySelector('#blackjackStart').onclick=async()=>{const r=await post('blackjackStart',{bet:document.querySelector('#blackjackBet').value});if(!r.ok){setMessage(r.error);return}setMessage('');renderBlackjack(r)};document.querySelectorAll('[data-action]').forEach(b=>b.onclick=async()=>{const r=await post('blackjackAction',{action:b.dataset.action});if(!r.ok){setMessage(r.error);return}renderBlackjack(r)});
|
||||||
|
window.addEventListener('message',e=>{const d=e.data||{};if(d.action==='open'){app.classList.remove('hidden');setChips(d.status?.chips||0);show(d.mode==='lobby'?'cashier':d.mode||'cashier');setMessage('')}if(d.action==='close')app.classList.add('hidden')});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="style.css"></head><body>
|
||||||
|
<main id="app" class="hidden"><section class="card"><button id="close">×</button><small>THE DIAMOND CASINO & RESORT</small><h1 id="title">Casino</h1><div class="wallet"><span>CHIPS</span><strong id="chips">0</strong></div>
|
||||||
|
<nav><button data-view="cashier">Kasse</button><button data-view="slots">Slots</button><button data-view="roulette">Roulette</button><button data-view="blackjack">Blackjack</button></nav>
|
||||||
|
<p id="message"></p>
|
||||||
|
<section id="cashier" class="view"><h2>Casino-Kasse</h2><input id="exchangeAmount" type="number" min="100" step="100" value="1000"><div class="row"><button id="buyChips">Chips kaufen</button><button id="sellChips">Chips einlösen</button></div></section>
|
||||||
|
<section id="slots" class="view hidden"><h2>Spielautomat</h2><div id="reels" class="reels"><b>?</b><b>?</b><b>?</b></div><input id="slotBet" type="number" min="100" max="5000" step="100" value="500"><button id="slotPlay">Drehen</button></section>
|
||||||
|
<section id="roulette" class="view hidden"><h2>Roulette</h2><div id="rouletteResult" class="roulette-result">–</div><input id="rouletteBet" type="number" min="100" max="10000" step="100" value="500"><div class="row three"><button data-color="red">Rot</button><button data-color="black">Schwarz</button><button data-color="green">Grün</button></div></section>
|
||||||
|
<section id="blackjack" class="view hidden"><h2>Blackjack</h2><div class="hands"><div><span>Dealer</span><strong id="dealerCards">–</strong></div><div><span>Du</span><strong id="playerCards">–</strong><small id="playerValue"></small></div></div><input id="blackjackBet" type="number" min="100" max="10000" step="100" value="500"><button id="blackjackStart">Runde starten</button><div id="blackjackActions" class="row hidden"><button data-action="hit">Karte</button><button data-action="stand">Halten</button></div></section>
|
||||||
|
</section></main><script src="app.js"></script></body></html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
:root{font-family:Inter,system-ui,sans-serif;color:#fff}*{box-sizing:border-box}body{margin:0;background:transparent;overflow:hidden}.hidden{display:none!important}button,input{font:inherit}#app{position:fixed;inset:0;display:grid;place-items:center;background:rgba(3,4,8,.52);backdrop-filter:blur(4px)}.card{position:relative;width:min(680px,calc(100vw - 70px));padding:28px;border:1px solid rgba(255,255,255,.14);border-radius:22px;background:linear-gradient(160deg,#171421,#0c0d13);box-shadow:0 35px 100px rgba(0,0,0,.65)}#close{position:absolute;right:18px;top:16px;border:0;background:transparent;color:white;font-size:28px;cursor:pointer}.card>small{color:#d8b66e;font-weight:900;letter-spacing:.18em}.card h1{margin:5px 0 16px}.wallet{position:absolute;right:58px;top:22px;text-align:right}.wallet span{display:block;font-size:9px;color:rgba(255,255,255,.55);letter-spacing:.13em}.wallet strong{font-size:22px;color:#f0cc80}nav{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:15px 0}button{border:1px solid rgba(255,255,255,.11);border-radius:10px;padding:11px;color:white;background:rgba(255,255,255,.06);cursor:pointer;font-weight:800}button:hover{background:rgba(213,176,101,.17);border-color:#d6b267}button:disabled{opacity:.45;cursor:not-allowed}.view{min-height:290px;padding:18px;border-radius:15px;background:rgba(255,255,255,.035)}.view h2{margin:0 0 18px}input{width:100%;padding:12px;margin-bottom:10px;border:1px solid rgba(255,255,255,.12);border-radius:10px;color:white;background:rgba(0,0,0,.25);outline:0}.row{display:grid;grid-template-columns:1fr 1fr;gap:10px}.row.three{grid-template-columns:repeat(3,1fr)}#message{min-height:22px;color:#f0cc80}.reels{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:18px 0}.reels b{height:90px;display:grid;place-items:center;border-radius:14px;background:#f7f1e6;color:#191720;font-size:22px}.roulette-result{height:90px;margin:18px 0;display:grid;place-items:center;border-radius:50%;width:90px;background:#333;font-size:28px;font-weight:900}.hands{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:15px}.hands>div{padding:15px;border-radius:12px;background:rgba(255,255,255,.06)}.hands span,.hands small{display:block;color:rgba(255,255,255,.55);font-size:10px}.hands strong{display:block;margin:8px 0;font-size:20px}
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
local selectorOpen = false
|
||||||
|
local selectorInitialized = false
|
||||||
|
local previewPed = nil
|
||||||
|
local previewCamera = nil
|
||||||
|
local previewModel = nil
|
||||||
|
local introRunning = false
|
||||||
|
local introCamera = nil
|
||||||
|
local introDriver = nil
|
||||||
|
local introVehicle = nil
|
||||||
|
|
||||||
|
local function notify(message)
|
||||||
|
BeginTextCommandThefeedPost('STRING')
|
||||||
|
AddTextComponentSubstringPlayerName(message)
|
||||||
|
EndTextCommandThefeedPostTicker(false, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function runNuiRpc(name, data, nuiCallback, afterResponse)
|
||||||
|
local replied = false
|
||||||
|
|
||||||
|
local function reply(result)
|
||||||
|
if replied then return end
|
||||||
|
replied = true
|
||||||
|
result = result or { ok = false, error = 'Leere Antwort vom Clientscript.' }
|
||||||
|
if afterResponse then afterResponse(result) end
|
||||||
|
nuiCallback(result)
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, errorMessage = pcall(function()
|
||||||
|
DevXTriggerCallback(name, data or {}, reply)
|
||||||
|
end)
|
||||||
|
|
||||||
|
if not ok then
|
||||||
|
print(('[devx_characters] Client-RPC %s konnte nicht gestartet werden: %s'):format(
|
||||||
|
name, tostring(errorMessage)
|
||||||
|
))
|
||||||
|
reply({ ok = false, error = ('Clientscriptfehler bei %s. Öffne F8.'):format(name) })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
SetTimeout(24000, function()
|
||||||
|
reply({ ok = false, error = ('Keine Antwort auf %s. Prüfe F8 und txAdmin.'):format(name) })
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function loadModel(model)
|
||||||
|
local hash = type(model) == 'number' and model or GetHashKey(model)
|
||||||
|
if not IsModelInCdimage(hash) or not IsModelValid(hash) then return nil end
|
||||||
|
|
||||||
|
RequestModel(hash)
|
||||||
|
local deadline = GetGameTimer() + 10000
|
||||||
|
while not HasModelLoaded(hash) and GetGameTimer() < deadline do Wait(0) end
|
||||||
|
return HasModelLoaded(hash) and hash or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function loadAnimDict(dict)
|
||||||
|
RequestAnimDict(dict)
|
||||||
|
local deadline = GetGameTimer() + 7000
|
||||||
|
while not HasAnimDictLoaded(dict) and GetGameTimer() < deadline do Wait(0) end
|
||||||
|
return HasAnimDictLoaded(dict)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function safeDeleteEntity(entity)
|
||||||
|
if entity and DoesEntityExist(entity) then
|
||||||
|
SetEntityAsMissionEntity(entity, true, true)
|
||||||
|
DeleteEntity(entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function applyBasicAppearance(ped, modelName)
|
||||||
|
if not ped or not DoesEntityExist(ped) then return end
|
||||||
|
|
||||||
|
SetPedDefaultComponentVariation(ped)
|
||||||
|
ClearAllPedProps(ped)
|
||||||
|
|
||||||
|
if modelName == 'mp_m_freemode_01' or modelName == 'mp_f_freemode_01' then
|
||||||
|
SetPedHeadBlendData(ped, 0, 0, 0, 0, 0, 0, 0.5, 0.5, 0.0, false)
|
||||||
|
SetPedComponentVariation(ped, 0, 0, 0, 2)
|
||||||
|
SetPedHairColor(ped, 1, 1)
|
||||||
|
SetPedEyeColor(ped, 2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function destroyPreviewScene()
|
||||||
|
if previewCamera and DoesCamExist(previewCamera) then
|
||||||
|
RenderScriptCams(false, true, 350, true, true)
|
||||||
|
DestroyCam(previewCamera, false)
|
||||||
|
end
|
||||||
|
previewCamera = nil
|
||||||
|
|
||||||
|
safeDeleteEntity(previewPed)
|
||||||
|
previewPed = nil
|
||||||
|
previewModel = nil
|
||||||
|
ClearFocus()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function createPreviewCharacter(modelName)
|
||||||
|
if not selectorOpen then return end
|
||||||
|
|
||||||
|
modelName = modelName or 'mp_m_freemode_01'
|
||||||
|
if previewPed and DoesEntityExist(previewPed) and previewModel == modelName then return end
|
||||||
|
|
||||||
|
local model = loadModel(modelName)
|
||||||
|
if not model then
|
||||||
|
modelName = 'mp_m_freemode_01'
|
||||||
|
model = loadModel(modelName)
|
||||||
|
end
|
||||||
|
if not model then
|
||||||
|
SendNUIMessage({ action = 'error', message = 'Vorschaumodell konnte nicht geladen werden.' })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
safeDeleteEntity(previewPed)
|
||||||
|
|
||||||
|
local position = DevXCharacterConfig.Preview.pedPosition
|
||||||
|
SetFocusPosAndVel(position.x, position.y, position.z, 0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
previewPed = CreatePed(4, model, position.x, position.y, position.z, position.heading, false, false)
|
||||||
|
if not DoesEntityExist(previewPed) then
|
||||||
|
SetModelAsNoLongerNeeded(model)
|
||||||
|
SendNUIMessage({ action = 'error', message = 'Charaktervorschau konnte nicht erzeugt werden.' })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
previewModel = modelName
|
||||||
|
SetEntityInvincible(previewPed, true)
|
||||||
|
SetEntityCollision(previewPed, false, false)
|
||||||
|
FreezeEntityPosition(previewPed, true)
|
||||||
|
SetBlockingOfNonTemporaryEvents(previewPed, true)
|
||||||
|
SetPedCanRagdoll(previewPed, false)
|
||||||
|
applyBasicAppearance(previewPed, modelName)
|
||||||
|
|
||||||
|
if loadAnimDict('anim@amb@business@bgen@bgen_no_work@') then
|
||||||
|
TaskPlayAnim(previewPed, 'anim@amb@business@bgen@bgen_no_work@', 'stand_phone_phoneputdown_idle_nowork',
|
||||||
|
3.0, -3.0, -1, 1, 0.0, false, false, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
local cameraPosition = DevXCharacterConfig.Preview.cameraPosition
|
||||||
|
if not previewCamera or not DoesCamExist(previewCamera) then
|
||||||
|
previewCamera = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
|
||||||
|
SetCamCoord(previewCamera, cameraPosition.x, cameraPosition.y, cameraPosition.z)
|
||||||
|
SetCamFov(previewCamera, DevXCharacterConfig.Preview.cameraFov or 32.0)
|
||||||
|
SetCamActive(previewCamera, true)
|
||||||
|
RenderScriptCams(true, true, 450, true, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
PointCamAtEntity(previewCamera, previewPed, 0.0, 0.0, 0.72, true)
|
||||||
|
SetModelAsNoLongerNeeded(model)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function prepareSelectorScene()
|
||||||
|
local stream = DevXCharacterConfig.Preview.streamPosition
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
|
||||||
|
SetEntityCoordsNoOffset(ped, stream.x, stream.y, stream.z, false, false, false)
|
||||||
|
SetEntityHeading(ped, stream.heading)
|
||||||
|
SetEntityVisible(ped, false, false)
|
||||||
|
SetEntityCollision(ped, false, false)
|
||||||
|
FreezeEntityPosition(ped, true)
|
||||||
|
createPreviewCharacter('mp_m_freemode_01')
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setSelector(open)
|
||||||
|
selectorOpen = open
|
||||||
|
SetNuiFocus(open, open)
|
||||||
|
SendNUIMessage({ action = open and 'open' or 'close' })
|
||||||
|
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
if open then
|
||||||
|
DisplayRadar(false)
|
||||||
|
prepareSelectorScene()
|
||||||
|
else
|
||||||
|
destroyPreviewScene()
|
||||||
|
if DoesEntityExist(ped) then
|
||||||
|
SetEntityVisible(ped, true, false)
|
||||||
|
SetEntityCollision(ped, true, true)
|
||||||
|
FreezeEntityPosition(ped, false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function requestCharacters()
|
||||||
|
DevXTriggerCallback('characters:list', {}, function(result)
|
||||||
|
if not result.ok then
|
||||||
|
SendNUIMessage({ action = 'error', message = result.error })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
SendNUIMessage({
|
||||||
|
action = 'characters',
|
||||||
|
characters = result.characters,
|
||||||
|
maxCharacters = result.maxCharacters
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function showDialogue(speaker, text)
|
||||||
|
SendNUIMessage({ action = 'dialogue', speaker = speaker, text = text })
|
||||||
|
end
|
||||||
|
|
||||||
|
local function hideDialogue()
|
||||||
|
SendNUIMessage({ action = 'dialogueHide' })
|
||||||
|
end
|
||||||
|
|
||||||
|
local function showSkipHelp()
|
||||||
|
BeginTextCommandDisplayHelp('STRING')
|
||||||
|
AddTextComponentSubstringPlayerName('Begrüßung überspringen: ~INPUT_JUMP~')
|
||||||
|
EndTextCommandDisplayHelp(0, false, true, -1)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function introControls()
|
||||||
|
showSkipHelp()
|
||||||
|
DisableControlAction(0, 24, true)
|
||||||
|
DisableControlAction(0, 25, true)
|
||||||
|
DisableControlAction(0, 37, true)
|
||||||
|
DisableControlAction(0, 75, true)
|
||||||
|
return IsControlJustReleased(0, 22)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function waitIntro(milliseconds)
|
||||||
|
local deadline = GetGameTimer() + milliseconds
|
||||||
|
while introRunning and GetGameTimer() < deadline do
|
||||||
|
if introControls() then return false end
|
||||||
|
Wait(0)
|
||||||
|
end
|
||||||
|
return introRunning
|
||||||
|
end
|
||||||
|
|
||||||
|
local function waitUntilIntro(predicate, timeoutMs)
|
||||||
|
local deadline = GetGameTimer() + timeoutMs
|
||||||
|
while introRunning and GetGameTimer() < deadline do
|
||||||
|
if introControls() then return 'skip' end
|
||||||
|
if predicate() then return 'ready' end
|
||||||
|
Wait(0)
|
||||||
|
end
|
||||||
|
return 'timeout'
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setIntroCamera(position, target, fov, transition)
|
||||||
|
if introCamera and DoesCamExist(introCamera) then
|
||||||
|
DestroyCam(introCamera, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
introCamera = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
|
||||||
|
SetCamCoord(introCamera, position.x, position.y, position.z)
|
||||||
|
PointCamAtCoord(introCamera, target.x, target.y, target.z)
|
||||||
|
SetCamFov(introCamera, fov or 48.0)
|
||||||
|
SetCamActive(introCamera, true)
|
||||||
|
RenderScriptCams(true, transition == true, transition and 700 or 0, true, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolveRoadPosition(point)
|
||||||
|
local ok, found, node, heading = pcall(function()
|
||||||
|
local success, position, roadHeading = GetClosestVehicleNodeWithHeading(
|
||||||
|
point.x, point.y, point.z, 1, 3.0, 0
|
||||||
|
)
|
||||||
|
return success, position, roadHeading
|
||||||
|
end)
|
||||||
|
|
||||||
|
if ok and found and node then
|
||||||
|
return { x = node.x, y = node.y, z = node.z, heading = heading or point.heading }
|
||||||
|
end
|
||||||
|
return point
|
||||||
|
end
|
||||||
|
|
||||||
|
local function playGesture(ped, animation)
|
||||||
|
local dict = 'gestures@m@standing@casual'
|
||||||
|
if not loadAnimDict(dict) then return end
|
||||||
|
TaskPlayAnim(ped, dict, animation, 4.0, -4.0, 2600, 48, 0.0, false, false, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function walkTo(ped, target, timeoutMs)
|
||||||
|
TaskFollowNavMeshToCoord(ped, target.x, target.y, target.z, 1.0, timeoutMs, 0.3, false, target.heading or 0.0)
|
||||||
|
local result = waitUntilIntro(function()
|
||||||
|
return #(GetEntityCoords(ped) - vector3(target.x, target.y, target.z)) < 1.4
|
||||||
|
end, timeoutMs)
|
||||||
|
|
||||||
|
if result == 'timeout' and DoesEntityExist(ped) then
|
||||||
|
SetEntityCoordsNoOffset(ped, target.x, target.y, target.z, false, false, false)
|
||||||
|
SetEntityHeading(ped, target.heading or GetEntityHeading(ped))
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local function finishWelcomeIntro(markCompleted)
|
||||||
|
introRunning = false
|
||||||
|
hideDialogue()
|
||||||
|
|
||||||
|
DoScreenFadeOut(350)
|
||||||
|
local fadeDeadline = GetGameTimer() + 1400
|
||||||
|
while not IsScreenFadedOut() and GetGameTimer() < fadeDeadline do Wait(0) end
|
||||||
|
|
||||||
|
if introCamera and DoesCamExist(introCamera) then
|
||||||
|
RenderScriptCams(false, false, 0, true, true)
|
||||||
|
DestroyCam(introCamera, false)
|
||||||
|
end
|
||||||
|
introCamera = nil
|
||||||
|
|
||||||
|
local playerPed = PlayerPedId()
|
||||||
|
local finalSpawn = DevXCharacterConfig.Intro.finalSpawn
|
||||||
|
SetEntityCoordsNoOffset(playerPed, finalSpawn.x, finalSpawn.y, finalSpawn.z, false, false, false)
|
||||||
|
SetEntityHeading(playerPed, finalSpawn.heading)
|
||||||
|
SetEntityVisible(playerPed, true, false)
|
||||||
|
SetEntityCollision(playerPed, true, true)
|
||||||
|
SetEntityInvincible(playerPed, false)
|
||||||
|
FreezeEntityPosition(playerPed, false)
|
||||||
|
ClearPedTasksImmediately(playerPed)
|
||||||
|
|
||||||
|
safeDeleteEntity(introDriver)
|
||||||
|
safeDeleteEntity(introVehicle)
|
||||||
|
introDriver = nil
|
||||||
|
introVehicle = nil
|
||||||
|
|
||||||
|
DisplayRadar(true)
|
||||||
|
ClearFocus()
|
||||||
|
TriggerEvent('devx_hud:client:setVisible', true)
|
||||||
|
TriggerServerEvent('devx_core:server:updatePosition', finalSpawn)
|
||||||
|
|
||||||
|
if markCompleted then
|
||||||
|
DevXTriggerCallback('characters:completeIntro', {}, function(result)
|
||||||
|
if not result.ok then
|
||||||
|
print(('[devx_characters] Introstatus konnte nicht gespeichert werden: %s'):format(
|
||||||
|
tostring(result.error)
|
||||||
|
))
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
DoScreenFadeIn(600)
|
||||||
|
notify('Willkommen in DevX-City. Dein neues Leben beginnt jetzt.')
|
||||||
|
end
|
||||||
|
|
||||||
|
local function startFollowCamera(ped)
|
||||||
|
local config = DevXCharacterConfig.Intro.followCamera
|
||||||
|
if introCamera and DoesCamExist(introCamera) then
|
||||||
|
DestroyCam(introCamera, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
introCamera = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
|
||||||
|
AttachCamToEntity(
|
||||||
|
introCamera,
|
||||||
|
ped,
|
||||||
|
config.offset.x, config.offset.y, config.offset.z,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
PointCamAtEntity(
|
||||||
|
introCamera,
|
||||||
|
ped,
|
||||||
|
config.lookAtOffset.x, config.lookAtOffset.y, config.lookAtOffset.z,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
SetCamFov(introCamera, config.fov or 48.0)
|
||||||
|
SetCamActive(introCamera, true)
|
||||||
|
RenderScriptCams(true, true, 600, true, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parkIntroVehicle(vehicle, position)
|
||||||
|
SetEntityCoordsNoOffset(vehicle, position.x, position.y, position.z, false, false, false)
|
||||||
|
SetEntityHeading(vehicle, position.heading)
|
||||||
|
SetVehicleOnGroundProperly(vehicle)
|
||||||
|
SetVehicleEngineOn(vehicle, false, true, false)
|
||||||
|
SetVehicleHandbrake(vehicle, true)
|
||||||
|
FreezeEntityPosition(vehicle, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function startWelcomeIntro()
|
||||||
|
if introRunning then return end
|
||||||
|
introRunning = true
|
||||||
|
|
||||||
|
local config = DevXCharacterConfig.Intro
|
||||||
|
local playerPed = PlayerPedId()
|
||||||
|
local driverModel = loadModel(config.driverModel)
|
||||||
|
local vehicleModel = loadModel(config.vehicleModel)
|
||||||
|
|
||||||
|
if not driverModel or not vehicleModel then
|
||||||
|
finishWelcomeIntro(true)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
TriggerEvent('devx_hud:client:setVisible', false)
|
||||||
|
SetNuiFocus(false, false)
|
||||||
|
DisplayRadar(false)
|
||||||
|
SetEntityInvincible(playerPed, true)
|
||||||
|
FreezeEntityPosition(playerPed, false)
|
||||||
|
|
||||||
|
local parked = config.vehicleParked
|
||||||
|
introVehicle = CreateVehicle(
|
||||||
|
vehicleModel, parked.x, parked.y, parked.z,
|
||||||
|
parked.heading, false, false
|
||||||
|
)
|
||||||
|
introDriver = CreatePed(
|
||||||
|
4, driverModel,
|
||||||
|
config.driverWait.x, config.driverWait.y, config.driverWait.z,
|
||||||
|
config.driverWait.heading, false, false
|
||||||
|
)
|
||||||
|
|
||||||
|
SetModelAsNoLongerNeeded(driverModel)
|
||||||
|
SetModelAsNoLongerNeeded(vehicleModel)
|
||||||
|
|
||||||
|
if not DoesEntityExist(introVehicle) or not DoesEntityExist(introDriver) then
|
||||||
|
finishWelcomeIntro(true)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
SetEntityAsMissionEntity(introVehicle, true, true)
|
||||||
|
SetEntityAsMissionEntity(introDriver, true, true)
|
||||||
|
SetEntityInvincible(introVehicle, true)
|
||||||
|
SetEntityInvincible(introDriver, true)
|
||||||
|
SetBlockingOfNonTemporaryEvents(introDriver, true)
|
||||||
|
SetPedCanRagdoll(introDriver, false)
|
||||||
|
SetVehicleDoorsLocked(introVehicle, 1)
|
||||||
|
parkIntroVehicle(introVehicle, parked)
|
||||||
|
|
||||||
|
startFollowCamera(playerPed)
|
||||||
|
|
||||||
|
DoScreenFadeIn(700)
|
||||||
|
if not waitIntro(900) then finishWelcomeIntro(true) return end
|
||||||
|
|
||||||
|
TaskFollowNavMeshToCoord(
|
||||||
|
playerPed,
|
||||||
|
config.terminalExit.x, config.terminalExit.y, config.terminalExit.z,
|
||||||
|
1.0, 17000, 0.2, false, config.terminalExit.heading
|
||||||
|
)
|
||||||
|
|
||||||
|
local walkResult = waitUntilIntro(function()
|
||||||
|
return #(GetEntityCoords(playerPed) - vector3(
|
||||||
|
config.terminalExit.x, config.terminalExit.y, config.terminalExit.z
|
||||||
|
)) < 1.5
|
||||||
|
end, 17000)
|
||||||
|
if walkResult == 'skip' then finishWelcomeIntro(true) return end
|
||||||
|
if walkResult == 'timeout' then
|
||||||
|
SetEntityCoordsNoOffset(
|
||||||
|
playerPed,
|
||||||
|
config.terminalExit.x, config.terminalExit.y, config.terminalExit.z,
|
||||||
|
false, false, false
|
||||||
|
)
|
||||||
|
SetEntityHeading(playerPed, config.terminalExit.heading)
|
||||||
|
end
|
||||||
|
|
||||||
|
local playerWalk = walkTo(playerPed, config.meetingPoint, 9000)
|
||||||
|
if playerWalk == 'skip' then finishWelcomeIntro(true) return end
|
||||||
|
|
||||||
|
TaskGoStraightToCoord(
|
||||||
|
introDriver,
|
||||||
|
config.meetingPoint.x + 1.30,
|
||||||
|
config.meetingPoint.y - 0.10,
|
||||||
|
config.meetingPoint.z,
|
||||||
|
1.0, 7000, config.meetingPoint.heading + 180.0, 0.15
|
||||||
|
)
|
||||||
|
local driverResult = waitUntilIntro(function()
|
||||||
|
return #(GetEntityCoords(introDriver) - vector3(
|
||||||
|
config.meetingPoint.x + 1.30,
|
||||||
|
config.meetingPoint.y - 0.10,
|
||||||
|
config.meetingPoint.z
|
||||||
|
)) < 1.4
|
||||||
|
end, 7000)
|
||||||
|
if driverResult == 'skip' then finishWelcomeIntro(true) return end
|
||||||
|
if driverResult == 'timeout' then
|
||||||
|
SetEntityCoordsNoOffset(
|
||||||
|
introDriver,
|
||||||
|
config.meetingPoint.x + 1.30,
|
||||||
|
config.meetingPoint.y - 0.10,
|
||||||
|
config.meetingPoint.z,
|
||||||
|
false, false, false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local greetingCamera = config.greetingCamera
|
||||||
|
setIntroCamera(greetingCamera, {
|
||||||
|
x = config.meetingPoint.x,
|
||||||
|
y = config.meetingPoint.y,
|
||||||
|
z = config.meetingPoint.z + 0.90
|
||||||
|
}, 43.0, true)
|
||||||
|
|
||||||
|
TaskTurnPedToFaceEntity(playerPed, introDriver, 1200)
|
||||||
|
TaskTurnPedToFaceEntity(introDriver, playerPed, 1200)
|
||||||
|
if not waitIntro(1200) then finishWelcomeIntro(true) return end
|
||||||
|
|
||||||
|
PlayAmbientSpeech1(introDriver, 'GENERIC_HI', 'SPEECH_PARAMS_FORCE')
|
||||||
|
playGesture(introDriver, 'gesture_hello')
|
||||||
|
showDialogue(config.driverName, 'Hey! Du musst neu hier sein. Willkommen in DevX-City.')
|
||||||
|
if not waitIntro(4300) then finishWelcomeIntro(true) return end
|
||||||
|
|
||||||
|
playGesture(introDriver, 'gesture_hand_right')
|
||||||
|
showDialogue(config.driverName, 'Ich bin Mason. Mein Wagen steht schon bereit – ich bringe dich direkt ins Zentrum.')
|
||||||
|
if not waitIntro(5000) then finishWelcomeIntro(true) return end
|
||||||
|
|
||||||
|
playGesture(playerPed, 'gesture_nod_yes_hard')
|
||||||
|
showDialogue('Du', 'Danke. Ich kenne mich hier noch überhaupt nicht aus.')
|
||||||
|
if not waitIntro(3700) then finishWelcomeIntro(true) return end
|
||||||
|
|
||||||
|
playGesture(introDriver, 'gesture_point')
|
||||||
|
showDialogue(config.driverName, 'Dann komm. Unterwegs erkläre ich dir die wichtigsten Dinge.')
|
||||||
|
if not waitIntro(4100) then finishWelcomeIntro(true) return end
|
||||||
|
hideDialogue()
|
||||||
|
|
||||||
|
ClearPedTasks(playerPed)
|
||||||
|
ClearPedTasks(introDriver)
|
||||||
|
FreezeEntityPosition(introVehicle, false)
|
||||||
|
SetVehicleHandbrake(introVehicle, false)
|
||||||
|
SetVehicleEngineOn(introVehicle, true, true, false)
|
||||||
|
SetVehicleDoorsLocked(introVehicle, 1)
|
||||||
|
|
||||||
|
TaskEnterVehicle(introDriver, introVehicle, 12000, -1, 1.0, 1, 0)
|
||||||
|
TaskEnterVehicle(playerPed, introVehicle, 12000, 0, 1.0, 1, 0)
|
||||||
|
|
||||||
|
local enterResult = waitUntilIntro(function()
|
||||||
|
return IsPedInVehicle(introDriver, introVehicle, false)
|
||||||
|
and IsPedInVehicle(playerPed, introVehicle, false)
|
||||||
|
end, 12000)
|
||||||
|
if enterResult == 'skip' then finishWelcomeIntro(true) return end
|
||||||
|
if enterResult ~= 'ready' then
|
||||||
|
SetPedIntoVehicle(introDriver, introVehicle, -1)
|
||||||
|
SetPedIntoVehicle(playerPed, introVehicle, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
if introCamera and DoesCamExist(introCamera) then
|
||||||
|
RenderScriptCams(false, true, 900, true, true)
|
||||||
|
DestroyCam(introCamera, false)
|
||||||
|
introCamera = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
TaskVehicleDriveToCoordLongrange(
|
||||||
|
introDriver, introVehicle,
|
||||||
|
config.destinationRoad.x, config.destinationRoad.y, config.destinationRoad.z,
|
||||||
|
config.driveSpeed, config.drivingStyle, 10.0
|
||||||
|
)
|
||||||
|
|
||||||
|
showDialogue(config.driverName, 'Am Anfang startest du klein. Arbeit, Kontakte und ein eigenes Zuhause kommen Schritt für Schritt.')
|
||||||
|
if not waitIntro(6200) then finishWelcomeIntro(true) return end
|
||||||
|
|
||||||
|
showDialogue(config.driverName, 'Mit M öffnest du dein Handy. Dort findest du deine Telefonnummer und dein Bankkonto.')
|
||||||
|
if not waitIntro(5600) then finishWelcomeIntro(true) return end
|
||||||
|
hideDialogue()
|
||||||
|
|
||||||
|
local driveDeadline = GetGameTimer() + config.maxDriveTimeMs
|
||||||
|
while introRunning and GetGameTimer() < driveDeadline do
|
||||||
|
if introControls() then finishWelcomeIntro(true) return end
|
||||||
|
if not DoesEntityExist(introVehicle) or not DoesEntityExist(introDriver) then break end
|
||||||
|
|
||||||
|
local coords = GetEntityCoords(introVehicle)
|
||||||
|
if #(coords - vector3(config.destinationRoad.x, config.destinationRoad.y, config.destinationRoad.z)) < 28.0 then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
Wait(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
if not introRunning then return end
|
||||||
|
TaskVehicleTempAction(introDriver, introVehicle, 27, 2500)
|
||||||
|
showDialogue(config.driverName, 'Da wären wir. Willkommen im Zentrum von DevX-City – und viel Erfolg!')
|
||||||
|
if not waitIntro(4700) then finishWelcomeIntro(true) return end
|
||||||
|
finishWelcomeIntro(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function spawnCharacter(player)
|
||||||
|
local shouldPlayIntro = DevXCharacterConfig.Intro.enabled and player.introCompleted ~= true
|
||||||
|
local position = shouldPlayIntro
|
||||||
|
and DevXCharacterConfig.Intro.terminalInside
|
||||||
|
or player.position
|
||||||
|
or DevXConfig.StreetSpawn
|
||||||
|
|
||||||
|
local modelName = player.model or 'mp_m_freemode_01'
|
||||||
|
local model = loadModel(modelName)
|
||||||
|
if not model then
|
||||||
|
SendNUIMessage({ action = 'error', message = 'Charaktermodell konnte nicht geladen werden.' })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
DoScreenFadeOut(250)
|
||||||
|
while not IsScreenFadedOut() do Wait(0) end
|
||||||
|
|
||||||
|
exports.spawnmanager:spawnPlayer({
|
||||||
|
x = position.x,
|
||||||
|
y = position.y,
|
||||||
|
z = position.z,
|
||||||
|
heading = position.heading or 0.0,
|
||||||
|
model = model,
|
||||||
|
skipFade = true
|
||||||
|
}, function()
|
||||||
|
SetModelAsNoLongerNeeded(model)
|
||||||
|
setSelector(false)
|
||||||
|
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
applyBasicAppearance(ped, modelName)
|
||||||
|
SetEntityCollision(ped, true, true)
|
||||||
|
SetEntityVisible(ped, true, false)
|
||||||
|
FreezeEntityPosition(ped, false)
|
||||||
|
TriggerEvent('devx_characters:client:spawned', player)
|
||||||
|
|
||||||
|
if shouldPlayIntro then
|
||||||
|
CreateThread(startWelcomeIntro)
|
||||||
|
else
|
||||||
|
DisplayRadar(true)
|
||||||
|
DoScreenFadeIn(500)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function initializeSelector()
|
||||||
|
if selectorInitialized then return end
|
||||||
|
selectorInitialized = true
|
||||||
|
|
||||||
|
exports.spawnmanager:setAutoSpawn(false)
|
||||||
|
while not NetworkIsSessionStarted() do Wait(100) end
|
||||||
|
|
||||||
|
local spawn = DevXCharacterConfig.Preview.streamPosition
|
||||||
|
local model = loadModel('mp_m_freemode_01') or loadModel('a_m_y_business_01')
|
||||||
|
if not model then return end
|
||||||
|
|
||||||
|
DoScreenFadeOut(0)
|
||||||
|
local initialSpawnFinished = false
|
||||||
|
|
||||||
|
local function finishInitialization()
|
||||||
|
if initialSpawnFinished then return end
|
||||||
|
initialSpawnFinished = true
|
||||||
|
SetModelAsNoLongerNeeded(model)
|
||||||
|
setSelector(true)
|
||||||
|
requestCharacters()
|
||||||
|
ShutdownLoadingScreen()
|
||||||
|
ShutdownLoadingScreenNui()
|
||||||
|
DoScreenFadeIn(500)
|
||||||
|
end
|
||||||
|
|
||||||
|
exports.spawnmanager:spawnPlayer({
|
||||||
|
x = spawn.x,
|
||||||
|
y = spawn.y,
|
||||||
|
z = spawn.z,
|
||||||
|
heading = spawn.heading,
|
||||||
|
model = model,
|
||||||
|
skipFade = true
|
||||||
|
}, finishInitialization)
|
||||||
|
|
||||||
|
SetTimeout(12000, finishInitialization)
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterNUICallback('ready', function(_, cb)
|
||||||
|
requestCharacters()
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('preview', function(data, cb)
|
||||||
|
createPreviewCharacter(data.model or (data.sex == 'f' and 'mp_f_freemode_01' or 'mp_m_freemode_01'))
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('create', function(data, cb)
|
||||||
|
runNuiRpc('characters:create', data, cb, function(result)
|
||||||
|
if result.ok then requestCharacters() end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('select', function(data, cb)
|
||||||
|
runNuiRpc('characters:select', data, cb, function(result)
|
||||||
|
if result.ok then spawnCharacter(result.player) end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('delete', function(data, cb)
|
||||||
|
runNuiRpc('characters:delete', data, cb, function(result)
|
||||||
|
if result.ok then requestCharacters() end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
CreateThread(initializeSelector)
|
||||||
|
|
||||||
|
AddEventHandler('onClientResourceStart', function(resourceName)
|
||||||
|
if resourceName ~= GetCurrentResourceName() then return end
|
||||||
|
exports.spawnmanager:setAutoSpawn(false)
|
||||||
|
CreateThread(initializeSelector)
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('onResourceStop', function(resourceName)
|
||||||
|
if resourceName ~= GetCurrentResourceName() then return end
|
||||||
|
introRunning = false
|
||||||
|
destroyPreviewScene()
|
||||||
|
safeDeleteEntity(introDriver)
|
||||||
|
safeDeleteEntity(introVehicle)
|
||||||
|
SetNuiFocus(false, false)
|
||||||
|
end)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
fx_version 'cerulean'
|
||||||
|
game 'gta5'
|
||||||
|
|
||||||
|
author 'Colin-Joel / DevX Studios'
|
||||||
|
description 'DevX RP character selection, preview and first-arrival intro'
|
||||||
|
version '0.4.3'
|
||||||
|
|
||||||
|
lua54 'yes'
|
||||||
|
|
||||||
|
shared_scripts {
|
||||||
|
'@devx_core/shared/config.lua',
|
||||||
|
'shared/config.lua'
|
||||||
|
}
|
||||||
|
|
||||||
|
ui_page 'web/index.html'
|
||||||
|
|
||||||
|
files {
|
||||||
|
'web/index.html',
|
||||||
|
'web/style.css',
|
||||||
|
'web/app.js'
|
||||||
|
}
|
||||||
|
|
||||||
|
client_scripts {
|
||||||
|
'@devx_core/client/callbacks.lua',
|
||||||
|
'client/main.lua'
|
||||||
|
}
|
||||||
|
|
||||||
|
server_scripts {
|
||||||
|
'@oxmysql/lib/MySQL.lua',
|
||||||
|
'@devx_core/server/callbacks.lua',
|
||||||
|
'server/main.lua'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
'devx_core',
|
||||||
|
'spawnmanager'
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
local function cleanName(value)
|
||||||
|
if type(value) ~= 'string' then return nil end
|
||||||
|
value = value:gsub('^%s+', ''):gsub('%s+$', '')
|
||||||
|
if #value < 2 or #value > 32 then return nil end
|
||||||
|
if not value:match("^[%aÀ-ÿ'%- ]+$") then return nil end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
local function validDate(value)
|
||||||
|
if type(value) ~= 'string' then return false end
|
||||||
|
local year, month, day = value:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$')
|
||||||
|
year, month, day = tonumber(year), tonumber(month), tonumber(day)
|
||||||
|
if not year or year < 1900 or year > 2010 then return false end
|
||||||
|
if not month or month < 1 or month > 12 then return false end
|
||||||
|
if not day or day < 1 or day > 31 then return false end
|
||||||
|
|
||||||
|
local daysInMonth = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
|
||||||
|
local leapYear = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0)
|
||||||
|
if leapYear then daysInMonth[2] = 29 end
|
||||||
|
return day <= daysInMonth[month]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function normalizeDate(value)
|
||||||
|
if type(value) == 'number' then
|
||||||
|
local seconds = value > 100000000000 and math.floor(value / 1000) or math.floor(value)
|
||||||
|
return os.date('!%Y-%m-%d', seconds)
|
||||||
|
end
|
||||||
|
|
||||||
|
local text = tostring(value or '')
|
||||||
|
local numeric = tonumber(text)
|
||||||
|
if numeric then
|
||||||
|
local seconds = numeric > 100000000000 and math.floor(numeric / 1000) or math.floor(numeric)
|
||||||
|
return os.date('!%Y-%m-%d', seconds)
|
||||||
|
end
|
||||||
|
|
||||||
|
return text:match('^(%d%d%d%d%-%d%d%-%d%d)') or text
|
||||||
|
end
|
||||||
|
|
||||||
|
local function accountNumberFor(characterId)
|
||||||
|
return ('LS%010d'):format(tonumber(characterId))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function phoneNumberFor(characterId)
|
||||||
|
return ('%s-%04d'):format(DevXConfig.PhonePrefix or '555', tonumber(characterId))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function decodeMetadata(value)
|
||||||
|
if type(value) ~= 'string' or value == '' then return {} end
|
||||||
|
local ok, result = pcall(json.decode, value)
|
||||||
|
return ok and type(result) == 'table' and result or {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function listCharacters(license)
|
||||||
|
local rows = MySQL.query.await([[
|
||||||
|
SELECT id, slot, first_name AS firstName, last_name AS lastName,
|
||||||
|
date_of_birth AS dateOfBirth, sex, model, phone_number AS phoneNumber, metadata
|
||||||
|
FROM devx_characters
|
||||||
|
WHERE owner_license = ? AND deleted_at IS NULL
|
||||||
|
ORDER BY slot ASC
|
||||||
|
]], { license }) or {}
|
||||||
|
|
||||||
|
for _, character in ipairs(rows) do
|
||||||
|
local metadata = decodeMetadata(character.metadata)
|
||||||
|
character.id = tonumber(character.id)
|
||||||
|
character.slot = tonumber(character.slot)
|
||||||
|
character.introCompleted = metadata.introCompleted == true
|
||||||
|
character.metadata = nil
|
||||||
|
character.dateOfBirth = normalizeDate(character.dateOfBirth)
|
||||||
|
if not character.phoneNumber or character.phoneNumber == '' then
|
||||||
|
character.phoneNumber = phoneNumberFor(character.id)
|
||||||
|
MySQL.update.await(
|
||||||
|
'UPDATE devx_characters SET phone_number = ? WHERE id = ?',
|
||||||
|
{ character.phoneNumber, character.id }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return rows
|
||||||
|
end
|
||||||
|
|
||||||
|
DevXRegisterCallback('characters:list', function(source)
|
||||||
|
local license = exports.devx_core:GetLicense(source)
|
||||||
|
if not license then return { ok = false, error = 'Keine Lizenzkennung.' } end
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok = true,
|
||||||
|
characters = listCharacters(license),
|
||||||
|
maxCharacters = DevXConfig.MaxCharacters
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('characters:create', function(source, payload)
|
||||||
|
local license = exports.devx_core:GetLicense(source)
|
||||||
|
if not license then return { ok = false, error = 'Keine Lizenzkennung.' } end
|
||||||
|
|
||||||
|
local firstName = cleanName(payload.firstName)
|
||||||
|
local lastName = cleanName(payload.lastName)
|
||||||
|
local sex = payload.sex
|
||||||
|
local dateOfBirth = payload.dateOfBirth
|
||||||
|
|
||||||
|
if not firstName or not lastName then
|
||||||
|
return { ok = false, error = 'Vor- und Nachname sind ungültig.' }
|
||||||
|
end
|
||||||
|
if sex ~= 'm' and sex ~= 'f' and sex ~= 'x' then
|
||||||
|
return { ok = false, error = 'Ungültige Geschlechtsangabe.' }
|
||||||
|
end
|
||||||
|
if not validDate(dateOfBirth) then
|
||||||
|
return { ok = false, error = 'Geburtsdatum ist ungültig.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
local existing = listCharacters(license)
|
||||||
|
if #existing >= DevXConfig.MaxCharacters then
|
||||||
|
return { ok = false, error = 'Alle Charakterplätze sind belegt.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
local used = {}
|
||||||
|
for _, character in ipairs(existing) do used[tonumber(character.slot)] = true end
|
||||||
|
local slot = 1
|
||||||
|
while used[slot] do slot = slot + 1 end
|
||||||
|
|
||||||
|
local model = sex == 'f' and 'mp_f_freemode_01' or 'mp_m_freemode_01'
|
||||||
|
local metadata = json.encode({ introCompleted = false, rpXp = 0 })
|
||||||
|
|
||||||
|
local characterId = tonumber(MySQL.insert.await([[
|
||||||
|
INSERT INTO devx_characters
|
||||||
|
(owner_license, slot, first_name, last_name, date_of_birth, sex, model, cash, metadata)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
]], {
|
||||||
|
license, slot, firstName, lastName, dateOfBirth, sex, model,
|
||||||
|
DevXConfig.StartCash, metadata
|
||||||
|
}))
|
||||||
|
|
||||||
|
if not characterId or characterId < 1 then
|
||||||
|
return { ok = false, error = 'Charakter konnte nicht in der Datenbank erstellt werden.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
local phoneNumber = phoneNumberFor(characterId)
|
||||||
|
MySQL.update.await('UPDATE devx_characters SET phone_number = ? WHERE id = ?', {
|
||||||
|
phoneNumber, characterId
|
||||||
|
})
|
||||||
|
|
||||||
|
local accountNumber = accountNumberFor(characterId)
|
||||||
|
local accountId = tonumber(MySQL.insert.await([[
|
||||||
|
INSERT INTO devx_bank_accounts
|
||||||
|
(character_id, account_number, account_name, account_type, balance)
|
||||||
|
VALUES (?, ?, ?, 'personal', ?)
|
||||||
|
]], {
|
||||||
|
characterId, accountNumber, ('%s %s'):format(firstName, lastName), DevXConfig.StartBank
|
||||||
|
}))
|
||||||
|
|
||||||
|
if not accountId or accountId < 1 then
|
||||||
|
MySQL.update.await('DELETE FROM devx_characters WHERE id = ?', { characterId })
|
||||||
|
return { ok = false, error = 'Bankkonto konnte nicht erstellt werden.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
MySQL.insert.await([[
|
||||||
|
INSERT INTO devx_bank_transactions
|
||||||
|
(account_id, direction, amount, balance_after, counterparty, reference)
|
||||||
|
VALUES (?, 'credit', ?, ?, 'Stadtverwaltung', 'Startguthaben')
|
||||||
|
]], { accountId, DevXConfig.StartBank, DevXConfig.StartBank })
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok = true,
|
||||||
|
characterId = characterId,
|
||||||
|
phoneNumber = phoneNumber,
|
||||||
|
characters = listCharacters(license)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('characters:select', function(source, payload)
|
||||||
|
local characterId = tonumber(payload.characterId)
|
||||||
|
if not characterId then return { ok = false, error = 'Ungültiger Charakter.' } end
|
||||||
|
|
||||||
|
local player, errorMessage = exports.devx_core:ActivateCharacter(source, characterId)
|
||||||
|
if not player then return { ok = false, error = errorMessage } end
|
||||||
|
|
||||||
|
return { ok = true, player = player }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('characters:completeIntro', function(source)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||||
|
|
||||||
|
local saved = exports.devx_core:SetMetadataValue(source, 'introCompleted', true)
|
||||||
|
if not saved then
|
||||||
|
return { ok = false, error = 'Introstatus konnte nicht gespeichert werden.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
local finalSpawn = DevXCharacterConfig.Intro.finalSpawn
|
||||||
|
player.position = {
|
||||||
|
x = finalSpawn.x,
|
||||||
|
y = finalSpawn.y,
|
||||||
|
z = finalSpawn.z,
|
||||||
|
heading = finalSpawn.heading
|
||||||
|
}
|
||||||
|
|
||||||
|
MySQL.update.await(
|
||||||
|
'UPDATE devx_characters SET position = ? WHERE id = ?',
|
||||||
|
{ json.encode(player.position), player.characterId }
|
||||||
|
)
|
||||||
|
|
||||||
|
return { ok = true }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('characters:delete', function(source, payload)
|
||||||
|
local license = exports.devx_core:GetLicense(source)
|
||||||
|
local characterId = tonumber(payload.characterId)
|
||||||
|
if not license or not characterId then
|
||||||
|
return { ok = false, error = 'Ungültige Anfrage.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
local character = MySQL.single.await([[
|
||||||
|
SELECT id, first_name, last_name
|
||||||
|
FROM devx_characters
|
||||||
|
WHERE id = ? AND owner_license = ? AND deleted_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
]], { characterId, license })
|
||||||
|
if not character then return { ok = false, error = 'Charakter wurde nicht gefunden.' } end
|
||||||
|
|
||||||
|
local ownedProperties = tonumber(MySQL.scalar.await([[
|
||||||
|
SELECT COUNT(*) FROM devx_property_owners WHERE character_id = ?
|
||||||
|
]], { characterId })) or 0
|
||||||
|
|
||||||
|
if ownedProperties > 0 then
|
||||||
|
return { ok = false, error = 'Verkaufe zuerst alle Wohnungen dieses Charakters.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
local changed = MySQL.update.await([[
|
||||||
|
DELETE FROM devx_characters
|
||||||
|
WHERE id = ? AND owner_license = ?
|
||||||
|
]], { characterId, license })
|
||||||
|
|
||||||
|
if not changed or changed < 1 then
|
||||||
|
return { ok = false, error = 'Charakter konnte nicht gelöscht werden.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
print(('[devx_characters] %s %s (ID %s) wurde von Lizenz %s gelöscht.'):format(
|
||||||
|
character.first_name, character.last_name, characterId, license
|
||||||
|
))
|
||||||
|
|
||||||
|
return { ok = true, characters = listCharacters(license) }
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterCommand('resetintro', function(source, args)
|
||||||
|
if source ~= 0 and not IsPlayerAceAllowed(source, 'devx.admin') then return end
|
||||||
|
|
||||||
|
local target = tonumber(args[1]) or source
|
||||||
|
if not target or target == 0 then return end
|
||||||
|
|
||||||
|
local player = exports.devx_core:GetPlayer(target)
|
||||||
|
if not player then return end
|
||||||
|
exports.devx_core:SetMetadataValue(target, 'introCompleted', false)
|
||||||
|
end, false)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
DevXCharacterConfig = {
|
||||||
|
Preview = {
|
||||||
|
streamPosition = { x = 402.90, y = -1001.05, z = -99.00, heading = 180.0 },
|
||||||
|
pedPosition = { x = 402.90, y = -997.05, z = -99.00, heading = 180.0 },
|
||||||
|
cameraPosition = { x = 402.90, y = -1000.05, z = -98.15 },
|
||||||
|
cameraFov = 32.0
|
||||||
|
},
|
||||||
|
|
||||||
|
Intro = {
|
||||||
|
enabled = true,
|
||||||
|
driverName = 'Mason',
|
||||||
|
driverModel = 'a_m_m_business_01',
|
||||||
|
vehicleModel = 'schafter2',
|
||||||
|
|
||||||
|
-- Der Spieler startet im Terminal und läuft automatisch nach draußen.
|
||||||
|
terminalInside = { x = -1042.75, y = -2745.65, z = 21.36, heading = 329.0 },
|
||||||
|
terminalExit = { x = -1038.10, y = -2738.20, z = 20.17, heading = 329.0 },
|
||||||
|
meetingPoint = { x = -1032.80, y = -2733.10, z = 20.17, heading = 238.0 },
|
||||||
|
|
||||||
|
-- Das Fahrzeug steht bereits am Straßenrand. Es fährt nicht erst auf das Flughafengelände.
|
||||||
|
vehicleParked = { x = -1024.72, y = -2729.45, z = 19.90, heading = 239.0 },
|
||||||
|
driverWait = { x = -1029.35, y = -2731.20, z = 20.05, heading = 61.0 },
|
||||||
|
|
||||||
|
-- Die Kamera ist während des Laufens am Spieler befestigt und folgt ihm.
|
||||||
|
followCamera = {
|
||||||
|
offset = { x = 2.35, y = -4.20, z = 1.65 },
|
||||||
|
lookAtOffset = { x = 0.0, y = 0.0, z = 0.75 },
|
||||||
|
fov = 48.0
|
||||||
|
},
|
||||||
|
greetingCamera = { x = -1035.65, y = -2729.15, z = 22.05 },
|
||||||
|
|
||||||
|
destinationRoad = { x = 231.52, y = -872.71, z = 29.95 },
|
||||||
|
finalSpawn = { x = 216.80, y = -884.15, z = 30.69, heading = 337.0 },
|
||||||
|
|
||||||
|
driveSpeed = 18.0,
|
||||||
|
drivingStyle = 786603,
|
||||||
|
maxDriveTimeMs = 120000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
const resource = typeof GetParentResourceName === 'function' ? GetParentResourceName() : 'devx_characters';
|
||||||
|
|
||||||
|
const app = document.querySelector('#app');
|
||||||
|
const charactersEl = document.querySelector('#characters');
|
||||||
|
const createOverlay = document.querySelector('#createOverlay');
|
||||||
|
const deleteOverlay = document.querySelector('#deleteOverlay');
|
||||||
|
const createForm = document.querySelector('#createForm');
|
||||||
|
const newButton = document.querySelector('#newCharacter');
|
||||||
|
const playButton = document.querySelector('#playCharacter');
|
||||||
|
const deleteButton = document.querySelector('#deleteCharacter');
|
||||||
|
const sexSelect = document.querySelector('#sexSelect');
|
||||||
|
const messageEl = document.querySelector('#message');
|
||||||
|
const slotCounter = document.querySelector('#slotCounter');
|
||||||
|
const selectedName = document.querySelector('#selectedName');
|
||||||
|
const selectedDob = document.querySelector('#selectedDob');
|
||||||
|
const selectedSlot = document.querySelector('#selectedSlot');
|
||||||
|
const selectedPhone = document.querySelector('#selectedPhone');
|
||||||
|
const selectedStatus = document.querySelector('#selectedStatus');
|
||||||
|
const dialogue = document.querySelector('#dialogue');
|
||||||
|
const dialogueSpeaker = document.querySelector('#dialogueSpeaker');
|
||||||
|
const dialogueText = document.querySelector('#dialogueText');
|
||||||
|
const createButton = createForm.querySelector('button[type="submit"]');
|
||||||
|
const deleteConfirmInput = document.querySelector('#deleteConfirmInput');
|
||||||
|
const confirmDeleteButton = document.querySelector('#confirmDelete');
|
||||||
|
|
||||||
|
let maxCharacters = 3;
|
||||||
|
let characters = [];
|
||||||
|
let selectedId = null;
|
||||||
|
let deleteTarget = null;
|
||||||
|
|
||||||
|
async function post(event, data = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://${resource}/${event}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
const body = await response.text();
|
||||||
|
if (!response.ok) throw new Error(`NUI ${event}: HTTP ${response.status}`);
|
||||||
|
if (!body) return { ok: true };
|
||||||
|
return JSON.parse(body);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') throw new Error('Der Server hat nach 30 Sekunden nicht geantwortet.');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>"']/g, char => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
}[char]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(text = '', type = 'error') {
|
||||||
|
messageEl.textContent = text;
|
||||||
|
messageEl.dataset.type = type;
|
||||||
|
messageEl.classList.toggle('hidden', !text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedCharacter() {
|
||||||
|
return characters.find(character => Number(character.id) === Number(selectedId)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (typeof value === 'number' || /^\d{11,}$/.test(String(value || ''))) {
|
||||||
|
const date = new Date(Number(value));
|
||||||
|
if (!Number.isNaN(date.getTime())) return date.toLocaleDateString('de-DE');
|
||||||
|
}
|
||||||
|
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
return match ? `${match[3]}.${match[2]}.${match[1]}` : String(value || '–');
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewCharacter(character) {
|
||||||
|
const payload = character
|
||||||
|
? { model: character.model, sex: character.sex }
|
||||||
|
: { model: sexSelect.value === 'f' ? 'mp_f_freemode_01' : 'mp_m_freemode_01', sex: sexSelect.value };
|
||||||
|
post('preview', payload).catch(error => message(`Vorschaufehler: ${error.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDetails() {
|
||||||
|
const character = selectedCharacter();
|
||||||
|
const hasCharacter = Boolean(character);
|
||||||
|
selectedName.textContent = hasCharacter ? `${character.firstName} ${character.lastName}` : 'Kein Charakter';
|
||||||
|
selectedDob.textContent = hasCharacter ? formatDate(character.dateOfBirth) : '–';
|
||||||
|
selectedSlot.textContent = hasCharacter ? `Slot ${character.slot}` : '–';
|
||||||
|
selectedPhone.textContent = hasCharacter ? (character.phoneNumber || 'Wird erstellt') : '–';
|
||||||
|
selectedStatus.textContent = hasCharacter ? (character.introCompleted ? 'Einwohner' : 'Neue Ankunft') : '–';
|
||||||
|
playButton.disabled = !hasCharacter;
|
||||||
|
deleteButton.disabled = !hasCharacter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCharacter(characterId, shouldPreview = true) {
|
||||||
|
selectedId = characterId == null ? null : Number(characterId);
|
||||||
|
document.querySelectorAll('.character-slot').forEach(card => {
|
||||||
|
card.classList.toggle('active', Number(card.dataset.characterId) === Number(selectedId));
|
||||||
|
});
|
||||||
|
updateDetails();
|
||||||
|
if (shouldPreview) previewCharacter(selectedCharacter());
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreator() {
|
||||||
|
if (characters.length >= maxCharacters) return;
|
||||||
|
message('');
|
||||||
|
createForm.reset();
|
||||||
|
createOverlay.classList.remove('hidden');
|
||||||
|
previewCharacter(null);
|
||||||
|
setTimeout(() => createForm.querySelector('input')?.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreator() {
|
||||||
|
createOverlay.classList.add('hidden');
|
||||||
|
createForm.reset();
|
||||||
|
previewCharacter(selectedCharacter());
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete() {
|
||||||
|
deleteTarget = selectedCharacter();
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
document.querySelector('#deleteDescription').textContent = `${deleteTarget.firstName} ${deleteTarget.lastName} wird mit Bankkonto, Telefonnummer und Fortschritt dauerhaft gelöscht.`;
|
||||||
|
deleteConfirmInput.value = '';
|
||||||
|
confirmDeleteButton.disabled = true;
|
||||||
|
deleteOverlay.classList.remove('hidden');
|
||||||
|
setTimeout(() => deleteConfirmInput.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDelete() {
|
||||||
|
deleteOverlay.classList.add('hidden');
|
||||||
|
deleteTarget = null;
|
||||||
|
deleteConfirmInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
charactersEl.innerHTML = '';
|
||||||
|
slotCounter.textContent = `${characters.length} / ${maxCharacters}`;
|
||||||
|
|
||||||
|
for (let slot = 1; slot <= maxCharacters; slot += 1) {
|
||||||
|
const character = characters.find(entry => Number(entry.slot) === slot);
|
||||||
|
const card = document.createElement('button');
|
||||||
|
card.type = 'button';
|
||||||
|
card.className = `character-slot ${character ? '' : 'vacant'}`;
|
||||||
|
|
||||||
|
if (character) {
|
||||||
|
card.dataset.characterId = character.id;
|
||||||
|
card.innerHTML = `
|
||||||
|
<span class="slot-number">${String(slot).padStart(2, '0')}</span>
|
||||||
|
<span class="slot-content">
|
||||||
|
<strong>${escapeHtml(character.firstName)} ${escapeHtml(character.lastName)}</strong>
|
||||||
|
<small>${character.introCompleted ? 'Einwohner von DevX-City' : 'Neue Ankunft'}</small>
|
||||||
|
</span>
|
||||||
|
<span class="slot-arrow">›</span>`;
|
||||||
|
card.addEventListener('click', () => selectCharacter(character.id));
|
||||||
|
} else {
|
||||||
|
card.innerHTML = `
|
||||||
|
<span class="slot-number">${String(slot).padStart(2, '0')}</span>
|
||||||
|
<span class="slot-content"><strong>Freier Charakterplatz</strong><small>Neue Identität erstellen</small></span>
|
||||||
|
<span class="slot-plus">+</span>`;
|
||||||
|
card.addEventListener('click', openCreator);
|
||||||
|
}
|
||||||
|
charactersEl.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
newButton.disabled = characters.length >= maxCharacters;
|
||||||
|
if (!selectedCharacter()) selectedId = characters[0]?.id ?? null;
|
||||||
|
selectCharacter(selectedId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
playButton.addEventListener('click', async () => {
|
||||||
|
const character = selectedCharacter();
|
||||||
|
if (!character) return;
|
||||||
|
playButton.disabled = true;
|
||||||
|
message('Charakter wird geladen …', 'info');
|
||||||
|
try {
|
||||||
|
const result = await post('select', { characterId: Number(character.id) });
|
||||||
|
if (!result.ok) {
|
||||||
|
message(result.error || 'Charakter konnte nicht geladen werden.');
|
||||||
|
playButton.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message(`Verbindungsfehler: ${error.message}`);
|
||||||
|
playButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteButton.addEventListener('click', openDelete);
|
||||||
|
document.querySelector('#cancelDelete').addEventListener('click', closeDelete);
|
||||||
|
deleteConfirmInput.addEventListener('input', () => {
|
||||||
|
confirmDeleteButton.disabled = deleteConfirmInput.value.trim().toUpperCase() !== 'LÖSCHEN';
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmDeleteButton.addEventListener('click', async () => {
|
||||||
|
if (!deleteTarget || deleteConfirmInput.value.trim().toUpperCase() !== 'LÖSCHEN') return;
|
||||||
|
confirmDeleteButton.disabled = true;
|
||||||
|
const targetId = Number(deleteTarget.id);
|
||||||
|
closeDelete();
|
||||||
|
message('Charakter wird gelöscht …', 'info');
|
||||||
|
try {
|
||||||
|
const result = await post('delete', { characterId: targetId });
|
||||||
|
if (!result.ok) {
|
||||||
|
message(result.error || 'Löschen fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
characters = Array.isArray(result.characters) ? result.characters : characters.filter(entry => Number(entry.id) !== targetId);
|
||||||
|
selectedId = characters[0]?.id ?? null;
|
||||||
|
render();
|
||||||
|
message('Charakter wurde dauerhaft gelöscht.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
message(`Verbindungsfehler: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
newButton.addEventListener('click', openCreator);
|
||||||
|
document.querySelector('#cancelCreate').addEventListener('click', closeCreator);
|
||||||
|
document.querySelector('#cancelCreateBottom').addEventListener('click', closeCreator);
|
||||||
|
sexSelect.addEventListener('change', () => previewCharacter(null));
|
||||||
|
|
||||||
|
createForm.addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
message('Charakter wird erstellt …', 'info');
|
||||||
|
createButton.disabled = true;
|
||||||
|
const oldLabel = createButton.textContent;
|
||||||
|
createButton.textContent = 'Wird erstellt …';
|
||||||
|
try {
|
||||||
|
const result = await post('create', Object.fromEntries(new FormData(createForm)));
|
||||||
|
if (!result.ok) {
|
||||||
|
message(result.error || 'Erstellung fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
characters = Array.isArray(result.characters) ? result.characters : characters;
|
||||||
|
selectedId = Number(result.characterId);
|
||||||
|
closeCreator();
|
||||||
|
render();
|
||||||
|
message(`Charakter erstellt. Telefonnummer: ${result.phoneNumber || 'wird geladen'}`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
message(`Verbindungsfehler: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
createButton.disabled = false;
|
||||||
|
createButton.textContent = oldLabel;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keydown', event => {
|
||||||
|
if (event.key !== 'Escape') return;
|
||||||
|
if (!deleteOverlay.classList.contains('hidden')) closeDelete();
|
||||||
|
else if (!createOverlay.classList.contains('hidden')) closeCreator();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('message', event => {
|
||||||
|
const data = event.data || {};
|
||||||
|
if (data.action === 'open') app.classList.remove('hidden');
|
||||||
|
if (data.action === 'close') {
|
||||||
|
app.classList.add('hidden');
|
||||||
|
createOverlay.classList.add('hidden');
|
||||||
|
deleteOverlay.classList.add('hidden');
|
||||||
|
message('');
|
||||||
|
}
|
||||||
|
if (data.action === 'characters') {
|
||||||
|
characters = data.characters || [];
|
||||||
|
maxCharacters = Number(data.maxCharacters) || 3;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
if (data.action === 'error') message(data.message || 'Unbekannter Fehler.');
|
||||||
|
if (data.action === 'dialogue') {
|
||||||
|
dialogueSpeaker.textContent = data.speaker || 'Unbekannt';
|
||||||
|
dialogueText.textContent = data.text || '';
|
||||||
|
dialogue.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
if (data.action === 'dialogueHide') dialogue.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
post('ready').catch(error => message(`Startfehler: ${error.message}`));
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="app" class="hidden">
|
||||||
|
<aside class="selector-panel">
|
||||||
|
<header class="brand">
|
||||||
|
<div class="brand-mark">DX</div>
|
||||||
|
<div>
|
||||||
|
<p>DEVX-CITY ROLEPLAY</p>
|
||||||
|
<h1>Charakterauswahl</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="message" class="message hidden"></div>
|
||||||
|
|
||||||
|
<section class="character-section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span>DEINE CHARAKTERE</span>
|
||||||
|
<small id="slotCounter">0 / 3</small>
|
||||||
|
</div>
|
||||||
|
<div id="characters" class="characters"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="details">
|
||||||
|
<p class="details-kicker">AUSGEWÄHLTER CHARAKTER</p>
|
||||||
|
<h2 id="selectedName">Kein Charakter</h2>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div><span>Geburtsdatum</span><strong id="selectedDob">–</strong></div>
|
||||||
|
<div><span>Charakterplatz</span><strong id="selectedSlot">–</strong></div>
|
||||||
|
<div><span>Telefon</span><strong id="selectedPhone">–</strong></div>
|
||||||
|
<div><span>Status</span><strong id="selectedStatus">–</strong></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="main-actions">
|
||||||
|
<button id="deleteCharacter" class="danger" disabled>Löschen</button>
|
||||||
|
<button id="newCharacter" class="secondary">Neuer Charakter</button>
|
||||||
|
<button id="playCharacter" class="primary" disabled>Stadt betreten</button>
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="preview-hint">
|
||||||
|
<span>DEVX-CITY</span>
|
||||||
|
<strong>Wähle dein neues Leben.</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="createOverlay" class="modal-overlay hidden">
|
||||||
|
<form id="createForm" class="modal-card create-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<p>NEUE IDENTITÄT</p>
|
||||||
|
<h2>Charakter erstellen</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="cancelCreate" class="close-button" aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label><span>Vorname</span><input name="firstName" minlength="2" maxlength="32" autocomplete="off" required></label>
|
||||||
|
<label><span>Nachname</span><input name="lastName" minlength="2" maxlength="32" autocomplete="off" required></label>
|
||||||
|
<label><span>Geburtsdatum</span><input name="dateOfBirth" type="date" min="1900-01-01" max="2010-12-31" required></label>
|
||||||
|
<label><span>Geschlecht</span>
|
||||||
|
<select name="sex" id="sexSelect">
|
||||||
|
<option value="m">Männlich</option>
|
||||||
|
<option value="f">Weiblich</option>
|
||||||
|
<option value="x">Divers</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="form-note">Deine Telefonnummer und dein persönliches Bankkonto werden automatisch erstellt.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" id="cancelCreateBottom" class="secondary">Abbrechen</button>
|
||||||
|
<button type="submit" class="primary">Charakter erstellen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="deleteOverlay" class="modal-overlay hidden">
|
||||||
|
<div class="modal-card delete-card">
|
||||||
|
<div class="warning-icon">!</div>
|
||||||
|
<h2>Charakter dauerhaft löschen?</h2>
|
||||||
|
<p id="deleteDescription">Dieser Vorgang kann nicht rückgängig gemacht werden.</p>
|
||||||
|
<label class="confirm-label">
|
||||||
|
<span>Tippe <strong id="deleteConfirmWord">LÖSCHEN</strong> zur Bestätigung</span>
|
||||||
|
<input id="deleteConfirmInput" autocomplete="off" placeholder="LÖSCHEN">
|
||||||
|
</label>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="cancelDelete" class="secondary">Abbrechen</button>
|
||||||
|
<button id="confirmDelete" class="danger" disabled>Endgültig löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section id="dialogue" class="dialogue hidden" aria-live="polite">
|
||||||
|
<strong id="dialogueSpeaker">Mason</strong>
|
||||||
|
<p id="dialogueText"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
:root {
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: #f7f8fc;
|
||||||
|
--panel: rgba(9, 12, 18, .975);
|
||||||
|
--panel-soft: rgba(16, 20, 29, .985);
|
||||||
|
--line: rgba(255, 255, 255, .11);
|
||||||
|
--muted: #939cab;
|
||||||
|
--accent: #4d7cff;
|
||||||
|
--accent-bright: #7b9cff;
|
||||||
|
--danger: #d65464;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; overflow: hidden; background: transparent; user-select: none; }
|
||||||
|
button, input, select { font: inherit; }
|
||||||
|
button { color: inherit; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
#app {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, rgba(0, 2, 7, .45) 0, rgba(0, 2, 7, .08) 46%, transparent 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-panel {
|
||||||
|
width: min(455px, 36vw);
|
||||||
|
min-width: 390px;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 34px 30px 26px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: linear-gradient(180deg, rgba(13, 17, 25, .99), rgba(6, 8, 13, .985));
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
box-shadow: 26px 0 70px rgba(0, 0, 0, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand { display: flex; align-items: center; gap: 14px; margin-bottom: 28px; }
|
||||||
|
.brand-mark {
|
||||||
|
width: 46px; height: 46px; border-radius: 11px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: linear-gradient(145deg, var(--accent-bright), #345bd6);
|
||||||
|
box-shadow: 0 10px 28px rgba(67, 108, 234, .3);
|
||||||
|
font-weight: 900; letter-spacing: -.06em;
|
||||||
|
}
|
||||||
|
.brand p, .modal-header p { margin: 0 0 4px; color: var(--accent-bright); font-size: 9px; letter-spacing: .2em; font-weight: 850; }
|
||||||
|
.brand h1 { margin: 0; font-size: 24px; line-height: 1.1; letter-spacing: -.025em; }
|
||||||
|
|
||||||
|
.section-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||||
|
.section-heading span { color: #c6cbd5; font-size: 9px; font-weight: 850; letter-spacing: .16em; }
|
||||||
|
.section-heading small { color: var(--muted); font-size: 12px; }
|
||||||
|
.characters { display: grid; gap: 8px; }
|
||||||
|
.character-slot {
|
||||||
|
width: 100%; min-height: 68px; padding: 0 14px;
|
||||||
|
border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
display: grid; grid-template-columns: 36px 1fr 22px; align-items: center; gap: 11px;
|
||||||
|
text-align: left; cursor: pointer;
|
||||||
|
background: rgba(255, 255, 255, .034);
|
||||||
|
transition: border-color .16s ease, background .16s ease, transform .16s ease;
|
||||||
|
}
|
||||||
|
.character-slot:hover { background: rgba(255, 255, 255, .064); transform: translateX(3px); }
|
||||||
|
.character-slot.active {
|
||||||
|
border-color: rgba(83, 126, 255, .82);
|
||||||
|
background: linear-gradient(90deg, rgba(70, 111, 241, .22), rgba(255, 255, 255, .035));
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
.character-slot.vacant { border-style: dashed; opacity: .7; }
|
||||||
|
.character-slot.vacant:hover { opacity: 1; }
|
||||||
|
.slot-number { color: #697386; font-size: 12px; font-weight: 850; }
|
||||||
|
.slot-content { display: grid; gap: 3px; }
|
||||||
|
.slot-content strong { font-size: 14px; font-weight: 780; }
|
||||||
|
.slot-content small { color: var(--muted); font-size: 10px; }
|
||||||
|
.slot-arrow, .slot-plus { justify-self: end; color: var(--accent-bright); font-size: 24px; font-weight: 300; }
|
||||||
|
|
||||||
|
.details {
|
||||||
|
margin-top: 20px; padding: 17px;
|
||||||
|
border: 1px solid var(--line); border-radius: 9px;
|
||||||
|
background: rgba(255, 255, 255, .027);
|
||||||
|
}
|
||||||
|
.details-kicker { margin: 0 0 7px; color: var(--muted); font-size: 8px; letter-spacing: .18em; font-weight: 850; }
|
||||||
|
.details h2 { margin: 0 0 15px; font-size: 21px; }
|
||||||
|
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.detail-grid div { display: grid; gap: 4px; }
|
||||||
|
.detail-grid span { color: var(--muted); font-size: 8px; text-transform: uppercase; letter-spacing: .08em; }
|
||||||
|
.detail-grid strong { font-size: 11px; font-weight: 680; }
|
||||||
|
|
||||||
|
.main-actions { margin-top: auto; padding-top: 20px; display: grid; grid-template-columns: .72fr 1fr 1.18fr; gap: 8px; }
|
||||||
|
button {
|
||||||
|
border: 0; border-radius: 7px; padding: 12px 13px;
|
||||||
|
font-weight: 780; cursor: pointer;
|
||||||
|
transition: filter .15s ease, transform .15s ease, opacity .15s ease;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) { filter: brightness(1.1); transform: translateY(-1px); }
|
||||||
|
button:disabled { opacity: .36; cursor: not-allowed; }
|
||||||
|
.primary { background: linear-gradient(135deg, var(--accent-bright), #3b65dd); }
|
||||||
|
.secondary { background: rgba(255, 255, 255, .085); border: 1px solid var(--line); }
|
||||||
|
.danger { background: rgba(198, 69, 85, .14); border: 1px solid rgba(214, 84, 100, .3); color: #ff9ca8; }
|
||||||
|
|
||||||
|
.preview-hint {
|
||||||
|
position: absolute; right: 42px; bottom: 36px;
|
||||||
|
display: grid; justify-items: end; gap: 5px;
|
||||||
|
text-shadow: 0 3px 16px rgba(0,0,0,.9); pointer-events: none;
|
||||||
|
}
|
||||||
|
.preview-hint span { color: var(--accent-bright); font-size: 10px; letter-spacing: .28em; font-weight: 900; }
|
||||||
|
.preview-hint strong { font-size: 16px; font-weight: 650; }
|
||||||
|
|
||||||
|
.message { margin: -10px 0 15px; padding: 10px 12px; border-radius: 7px; border: 1px solid rgba(214, 84, 100, .25); background: rgba(186, 52, 69, .13); color: #ffadb6; font-size: 11px; }
|
||||||
|
.message[data-type="info"] { border-color: rgba(80, 126, 255, .3); background: rgba(67, 108, 234, .14); color: #b8c9ff; }
|
||||||
|
.message[data-type="success"] { border-color: rgba(64, 181, 124, .3); background: rgba(51, 153, 104, .14); color: #a9ebc9; }
|
||||||
|
|
||||||
|
.modal-overlay { position: fixed; inset: 0; display: grid; place-items: center; background: rgba(3, 5, 9, .68); }
|
||||||
|
.modal-card { width: min(610px, 88vw); padding: 27px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel-soft); box-shadow: 0 30px 100px rgba(0,0,0,.6); }
|
||||||
|
.modal-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 23px; }
|
||||||
|
.modal-header h2, .delete-card h2 { margin: 0; font-size: 26px; }
|
||||||
|
.close-button { width: 38px; height: 38px; padding: 0; border: 1px solid var(--line); background: rgba(255,255,255,.06); font-size: 23px; font-weight: 400; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
|
||||||
|
label { display: grid; gap: 7px; }
|
||||||
|
label span { color: #b8bfcb; font-size: 10px; font-weight: 700; }
|
||||||
|
input, select { width: 100%; padding: 12px 13px; border: 1px solid var(--line); border-radius: 7px; outline: none; background: rgba(0,0,0,.27); color: white; }
|
||||||
|
input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(77,124,255,.12); }
|
||||||
|
option { color: black; }
|
||||||
|
.form-note { color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||||
|
.modal-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 21px; }
|
||||||
|
|
||||||
|
.delete-card { width: min(480px, 86vw); text-align: center; }
|
||||||
|
.warning-icon { width: 48px; height: 48px; margin: 0 auto 16px; display: grid; place-items: center; border-radius: 50%; background: rgba(214,84,100,.16); border: 1px solid rgba(214,84,100,.34); color: #ff9ca8; font-size: 24px; font-weight: 900; }
|
||||||
|
.delete-card > p { color: var(--muted); line-height: 1.55; }
|
||||||
|
.confirm-label { margin-top: 18px; text-align: left; }
|
||||||
|
|
||||||
|
.dialogue {
|
||||||
|
position: fixed; left: 50%; bottom: 6.5vh; transform: translateX(-50%);
|
||||||
|
width: min(940px, 84vw); text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
text-shadow: 0 2px 4px #000, 0 0 14px rgba(0,0,0,.95);
|
||||||
|
}
|
||||||
|
.dialogue strong {
|
||||||
|
display: block; margin-bottom: 6px;
|
||||||
|
color: #79a0ff; font-size: 13px; letter-spacing: .09em; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.dialogue p { margin: 0; font-size: 20px; line-height: 1.38; font-weight: 650; }
|
||||||
|
|
||||||
|
@media (max-width: 1050px) {
|
||||||
|
.selector-panel { width: 420px; min-width: 420px; }
|
||||||
|
.preview-hint { display: none; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
local pendingCallbacks = {}
|
||||||
|
local callbackCounter = 0
|
||||||
|
local resourceName = GetCurrentResourceName()
|
||||||
|
local responseEvent = ('devx_core:client:callback:%s'):format(resourceName)
|
||||||
|
|
||||||
|
function DevXTriggerCallback(name, payload, callback)
|
||||||
|
assert(type(name) == 'string' and name:match('^[%w_%-]+:[%w_%-]+$'),
|
||||||
|
'Callbackname ist ungültig.')
|
||||||
|
assert(type(callback) == 'function', 'Clientcallback muss eine Funktion sein.')
|
||||||
|
|
||||||
|
callbackCounter = callbackCounter + 1
|
||||||
|
local requestId = ('%s:%s:%s'):format(resourceName, GetGameTimer(), callbackCounter)
|
||||||
|
|
||||||
|
pendingCallbacks[requestId] = callback
|
||||||
|
|
||||||
|
TriggerServerEvent(
|
||||||
|
('devx_core:server:callback:%s:%s'):format(resourceName, name),
|
||||||
|
requestId,
|
||||||
|
payload or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
SetTimeout(22000, function()
|
||||||
|
local pending = pendingCallbacks[requestId]
|
||||||
|
if not pending then return end
|
||||||
|
|
||||||
|
pendingCallbacks[requestId] = nil
|
||||||
|
pending({
|
||||||
|
ok = false,
|
||||||
|
error = ('Keine Antwort auf %s. Prüfe die txAdmin- und F8-Konsole.'):format(name)
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterNetEvent(responseEvent)
|
||||||
|
AddEventHandler(responseEvent, function(requestId, result)
|
||||||
|
local callback = pendingCallbacks[requestId]
|
||||||
|
if not callback then return end
|
||||||
|
|
||||||
|
pendingCallbacks[requestId] = nil
|
||||||
|
callback(result or { ok = false, error = 'Leere Serverantwort.' })
|
||||||
|
end)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
local PlayerData = nil
|
||||||
|
|
||||||
|
local function notify(message)
|
||||||
|
BeginTextCommandThefeedPost('STRING')
|
||||||
|
AddTextComponentSubstringPlayerName(message)
|
||||||
|
EndTextCommandThefeedPostTicker(false, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
exports('GetPlayerData', function()
|
||||||
|
return PlayerData
|
||||||
|
end)
|
||||||
|
|
||||||
|
exports('IsPlayerLoaded', function()
|
||||||
|
return PlayerData ~= nil
|
||||||
|
end)
|
||||||
|
|
||||||
|
exports('Notify', notify)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_core:client:playerData', function(data)
|
||||||
|
PlayerData = data
|
||||||
|
LocalPlayer.state:set('devxCharacterId', data.characterId, true)
|
||||||
|
TriggerEvent('devx_core:client:playerLoaded', data)
|
||||||
|
end)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while true do
|
||||||
|
Wait(DevXConfig.PositionSaveIntervalMs)
|
||||||
|
if PlayerData then
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
if DoesEntityExist(ped) and not IsEntityDead(ped) then
|
||||||
|
local coords = GetEntityCoords(ped)
|
||||||
|
TriggerServerEvent('devx_core:server:updatePosition', {
|
||||||
|
x = coords.x,
|
||||||
|
y = coords.y,
|
||||||
|
z = coords.z,
|
||||||
|
heading = GetEntityHeading(ped)
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterCommand('coords', function()
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
local coords = GetEntityCoords(ped)
|
||||||
|
local text = ('vector4(%.2f, %.2f, %.2f, %.2f)'):format(
|
||||||
|
coords.x, coords.y, coords.z, GetEntityHeading(ped)
|
||||||
|
)
|
||||||
|
print(text)
|
||||||
|
notify(text)
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
RegisterCommand('unstuck', function()
|
||||||
|
if not PlayerData then return end
|
||||||
|
local spawn = DevXConfig.StreetSpawn
|
||||||
|
SetEntityCoords(PlayerPedId(), spawn.x, spawn.y, spawn.z, false, false, false, false)
|
||||||
|
SetEntityHeading(PlayerPedId(), spawn.heading)
|
||||||
|
notify('Du wurdest zum Straßenstart teleportiert.')
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_core:client:returnedToWorld', function()
|
||||||
|
notify('Du wurdest in die öffentliche Welt zurückgesetzt.')
|
||||||
|
end)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
fx_version 'cerulean'
|
||||||
|
game 'gta5'
|
||||||
|
|
||||||
|
author 'Colin-Joel / DevX Studios'
|
||||||
|
description 'DevX RP core sessions, callbacks and player state'
|
||||||
|
version '0.4.3'
|
||||||
|
|
||||||
|
lua54 'yes'
|
||||||
|
|
||||||
|
shared_script 'shared/config.lua'
|
||||||
|
|
||||||
|
-- This helper is loaded by dependent resources using
|
||||||
|
-- @devx_core/client/callbacks.lua. It must be part of the client packfile.
|
||||||
|
files {
|
||||||
|
'client/callbacks.lua'
|
||||||
|
}
|
||||||
|
|
||||||
|
client_script 'client/main.lua'
|
||||||
|
|
||||||
|
server_scripts {
|
||||||
|
'@oxmysql/lib/MySQL.lua',
|
||||||
|
'server/main.lua'
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
local registeredCallbacks = {}
|
||||||
|
local pendingExecutions = {}
|
||||||
|
local resourceName = GetCurrentResourceName()
|
||||||
|
|
||||||
|
print(('[%s] DevX RPC v0.4.1 aktiv.'):format(resourceName))
|
||||||
|
|
||||||
|
local function callbackEventName(name)
|
||||||
|
return ('devx_core:server:callback:%s:%s'):format(resourceName, name)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function responseEventName()
|
||||||
|
return ('devx_core:client:callback:%s'):format(resourceName)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sendResult(requestSource, requestId, result)
|
||||||
|
TriggerClientEvent(responseEventName(), requestSource, requestId, result)
|
||||||
|
end
|
||||||
|
|
||||||
|
function DevXRegisterCallback(name, handler)
|
||||||
|
assert(type(name) == 'string' and name:match('^[%w_%-]+:[%w_%-]+$'),
|
||||||
|
'Callbackname ist ungültig.')
|
||||||
|
assert(type(handler) == 'function', 'Callbackhandler muss eine Funktion sein.')
|
||||||
|
assert(not registeredCallbacks[name], ('Callback %s wurde doppelt registriert.'):format(name))
|
||||||
|
|
||||||
|
registeredCallbacks[name] = true
|
||||||
|
local eventName = callbackEventName(name)
|
||||||
|
|
||||||
|
RegisterNetEvent(eventName)
|
||||||
|
AddEventHandler(eventName, function(requestId, payload)
|
||||||
|
local requestSource = source
|
||||||
|
|
||||||
|
if type(requestId) ~= 'string' or #requestId > 160 or type(payload) ~= 'table' then
|
||||||
|
sendResult(requestSource, tostring(requestId or ''), {
|
||||||
|
ok = false,
|
||||||
|
error = 'Ungültige Serveranfrage.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local executionKey = ('%s:%s'):format(requestSource, requestId)
|
||||||
|
if pendingExecutions[executionKey] then
|
||||||
|
sendResult(requestSource, requestId, {
|
||||||
|
ok = false,
|
||||||
|
error = 'Diese Serveranfrage wird bereits verarbeitet.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
pendingExecutions[executionKey] = true
|
||||||
|
local completed = false
|
||||||
|
|
||||||
|
local function finish(result)
|
||||||
|
if completed then return end
|
||||||
|
completed = true
|
||||||
|
pendingExecutions[executionKey] = nil
|
||||||
|
|
||||||
|
if result == nil then result = { ok = true } end
|
||||||
|
sendResult(requestSource, requestId, result)
|
||||||
|
end
|
||||||
|
|
||||||
|
print(('[%s] RPC empfangen: %s von Spieler %s.'):format(resourceName, name, requestSource))
|
||||||
|
|
||||||
|
SetTimeout(20000, function()
|
||||||
|
if completed then return end
|
||||||
|
print(('[%s] RPC %s für Spieler %s hat nach 20 Sekunden nicht geantwortet.'):format(
|
||||||
|
resourceName, name, requestSource
|
||||||
|
))
|
||||||
|
finish({
|
||||||
|
ok = false,
|
||||||
|
error = ('Serververarbeitung für %s abgelaufen. Prüfe die txAdmin-Konsole.'):format(name)
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
local result = handler(requestSource, payload)
|
||||||
|
finish(result)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
local Players = {}
|
||||||
|
local positionRateLimit = {}
|
||||||
|
|
||||||
|
local function getLicense(source)
|
||||||
|
for _, identifier in ipairs(GetPlayerIdentifiers(source)) do
|
||||||
|
if identifier:sub(1, 8) == 'license:' then
|
||||||
|
return identifier
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sanitizeInteger(value, maximum)
|
||||||
|
local number = tonumber(value)
|
||||||
|
if not number then return nil end
|
||||||
|
number = math.floor(number)
|
||||||
|
if number < 0 then return nil end
|
||||||
|
if maximum and number > maximum then return nil end
|
||||||
|
return number
|
||||||
|
end
|
||||||
|
|
||||||
|
local function normalizeDateOfBirth(value)
|
||||||
|
if type(value) == 'number' then
|
||||||
|
local seconds = value > 100000000000 and math.floor(value / 1000) or math.floor(value)
|
||||||
|
return os.date('!%Y-%m-%d', seconds)
|
||||||
|
end
|
||||||
|
|
||||||
|
local text = tostring(value or '')
|
||||||
|
local numeric = tonumber(text)
|
||||||
|
if numeric then
|
||||||
|
local seconds = numeric > 100000000000 and math.floor(numeric / 1000) or math.floor(numeric)
|
||||||
|
return os.date('!%Y-%m-%d', seconds)
|
||||||
|
end
|
||||||
|
|
||||||
|
local date = text:match('^(%d%d%d%d%-%d%d%-%d%d)')
|
||||||
|
return date or text
|
||||||
|
end
|
||||||
|
|
||||||
|
local function phoneNumberFor(characterId)
|
||||||
|
return ('%s-%04d'):format(DevXConfig.PhonePrefix or '555', tonumber(characterId))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensurePhoneNumber(character)
|
||||||
|
if character.phone_number and character.phone_number ~= '' then
|
||||||
|
return character.phone_number
|
||||||
|
end
|
||||||
|
|
||||||
|
local phoneNumber = phoneNumberFor(character.id)
|
||||||
|
MySQL.update.await(
|
||||||
|
'UPDATE devx_characters SET phone_number = ? WHERE id = ? AND (phone_number IS NULL OR phone_number = "")',
|
||||||
|
{ phoneNumber, character.id }
|
||||||
|
)
|
||||||
|
return phoneNumber
|
||||||
|
end
|
||||||
|
|
||||||
|
local function emitPlayerData(source)
|
||||||
|
local player = Players[source]
|
||||||
|
if not player then return end
|
||||||
|
TriggerClientEvent('devx_core:client:playerData', source, player)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function loadCharacter(source, characterId)
|
||||||
|
local license = getLicense(source)
|
||||||
|
characterId = sanitizeInteger(characterId)
|
||||||
|
if not license or not characterId then
|
||||||
|
return nil, 'Ungültige Sitzung.'
|
||||||
|
end
|
||||||
|
|
||||||
|
local character = MySQL.single.await([[
|
||||||
|
SELECT id, slot, first_name, last_name, date_of_birth, sex, model,
|
||||||
|
phone_number, cash, position, metadata
|
||||||
|
FROM devx_characters
|
||||||
|
WHERE id = ? AND owner_license = ? AND deleted_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
]], { characterId, license })
|
||||||
|
|
||||||
|
if not character then
|
||||||
|
return nil, 'Charakter wurde nicht gefunden.'
|
||||||
|
end
|
||||||
|
|
||||||
|
local account = MySQL.single.await([[
|
||||||
|
SELECT id, account_number, account_name, account_type, balance
|
||||||
|
FROM devx_bank_accounts
|
||||||
|
WHERE character_id = ? AND account_type = 'personal'
|
||||||
|
LIMIT 1
|
||||||
|
]], { character.id })
|
||||||
|
|
||||||
|
local metadata = {}
|
||||||
|
if character.metadata and character.metadata ~= '' then
|
||||||
|
local ok, decoded = pcall(json.decode, character.metadata)
|
||||||
|
if ok and type(decoded) == 'table' then metadata = decoded end
|
||||||
|
end
|
||||||
|
|
||||||
|
local position = nil
|
||||||
|
if character.position and character.position ~= '' then
|
||||||
|
local ok, decoded = pcall(json.decode, character.position)
|
||||||
|
if ok and type(decoded) == 'table' then position = decoded end
|
||||||
|
end
|
||||||
|
|
||||||
|
local phoneNumber = ensurePhoneNumber(character)
|
||||||
|
|
||||||
|
Players[source] = {
|
||||||
|
source = source,
|
||||||
|
license = license,
|
||||||
|
characterId = tonumber(character.id),
|
||||||
|
slot = tonumber(character.slot),
|
||||||
|
firstName = character.first_name,
|
||||||
|
lastName = character.last_name,
|
||||||
|
fullName = ('%s %s'):format(character.first_name, character.last_name),
|
||||||
|
dateOfBirth = normalizeDateOfBirth(character.date_of_birth),
|
||||||
|
sex = character.sex,
|
||||||
|
model = character.model,
|
||||||
|
phoneNumber = phoneNumber,
|
||||||
|
cash = tonumber(character.cash) or 0,
|
||||||
|
bank = account and tonumber(account.balance) or 0,
|
||||||
|
accountId = account and tonumber(account.id) or nil,
|
||||||
|
accountNumber = account and account.account_number or nil,
|
||||||
|
position = position,
|
||||||
|
metadata = metadata,
|
||||||
|
rpXp = tonumber(metadata.rpXp) or 0,
|
||||||
|
introCompleted = metadata.introCompleted == true
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Jeder frisch ausgewählte Charakter startet sicher in der öffentlichen Welt.
|
||||||
|
-- So bleiben keine alten Apartment-/Missions-Buckets nach einem Reconnect hängen.
|
||||||
|
SetPlayerRoutingBucket(source, 0)
|
||||||
|
Player(source).state:set('devxCharacterId', Players[source].characterId, true)
|
||||||
|
Player(source).state:set('devxCharacterName', Players[source].fullName, true)
|
||||||
|
Player(source).state:set('devxPhoneNumber', Players[source].phoneNumber, true)
|
||||||
|
|
||||||
|
emitPlayerData(source)
|
||||||
|
return Players[source]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function savePosition(source)
|
||||||
|
local player = Players[source]
|
||||||
|
if not player or not player.position then return end
|
||||||
|
|
||||||
|
MySQL.update.await(
|
||||||
|
'UPDATE devx_characters SET position = ? WHERE id = ?',
|
||||||
|
{ json.encode(player.position), player.characterId }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function persistMetadata(source)
|
||||||
|
local player = Players[source]
|
||||||
|
if not player then return false end
|
||||||
|
|
||||||
|
local changed = MySQL.update.await(
|
||||||
|
'UPDATE devx_characters SET metadata = ? WHERE id = ?',
|
||||||
|
{ json.encode(player.metadata or {}), player.characterId }
|
||||||
|
)
|
||||||
|
return changed ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
exports('GetLicense', getLicense)
|
||||||
|
exports('GetPlayer', function(source)
|
||||||
|
return Players[tonumber(source)]
|
||||||
|
end)
|
||||||
|
|
||||||
|
exports('GetActivePlayers', function()
|
||||||
|
return Players
|
||||||
|
end)
|
||||||
|
|
||||||
|
exports('ActivateCharacter', function(source, characterId)
|
||||||
|
return loadCharacter(tonumber(source), characterId)
|
||||||
|
end)
|
||||||
|
|
||||||
|
exports('SetMetadataValue', function(source, key, value)
|
||||||
|
source = tonumber(source)
|
||||||
|
local player = Players[source]
|
||||||
|
|
||||||
|
if not player or type(key) ~= 'string' or #key < 1 or #key > 64 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
player.metadata = player.metadata or {}
|
||||||
|
player.metadata[key] = value
|
||||||
|
if not persistMetadata(source) then return false end
|
||||||
|
|
||||||
|
if key == 'introCompleted' then player.introCompleted = value == true end
|
||||||
|
if key == 'rpXp' then player.rpXp = tonumber(value) or 0 end
|
||||||
|
|
||||||
|
emitPlayerData(source)
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
|
||||||
|
exports('AddMetadataNumber', function(source, key, amount)
|
||||||
|
source = tonumber(source)
|
||||||
|
amount = tonumber(amount)
|
||||||
|
local player = Players[source]
|
||||||
|
if not player or not amount or type(key) ~= 'string' then return false end
|
||||||
|
|
||||||
|
player.metadata = player.metadata or {}
|
||||||
|
local newValue = math.floor((tonumber(player.metadata[key]) or 0) + amount)
|
||||||
|
player.metadata[key] = newValue
|
||||||
|
if not persistMetadata(source) then return false end
|
||||||
|
|
||||||
|
if key == 'rpXp' then player.rpXp = newValue end
|
||||||
|
emitPlayerData(source)
|
||||||
|
return true, newValue
|
||||||
|
end)
|
||||||
|
|
||||||
|
exports('SetBankCache', function(source, balance)
|
||||||
|
local player = Players[tonumber(source)]
|
||||||
|
balance = sanitizeInteger(balance)
|
||||||
|
if not player or balance == nil then return false end
|
||||||
|
player.bank = balance
|
||||||
|
emitPlayerData(tonumber(source))
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
|
||||||
|
exports('SetCash', function(source, amount, reason)
|
||||||
|
source = tonumber(source)
|
||||||
|
amount = sanitizeInteger(amount, DevXConfig.MaxMoneyOperation)
|
||||||
|
local player = Players[source]
|
||||||
|
if not player or amount == nil then return false end
|
||||||
|
|
||||||
|
local changed = MySQL.update.await(
|
||||||
|
'UPDATE devx_characters SET cash = ? WHERE id = ?',
|
||||||
|
{ amount, player.characterId }
|
||||||
|
)
|
||||||
|
if not changed or changed < 1 then return false end
|
||||||
|
|
||||||
|
player.cash = amount
|
||||||
|
emitPlayerData(source)
|
||||||
|
TriggerEvent('devx_core:server:moneyAudit', source, 'cash_set', amount, reason or 'Unbekannt')
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
|
||||||
|
exports('AddCash', function(source, amount, reason)
|
||||||
|
source = tonumber(source)
|
||||||
|
amount = sanitizeInteger(amount, DevXConfig.MaxMoneyOperation)
|
||||||
|
local player = Players[source]
|
||||||
|
if not player or not amount or amount == 0 then return false end
|
||||||
|
|
||||||
|
local newCash = player.cash + amount
|
||||||
|
local changed = MySQL.update.await(
|
||||||
|
'UPDATE devx_characters SET cash = ? WHERE id = ?',
|
||||||
|
{ newCash, player.characterId }
|
||||||
|
)
|
||||||
|
|
||||||
|
if not changed or changed < 1 then return false end
|
||||||
|
player.cash = newCash
|
||||||
|
emitPlayerData(source)
|
||||||
|
TriggerEvent('devx_core:server:moneyAudit', source, 'cash_credit', amount, reason or 'Unbekannt')
|
||||||
|
return true, newCash
|
||||||
|
end)
|
||||||
|
|
||||||
|
exports('RemoveCash', function(source, amount, reason)
|
||||||
|
source = tonumber(source)
|
||||||
|
amount = sanitizeInteger(amount, DevXConfig.MaxMoneyOperation)
|
||||||
|
local player = Players[source]
|
||||||
|
if not player or not amount or amount == 0 or player.cash < amount then return false end
|
||||||
|
|
||||||
|
local newCash = player.cash - amount
|
||||||
|
local changed = MySQL.update.await(
|
||||||
|
'UPDATE devx_characters SET cash = ? WHERE id = ? AND cash >= ?',
|
||||||
|
{ newCash, player.characterId, amount }
|
||||||
|
)
|
||||||
|
|
||||||
|
if not changed or changed < 1 then return false end
|
||||||
|
player.cash = newCash
|
||||||
|
emitPlayerData(source)
|
||||||
|
TriggerEvent('devx_core:server:moneyAudit', source, 'cash_debit', amount, reason or 'Unbekannt')
|
||||||
|
return true, newCash
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_core:server:updatePosition', function(position)
|
||||||
|
local source = source
|
||||||
|
local player = Players[source]
|
||||||
|
if not player or type(position) ~= 'table' then return end
|
||||||
|
|
||||||
|
local now = os.time()
|
||||||
|
if positionRateLimit[source] and now - positionRateLimit[source] < 10 then return end
|
||||||
|
positionRateLimit[source] = now
|
||||||
|
|
||||||
|
local x, y, z, heading = tonumber(position.x), tonumber(position.y),
|
||||||
|
tonumber(position.z), tonumber(position.heading)
|
||||||
|
if not x or not y or not z or not heading then return end
|
||||||
|
if math.abs(x) > 10000 or math.abs(y) > 10000 or math.abs(z) > 3000 then return end
|
||||||
|
|
||||||
|
player.position = { x = x, y = y, z = z, heading = heading }
|
||||||
|
savePosition(source)
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('playerDropped', function()
|
||||||
|
local source = source
|
||||||
|
savePosition(source)
|
||||||
|
Players[source] = nil
|
||||||
|
positionRateLimit[source] = nil
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('onResourceStop', function(resourceName)
|
||||||
|
if resourceName ~= GetCurrentResourceName() then return end
|
||||||
|
for source in pairs(Players) do savePosition(source) end
|
||||||
|
end)
|
||||||
|
|
||||||
|
MySQL.ready(function()
|
||||||
|
MySQL.query.await([[
|
||||||
|
ALTER TABLE devx_characters
|
||||||
|
ADD COLUMN IF NOT EXISTS phone_number VARCHAR(16) NULL AFTER model
|
||||||
|
]])
|
||||||
|
|
||||||
|
MySQL.query.await([[
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_devx_character_phone
|
||||||
|
ON devx_characters (phone_number)
|
||||||
|
]])
|
||||||
|
|
||||||
|
MySQL.query.await([[
|
||||||
|
CREATE TABLE IF NOT EXISTS devx_bans (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
license VARCHAR(80) NOT NULL,
|
||||||
|
player_name VARCHAR(96) NULL,
|
||||||
|
reason VARCHAR(255) NOT NULL,
|
||||||
|
banned_by VARCHAR(96) NOT NULL,
|
||||||
|
expires_at DATETIME NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
revoked_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_devx_bans_license (license),
|
||||||
|
KEY idx_devx_bans_active (license, revoked_at, expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
]])
|
||||||
|
|
||||||
|
print('[devx_core] Datenbankschema v0.4.1 geprüft.')
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('playerConnecting', function(_, _, deferrals)
|
||||||
|
local source = source
|
||||||
|
deferrals.defer()
|
||||||
|
Wait(0)
|
||||||
|
|
||||||
|
local license = getLicense(source)
|
||||||
|
if not license then
|
||||||
|
deferrals.done('FiveM konnte keine license:-Kennung ermitteln.')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, ban = pcall(function()
|
||||||
|
return MySQL.single.await([[
|
||||||
|
SELECT reason, expires_at
|
||||||
|
FROM devx_bans
|
||||||
|
WHERE license = ?
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
]], { license })
|
||||||
|
end)
|
||||||
|
|
||||||
|
if ok and ban then
|
||||||
|
local expiry = ban.expires_at and ('\nAblauf: %s'):format(tostring(ban.expires_at)) or '\nDauer: permanent'
|
||||||
|
deferrals.done(('Du bist von DevX-City gesperrt.\nGrund: %s%s'):format(ban.reason, expiry))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
deferrals.done()
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_core:server:returnToWorld', function()
|
||||||
|
local source = source
|
||||||
|
if not Players[source] then return end
|
||||||
|
SetPlayerRoutingBucket(source, 0)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterCommand('world', function(source)
|
||||||
|
if source == 0 then return end
|
||||||
|
if not Players[source] then return end
|
||||||
|
SetPlayerRoutingBucket(source, 0)
|
||||||
|
TriggerClientEvent('devx_core:client:returnedToWorld', source)
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
RegisterCommand('setcash', function(source, args)
|
||||||
|
if source ~= 0 and not IsPlayerAceAllowed(source, 'devx.admin') then return end
|
||||||
|
|
||||||
|
local target = tonumber(args[1])
|
||||||
|
local amount = sanitizeInteger(args[2], DevXConfig.MaxMoneyOperation)
|
||||||
|
if not target or amount == nil or not Players[target] then return end
|
||||||
|
exports.devx_core:SetCash(target, amount, ('Admin %s'):format(source))
|
||||||
|
end, false)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
DevXConfig = {
|
||||||
|
MaxCharacters = 3,
|
||||||
|
StartCash = 150,
|
||||||
|
StartBank = 500,
|
||||||
|
|
||||||
|
StreetSpawn = {
|
||||||
|
x = -1037.72,
|
||||||
|
y = -2737.88,
|
||||||
|
z = 20.17,
|
||||||
|
heading = 329.0
|
||||||
|
},
|
||||||
|
|
||||||
|
PhonePrefix = '555',
|
||||||
|
PositionSaveIntervalMs = 30000,
|
||||||
|
MaxMoneyOperation = 100000000,
|
||||||
|
|
||||||
|
AdminWeapons = {
|
||||||
|
WEAPON_PISTOL = 'Pistole',
|
||||||
|
WEAPON_COMBATPISTOL = 'Kampfpistole',
|
||||||
|
WEAPON_SMG = 'MP',
|
||||||
|
WEAPON_CARBINERIFLE = 'Karabiner',
|
||||||
|
WEAPON_PUMPSHOTGUN = 'Pumpgun',
|
||||||
|
WEAPON_STUNGUN = 'Taser',
|
||||||
|
WEAPON_FLASHLIGHT = 'Taschenlampe',
|
||||||
|
WEAPON_NIGHTSTICK = 'Schlagstock',
|
||||||
|
WEAPON_KNIFE = 'Messer'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
local progress = { drill = false, card = false, vehicle = false }
|
||||||
|
local robberyRunning = false
|
||||||
|
local activePrep = nil
|
||||||
|
local objectiveProp = nil
|
||||||
|
local plannerPeds = {}
|
||||||
|
local warehousePeds = {}
|
||||||
|
local missionProps = {}
|
||||||
|
local missionVehicles = {}
|
||||||
|
local cutsceneRunning = false
|
||||||
|
|
||||||
|
local function notify(message) exports.devx_core:Notify(message) end
|
||||||
|
local function help(message)
|
||||||
|
BeginTextCommandDisplayHelp('STRING')
|
||||||
|
AddTextComponentSubstringPlayerName(message)
|
||||||
|
EndTextCommandDisplayHelp(0, false, true, -1)
|
||||||
|
end
|
||||||
|
local function subtitle(message, duration)
|
||||||
|
BeginTextCommandPrint('STRING')
|
||||||
|
AddTextComponentSubstringPlayerName(message)
|
||||||
|
EndTextCommandPrint(duration or 3500, true)
|
||||||
|
end
|
||||||
|
local function drawMarkerAt(coords, red, green, blue)
|
||||||
|
DrawMarker(1, coords.x, coords.y, coords.z - 1.0, 0.0,0.0,0.0,0.0,0.0,0.0,
|
||||||
|
1.2,1.2,0.35,red,green,blue,130,false,false,2,false,nil,nil,false)
|
||||||
|
end
|
||||||
|
local function loadModel(name)
|
||||||
|
local hash = GetHashKey(name)
|
||||||
|
RequestModel(hash)
|
||||||
|
local deadline = GetGameTimer() + 7000
|
||||||
|
while not HasModelLoaded(hash) and GetGameTimer() < deadline do Wait(0) end
|
||||||
|
return HasModelLoaded(hash) and hash or nil
|
||||||
|
end
|
||||||
|
local function loadAnim(dict)
|
||||||
|
RequestAnimDict(dict)
|
||||||
|
local deadline = GetGameTimer() + 5000
|
||||||
|
while not HasAnimDictLoaded(dict) and GetGameTimer() < deadline do Wait(0) end
|
||||||
|
return HasAnimDictLoaded(dict)
|
||||||
|
end
|
||||||
|
local function teleport(coords)
|
||||||
|
DoScreenFadeOut(250)
|
||||||
|
local deadline = GetGameTimer() + 1500
|
||||||
|
while not IsScreenFadedOut() and GetGameTimer() < deadline do Wait(0) end
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
FreezeEntityPosition(ped, true)
|
||||||
|
if coords.z < -10.0 then SetFocusPosAndVel(coords.x, coords.y, coords.z, 0.0, 0.0, 0.0) end
|
||||||
|
RequestCollisionAtCoord(coords.x, coords.y, coords.z)
|
||||||
|
SetEntityCoordsNoOffset(ped, coords.x, coords.y, coords.z, false, false, false)
|
||||||
|
SetEntityHeading(ped, coords.w or coords.heading or 0.0)
|
||||||
|
local collisionDeadline = GetGameTimer() + 5000
|
||||||
|
while not HasCollisionLoadedAroundEntity(ped) and GetGameTimer() < collisionDeadline do
|
||||||
|
RequestCollisionAtCoord(coords.x, coords.y, coords.z)
|
||||||
|
Wait(0)
|
||||||
|
end
|
||||||
|
ClearPedTasksImmediately(ped)
|
||||||
|
FreezeEntityPosition(ped, false)
|
||||||
|
Wait(180)
|
||||||
|
ClearFocus()
|
||||||
|
DoScreenFadeIn(350)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensurePrepInterior()
|
||||||
|
local interior = DevXCrimeConfig.PrepInterior
|
||||||
|
local position = interior.loadAt or interior.spawn
|
||||||
|
SetFocusPosAndVel(position.x, position.y, position.z, 0.0, 0.0, 0.0)
|
||||||
|
RequestCollisionAtCoord(position.x, position.y, position.z)
|
||||||
|
RequestAdditionalCollisionAtCoord(position.x, position.y, position.z)
|
||||||
|
NewLoadSceneStartSphere(position.x, position.y, position.z, 110.0, 0)
|
||||||
|
|
||||||
|
local interiorId = GetInteriorAtCoords(position.x, position.y, position.z)
|
||||||
|
if interiorId ~= 0 then
|
||||||
|
PinInteriorInMemory(interiorId)
|
||||||
|
RefreshInterior(interiorId)
|
||||||
|
end
|
||||||
|
|
||||||
|
local deadline = GetGameTimer() + 12000
|
||||||
|
local ready = false
|
||||||
|
while GetGameTimer() < deadline do
|
||||||
|
local sceneReady = IsNewLoadSceneLoaded()
|
||||||
|
local interiorReady = interiorId == 0 or IsInteriorReady(interiorId)
|
||||||
|
if sceneReady and interiorReady then
|
||||||
|
ready = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
RequestCollisionAtCoord(position.x, position.y, position.z)
|
||||||
|
Wait(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
NewLoadSceneStop()
|
||||||
|
ClearFocus()
|
||||||
|
if not ready then
|
||||||
|
print(('[devx_crime] Sicheres Garagen-Interior nicht bereit. interior=%s'):format(interiorId))
|
||||||
|
end
|
||||||
|
return ready
|
||||||
|
end
|
||||||
|
|
||||||
|
local function deletePeds(list)
|
||||||
|
for _, ped in pairs(list) do
|
||||||
|
if ped and DoesEntityExist(ped) then DeleteEntity(ped) end
|
||||||
|
end
|
||||||
|
for key in pairs(list) do list[key] = nil end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
local function deleteProps(list)
|
||||||
|
for _, object in pairs(list) do
|
||||||
|
if object and DoesEntityExist(object) then DeleteEntity(object) end
|
||||||
|
end
|
||||||
|
for key in pairs(list) do list[key] = nil end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function deleteVehicles(list)
|
||||||
|
for _, vehicle in pairs(list) do
|
||||||
|
if vehicle and DoesEntityExist(vehicle) then DeleteEntity(vehicle) end
|
||||||
|
end
|
||||||
|
for key in pairs(list) do list[key] = nil end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function spawnMissionVehicles()
|
||||||
|
deleteVehicles(missionVehicles)
|
||||||
|
for index, entry in ipairs(DevXCrimeConfig.PrepInterior.vehicles or {}) do
|
||||||
|
local model = loadModel(entry.model)
|
||||||
|
if model then
|
||||||
|
local c = entry.coords
|
||||||
|
local vehicle = CreateVehicle(model, c.x, c.y, c.z, c.w or 0.0, false, false)
|
||||||
|
SetVehicleOnGroundProperly(vehicle)
|
||||||
|
SetVehicleDoorsLocked(vehicle, 2)
|
||||||
|
SetEntityInvincible(vehicle, true)
|
||||||
|
FreezeEntityPosition(vehicle, true)
|
||||||
|
missionVehicles[index] = vehicle
|
||||||
|
SetModelAsNoLongerNeeded(model)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function spawnMissionSetDressing()
|
||||||
|
deleteProps(missionProps)
|
||||||
|
for index, entry in ipairs(DevXCrimeConfig.PrepInterior.setDressing or {}) do
|
||||||
|
local model = loadModel(entry.model)
|
||||||
|
if model then
|
||||||
|
local c = entry.coords
|
||||||
|
local object = CreateObject(model, c.x, c.y, c.z, false, false, false)
|
||||||
|
SetEntityHeading(object, c.w or 0.0)
|
||||||
|
FreezeEntityPosition(object, true)
|
||||||
|
SetEntityCollision(object, true, true)
|
||||||
|
missionProps[index] = object
|
||||||
|
SetModelAsNoLongerNeeded(model)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function spawnPlanners()
|
||||||
|
deletePeds(plannerPeds)
|
||||||
|
local model = loadModel(DevXCrimeConfig.PlannerModel)
|
||||||
|
if not model then return end
|
||||||
|
for _, prep in ipairs(DevXCrimeConfig.PrepLocations) do
|
||||||
|
local c = prep.planner
|
||||||
|
local ped = CreatePed(4, model, c.x, c.y, c.z - 1.0, c.w, false, false)
|
||||||
|
SetEntityInvincible(ped, true)
|
||||||
|
SetBlockingOfNonTemporaryEvents(ped, true)
|
||||||
|
FreezeEntityPosition(ped, true)
|
||||||
|
TaskStartScenarioInPlace(ped, 'WORLD_HUMAN_CLIPBOARD', 0, true)
|
||||||
|
plannerPeds[prep.key] = ped
|
||||||
|
end
|
||||||
|
SetModelAsNoLongerNeeded(model)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function spawnWarehousePeds()
|
||||||
|
deletePeds(warehousePeds)
|
||||||
|
for index, worker in ipairs(DevXCrimeConfig.WarehouseWorkers or {}) do
|
||||||
|
local model = loadModel(worker.model)
|
||||||
|
if model then
|
||||||
|
local c = worker.coords
|
||||||
|
local ped = CreatePed(4, model, c.x, c.y, c.z, c.w, false, false)
|
||||||
|
SetEntityInvincible(ped, true)
|
||||||
|
SetBlockingOfNonTemporaryEvents(ped, true)
|
||||||
|
FreezeEntityPosition(ped, true)
|
||||||
|
TaskStartScenarioInPlace(ped, worker.scenario or 'WORLD_HUMAN_CLIPBOARD', 0, true)
|
||||||
|
warehousePeds[index] = ped
|
||||||
|
SetModelAsNoLongerNeeded(model)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function removeObjectiveProp()
|
||||||
|
if objectiveProp and DoesEntityExist(objectiveProp) then DeleteEntity(objectiveProp) end
|
||||||
|
objectiveProp = nil
|
||||||
|
end
|
||||||
|
local function spawnObjectiveProp(prep)
|
||||||
|
removeObjectiveProp()
|
||||||
|
local model = loadModel(prep.prop)
|
||||||
|
if not model then return end
|
||||||
|
objectiveProp = CreateObject(model, prep.objective.x, prep.objective.y, prep.objective.z + 0.05, false, false, false)
|
||||||
|
SetEntityHeading(objectiveProp, prep.objective.w or 0.0)
|
||||||
|
FreezeEntityPosition(objectiveProp, true)
|
||||||
|
SetModelAsNoLongerNeeded(model)
|
||||||
|
end
|
||||||
|
local function timedAction(label, seconds)
|
||||||
|
local endTime = GetGameTimer() + seconds * 1000
|
||||||
|
FreezeEntityPosition(PlayerPedId(), true)
|
||||||
|
while GetGameTimer() < endTime do
|
||||||
|
local left = math.ceil((endTime - GetGameTimer()) / 1000)
|
||||||
|
help(('%s ~b~%ss'):format(label, left))
|
||||||
|
DisableAllControlActions(0)
|
||||||
|
EnableControlAction(0, 200, true)
|
||||||
|
Wait(0)
|
||||||
|
end
|
||||||
|
FreezeEntityPosition(PlayerPedId(), false)
|
||||||
|
end
|
||||||
|
local function refreshStatus()
|
||||||
|
DevXTriggerCallback('crime:status', {}, function(result)
|
||||||
|
if result.ok then progress = result.progress or progress end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
local function leavePrep()
|
||||||
|
if not activePrep then return end
|
||||||
|
DevXTriggerCallback('crime:leavePrep', {}, function(result)
|
||||||
|
if result.ok then
|
||||||
|
teleport(result.exterior)
|
||||||
|
TriggerEvent('devx_ipl:client:restoreWorld', 'crime-leave')
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
activePrep = nil
|
||||||
|
removeObjectiveProp()
|
||||||
|
deletePeds(warehousePeds)
|
||||||
|
deleteProps(missionProps)
|
||||||
|
deleteVehicles(missionVehicles)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function runPlanningScene(prep, callback)
|
||||||
|
if cutsceneRunning then return end
|
||||||
|
cutsceneRunning = true
|
||||||
|
local planner = plannerPeds[prep.key]
|
||||||
|
if not planner or not DoesEntityExist(planner) then
|
||||||
|
cutsceneRunning = false
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local player = PlayerPedId()
|
||||||
|
FreezeEntityPosition(player, true)
|
||||||
|
ClearPedTasksImmediately(planner)
|
||||||
|
TaskTurnPedToFaceEntity(player, planner, 1500)
|
||||||
|
TaskTurnPedToFaceEntity(planner, player, 1500)
|
||||||
|
Wait(1000)
|
||||||
|
|
||||||
|
local playerCoords = GetEntityCoords(player)
|
||||||
|
local plannerCoords = GetEntityCoords(planner)
|
||||||
|
local midpoint = (playerCoords + plannerCoords) / 2.0
|
||||||
|
local cam = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
|
||||||
|
SetCamCoord(cam, midpoint.x + 2.4, midpoint.y + 2.4, midpoint.z + 1.45)
|
||||||
|
PointCamAtCoord(cam, midpoint.x, midpoint.y, midpoint.z + 0.85)
|
||||||
|
SetCamFov(cam, 43.0)
|
||||||
|
SetCamActive(cam, true)
|
||||||
|
RenderScriptCams(true, true, 600, true, true)
|
||||||
|
|
||||||
|
if loadAnim('gestures@m@standing@casual') then
|
||||||
|
for _, line in ipairs(prep.dialogue or {}) do
|
||||||
|
TaskPlayAnim(planner, 'gestures@m@standing@casual', 'gesture_hand_down', 2.0, -2.0, 2800, 49, 0.0, false, false, false)
|
||||||
|
subtitle(('~b~%s:~s~ %s'):format(DevXCrimeConfig.PlannerName, line), 3300)
|
||||||
|
Wait(3400)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
for _, line in ipairs(prep.dialogue or {}) do
|
||||||
|
subtitle(('~b~%s:~s~ %s'):format(DevXCrimeConfig.PlannerName, line), 3300)
|
||||||
|
Wait(3400)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
RenderScriptCams(false, true, 550, true, true)
|
||||||
|
DestroyCam(cam, false)
|
||||||
|
FreezeEntityPosition(player, false)
|
||||||
|
TaskStartScenarioInPlace(planner, 'WORLD_HUMAN_CLIPBOARD', 0, true)
|
||||||
|
cutsceneRunning = false
|
||||||
|
callback()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function beginPrep(prep)
|
||||||
|
runPlanningScene(prep, function()
|
||||||
|
DevXTriggerCallback('crime:enterPrep', { key = prep.key }, function(result)
|
||||||
|
if not result.ok then notify(result.error); return end
|
||||||
|
if not ensurePrepInterior() then
|
||||||
|
notify('Das Missionslager konnte nicht geladen werden. Die Mission wurde sicher abgebrochen.')
|
||||||
|
DevXTriggerCallback('crime:leavePrep', {}, function() end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
activePrep = result.prep
|
||||||
|
teleport(result.interior.spawn)
|
||||||
|
spawnMissionSetDressing()
|
||||||
|
spawnMissionVehicles()
|
||||||
|
spawnObjectiveProp(activePrep)
|
||||||
|
spawnWarehousePeds()
|
||||||
|
subtitle(('~b~MISSION:~s~ %s'):format(activePrep.objectiveLabel), 4500)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
ensurePrepInterior()
|
||||||
|
while not exports.devx_core:IsPlayerLoaded() do Wait(500) end
|
||||||
|
refreshStatus()
|
||||||
|
spawnPlanners()
|
||||||
|
for _, entry in ipairs(DevXCrimeConfig.PrepLocations) do
|
||||||
|
local blip = AddBlipForCoord(entry.exterior.x, entry.exterior.y, entry.exterior.z)
|
||||||
|
SetBlipSprite(blip,478);SetBlipScale(blip,.65);SetBlipColour(blip,47);SetBlipAsShortRange(blip,true)
|
||||||
|
BeginTextCommandSetBlipName('STRING');AddTextComponentString('Bankraub-Vorbereitung');EndTextCommandSetBlipName(blip)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while true do
|
||||||
|
local sleep = 700
|
||||||
|
if exports.devx_core:IsPlayerLoaded() and not robberyRunning and not cutsceneRunning then
|
||||||
|
local coords = GetEntityCoords(PlayerPedId())
|
||||||
|
if activePrep then
|
||||||
|
local objective = activePrep.objective
|
||||||
|
local objectiveDistance = #(coords - vector3(objective.x, objective.y, objective.z))
|
||||||
|
local exit = DevXCrimeConfig.PrepInterior.exit
|
||||||
|
local exitDistance = #(coords - exit)
|
||||||
|
if objectiveDistance < 15.0 then
|
||||||
|
sleep = 0;drawMarkerAt(objective,70,150,245)
|
||||||
|
if objectiveDistance < 1.6 then
|
||||||
|
help(('~INPUT_CONTEXT~ %s'):format(activePrep.objectiveLabel))
|
||||||
|
if IsControlJustReleased(0,38) then
|
||||||
|
timedAction(activePrep.objectiveLabel,7)
|
||||||
|
DevXTriggerCallback('crime:completePrep',{key=activePrep.key},function(result)
|
||||||
|
if result.ok then
|
||||||
|
progress=result.progress;removeObjectiveProp();deletePeds(warehousePeds);deleteProps(missionProps);deleteVehicles(missionVehicles);activePrep=nil;teleport(result.exterior);TriggerEvent('devx_ipl:client:restoreWorld', 'crime-complete')
|
||||||
|
notify('Vorbereitungsmission abgeschlossen.')
|
||||||
|
else notify(result.error or 'Mission fehlgeschlagen.') end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if exitDistance < 8.0 then
|
||||||
|
sleep=0;drawMarkerAt(exit,230,230,230)
|
||||||
|
if exitDistance < 1.6 then
|
||||||
|
help('~INPUT_CONTEXT~ Lager verlassen')
|
||||||
|
if IsControlJustReleased(0,38) then leavePrep() end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
for _, entry in ipairs(DevXCrimeConfig.PrepLocations) do
|
||||||
|
local distance = #(coords - vector3(entry.exterior.x,entry.exterior.y,entry.exterior.z))
|
||||||
|
if distance < 18.0 then
|
||||||
|
sleep=0;drawMarkerAt(entry.exterior,progress[entry.key] and 70 or 230,progress[entry.key] and 190 or 150,90)
|
||||||
|
if distance < 1.8 then
|
||||||
|
if progress[entry.key] then help(('Vorbereitung abgeschlossen: %s'):format(entry.label))
|
||||||
|
else
|
||||||
|
help(('~INPUT_CONTEXT~ mit %s planen: %s'):format(DevXCrimeConfig.PlannerName, entry.label))
|
||||||
|
if IsControlJustReleased(0,38) then beginPrep(entry) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local bankDistance=#(coords-DevXCrimeConfig.Bank)
|
||||||
|
if bankDistance<18.0 then
|
||||||
|
sleep=0;drawMarkerAt(DevXCrimeConfig.Bank,210,55,75)
|
||||||
|
if bankDistance<1.8 then
|
||||||
|
help('~INPUT_CONTEXT~ vorbereiteten Bankraub starten')
|
||||||
|
if IsControlJustReleased(0,38) then
|
||||||
|
DevXTriggerCallback('crime:startRobbery',{},function(result)
|
||||||
|
if not result.ok then notify(result.error or 'Bankraub konnte nicht gestartet werden.');return end
|
||||||
|
robberyRunning=true
|
||||||
|
CreateThread(function()
|
||||||
|
timedAction('Tresor wird geöffnet …',result.duration)
|
||||||
|
DevXTriggerCallback('crime:finishRobbery',{},function(finish)
|
||||||
|
robberyRunning=false
|
||||||
|
if finish.ok then progress={drill=false,card=false,vehicle=false};notify(('Bankraub erfolgreich: %s $ und %s RP-XP.'):format(finish.reward,finish.xp))
|
||||||
|
else notify(finish.error or 'Bankraub fehlgeschlagen.') end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Wait(sleep)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('onResourceStop',function(resourceName)
|
||||||
|
if resourceName~=GetCurrentResourceName() then return end
|
||||||
|
removeObjectiveProp();deletePeds(plannerPeds);deletePeds(warehousePeds);deleteProps(missionProps);deleteVehicles(missionVehicles);TriggerServerEvent('devx_core:server:returnToWorld');TriggerEvent('devx_ipl:client:restoreWorld', 'crime-stop')
|
||||||
|
end)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
fx_version 'cerulean'
|
||||||
|
game 'gta5'
|
||||||
|
author 'Colin-Joel / DevX Studios'
|
||||||
|
description 'DevX RP bank robbery with preparation missions'
|
||||||
|
version '0.4.3'
|
||||||
|
lua54 'yes'
|
||||||
|
shared_scripts { '@devx_core/shared/config.lua', 'shared/config.lua' }
|
||||||
|
client_scripts { '@devx_core/client/callbacks.lua', 'client/main.lua' }
|
||||||
|
server_scripts { '@oxmysql/lib/MySQL.lua', '@devx_core/server/callbacks.lua', 'server/main.lua' }
|
||||||
|
dependencies { 'devx_core', 'devx_banking', 'devx_ipl' }
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
local activeRobberies = {}
|
||||||
|
local prepSessions = {}
|
||||||
|
local globalCooldownUntil = 0
|
||||||
|
|
||||||
|
local function nearLocation(source, coords, maximum)
|
||||||
|
local ped = GetPlayerPed(source)
|
||||||
|
if not ped or ped <= 0 then return false end
|
||||||
|
local playerCoords = GetEntityCoords(ped)
|
||||||
|
return #(playerCoords - vector3(coords.x, coords.y, coords.z)) <= maximum
|
||||||
|
end
|
||||||
|
|
||||||
|
local function findPrep(key)
|
||||||
|
for _, entry in ipairs(DevXCrimeConfig.PrepLocations) do
|
||||||
|
if entry.key == key then return entry end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getProgress(characterId)
|
||||||
|
local row = MySQL.single.await([[
|
||||||
|
SELECT drill_done AS drillDone, card_done AS cardDone, vehicle_done AS vehicleDone
|
||||||
|
FROM devx_heist_progress WHERE character_id = ? LIMIT 1
|
||||||
|
]], { characterId })
|
||||||
|
if not row then
|
||||||
|
MySQL.insert.await('INSERT IGNORE INTO devx_heist_progress (character_id) VALUES (?)', { characterId })
|
||||||
|
row = { drillDone = 0, cardDone = 0, vehicleDone = 0 }
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
drill = tonumber(row.drillDone) == 1,
|
||||||
|
card = tonumber(row.cardDone) == 1,
|
||||||
|
vehicle = tonumber(row.vehicleDone) == 1
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function leavePrep(source)
|
||||||
|
prepSessions[source] = nil
|
||||||
|
SetPlayerRoutingBucket(source, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
MySQL.ready(function()
|
||||||
|
MySQL.query.await([[
|
||||||
|
CREATE TABLE IF NOT EXISTS devx_heist_progress (
|
||||||
|
character_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
drill_done TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
card_done TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
vehicle_done TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (character_id),
|
||||||
|
CONSTRAINT fk_devx_heist_character FOREIGN KEY (character_id)
|
||||||
|
REFERENCES devx_characters (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
]])
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('crime:status', function(source)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||||
|
return {
|
||||||
|
ok = true,
|
||||||
|
progress = getProgress(player.characterId),
|
||||||
|
cooldownSeconds = math.max(0, globalCooldownUntil - os.time()),
|
||||||
|
active = activeRobberies[source] ~= nil
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('crime:enterPrep', function(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
local prep = findPrep(tostring(payload.key or ''))
|
||||||
|
if not player or not prep then return { ok = false, error = 'Ungültige Vorbereitungsmission.' } end
|
||||||
|
if not nearLocation(source, prep.exterior, 6.0) then return { ok = false, error = 'Du bist nicht am Lagereingang.' } end
|
||||||
|
|
||||||
|
local bucket = DevXCrimeConfig.MissionBucketBase + tonumber(source)
|
||||||
|
prepSessions[source] = { key = prep.key, exterior = prep.exterior, bucket = bucket }
|
||||||
|
SetRoutingBucketPopulationEnabled(bucket, false)
|
||||||
|
SetRoutingBucketEntityLockdownMode(bucket, 'relaxed')
|
||||||
|
SetPlayerRoutingBucket(source, bucket)
|
||||||
|
return { ok = true, interior = DevXCrimeConfig.PrepInterior, prep = prep }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('crime:leavePrep', function(source)
|
||||||
|
local session = prepSessions[source]
|
||||||
|
if not session then return { ok = false, error = 'Keine Vorbereitungsmission aktiv.' } end
|
||||||
|
local exterior = session.exterior
|
||||||
|
leavePrep(source)
|
||||||
|
return { ok = true, exterior = exterior }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('crime:completePrep', function(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
local key = tostring(payload.key or '')
|
||||||
|
local prep = findPrep(key)
|
||||||
|
local session = prepSessions[source]
|
||||||
|
if not player or not prep or not session or session.key ~= key then
|
||||||
|
return { ok = false, error = 'Keine passende Vorbereitungsmission aktiv.' }
|
||||||
|
end
|
||||||
|
if not nearLocation(source, prep.objective, 5.0) then return { ok = false, error = 'Du bist nicht am Missionsziel.' } end
|
||||||
|
|
||||||
|
local column = ({ drill = 'drill_done', card = 'card_done', vehicle = 'vehicle_done' })[key]
|
||||||
|
MySQL.query.await('INSERT IGNORE INTO devx_heist_progress (character_id) VALUES (?)', { player.characterId })
|
||||||
|
MySQL.update.await(('UPDATE devx_heist_progress SET %s = 1 WHERE character_id = ?'):format(column), { player.characterId })
|
||||||
|
|
||||||
|
local exterior = session.exterior
|
||||||
|
leavePrep(source)
|
||||||
|
return { ok = true, progress = getProgress(player.characterId), exterior = exterior }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('crime:startRobbery', function(source)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||||
|
if not nearLocation(source, DevXCrimeConfig.Bank, 6.0) then return { ok = false, error = 'Du bist nicht im Tresorbereich.' } end
|
||||||
|
if activeRobberies[source] then return { ok = false, error = 'Der Raub läuft bereits.' } end
|
||||||
|
if os.time() < globalCooldownUntil then
|
||||||
|
return { ok = false, error = ('Die Bank ist noch %s Minuten gesperrt.'):format(math.ceil((globalCooldownUntil - os.time()) / 60)) }
|
||||||
|
end
|
||||||
|
|
||||||
|
local progress = getProgress(player.characterId)
|
||||||
|
if not progress.drill or not progress.card or not progress.vehicle then
|
||||||
|
return { ok = false, error = 'Schließe zuerst alle drei Vorbereitungsmissionen ab.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
local duration = DevXCrimeConfig.RobberyDurationSeconds
|
||||||
|
activeRobberies[source] = { characterId = player.characterId, completeAt = os.time() + duration }
|
||||||
|
globalCooldownUntil = os.time() + DevXCrimeConfig.GlobalCooldownSeconds
|
||||||
|
return { ok = true, duration = duration }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('crime:finishRobbery', function(source)
|
||||||
|
local robbery = activeRobberies[source]
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not robbery or not player or robbery.characterId ~= player.characterId then
|
||||||
|
return { ok = false, error = 'Kein aktiver Bankraub.' }
|
||||||
|
end
|
||||||
|
if os.time() < robbery.completeAt then return { ok = false, error = 'Der Tresor ist noch nicht geöffnet.' } end
|
||||||
|
if not nearLocation(source, DevXCrimeConfig.Bank, 12.0) then
|
||||||
|
activeRobberies[source] = nil
|
||||||
|
return { ok = false, error = 'Du hast den Bankraubbereich verlassen.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
local reward = math.random(DevXCrimeConfig.MinimumReward, DevXCrimeConfig.MaximumReward)
|
||||||
|
exports.devx_core:AddCash(source, reward, 'Fleeca-Bankraub')
|
||||||
|
exports.devx_core:AddMetadataNumber(source, 'rpXp', 75)
|
||||||
|
MySQL.update.await([[
|
||||||
|
UPDATE devx_heist_progress
|
||||||
|
SET drill_done = 0, card_done = 0, vehicle_done = 0
|
||||||
|
WHERE character_id = ?
|
||||||
|
]], { player.characterId })
|
||||||
|
activeRobberies[source] = nil
|
||||||
|
return { ok = true, reward = reward, xp = 75 }
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('playerDropped', function()
|
||||||
|
activeRobberies[source] = nil
|
||||||
|
leavePrep(source)
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('onResourceStop', function(resourceName)
|
||||||
|
if resourceName ~= GetCurrentResourceName() then return end
|
||||||
|
for _, playerId in ipairs(GetPlayers()) do SetPlayerRoutingBucket(tonumber(playerId), 0) end
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterCommand('resetheist', function(source)
|
||||||
|
if source ~= 0 and not IsPlayerAceAllowed(source, 'devx.admin') then return end
|
||||||
|
globalCooldownUntil = 0
|
||||||
|
activeRobberies = {}
|
||||||
|
end, false)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
DevXCrimeConfig = {
|
||||||
|
MissionBucketBase = 20000,
|
||||||
|
|
||||||
|
-- v0.4.3 deliberately uses the always-available 10-car garage interior.
|
||||||
|
-- It needs no IPL and therefore cannot appear as an empty warehouse when a
|
||||||
|
-- DLC placement fails to stream. Mission props/NPCs create the warehouse set.
|
||||||
|
PrepInterior = {
|
||||||
|
ipls = {},
|
||||||
|
spawn = vector4(239.86, -1004.71, -99.00, 88.5),
|
||||||
|
exit = vector3(239.55, -1004.55, -99.00),
|
||||||
|
loadAt = vector3(229.96, -981.79, -99.66),
|
||||||
|
setDressing = {
|
||||||
|
{ model = 'prop_boxpile_07d', coords = vector4(233.60, -1003.00, -99.65, 90.0) },
|
||||||
|
{ model = 'prop_boxpile_06b', coords = vector4(233.55, -999.55, -99.65, 90.0) },
|
||||||
|
{ model = 'prop_tool_bench02', coords = vector4(224.25, -1001.50, -99.65, 270.0) },
|
||||||
|
{ model = 'prop_crate_11e', coords = vector4(225.90, -998.80, -99.65, 0.0) },
|
||||||
|
{ model = 'prop_generator_03b', coords = vector4(228.20, -997.30, -99.65, 180.0) },
|
||||||
|
{ model = 'prop_worklight_03b', coords = vector4(229.70, -1003.20, -99.65, 90.0) },
|
||||||
|
{ model = 'prop_shelves_02', coords = vector4(222.40, -998.10, -99.65, 270.0) },
|
||||||
|
{ model = 'prop_shelves_02', coords = vector4(222.40, -1001.20, -99.65, 270.0) },
|
||||||
|
{ model = 'prop_box_wood05a', coords = vector4(236.40, -998.20, -99.65, 0.0) },
|
||||||
|
{ model = 'prop_cratepile_07a', coords = vector4(236.25, -1001.60, -99.65, 180.0) },
|
||||||
|
{ model = 'prop_toolchest_05', coords = vector4(225.10, -996.65, -99.65, 90.0) },
|
||||||
|
{ model = 'prop_barrel_02a', coords = vector4(238.00, -995.80, -99.65, 0.0) },
|
||||||
|
{ model = 'prop_barrel_02a', coords = vector4(238.00, -994.90, -99.65, 0.0) },
|
||||||
|
{ model = 'prop_worklight_04b', coords = vector4(222.95, -1003.55, -99.65, 270.0) },
|
||||||
|
{ model = 'prop_folding_chair_01', coords = vector4(228.40, -1003.80, -99.65, 180.0) },
|
||||||
|
{ model = 'prop_table_03', coords = vector4(230.00, -1003.85, -99.65, 0.0) }
|
||||||
|
},
|
||||||
|
vehicles = {
|
||||||
|
{ model = 'burrito3', coords = vector4(235.25, -988.10, -99.45, 180.0) },
|
||||||
|
{ model = 'sultan', coords = vector4(229.80, -988.10, -99.45, 180.0) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PlannerModel = 's_m_m_highsec_01',
|
||||||
|
PlannerName = 'Vincent',
|
||||||
|
PrepLocations = {
|
||||||
|
{
|
||||||
|
key = 'drill',
|
||||||
|
label = 'Bohrgerät am Hafen beschaffen',
|
||||||
|
exterior = vector4(1204.15, -3116.62, 5.54, 90.0),
|
||||||
|
planner = vector4(1202.40, -3115.15, 5.54, 178.0),
|
||||||
|
objective = vector4(224.45, -1001.20, -99.65, 270.0),
|
||||||
|
objectiveLabel = 'Industriebohrer von der Werkbank nehmen',
|
||||||
|
prop = 'prop_tool_drill',
|
||||||
|
dialogue = {
|
||||||
|
'Der Tresor braucht mehr als rohe Gewalt.',
|
||||||
|
'Unser Werkzeug liegt in einer abgeschirmten Garage.',
|
||||||
|
'Hol den Industriebohrer und verschwinde unauffällig.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key = 'card',
|
||||||
|
label = 'Zugangskarte aus dem Lager holen',
|
||||||
|
exterior = vector4(707.31, -966.86, 30.41, 180.0),
|
||||||
|
planner = vector4(705.55, -966.10, 30.41, 260.0),
|
||||||
|
objective = vector4(226.10, -998.75, -99.65, 0.0),
|
||||||
|
objectiveLabel = 'Sicherheitskarte aus der Transportkiste nehmen',
|
||||||
|
prop = 'prop_cs_swipe_card',
|
||||||
|
dialogue = {
|
||||||
|
'Die Banktür prüft eine Mitarbeiterkarte.',
|
||||||
|
'Unser Kontakt hat eine Karte in einer Kiste deponiert.',
|
||||||
|
'Nimm sie und hinterlasse keine Spuren.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key = 'vehicle',
|
||||||
|
label = 'Fluchtfahrzeug vorbereiten',
|
||||||
|
exterior = vector4(970.25, -1832.05, 31.11, 175.0),
|
||||||
|
planner = vector4(968.65, -1831.40, 31.11, 260.0),
|
||||||
|
objective = vector4(228.35, -997.15, -99.65, 180.0),
|
||||||
|
objectiveLabel = 'Fahrzeugsteuergerät am Laptop programmieren',
|
||||||
|
prop = 'prop_laptop_01a',
|
||||||
|
dialogue = {
|
||||||
|
'Ohne sauberes Fluchtfahrzeug endet der Plan an der ersten Kreuzung.',
|
||||||
|
'Programmiere das Steuergerät auf unsere Kennung.',
|
||||||
|
'Danach ist das Fahrzeug für das Finale vorbereitet.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
WarehouseWorkers = {
|
||||||
|
{ model = 's_m_m_dockwork_01', coords = vector4(232.20, -997.80, -99.65, 180.0), scenario = 'WORLD_HUMAN_CLIPBOARD' },
|
||||||
|
{ model = 's_m_y_dockwork_01', coords = vector4(225.10, -1003.35, -99.65, 270.0), scenario = 'WORLD_HUMAN_WELDING' }
|
||||||
|
},
|
||||||
|
Bank = vector3(146.74, -1044.82, 29.37),
|
||||||
|
RobberyDurationSeconds = 75,
|
||||||
|
GlobalCooldownSeconds = 3600,
|
||||||
|
MinimumReward = 4500,
|
||||||
|
MaximumReward = 8500
|
||||||
|
}
|
||||||
@@ -0,0 +1,989 @@
|
|||||||
|
local propertyState = {}
|
||||||
|
local ownedPropertyKey = nil
|
||||||
|
local ownedPropertyLabel = nil
|
||||||
|
local locationMode = 'world'
|
||||||
|
local currentBuilding = nil
|
||||||
|
local currentFloor = nil
|
||||||
|
local currentProperty = nil
|
||||||
|
local currentInteriorStyle = nil
|
||||||
|
local previewOpen = false
|
||||||
|
local previewUnits = {}
|
||||||
|
local previewIndex = 1
|
||||||
|
local previewCamera = nil
|
||||||
|
local previewShotIndex = 1
|
||||||
|
local nextPreviewShotAt = 0
|
||||||
|
local pendingDoorbell = nil
|
||||||
|
local pendingInvite = nil
|
||||||
|
local activeInteraction = false
|
||||||
|
local hallwayObjects = {}
|
||||||
|
local lastPropertyRefresh = 0
|
||||||
|
|
||||||
|
local function notify(message)
|
||||||
|
exports.devx_core:Notify(message)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function help(text)
|
||||||
|
BeginTextCommandDisplayHelp('STRING')
|
||||||
|
AddTextComponentSubstringPlayerName(text)
|
||||||
|
EndTextCommandDisplayHelp(0, false, true, -1)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drawText3D(coords, lineOne, lineTwo)
|
||||||
|
local onScreen, screenX, screenY = World3dToScreen2d(coords.x, coords.y, coords.z)
|
||||||
|
if not onScreen then return end
|
||||||
|
|
||||||
|
SetTextScale(0.0, 0.30)
|
||||||
|
SetTextFont(4)
|
||||||
|
SetTextProportional(true)
|
||||||
|
SetTextColour(255, 255, 255, 235)
|
||||||
|
SetTextCentre(true)
|
||||||
|
SetTextOutline()
|
||||||
|
BeginTextCommandDisplayText('STRING')
|
||||||
|
AddTextComponentSubstringPlayerName(lineOne or '')
|
||||||
|
EndTextCommandDisplayText(screenX, screenY)
|
||||||
|
|
||||||
|
if lineTwo and lineTwo ~= '' then
|
||||||
|
SetTextScale(0.0, 0.25)
|
||||||
|
SetTextFont(4)
|
||||||
|
SetTextProportional(true)
|
||||||
|
SetTextColour(155, 190, 255, 230)
|
||||||
|
SetTextCentre(true)
|
||||||
|
SetTextOutline()
|
||||||
|
BeginTextCommandDisplayText('STRING')
|
||||||
|
AddTextComponentSubstringPlayerName(lineTwo)
|
||||||
|
EndTextCommandDisplayText(screenX, screenY + 0.018)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function findBuilding(key)
|
||||||
|
for _, building in ipairs(DevXHousing.Buildings) do
|
||||||
|
if building.key == key then return building end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function findUnit(key)
|
||||||
|
for _, building in ipairs(DevXHousing.Buildings) do
|
||||||
|
for _, unit in ipairs(building.units) do
|
||||||
|
if unit.key == key then return unit, building end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function requestAnim(dict)
|
||||||
|
RequestAnimDict(dict)
|
||||||
|
local deadline = GetGameTimer() + 5000
|
||||||
|
while not HasAnimDictLoaded(dict) and GetGameTimer() < deadline do Wait(0) end
|
||||||
|
return HasAnimDictLoaded(dict)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function requestModel(name)
|
||||||
|
local hash = GetHashKey(name)
|
||||||
|
if not IsModelInCdimage(hash) or not IsModelValid(hash) then return nil end
|
||||||
|
RequestModel(hash)
|
||||||
|
local deadline = GetGameTimer() + 5000
|
||||||
|
while not HasModelLoaded(hash) and GetGameTimer() < deadline do Wait(0) end
|
||||||
|
return HasModelLoaded(hash) and hash or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clearHallwayObjects()
|
||||||
|
for _, entity in ipairs(hallwayObjects) do
|
||||||
|
if entity and DoesEntityExist(entity) then DeleteEntity(entity) end
|
||||||
|
end
|
||||||
|
hallwayObjects = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function createHallwayObject(modelName, coords, collision)
|
||||||
|
local model = requestModel(modelName)
|
||||||
|
if not model then
|
||||||
|
print(('[devx_housing] Dekorationsmodell fehlt: %s'):format(modelName))
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local object = CreateObjectNoOffset(model, coords.x, coords.y, coords.z, false, false, false)
|
||||||
|
if object and object ~= 0 then
|
||||||
|
SetEntityHeading(object, coords.heading or coords.w or 0.0)
|
||||||
|
FreezeEntityPosition(object, true)
|
||||||
|
SetEntityCollision(object, collision ~= false, collision ~= false)
|
||||||
|
SetEntityAsMissionEntity(object, true, true)
|
||||||
|
hallwayObjects[#hallwayObjects + 1] = object
|
||||||
|
end
|
||||||
|
SetModelAsNoLongerNeeded(model)
|
||||||
|
return object
|
||||||
|
end
|
||||||
|
|
||||||
|
local function spawnHallwayObjects(showDoors)
|
||||||
|
clearHallwayObjects()
|
||||||
|
local shell = DevXHousing.FloorShell
|
||||||
|
for _, entry in ipairs(shell.setDressing or {}) do
|
||||||
|
createHallwayObject(entry.model, entry.coords, true)
|
||||||
|
end
|
||||||
|
if showDoors then
|
||||||
|
for _, door in ipairs(shell.doors or {}) do
|
||||||
|
createHallwayObject(shell.doorModel, door, true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restoreWorldStreaming(reason)
|
||||||
|
clearHallwayObjects()
|
||||||
|
TriggerEvent('devx_ipl:client:restoreWorld', reason or 'housing')
|
||||||
|
end
|
||||||
|
|
||||||
|
local function prepareInterior(definition)
|
||||||
|
local position = definition and (definition.loadAt or definition.spawn) or nil
|
||||||
|
if not position then return true end
|
||||||
|
|
||||||
|
-- v0.4.3 never removes or swaps global apartment IPLs. It only streams the
|
||||||
|
-- already existing interior around the target coordinate.
|
||||||
|
NewLoadSceneStop()
|
||||||
|
ClearFocus()
|
||||||
|
SetFocusPosAndVel(position.x, position.y, position.z, 0.0, 0.0, 0.0)
|
||||||
|
RequestCollisionAtCoord(position.x, position.y, position.z)
|
||||||
|
RequestAdditionalCollisionAtCoord(position.x, position.y, position.z)
|
||||||
|
NewLoadSceneStartSphere(position.x, position.y, position.z, 120.0, 0)
|
||||||
|
|
||||||
|
local interiorId = GetInteriorAtCoords(position.x, position.y, position.z)
|
||||||
|
if interiorId == 0 then interiorId = GetInteriorAtCoords(position.x, position.y, position.z + 1.0) end
|
||||||
|
if interiorId ~= 0 then
|
||||||
|
PinInteriorInMemory(interiorId)
|
||||||
|
RefreshInterior(interiorId)
|
||||||
|
end
|
||||||
|
|
||||||
|
local deadline = GetGameTimer() + 15000
|
||||||
|
local ready = false
|
||||||
|
while GetGameTimer() < deadline do
|
||||||
|
local sceneReady = IsNewLoadSceneLoaded()
|
||||||
|
local interiorReady = interiorId == 0 or IsInteriorReady(interiorId)
|
||||||
|
if sceneReady and interiorReady then
|
||||||
|
ready = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
RequestCollisionAtCoord(position.x, position.y, position.z)
|
||||||
|
RequestAdditionalCollisionAtCoord(position.x, position.y, position.z)
|
||||||
|
Wait(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
NewLoadSceneStop()
|
||||||
|
if not ready then
|
||||||
|
print(('[devx_housing] Streaming-Timeout bei %.4f %.4f %.4f (interior=%s)'):format(
|
||||||
|
position.x, position.y, position.z, interiorId
|
||||||
|
))
|
||||||
|
end
|
||||||
|
return ready
|
||||||
|
end
|
||||||
|
|
||||||
|
local function teleport(position, interiorDefinition)
|
||||||
|
DoScreenFadeOut(280)
|
||||||
|
local fadeDeadline = GetGameTimer() + 1500
|
||||||
|
while not IsScreenFadedOut() and GetGameTimer() < fadeDeadline do Wait(0) end
|
||||||
|
|
||||||
|
if interiorDefinition and not prepareInterior(interiorDefinition) then
|
||||||
|
ClearFocus()
|
||||||
|
DoScreenFadeIn(300)
|
||||||
|
notify('Der Innenraum konnte nicht gestreamt werden. Du wurdest nicht teleportiert.')
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
FreezeEntityPosition(ped, true)
|
||||||
|
SetEntityCollision(ped, true, true)
|
||||||
|
RequestCollisionAtCoord(position.x, position.y, position.z)
|
||||||
|
RequestAdditionalCollisionAtCoord(position.x, position.y, position.z)
|
||||||
|
SetEntityCoordsNoOffset(ped, position.x, position.y, position.z, false, false, false)
|
||||||
|
SetEntityHeading(ped, position.heading or position.w or 0.0)
|
||||||
|
ClearPedTasksImmediately(ped)
|
||||||
|
|
||||||
|
local collisionDeadline = GetGameTimer() + 10000
|
||||||
|
while not HasCollisionLoadedAroundEntity(ped) and GetGameTimer() < collisionDeadline do
|
||||||
|
RequestCollisionAtCoord(position.x, position.y, position.z)
|
||||||
|
RequestAdditionalCollisionAtCoord(position.x, position.y, position.z)
|
||||||
|
Wait(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
FreezeEntityPosition(ped, false)
|
||||||
|
Wait(180)
|
||||||
|
ClearFocus()
|
||||||
|
DoScreenFadeIn(380)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function refreshProperties(callback)
|
||||||
|
DevXTriggerCallback('housing:list', {}, function(result)
|
||||||
|
if result.ok then
|
||||||
|
propertyState = {}
|
||||||
|
ownedPropertyKey = result.ownedPropertyKey
|
||||||
|
ownedPropertyLabel = result.ownedPropertyLabel
|
||||||
|
for _, property in ipairs(result.properties or {}) do
|
||||||
|
propertyState[property.propertyKey] = {
|
||||||
|
id = property.id,
|
||||||
|
ownerCharacterId = property.ownerCharacterId,
|
||||||
|
ownerName = property.ownerName,
|
||||||
|
characterId = result.characterId,
|
||||||
|
price = tonumber(property.price) or 0,
|
||||||
|
label = property.label
|
||||||
|
}
|
||||||
|
end
|
||||||
|
lastPropertyRefresh = GetGameTimer()
|
||||||
|
end
|
||||||
|
if callback then callback(result) end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function stateFor(unit)
|
||||||
|
local player = exports.devx_core:GetPlayerData()
|
||||||
|
local state = propertyState[unit.key] or {
|
||||||
|
characterId = player and player.characterId,
|
||||||
|
price = unit.price
|
||||||
|
}
|
||||||
|
local ownerName = type(state.ownerName) == 'string' and state.ownerName:gsub('^%s+', ''):gsub('%s+$', '') or ''
|
||||||
|
state.ownerName = ownerName ~= '' and ownerName or nil
|
||||||
|
state.ownedByMe = state.ownerCharacterId
|
||||||
|
and tonumber(state.ownerCharacterId) == tonumber(state.characterId)
|
||||||
|
state.sold = state.ownerCharacterId ~= nil and state.ownerName ~= nil
|
||||||
|
return state
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sendUi(action, data)
|
||||||
|
data = data or {}
|
||||||
|
data.action = action
|
||||||
|
SendNUIMessage(data)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function destroyPreviewCamera()
|
||||||
|
if previewCamera and DoesCamExist(previewCamera) then
|
||||||
|
RenderScriptCams(false, true, 450, true, true)
|
||||||
|
DestroyCam(previewCamera, false)
|
||||||
|
end
|
||||||
|
previewCamera = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function destroyPreview()
|
||||||
|
previewOpen = false
|
||||||
|
destroyPreviewCamera()
|
||||||
|
SetNuiFocus(false, false)
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
SetEntityVisible(ped, true, false)
|
||||||
|
SetEntityCollision(ped, true, true)
|
||||||
|
FreezeEntityPosition(ped, false)
|
||||||
|
ClearFocus()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function currentPreviewUnit()
|
||||||
|
return previewUnits[previewIndex]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function updatePreviewUi()
|
||||||
|
local unit = currentPreviewUnit()
|
||||||
|
if not unit then return end
|
||||||
|
local state = stateFor(unit)
|
||||||
|
sendUi('previewData', {
|
||||||
|
label = unit.label,
|
||||||
|
building = unit.buildingLabel,
|
||||||
|
floor = unit.floor,
|
||||||
|
unitNumber = unit.unitNumber,
|
||||||
|
style = DevXHousing.Interiors[unit.style].label,
|
||||||
|
price = state.price or unit.price,
|
||||||
|
sold = state.sold,
|
||||||
|
ownedByMe = state.ownedByMe,
|
||||||
|
ownerName = state.ownerName,
|
||||||
|
index = previewIndex,
|
||||||
|
total = #previewUnits
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function usePreviewShot(instant)
|
||||||
|
local unit = currentPreviewUnit()
|
||||||
|
if not unit then return end
|
||||||
|
local interior = DevXHousing.Interiors[unit.style]
|
||||||
|
local shots = interior.cameraShots or {}
|
||||||
|
if #shots == 0 then return end
|
||||||
|
if previewShotIndex > #shots then previewShotIndex = 1 end
|
||||||
|
local shot = shots[previewShotIndex]
|
||||||
|
|
||||||
|
local newCamera = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
|
||||||
|
SetCamCoord(newCamera, shot.cam.x, shot.cam.y, shot.cam.z)
|
||||||
|
PointCamAtCoord(newCamera, shot.target.x, shot.target.y, shot.target.z)
|
||||||
|
SetCamFov(newCamera, 48.0)
|
||||||
|
SetCamActive(newCamera, true)
|
||||||
|
|
||||||
|
if previewCamera and DoesCamExist(previewCamera) and not instant then
|
||||||
|
SetCamActiveWithInterp(newCamera, previewCamera, 1450, true, true)
|
||||||
|
local oldCamera = previewCamera
|
||||||
|
CreateThread(function()
|
||||||
|
Wait(1550)
|
||||||
|
if DoesCamExist(oldCamera) then DestroyCam(oldCamera, false) end
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
RenderScriptCams(true, true, 450, true, true)
|
||||||
|
if previewCamera and DoesCamExist(previewCamera) then DestroyCam(previewCamera, false) end
|
||||||
|
end
|
||||||
|
|
||||||
|
previewCamera = newCamera
|
||||||
|
nextPreviewShotAt = GetGameTimer() + 6000
|
||||||
|
end
|
||||||
|
|
||||||
|
local function showPreviewUnit()
|
||||||
|
local unit = currentPreviewUnit()
|
||||||
|
if not unit then return end
|
||||||
|
local interior = DevXHousing.Interiors[unit.style]
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
|
||||||
|
DoScreenFadeOut(180)
|
||||||
|
while not IsScreenFadedOut() do Wait(0) end
|
||||||
|
if not prepareInterior(interior) then
|
||||||
|
DoScreenFadeIn(250)
|
||||||
|
notify('Diese Apartmentansicht konnte nicht geladen werden.')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
SetEntityCoordsNoOffset(ped, interior.spawn.x, interior.spawn.y, interior.spawn.z, false, false, false)
|
||||||
|
SetEntityHeading(ped, interior.spawn.heading or 0.0)
|
||||||
|
SetEntityVisible(ped, false, false)
|
||||||
|
SetEntityCollision(ped, false, false)
|
||||||
|
FreezeEntityPosition(ped, true)
|
||||||
|
|
||||||
|
destroyPreviewCamera()
|
||||||
|
previewShotIndex = 1
|
||||||
|
usePreviewShot(true)
|
||||||
|
updatePreviewUi()
|
||||||
|
Wait(120)
|
||||||
|
DoScreenFadeIn(260)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function closePreview(returnToWorld)
|
||||||
|
local building = currentBuilding
|
||||||
|
destroyPreview()
|
||||||
|
sendUi('close')
|
||||||
|
DevXTriggerCallback('housing:previewEnd', {}, function() end)
|
||||||
|
locationMode = 'world'
|
||||||
|
if returnToWorld ~= false and building then
|
||||||
|
teleport(building.exterior)
|
||||||
|
restoreWorldStreaming('housing-preview-end')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function startPreview(building)
|
||||||
|
if previewOpen then return end
|
||||||
|
clearHallwayObjects()
|
||||||
|
refreshProperties(function(result)
|
||||||
|
if not result.ok then notify(result.error or 'Wohnungen konnten nicht geladen werden.'); return end
|
||||||
|
if ownedPropertyKey then
|
||||||
|
notify(('Du besitzt bereits %s. Pro Charakter ist nur ein Apartment möglich.'):format(ownedPropertyLabel or 'ein Apartment'))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
previewUnits = {}
|
||||||
|
for _, unit in ipairs(building.units) do
|
||||||
|
if not stateFor(unit).sold then previewUnits[#previewUnits + 1] = unit end
|
||||||
|
end
|
||||||
|
table.sort(previewUnits, function(a, b) return a.price < b.price end)
|
||||||
|
if #previewUnits == 0 then notify('In diesem Gebäude sind derzeit keine Apartments frei.'); return end
|
||||||
|
|
||||||
|
previewIndex = 1
|
||||||
|
currentBuilding = building
|
||||||
|
DevXTriggerCallback('housing:previewStart', { buildingKey = building.key }, function(startResult)
|
||||||
|
if not startResult.ok then notify(startResult.error or 'Vorschau konnte nicht gestartet werden.'); return end
|
||||||
|
previewOpen = true
|
||||||
|
locationMode = 'preview'
|
||||||
|
SetNuiFocus(true, true)
|
||||||
|
sendUi('previewOpen')
|
||||||
|
showPreviewUnit()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function applyUnitResult(result, unit, building)
|
||||||
|
clearHallwayObjects()
|
||||||
|
locationMode = 'unit'
|
||||||
|
currentProperty = unit.key
|
||||||
|
currentBuilding = building
|
||||||
|
currentFloor = unit.floor
|
||||||
|
currentInteriorStyle = unit.style
|
||||||
|
if teleport(result.interior.spawn, result.interior) then
|
||||||
|
notify(('Du bist in %s.'):format(result.label or unit.label))
|
||||||
|
else
|
||||||
|
locationMode = 'world'
|
||||||
|
currentProperty = nil
|
||||||
|
currentInteriorStyle = nil
|
||||||
|
TriggerServerEvent('devx_core:server:returnToWorld')
|
||||||
|
teleport(building.exterior)
|
||||||
|
restoreWorldStreaming('housing-unit-failed')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function enterUnit(unit)
|
||||||
|
DevXTriggerCallback('housing:enterUnit', { propertyKey = unit.key }, function(result)
|
||||||
|
if not result.ok then notify(result.error or 'Wohnung konnte nicht betreten werden.'); return end
|
||||||
|
local _, building = findUnit(unit.key)
|
||||||
|
applyUnitResult(result, unit, building)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function enterOwned(building)
|
||||||
|
DevXTriggerCallback('housing:enterOwned', { buildingKey = building.key }, function(result)
|
||||||
|
if not result.ok then notify(result.error or 'Dein Apartment konnte nicht betreten werden.'); return end
|
||||||
|
local unit, unitBuilding = findUnit(result.propertyKey)
|
||||||
|
if unit then applyUnitResult(result, unit, unitBuilding) end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function enterLobby(building)
|
||||||
|
DevXTriggerCallback('housing:enterLobby', { buildingKey = building.key }, function(result)
|
||||||
|
if not result.ok then notify(result.error); return end
|
||||||
|
currentBuilding = building
|
||||||
|
currentFloor = 0
|
||||||
|
currentProperty = nil
|
||||||
|
currentInteriorStyle = nil
|
||||||
|
locationMode = 'lobby'
|
||||||
|
if teleport(result.position, DevXHousing.FloorShell) then
|
||||||
|
spawnHallwayObjects(false)
|
||||||
|
notify(('Lobby von %s betreten. Am Aufzug kannst du eine Etage wählen.'):format(building.label))
|
||||||
|
else
|
||||||
|
TriggerServerEvent('devx_core:server:returnToWorld')
|
||||||
|
locationMode = 'world'
|
||||||
|
teleport(building.exterior)
|
||||||
|
restoreWorldStreaming('housing-lobby-failed')
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function openDoorMenu(unit)
|
||||||
|
local state = stateFor(unit)
|
||||||
|
if not state.sold then return end
|
||||||
|
SetNuiFocus(true, true)
|
||||||
|
sendUi('doorOpen', {
|
||||||
|
propertyKey = unit.key,
|
||||||
|
label = ('Wohnung %s'):format(unit.doorNumber),
|
||||||
|
ownerName = state.ownerName,
|
||||||
|
sold = true,
|
||||||
|
ownedByMe = state.ownedByMe
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function openInviteMenu()
|
||||||
|
DevXTriggerCallback('housing:inviteCandidates', {}, function(result)
|
||||||
|
if not result.ok then notify(result.error or 'Spielerliste konnte nicht geladen werden.'); return end
|
||||||
|
SetNuiFocus(true, true)
|
||||||
|
sendUi('inviteOpen', {
|
||||||
|
title = 'Spieler einladen',
|
||||||
|
players = result.players or {}
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function playDrink(kind)
|
||||||
|
if activeInteraction then return end
|
||||||
|
activeInteraction = true
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
local modelName = kind == 'whiskey' and 'prop_drink_whisky' or 'prop_amb_beer_bottle'
|
||||||
|
local model = requestModel(modelName)
|
||||||
|
local prop = nil
|
||||||
|
if model then
|
||||||
|
prop = CreateObject(model, 0.0, 0.0, 0.0, false, false, false)
|
||||||
|
AttachEntityToEntity(prop, ped, GetPedBoneIndex(ped, 28422), 0.0, -0.01, -0.03, 0.0, 0.0, 0.0, true, true, false, true, 1, true)
|
||||||
|
end
|
||||||
|
if requestAnim('amb@world_human_drinking@beer@male@idle_a') then
|
||||||
|
TaskPlayAnim(ped, 'amb@world_human_drinking@beer@male@idle_a', 'idle_c', 3.0, -3.0, 6500, 49, 0.0, false, false, false)
|
||||||
|
end
|
||||||
|
Wait(6500)
|
||||||
|
ClearPedSecondaryTask(ped)
|
||||||
|
if prop and DoesEntityExist(prop) then DeleteEntity(prop) end
|
||||||
|
if model then SetModelAsNoLongerNeeded(model) end
|
||||||
|
notify(kind == 'whiskey' and 'Du hast einen Whiskey getrunken.' or 'Du hast ein Bier getrunken.')
|
||||||
|
activeInteraction = false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function playSleep(point)
|
||||||
|
if activeInteraction then return end
|
||||||
|
activeInteraction = true
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
SetEntityCoordsNoOffset(ped, point.coords.x, point.coords.y, point.coords.z + 0.15, false, false, false)
|
||||||
|
SetEntityHeading(ped, point.heading or 0.0)
|
||||||
|
if requestAnim('timetable@tracy@sleep@') then
|
||||||
|
TaskPlayAnim(ped, 'timetable@tracy@sleep@', 'idle_c', 2.0, -2.0, 10000, 1, 0.0, false, false, false)
|
||||||
|
end
|
||||||
|
notify('Du schläfst kurz …')
|
||||||
|
Wait(10000)
|
||||||
|
ClearPedTasksImmediately(ped)
|
||||||
|
SetEntityHealth(ped, math.min(GetEntityMaxHealth(ped), GetEntityHealth(ped) + 15))
|
||||||
|
activeInteraction = false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function playBong()
|
||||||
|
if activeInteraction then return end
|
||||||
|
activeInteraction = true
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
TaskStartScenarioInPlace(ped, 'WORLD_HUMAN_SMOKING_POT', 0, true)
|
||||||
|
StartScreenEffect('DrugsMichaelAliensFight', 0, false)
|
||||||
|
Wait(9000)
|
||||||
|
StopScreenEffect('DrugsMichaelAliensFight')
|
||||||
|
ClearPedTasksImmediately(ped)
|
||||||
|
activeInteraction = false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function useApartmentInteraction(point)
|
||||||
|
if point.type == 'beer' or point.type == 'whiskey' then
|
||||||
|
CreateThread(function() playDrink(point.type) end)
|
||||||
|
elseif point.type == 'sleep' then
|
||||||
|
CreateThread(function() playSleep(point) end)
|
||||||
|
elseif point.type == 'bong' then
|
||||||
|
CreateThread(playBong)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while true do
|
||||||
|
if previewOpen and GetGameTimer() >= nextPreviewShotAt then
|
||||||
|
local unit = currentPreviewUnit()
|
||||||
|
if unit then
|
||||||
|
local shots = DevXHousing.Interiors[unit.style].cameraShots or {}
|
||||||
|
previewShotIndex = previewShotIndex + 1
|
||||||
|
if previewShotIndex > #shots then previewShotIndex = 1 end
|
||||||
|
usePreviewShot(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Wait(250)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while true do
|
||||||
|
if previewOpen then
|
||||||
|
DisableAllControlActions(0)
|
||||||
|
EnableControlAction(0, 200, true)
|
||||||
|
Wait(0)
|
||||||
|
else
|
||||||
|
Wait(500)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('previewPrevious', function(_, cb)
|
||||||
|
if #previewUnits > 0 then
|
||||||
|
previewIndex = previewIndex - 1
|
||||||
|
if previewIndex < 1 then previewIndex = #previewUnits end
|
||||||
|
showPreviewUnit()
|
||||||
|
end
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('previewNext', function(_, cb)
|
||||||
|
if #previewUnits > 0 then
|
||||||
|
previewIndex = previewIndex + 1
|
||||||
|
if previewIndex > #previewUnits then previewIndex = 1 end
|
||||||
|
showPreviewUnit()
|
||||||
|
end
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('previewBuy', function(_, cb)
|
||||||
|
local unit = currentPreviewUnit()
|
||||||
|
if not unit then cb({ ok = false, error = 'Keine Wohnung ausgewählt.' }); return end
|
||||||
|
DevXTriggerCallback('housing:buy', { propertyKey = unit.key }, function(result)
|
||||||
|
if result.ok then
|
||||||
|
refreshProperties(function()
|
||||||
|
notify('Wohnung erfolgreich gekauft. Am Gebäudeeingang kannst du sie jetzt direkt betreten.')
|
||||||
|
closePreview(true)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
cb(result)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('previewClose', function(_, cb)
|
||||||
|
closePreview(true)
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('elevatorFloor', function(data, cb)
|
||||||
|
local building = currentBuilding
|
||||||
|
local floor = tonumber(data.floor)
|
||||||
|
if not building or not floor then cb({ ok = false }); return end
|
||||||
|
|
||||||
|
DevXTriggerCallback('housing:goFloor', { buildingKey = building.key, floor = floor }, function(result)
|
||||||
|
if result.ok then
|
||||||
|
SetNuiFocus(false, false)
|
||||||
|
sendUi('close')
|
||||||
|
locationMode = 'floor'
|
||||||
|
currentFloor = floor
|
||||||
|
refreshProperties()
|
||||||
|
if teleport(result.position, DevXHousing.FloorShell) then
|
||||||
|
spawnHallwayObjects(true)
|
||||||
|
else
|
||||||
|
locationMode = 'world'
|
||||||
|
TriggerServerEvent('devx_core:server:returnToWorld')
|
||||||
|
teleport(building.exterior)
|
||||||
|
restoreWorldStreaming('housing-floor-failed')
|
||||||
|
end
|
||||||
|
else
|
||||||
|
notify(result.error)
|
||||||
|
end
|
||||||
|
cb(result)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('uiClose', function(_, cb)
|
||||||
|
SetNuiFocus(false, false)
|
||||||
|
sendUi('close')
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('doorRing', function(data, cb)
|
||||||
|
DevXTriggerCallback('housing:ring', { propertyKey = data.propertyKey }, function(result)
|
||||||
|
notify(result.message or result.error or 'Klingelanfrage beendet.')
|
||||||
|
cb(result)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('doorKnock', function(data, cb)
|
||||||
|
DevXTriggerCallback('housing:knock', { propertyKey = data.propertyKey }, function(result)
|
||||||
|
notify(result.message or result.error or 'Klopfen beendet.')
|
||||||
|
cb(result)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('doorEnter', function(data, cb)
|
||||||
|
local unit = findUnit(data.propertyKey)
|
||||||
|
SetNuiFocus(false, false)
|
||||||
|
sendUi('close')
|
||||||
|
if unit then enterUnit(unit) end
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('invitePlayer', function(data, cb)
|
||||||
|
DevXTriggerCallback('housing:invite', { target = tonumber(data.target) }, function(result)
|
||||||
|
if result.ok then
|
||||||
|
notify('Apartment-Einladung wurde gesendet. Der Spieler muss sie zuerst annehmen.')
|
||||||
|
SetNuiFocus(false, false)
|
||||||
|
sendUi('close')
|
||||||
|
else
|
||||||
|
notify(result.error or 'Einladung konnte nicht gesendet werden.')
|
||||||
|
end
|
||||||
|
cb(result)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_housing:client:notify', notify)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_housing:client:invite', function(data)
|
||||||
|
pendingInvite = {
|
||||||
|
propertyKey = data.propertyKey,
|
||||||
|
label = data.label or 'Apartment',
|
||||||
|
invitedBy = data.invitedBy or 'Jemand',
|
||||||
|
expiresAt = GetGameTimer() + ((tonumber(data.expiresIn) or 120) * 1000)
|
||||||
|
}
|
||||||
|
PlaySoundFrontend(-1, 'Menu_Accept', 'Phone_SoundSet_Default', true)
|
||||||
|
notify(('%s lädt dich in %s ein. Drücke E zum Annehmen oder X zum Ablehnen.'):format(
|
||||||
|
pendingInvite.invitedBy, pendingInvite.label
|
||||||
|
))
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_housing:client:enterGranted', function(result)
|
||||||
|
local unit, building = findUnit(result.propertyKey)
|
||||||
|
if not unit then return end
|
||||||
|
pendingInvite = nil
|
||||||
|
applyUnitResult(result, unit, building)
|
||||||
|
notify(('Zutritt zu %s erhalten.'):format(result.label or unit.label))
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_housing:client:doorbell', function(data)
|
||||||
|
pendingDoorbell = data
|
||||||
|
pendingDoorbell.expiresAt = GetGameTimer() + 30000
|
||||||
|
PlaySoundFrontend(-1, 'Door_Open', 'DOCKS_HEIST_FINALE_2B_SOUNDS', true)
|
||||||
|
notify(('%s klingelt an deiner Wohnung. Drücke E zum Öffnen oder X zum Ablehnen.'):format(data.visitorName))
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_housing:client:knock', function(visitorName)
|
||||||
|
PlaySoundFrontend(-1, 'Knuckle_Crack_Hard_Cel', 'MP_SNACKS_SOUNDSET', true)
|
||||||
|
notify(('%s klopft an der Wohnungstür.'):format(visitorName or 'Jemand'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_housing:client:visitorAccepted', function(visitorName)
|
||||||
|
notify(('%s wurde in deine Wohnung gelassen.'):format(visitorName or 'Der Besucher'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while true do
|
||||||
|
if pendingInvite then
|
||||||
|
if GetGameTimer() > pendingInvite.expiresAt then
|
||||||
|
pendingInvite = nil
|
||||||
|
notify('Die Apartment-Einladung ist abgelaufen.')
|
||||||
|
else
|
||||||
|
help(('%s lädt dich ein: ~INPUT_CONTEXT~ annehmen | ~INPUT_VEH_DUCK~ ablehnen'):format(pendingInvite.invitedBy))
|
||||||
|
if IsControlJustReleased(0, 38) then
|
||||||
|
DevXTriggerCallback('housing:acceptInvite', {}, function(result)
|
||||||
|
if result.ok then
|
||||||
|
local unit, building = findUnit(result.propertyKey)
|
||||||
|
if unit then applyUnitResult(result, unit, building) end
|
||||||
|
else
|
||||||
|
notify(result.error or 'Einladung ist nicht mehr gültig.')
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
pendingInvite = nil
|
||||||
|
elseif IsControlJustReleased(0, 73) then
|
||||||
|
DevXTriggerCallback('housing:declineInvite', {}, function() end)
|
||||||
|
pendingInvite = nil
|
||||||
|
notify('Apartment-Einladung abgelehnt.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Wait(0)
|
||||||
|
else
|
||||||
|
Wait(400)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while true do
|
||||||
|
if pendingDoorbell then
|
||||||
|
if pendingDoorbell.expiresAt and GetGameTimer() > pendingDoorbell.expiresAt then
|
||||||
|
pendingDoorbell = nil
|
||||||
|
else
|
||||||
|
help(('%s klingelt: ~INPUT_CONTEXT~ öffnen | ~INPUT_VEH_DUCK~ ablehnen'):format(
|
||||||
|
pendingDoorbell.visitorName or 'Besucher'
|
||||||
|
))
|
||||||
|
if IsControlJustReleased(0, 38) then
|
||||||
|
DevXTriggerCallback('housing:acceptVisitor', {}, function(result)
|
||||||
|
if not result.ok then notify(result.error) end
|
||||||
|
end)
|
||||||
|
pendingDoorbell = nil
|
||||||
|
elseif IsControlJustReleased(0, 73) then
|
||||||
|
DevXTriggerCallback('housing:declineVisitor', {}, function() end)
|
||||||
|
pendingDoorbell = nil
|
||||||
|
notify('Klingelanfrage abgelehnt.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Wait(0)
|
||||||
|
else
|
||||||
|
Wait(400)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('devx_core:client:playerLoaded', function()
|
||||||
|
locationMode = 'world'
|
||||||
|
currentBuilding = nil
|
||||||
|
currentFloor = nil
|
||||||
|
currentProperty = nil
|
||||||
|
currentInteriorStyle = nil
|
||||||
|
clearHallwayObjects()
|
||||||
|
refreshProperties()
|
||||||
|
end)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while not exports.devx_core:IsPlayerLoaded() do Wait(500) end
|
||||||
|
refreshProperties()
|
||||||
|
|
||||||
|
for _, building in ipairs(DevXHousing.Buildings) do
|
||||||
|
local blip = AddBlipForCoord(building.exterior.x, building.exterior.y, building.exterior.z)
|
||||||
|
SetBlipSprite(blip, 40)
|
||||||
|
SetBlipScale(blip, 0.72)
|
||||||
|
SetBlipColour(blip, 3)
|
||||||
|
SetBlipAsShortRange(blip, true)
|
||||||
|
BeginTextCommandSetBlipName('STRING')
|
||||||
|
AddTextComponentString(building.label)
|
||||||
|
EndTextCommandSetBlipName(blip)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while true do
|
||||||
|
if locationMode == 'floor' and GetGameTimer() - lastPropertyRefresh >= (DevXHousing.PropertyRefreshMs or 8000) then
|
||||||
|
refreshProperties()
|
||||||
|
end
|
||||||
|
Wait(1000)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while true do
|
||||||
|
local sleep = 900
|
||||||
|
if exports.devx_core:IsPlayerLoaded() and not previewOpen then
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
local coords = GetEntityCoords(ped)
|
||||||
|
|
||||||
|
if locationMode == 'world' then
|
||||||
|
local _, ownedBuilding = nil, nil
|
||||||
|
if ownedPropertyKey then _, ownedBuilding = findUnit(ownedPropertyKey) end
|
||||||
|
|
||||||
|
for _, building in ipairs(DevXHousing.Buildings) do
|
||||||
|
local entry = building.exterior
|
||||||
|
local distance = #(coords - vector3(entry.x, entry.y, entry.z))
|
||||||
|
if distance < DevXHousing.MarkerDistance then
|
||||||
|
sleep = 0
|
||||||
|
DrawMarker(1, entry.x, entry.y, entry.z - 1.0, 0.0,0.0,0.0,0.0,0.0,0.0,
|
||||||
|
1.0,1.0,0.25,65,110,245,125,false,false,2,false,nil,nil,false)
|
||||||
|
if distance < DevXHousing.InteractionDistance then
|
||||||
|
if ownedPropertyKey and ownedBuilding and ownedBuilding.key == building.key then
|
||||||
|
help(('~INPUT_CONTEXT~ Apartment betreten | ~INPUT_DETONATE~ Lobby von %s'):format(building.label))
|
||||||
|
if IsControlJustReleased(0, 38) then enterOwned(building)
|
||||||
|
elseif IsControlJustReleased(0, 47) then enterLobby(building) end
|
||||||
|
elseif ownedPropertyKey then
|
||||||
|
help(('Du besitzt bereits %s | ~INPUT_DETONATE~ Lobby betreten'):format(ownedPropertyLabel or 'ein Apartment'))
|
||||||
|
if IsControlJustReleased(0, 47) then enterLobby(building) end
|
||||||
|
else
|
||||||
|
help(('~INPUT_CONTEXT~ Apartments ansehen | ~INPUT_DETONATE~ Lobby von %s betreten'):format(building.label))
|
||||||
|
if IsControlJustReleased(0, 38) then startPreview(building)
|
||||||
|
elseif IsControlJustReleased(0, 47) then enterLobby(building) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif locationMode == 'lobby' or locationMode == 'floor' then
|
||||||
|
local shell = DevXHousing.FloorShell
|
||||||
|
local elevatorDistance = #(coords - vector3(shell.elevator.x, shell.elevator.y, shell.elevator.z))
|
||||||
|
local exitDistance = #(coords - vector3(shell.exit.x, shell.exit.y, shell.exit.z))
|
||||||
|
|
||||||
|
if elevatorDistance < 10.0 then
|
||||||
|
sleep = 0
|
||||||
|
DrawMarker(1, shell.elevator.x, shell.elevator.y, shell.elevator.z - 1.0,
|
||||||
|
0.0,0.0,0.0,0.0,0.0,0.0,0.75,0.75,0.22,80,145,255,130,false,false,2,false,nil,nil,false)
|
||||||
|
if elevatorDistance < 1.5 then
|
||||||
|
help('Drücke ~INPUT_CONTEXT~, um den Aufzug zu benutzen.')
|
||||||
|
if IsControlJustReleased(0, 38) then
|
||||||
|
SetNuiFocus(true, true)
|
||||||
|
sendUi('elevatorOpen', {
|
||||||
|
building = currentBuilding.label,
|
||||||
|
floors = currentBuilding.floors,
|
||||||
|
currentFloor = currentFloor or 0
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if exitDistance < 8.0 then
|
||||||
|
sleep = 0
|
||||||
|
DrawMarker(1, shell.exit.x, shell.exit.y, shell.exit.z - 1.0,
|
||||||
|
0.0,0.0,0.0,0.0,0.0,0.0,0.75,0.75,0.22,255,255,255,100,false,false,2,false,nil,nil,false)
|
||||||
|
if exitDistance < 1.5 then
|
||||||
|
help('Drücke ~INPUT_CONTEXT~, um das Gebäude zu verlassen.')
|
||||||
|
if IsControlJustReleased(0, 38) then
|
||||||
|
DevXTriggerCallback('housing:leaveBuilding', {}, function(result)
|
||||||
|
if result.ok then
|
||||||
|
locationMode = 'world'
|
||||||
|
currentFloor = nil
|
||||||
|
clearHallwayObjects()
|
||||||
|
teleport(result.position)
|
||||||
|
restoreWorldStreaming('housing-leave-building')
|
||||||
|
else notify(result.error) end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if locationMode == 'floor' and currentBuilding and currentFloor then
|
||||||
|
for _, unit in ipairs(currentBuilding.units) do
|
||||||
|
if unit.floor == currentFloor then
|
||||||
|
local state = stateFor(unit)
|
||||||
|
-- Free units have no resident sign and no door action.
|
||||||
|
if state.sold then
|
||||||
|
local door = unit.door
|
||||||
|
local distance = #(coords - vector3(door.x, door.y, door.z))
|
||||||
|
if distance < 12.0 then
|
||||||
|
sleep = 0
|
||||||
|
drawText3D(door.sign or { x = door.x + 0.75, y = door.y, z = door.z + 1.2 },
|
||||||
|
('Wohnung %s'):format(unit.doorNumber),
|
||||||
|
state.ownedByMe and ('~b~%s (Deine Wohnung)'):format(state.ownerName) or state.ownerName)
|
||||||
|
if distance < 1.55 then
|
||||||
|
if state.ownedByMe then
|
||||||
|
help(('~INPUT_CONTEXT~ Wohnung %s betreten'):format(unit.doorNumber))
|
||||||
|
if IsControlJustReleased(0, 38) then enterUnit(unit) end
|
||||||
|
else
|
||||||
|
help(('~INPUT_CONTEXT~ bei %s klingeln oder klopfen'):format(state.ownerName))
|
||||||
|
if IsControlJustReleased(0, 38) then openDoorMenu(unit) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif locationMode == 'unit' and currentProperty then
|
||||||
|
local unit = findUnit(currentProperty)
|
||||||
|
if unit then
|
||||||
|
local interior = DevXHousing.Interiors[unit.style]
|
||||||
|
local exit = interior.exit
|
||||||
|
local distance = #(coords - vector3(exit.x, exit.y, exit.z))
|
||||||
|
if distance < 10.0 then
|
||||||
|
sleep = 0
|
||||||
|
DrawMarker(1, exit.x, exit.y, exit.z - 1.0, 0.0,0.0,0.0,0.0,0.0,0.0,
|
||||||
|
0.85,0.85,0.22,255,255,255,105,false,false,2,false,nil,nil,false)
|
||||||
|
if distance < 1.55 then
|
||||||
|
local state = stateFor(unit)
|
||||||
|
if state.ownedByMe then
|
||||||
|
help('~INPUT_CONTEXT~ Apartment verlassen | ~INPUT_DETONATE~ Spieler einladen')
|
||||||
|
else
|
||||||
|
help('~INPUT_CONTEXT~ Apartment verlassen')
|
||||||
|
end
|
||||||
|
|
||||||
|
if IsControlJustReleased(0, 38) then
|
||||||
|
DevXTriggerCallback('housing:exitUnit', {}, function(result)
|
||||||
|
if result.ok then
|
||||||
|
TriggerEvent('devx_housing:client:leftApartment')
|
||||||
|
locationMode = 'floor'
|
||||||
|
currentProperty = nil
|
||||||
|
currentInteriorStyle = nil
|
||||||
|
currentFloor = result.floor
|
||||||
|
refreshProperties()
|
||||||
|
if teleport(result.position, DevXHousing.FloorShell) then
|
||||||
|
spawnHallwayObjects(true)
|
||||||
|
end
|
||||||
|
else notify(result.error) end
|
||||||
|
end)
|
||||||
|
elseif state.ownedByMe and IsControlJustReleased(0, 47) then
|
||||||
|
openInviteMenu()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, point in ipairs(interior.interactions or {}) do
|
||||||
|
local pointDistance = #(coords - vector3(point.coords.x, point.coords.y, point.coords.z))
|
||||||
|
if pointDistance < 7.0 then
|
||||||
|
sleep = 0
|
||||||
|
DrawMarker(2, point.coords.x, point.coords.y, point.coords.z + 0.15,
|
||||||
|
0.0,0.0,0.0,0.0,180.0,0.0,0.22,0.22,0.22,95,160,255,155,false,false,2,false,nil,nil,false)
|
||||||
|
if pointDistance < 1.35 then
|
||||||
|
help(('~INPUT_CONTEXT~ %s'):format(point.label))
|
||||||
|
if IsControlJustReleased(0, 38) then useApartmentInteraction(point) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Wait(sleep)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterCommand('inviteapartment', function(_, args)
|
||||||
|
local target = tonumber(args[1])
|
||||||
|
if not target then
|
||||||
|
openInviteMenu()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
DevXTriggerCallback('housing:invite', { target = target }, function(result)
|
||||||
|
notify(result.ok and 'Apartment-Einladung gesendet.' or (result.error or 'Einladung fehlgeschlagen.'))
|
||||||
|
end)
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
exports('GetHousingState', function()
|
||||||
|
return {
|
||||||
|
mode = locationMode,
|
||||||
|
propertyKey = currentProperty,
|
||||||
|
buildingKey = currentBuilding and currentBuilding.key or nil,
|
||||||
|
floor = currentFloor,
|
||||||
|
ownedPropertyKey = ownedPropertyKey,
|
||||||
|
interiorStyle = currentInteriorStyle,
|
||||||
|
interior = currentInteriorStyle and DevXHousing.Interiors[currentInteriorStyle] or nil
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('onResourceStop', function(resourceName)
|
||||||
|
if resourceName ~= GetCurrentResourceName() then return end
|
||||||
|
destroyPreview()
|
||||||
|
clearHallwayObjects()
|
||||||
|
TriggerServerEvent('devx_core:server:returnToWorld')
|
||||||
|
TriggerEvent('devx_ipl:client:restoreWorld', 'housing-stop')
|
||||||
|
end)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
fx_version 'cerulean'
|
||||||
|
game 'gta5'
|
||||||
|
|
||||||
|
author 'Colin-Joel / DevX Studios'
|
||||||
|
description 'DevX RP multi-floor apartments, previews, elevators and visitors'
|
||||||
|
version '0.4.3'
|
||||||
|
|
||||||
|
lua54 'yes'
|
||||||
|
|
||||||
|
shared_script 'shared/config.lua'
|
||||||
|
ui_page 'web/index.html'
|
||||||
|
|
||||||
|
files {
|
||||||
|
'web/index.html',
|
||||||
|
'web/style.css',
|
||||||
|
'web/app.js'
|
||||||
|
}
|
||||||
|
|
||||||
|
client_scripts {
|
||||||
|
'@devx_core/client/callbacks.lua',
|
||||||
|
'client/main.lua'
|
||||||
|
}
|
||||||
|
|
||||||
|
server_scripts {
|
||||||
|
'@oxmysql/lib/MySQL.lua',
|
||||||
|
'@devx_core/server/callbacks.lua',
|
||||||
|
'server/main.lua'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
'devx_core',
|
||||||
|
'devx_banking',
|
||||||
|
'devx_ipl'
|
||||||
|
}
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
local propertyLocks = {}
|
||||||
|
local characterPurchaseLocks = {}
|
||||||
|
local playerLocation = {}
|
||||||
|
local temporaryInvites = {}
|
||||||
|
local directInvites = {}
|
||||||
|
local pendingDoorbells = {}
|
||||||
|
local configuredPropertyKeys = {}
|
||||||
|
|
||||||
|
for _, building in ipairs(DevXHousing.Buildings) do
|
||||||
|
for _, unit in ipairs(building.units) do
|
||||||
|
configuredPropertyKeys[unit.key] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function findBuilding(key)
|
||||||
|
for _, building in ipairs(DevXHousing.Buildings) do
|
||||||
|
if building.key == key then return building end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function findUnit(key)
|
||||||
|
for _, building in ipairs(DevXHousing.Buildings) do
|
||||||
|
for _, unit in ipairs(building.units) do
|
||||||
|
if unit.key == key then return unit, building end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function unitRow(propertyKey)
|
||||||
|
return MySQL.single.await([[
|
||||||
|
SELECT p.id, p.property_key AS propertyKey, p.price,
|
||||||
|
po.character_id AS ownerCharacterId,
|
||||||
|
CONCAT(c.first_name, ' ', c.last_name) AS ownerName
|
||||||
|
FROM devx_properties p
|
||||||
|
LEFT JOIN devx_property_owners po ON po.property_id = p.id
|
||||||
|
LEFT JOIN devx_characters c ON c.id = po.character_id AND c.deleted_at IS NULL
|
||||||
|
WHERE p.property_key = ?
|
||||||
|
LIMIT 1
|
||||||
|
]], { propertyKey })
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ownedUnitRow(characterId)
|
||||||
|
return MySQL.single.await([[
|
||||||
|
SELECT p.id, p.property_key AS propertyKey, p.label, p.price,
|
||||||
|
po.character_id AS ownerCharacterId,
|
||||||
|
CONCAT(c.first_name, ' ', c.last_name) AS ownerName
|
||||||
|
FROM devx_property_owners po
|
||||||
|
INNER JOIN devx_properties p ON p.id = po.property_id
|
||||||
|
LEFT JOIN devx_characters c ON c.id = po.character_id AND c.deleted_at IS NULL
|
||||||
|
WHERE po.character_id = ? AND p.property_type = 'apartment'
|
||||||
|
ORDER BY po.purchased_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
]], { characterId })
|
||||||
|
end
|
||||||
|
|
||||||
|
local function floorBucket(building, floor)
|
||||||
|
return DevXHousing.FloorBucketBase + (building.index * 100) + tonumber(floor or 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function unitBucket(propertyId)
|
||||||
|
return DevXHousing.UnitBucketBase + tonumber(propertyId)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setLocation(source, location, bucket)
|
||||||
|
playerLocation[source] = location
|
||||||
|
SetPlayerRoutingBucket(source, bucket or 0)
|
||||||
|
if bucket and bucket > 0 then
|
||||||
|
SetRoutingBucketPopulationEnabled(bucket, false)
|
||||||
|
SetRoutingBucketEntityLockdownMode(bucket, 'relaxed')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resetPlayer(source)
|
||||||
|
playerLocation[source] = nil
|
||||||
|
temporaryInvites[source] = nil
|
||||||
|
directInvites[source] = nil
|
||||||
|
pendingDoorbells[source] = nil
|
||||||
|
SetPlayerRoutingBucket(source, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function syncProperties()
|
||||||
|
for _, building in ipairs(DevXHousing.Buildings) do
|
||||||
|
for _, unit in ipairs(building.units) do
|
||||||
|
local interior = DevXHousing.Interiors[unit.style]
|
||||||
|
MySQL.query.await([[
|
||||||
|
INSERT INTO devx_properties
|
||||||
|
(property_key, label, price, property_type, entry_data, interior_data)
|
||||||
|
VALUES (?, ?, ?, 'apartment', ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
label = VALUES(label),
|
||||||
|
price = VALUES(price),
|
||||||
|
entry_data = VALUES(entry_data),
|
||||||
|
interior_data = VALUES(interior_data)
|
||||||
|
]], {
|
||||||
|
unit.key,
|
||||||
|
unit.label,
|
||||||
|
unit.price,
|
||||||
|
json.encode({
|
||||||
|
buildingKey = building.key,
|
||||||
|
floor = unit.floor,
|
||||||
|
unitNumber = unit.unitNumber,
|
||||||
|
exterior = building.exterior
|
||||||
|
}),
|
||||||
|
json.encode(interior)
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Soft-deleted or otherwise missing characters must not keep a door sign.
|
||||||
|
-- This also repairs ownership rows left behind by older test versions.
|
||||||
|
MySQL.query.await([[
|
||||||
|
DELETE po FROM devx_property_owners po
|
||||||
|
LEFT JOIN devx_characters c ON c.id = po.character_id
|
||||||
|
WHERE c.id IS NULL OR c.deleted_at IS NOT NULL
|
||||||
|
]])
|
||||||
|
|
||||||
|
print('[devx_housing] Apartmentdaten v0.4.3 synchronisiert; verwaiste Eigentümer bereinigt.')
|
||||||
|
end
|
||||||
|
|
||||||
|
MySQL.ready(syncProperties)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:list', function(source)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||||
|
|
||||||
|
local rows = MySQL.query.await([[
|
||||||
|
SELECT p.id, p.property_key AS propertyKey, p.label, p.price,
|
||||||
|
po.character_id AS ownerCharacterId,
|
||||||
|
CONCAT(c.first_name, ' ', c.last_name) AS ownerName
|
||||||
|
FROM devx_properties p
|
||||||
|
LEFT JOIN devx_property_owners po ON po.property_id = p.id
|
||||||
|
LEFT JOIN devx_characters c ON c.id = po.character_id AND c.deleted_at IS NULL
|
||||||
|
WHERE p.property_type = 'apartment'
|
||||||
|
ORDER BY p.id
|
||||||
|
]]) or {}
|
||||||
|
|
||||||
|
local filtered = {}
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
if configuredPropertyKeys[row.propertyKey] then
|
||||||
|
-- An ownership row without a valid active character is never exposed
|
||||||
|
-- to the hallway UI as an occupied apartment.
|
||||||
|
if row.ownerCharacterId and (not row.ownerName or row.ownerName == '') then
|
||||||
|
MySQL.query.await('DELETE FROM devx_property_owners WHERE property_id = ?', { row.id })
|
||||||
|
row.ownerCharacterId = nil
|
||||||
|
row.ownerName = nil
|
||||||
|
end
|
||||||
|
filtered[#filtered + 1] = row
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local owned = ownedUnitRow(player.characterId)
|
||||||
|
if owned and not configuredPropertyKeys[owned.propertyKey] then owned = nil end
|
||||||
|
return {
|
||||||
|
ok = true,
|
||||||
|
properties = filtered,
|
||||||
|
characterId = player.characterId,
|
||||||
|
ownedPropertyKey = owned and owned.propertyKey or nil,
|
||||||
|
ownedPropertyLabel = owned and owned.label or nil
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:buy', function(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
local unit = findUnit(payload.propertyKey)
|
||||||
|
if not player or not unit then return { ok = false, error = 'Ungültige Wohnung.' } end
|
||||||
|
|
||||||
|
local existing = ownedUnitRow(player.characterId)
|
||||||
|
if existing then
|
||||||
|
return {
|
||||||
|
ok = false,
|
||||||
|
error = ('Du besitzt bereits %s. Pro Charakter ist derzeit nur ein Apartment erlaubt.'):format(existing.label)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
if characterPurchaseLocks[player.characterId] then
|
||||||
|
return { ok = false, error = 'Ein anderer Wohnungskauf wird bereits verarbeitet.' }
|
||||||
|
end
|
||||||
|
if propertyLocks[unit.key] then return { ok = false, error = 'Die Wohnung wird gerade bearbeitet.' } end
|
||||||
|
characterPurchaseLocks[player.characterId] = true
|
||||||
|
propertyLocks[unit.key] = true
|
||||||
|
|
||||||
|
local ok, response = pcall(function()
|
||||||
|
local row = unitRow(unit.key)
|
||||||
|
if not row then return { ok = false, error = 'Wohnung fehlt in der Datenbank.' } end
|
||||||
|
if row.ownerCharacterId then return { ok = false, error = 'Die Wohnung ist bereits verkauft.' } end
|
||||||
|
|
||||||
|
local charged, chargeResult = exports.devx_banking:ChargeCharacter(
|
||||||
|
player.characterId,
|
||||||
|
tonumber(row.price),
|
||||||
|
('Kauf: %s'):format(unit.label),
|
||||||
|
'Immobilienverwaltung DevX-City'
|
||||||
|
)
|
||||||
|
if not charged then return { ok = false, error = chargeResult } end
|
||||||
|
|
||||||
|
local ownerId = MySQL.insert.await([[
|
||||||
|
INSERT INTO devx_property_owners (property_id, character_id, purchase_price)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
]], { row.id, player.characterId, row.price })
|
||||||
|
|
||||||
|
if not ownerId then
|
||||||
|
exports.devx_banking:CreditCharacter(
|
||||||
|
player.characterId,
|
||||||
|
tonumber(row.price),
|
||||||
|
('Rückbuchung: %s'):format(unit.label),
|
||||||
|
'Immobilienverwaltung DevX-City'
|
||||||
|
)
|
||||||
|
return { ok = false, error = 'Kauf wurde abgebrochen und zurückgebucht.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
local overview = MySQL.single.await('SELECT balance FROM devx_bank_accounts WHERE id = ?', { player.accountId })
|
||||||
|
if overview then exports.devx_core:SetBankCache(source, tonumber(overview.balance)) end
|
||||||
|
return { ok = true, propertyKey = unit.key }
|
||||||
|
end)
|
||||||
|
|
||||||
|
propertyLocks[unit.key] = nil
|
||||||
|
characterPurchaseLocks[player.characterId] = nil
|
||||||
|
if not ok then
|
||||||
|
print(('[devx_housing] Kauf fehlgeschlagen: %s'):format(response))
|
||||||
|
return { ok = false, error = 'Interner Immobilienfehler.' }
|
||||||
|
end
|
||||||
|
return response
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:previewStart', function(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
local building = findBuilding(payload.buildingKey)
|
||||||
|
if not player or not building then return { ok = false, error = 'Ungültiges Gebäude.' } end
|
||||||
|
local owned = ownedUnitRow(player.characterId)
|
||||||
|
if owned then
|
||||||
|
return { ok = false, error = ('Du besitzt bereits %s.'):format(owned.label or 'ein Apartment') }
|
||||||
|
end
|
||||||
|
|
||||||
|
local bucket = DevXHousing.PreviewBucketBase + tonumber(source)
|
||||||
|
setLocation(source, { type = 'preview', buildingKey = building.key }, bucket)
|
||||||
|
return { ok = true }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:previewEnd', function(source)
|
||||||
|
local location = playerLocation[source]
|
||||||
|
if location and location.type == 'preview' then resetPlayer(source) end
|
||||||
|
return { ok = true }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:enterLobby', function(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
local building = findBuilding(payload.buildingKey)
|
||||||
|
if not player or not building then return { ok = false, error = 'Ungültiges Gebäude.' } end
|
||||||
|
|
||||||
|
setLocation(source, { type = 'lobby', buildingKey = building.key, floor = 0 }, floorBucket(building, 0))
|
||||||
|
return { ok = true, position = DevXHousing.FloorShell.spawn, building = building.key }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:goFloor', function(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
local building = findBuilding(payload.buildingKey)
|
||||||
|
local floor = tonumber(payload.floor)
|
||||||
|
if not player or not building or not floor or floor < 1 or floor > building.floors then
|
||||||
|
return { ok = false, error = 'Ungültige Etage.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
setLocation(source, { type = 'floor', buildingKey = building.key, floor = floor }, floorBucket(building, floor))
|
||||||
|
return { ok = true, position = DevXHousing.FloorShell.spawn, floor = floor }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:leaveBuilding', function(source)
|
||||||
|
local location = playerLocation[source]
|
||||||
|
if not location then return { ok = false, error = 'Du bist in keinem Gebäude.' } end
|
||||||
|
local building = findBuilding(location.buildingKey)
|
||||||
|
if not building then return { ok = false, error = 'Gebäude nicht gefunden.' } end
|
||||||
|
|
||||||
|
resetPlayer(source)
|
||||||
|
return { ok = true, position = building.exterior }
|
||||||
|
end)
|
||||||
|
|
||||||
|
local function hasUnitAccess(source, row)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not player or not row then return false end
|
||||||
|
if tonumber(row.ownerCharacterId) == tonumber(player.characterId) then return true end
|
||||||
|
|
||||||
|
local invite = temporaryInvites[source]
|
||||||
|
return invite
|
||||||
|
and invite.propertyKey == row.propertyKey
|
||||||
|
and invite.expiresAt >= os.time()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function enterUnitFor(source, propertyKey)
|
||||||
|
local unit, building = findUnit(propertyKey)
|
||||||
|
local row = unit and unitRow(propertyKey) or nil
|
||||||
|
if not unit or not building or not row or not row.ownerCharacterId then
|
||||||
|
return { ok = false, error = 'Diese Wohnung ist nicht bewohnt.' }
|
||||||
|
end
|
||||||
|
if not hasUnitAccess(source, row) then
|
||||||
|
return { ok = false, error = 'Du hast keine Einladung für diese Wohnung.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
local interior = DevXHousing.Interiors[unit.style]
|
||||||
|
setLocation(source, {
|
||||||
|
type = 'unit',
|
||||||
|
propertyKey = unit.key,
|
||||||
|
propertyId = tonumber(row.id),
|
||||||
|
buildingKey = building.key,
|
||||||
|
floor = unit.floor,
|
||||||
|
ownerCharacterId = tonumber(row.ownerCharacterId)
|
||||||
|
}, unitBucket(row.id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok = true,
|
||||||
|
interior = interior,
|
||||||
|
propertyKey = unit.key,
|
||||||
|
label = unit.label,
|
||||||
|
ownerName = row.ownerName,
|
||||||
|
floor = unit.floor,
|
||||||
|
style = unit.style,
|
||||||
|
interactions = interior.interactions or {}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:enterUnit', function(source, payload)
|
||||||
|
return enterUnitFor(source, payload.propertyKey)
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:enterOwned', function(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||||
|
|
||||||
|
local owned = ownedUnitRow(player.characterId)
|
||||||
|
if not owned then return { ok = false, error = 'Du besitzt noch kein Apartment.' } end
|
||||||
|
|
||||||
|
local unit, building = findUnit(owned.propertyKey)
|
||||||
|
if not unit or not building then return { ok = false, error = 'Dein Apartment ist nicht mehr konfiguriert.' } end
|
||||||
|
if payload and payload.buildingKey and payload.buildingKey ~= building.key then
|
||||||
|
return { ok = false, error = ('Dein Apartment befindet sich in %s.'):format(building.label) }
|
||||||
|
end
|
||||||
|
|
||||||
|
return enterUnitFor(source, owned.propertyKey)
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:exitUnit', function(source)
|
||||||
|
local location = playerLocation[source]
|
||||||
|
if not location or location.type ~= 'unit' then return { ok = false, error = 'Du bist in keiner Wohnung.' } end
|
||||||
|
|
||||||
|
local building = findBuilding(location.buildingKey)
|
||||||
|
setLocation(source, {
|
||||||
|
type = 'floor',
|
||||||
|
buildingKey = location.buildingKey,
|
||||||
|
floor = location.floor
|
||||||
|
}, floorBucket(building, location.floor))
|
||||||
|
|
||||||
|
return { ok = true, position = DevXHousing.FloorShell.spawn, floor = location.floor }
|
||||||
|
end)
|
||||||
|
|
||||||
|
local function activeSourceForCharacter(characterId)
|
||||||
|
for source, player in pairs(exports.devx_core:GetActivePlayers()) do
|
||||||
|
if tonumber(player.characterId) == tonumber(characterId) then return tonumber(source) end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:ring', function(source, payload)
|
||||||
|
local visitor = exports.devx_core:GetPlayer(source)
|
||||||
|
local row = unitRow(payload.propertyKey)
|
||||||
|
local unit = findUnit(payload.propertyKey)
|
||||||
|
if not visitor or not row or not row.ownerCharacterId or not unit then
|
||||||
|
return { ok = false, error = 'Hier wohnt niemand.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
local ownerSource = activeSourceForCharacter(row.ownerCharacterId)
|
||||||
|
if not ownerSource then return { ok = false, error = 'Der Bewohner ist derzeit nicht online.' } end
|
||||||
|
|
||||||
|
pendingDoorbells[ownerSource] = {
|
||||||
|
visitorSource = source,
|
||||||
|
visitorName = visitor.fullName,
|
||||||
|
propertyKey = payload.propertyKey,
|
||||||
|
expiresAt = os.time() + 30
|
||||||
|
}
|
||||||
|
TriggerClientEvent('devx_housing:client:doorbell', ownerSource, {
|
||||||
|
visitorSource = source,
|
||||||
|
visitorName = visitor.fullName,
|
||||||
|
propertyKey = payload.propertyKey,
|
||||||
|
label = unit.label
|
||||||
|
})
|
||||||
|
return { ok = true, message = 'Du hast geklingelt.' }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:knock', function(source, payload)
|
||||||
|
local visitor = exports.devx_core:GetPlayer(source)
|
||||||
|
local row = unitRow(payload.propertyKey)
|
||||||
|
if not visitor or not row or not row.ownerCharacterId then return { ok = false, error = 'Hier wohnt niemand.' } end
|
||||||
|
|
||||||
|
for target, location in pairs(playerLocation) do
|
||||||
|
if location.type == 'unit' and location.propertyKey == payload.propertyKey then
|
||||||
|
TriggerClientEvent('devx_housing:client:knock', target, visitor.fullName)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return { ok = true, message = 'Du hast angeklopft.' }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:acceptVisitor', function(source)
|
||||||
|
local owner = exports.devx_core:GetPlayer(source)
|
||||||
|
local location = playerLocation[source]
|
||||||
|
local bell = pendingDoorbells[source]
|
||||||
|
if not owner or not location or location.type ~= 'unit' or not bell or bell.expiresAt < os.time() then
|
||||||
|
return { ok = false, error = 'Keine aktuelle Klingelanfrage.' }
|
||||||
|
end
|
||||||
|
if bell.propertyKey ~= location.propertyKey then return { ok = false, error = 'Die Anfrage gehört zu einer anderen Wohnung.' } end
|
||||||
|
|
||||||
|
temporaryInvites[bell.visitorSource] = {
|
||||||
|
propertyKey = bell.propertyKey,
|
||||||
|
expiresAt = os.time() + DevXHousing.InvitationSeconds
|
||||||
|
}
|
||||||
|
pendingDoorbells[source] = nil
|
||||||
|
|
||||||
|
local result = enterUnitFor(bell.visitorSource, bell.propertyKey)
|
||||||
|
if result.ok then
|
||||||
|
TriggerClientEvent('devx_housing:client:enterGranted', bell.visitorSource, result)
|
||||||
|
TriggerClientEvent('devx_housing:client:visitorAccepted', source, bell.visitorName)
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:inviteCandidates', function(source)
|
||||||
|
local owner = exports.devx_core:GetPlayer(source)
|
||||||
|
local location = playerLocation[source]
|
||||||
|
if not owner or not location or location.type ~= 'unit' then
|
||||||
|
return { ok = false, error = 'Du musst dich in deiner Wohnung befinden.' }
|
||||||
|
end
|
||||||
|
if tonumber(location.ownerCharacterId) ~= tonumber(owner.characterId) then
|
||||||
|
return { ok = false, error = 'Nur der Eigentümer kann Spieler einladen.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
local players = {}
|
||||||
|
for playerSource, player in pairs(exports.devx_core:GetActivePlayers()) do
|
||||||
|
local numericSource = tonumber(playerSource)
|
||||||
|
if numericSource and numericSource ~= tonumber(source) then
|
||||||
|
players[#players + 1] = {
|
||||||
|
source = numericSource,
|
||||||
|
name = player.fullName or GetPlayerName(numericSource) or ('Spieler %s'):format(numericSource)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(players, function(a, b) return a.source < b.source end)
|
||||||
|
return { ok = true, players = players }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:invite', function(source, payload)
|
||||||
|
local owner = exports.devx_core:GetPlayer(source)
|
||||||
|
local target = tonumber(payload.target)
|
||||||
|
local location = playerLocation[source]
|
||||||
|
if not owner or not location or location.type ~= 'unit' then
|
||||||
|
return { ok = false, error = 'Du musst dich in deiner Wohnung befinden.' }
|
||||||
|
end
|
||||||
|
if tonumber(location.ownerCharacterId) ~= tonumber(owner.characterId) then
|
||||||
|
return { ok = false, error = 'Nur der Eigentümer kann Gäste einladen.' }
|
||||||
|
end
|
||||||
|
if not target or not exports.devx_core:GetPlayer(target) then return { ok = false, error = 'Spieler nicht gefunden.' } end
|
||||||
|
if target == tonumber(source) then return { ok = false, error = 'Du kannst dich nicht selbst einladen.' } end
|
||||||
|
|
||||||
|
local unit = findUnit(location.propertyKey)
|
||||||
|
directInvites[target] = {
|
||||||
|
propertyKey = location.propertyKey,
|
||||||
|
invitedBy = owner.fullName,
|
||||||
|
expiresAt = os.time() + DevXHousing.InvitationSeconds
|
||||||
|
}
|
||||||
|
TriggerClientEvent('devx_housing:client:invite', target, {
|
||||||
|
propertyKey = location.propertyKey,
|
||||||
|
invitedBy = owner.fullName,
|
||||||
|
label = unit and unit.label or 'Apartment',
|
||||||
|
expiresIn = DevXHousing.InvitationSeconds
|
||||||
|
})
|
||||||
|
return { ok = true }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:acceptInvite', function(source)
|
||||||
|
local invite = directInvites[source]
|
||||||
|
if not invite or invite.expiresAt < os.time() then
|
||||||
|
directInvites[source] = nil
|
||||||
|
return { ok = false, error = 'Du hast keine gültige Apartment-Einladung.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
temporaryInvites[source] = {
|
||||||
|
propertyKey = invite.propertyKey,
|
||||||
|
expiresAt = invite.expiresAt
|
||||||
|
}
|
||||||
|
directInvites[source] = nil
|
||||||
|
return enterUnitFor(source, invite.propertyKey)
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:declineInvite', function(source)
|
||||||
|
directInvites[source] = nil
|
||||||
|
return { ok = true }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('housing:declineVisitor', function(source)
|
||||||
|
pendingDoorbells[source] = nil
|
||||||
|
return { ok = true }
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- /inviteapartment is handled client-side so it can open the player picker.
|
||||||
|
|
||||||
|
RegisterCommand('acceptapartment', function(source)
|
||||||
|
if source == 0 then return end
|
||||||
|
local invite = directInvites[source]
|
||||||
|
if not invite or invite.expiresAt < os.time() then
|
||||||
|
directInvites[source] = nil
|
||||||
|
TriggerClientEvent('devx_housing:client:notify', source, 'Keine gültige Apartment-Einladung.')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
temporaryInvites[source] = { propertyKey = invite.propertyKey, expiresAt = invite.expiresAt }
|
||||||
|
directInvites[source] = nil
|
||||||
|
local result = enterUnitFor(source, invite.propertyKey)
|
||||||
|
if result.ok then TriggerClientEvent('devx_housing:client:enterGranted', source, result)
|
||||||
|
else TriggerClientEvent('devx_housing:client:notify', source, result.error) end
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
RegisterCommand('acceptvisitor', function(source)
|
||||||
|
if source == 0 then return end
|
||||||
|
local bell = pendingDoorbells[source]
|
||||||
|
local location = playerLocation[source]
|
||||||
|
if not bell or bell.expiresAt < os.time() or not location or location.type ~= 'unit' then
|
||||||
|
TriggerClientEvent('devx_housing:client:notify', source, 'Keine aktuelle Klingelanfrage.')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
temporaryInvites[bell.visitorSource] = { propertyKey = bell.propertyKey, expiresAt = os.time() + DevXHousing.InvitationSeconds }
|
||||||
|
pendingDoorbells[source] = nil
|
||||||
|
local result = enterUnitFor(bell.visitorSource, bell.propertyKey)
|
||||||
|
if result.ok then
|
||||||
|
TriggerClientEvent('devx_housing:client:enterGranted', bell.visitorSource, result)
|
||||||
|
TriggerClientEvent('devx_housing:client:visitorAccepted', source, bell.visitorName)
|
||||||
|
end
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
exports('GetPlayerHousingLocation', function(source)
|
||||||
|
local location = playerLocation[tonumber(source)]
|
||||||
|
if not location then return nil end
|
||||||
|
local copy = {}
|
||||||
|
for key, value in pairs(location) do copy[key] = value end
|
||||||
|
if location.propertyKey then
|
||||||
|
local unit = findUnit(location.propertyKey)
|
||||||
|
if unit then
|
||||||
|
copy.style = unit.style
|
||||||
|
copy.interior = DevXHousing.Interiors[unit.style]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return copy
|
||||||
|
end)
|
||||||
|
|
||||||
|
exports('GetOwnedApartment', function(characterId)
|
||||||
|
return ownedUnitRow(tonumber(characterId))
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('playerDropped', function()
|
||||||
|
resetPlayer(source)
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('onResourceStart', function(resourceName)
|
||||||
|
if resourceName ~= GetCurrentResourceName() then return end
|
||||||
|
for _, playerId in ipairs(GetPlayers()) do SetPlayerRoutingBucket(tonumber(playerId), 0) end
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('onResourceStop', function(resourceName)
|
||||||
|
if resourceName ~= GetCurrentResourceName() then return end
|
||||||
|
for _, playerId in ipairs(GetPlayers()) do SetPlayerRoutingBucket(tonumber(playerId), 0) end
|
||||||
|
end)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
DevXHousing = {
|
||||||
|
MarkerDistance = 25.0,
|
||||||
|
InteractionDistance = 1.8,
|
||||||
|
PreviewBucketBase = 50000,
|
||||||
|
UnitBucketBase = 40000,
|
||||||
|
FloorBucketBase = 30000,
|
||||||
|
InvitationSeconds = 120,
|
||||||
|
MaxPropertiesPerCharacter = 1,
|
||||||
|
PropertyRefreshMs = 8000,
|
||||||
|
|
||||||
|
-- v0.4.3 only uses GTA Online interiors that are available without
|
||||||
|
-- RequestIpl/RemoveIpl. This avoids overlapping apartment IPLs and world
|
||||||
|
-- holes after returning to Los Santos.
|
||||||
|
Interiors = {
|
||||||
|
low = {
|
||||||
|
label = 'Klassisch',
|
||||||
|
premium = 0,
|
||||||
|
ipls = {},
|
||||||
|
spawn = { x = 266.11, y = -1007.55, z = -101.01, heading = 357.0 },
|
||||||
|
exit = { x = 266.11, y = -1007.55, z = -101.01 },
|
||||||
|
loadAt = { x = 261.4586, y = -998.8196, z = -99.00863 },
|
||||||
|
cameraShots = {
|
||||||
|
{ cam = { x = 265.20, y = -1003.85, z = -99.38 }, target = { x = 263.00, y = -1000.55, z = -99.70 } },
|
||||||
|
{ cam = { x = 262.90, y = -1001.15, z = -99.42 }, target = { x = 266.10, y = -999.90, z = -99.72 } },
|
||||||
|
{ cam = { x = 268.05, y = -999.65, z = -99.40 }, target = { x = 265.60, y = -1003.10, z = -99.72 } }
|
||||||
|
},
|
||||||
|
companionSpawn = { x = 265.10, y = -1001.20, z = -99.00, heading = 165.0 },
|
||||||
|
interactions = {
|
||||||
|
{ key = 'beer', label = 'Bier trinken', type = 'beer', coords = { x = 263.45, y = -1002.25, z = -99.00 } },
|
||||||
|
{ key = 'sleep', label = 'Im Bett schlafen', type = 'sleep', coords = { x = 262.90, y = -1004.05, z = -99.00 }, heading = 178.0 },
|
||||||
|
{ key = 'bong', label = 'Bong benutzen', type = 'bong', coords = { x = 266.90, y = -999.15, z = -99.00 } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mid = {
|
||||||
|
label = 'Modern',
|
||||||
|
premium = 24000,
|
||||||
|
ipls = {},
|
||||||
|
spawn = { x = 346.52, y = -1012.90, z = -99.20, heading = 2.0 },
|
||||||
|
exit = { x = 346.52, y = -1012.90, z = -99.20 },
|
||||||
|
loadAt = { x = 347.2686, y = -999.2955, z = -99.19622 },
|
||||||
|
cameraShots = {
|
||||||
|
{ cam = { x = 344.80, y = -1008.00, z = -97.58 }, target = { x = 341.25, y = -1003.70, z = -97.95 } },
|
||||||
|
{ cam = { x = 340.25, y = -1003.20, z = -97.62 }, target = { x = 345.15, y = -1001.15, z = -97.95 } },
|
||||||
|
{ cam = { x = 348.55, y = -1003.20, z = -97.60 }, target = { x = 343.75, y = -1006.50, z = -97.95 } }
|
||||||
|
},
|
||||||
|
companionSpawn = { x = 342.80, y = -1004.90, z = -99.20, heading = 95.0 },
|
||||||
|
interactions = {
|
||||||
|
{ key = 'whiskey', label = 'Whiskey trinken', type = 'whiskey', coords = { x = 343.65, y = -1002.65, z = -99.20 } },
|
||||||
|
{ key = 'beer', label = 'Bier trinken', type = 'beer', coords = { x = 341.20, y = -1003.10, z = -99.20 } },
|
||||||
|
{ key = 'sleep', label = 'Im Bett schlafen', type = 'sleep', coords = { x = 350.15, y = -1007.55, z = -99.20 }, heading = 270.0 },
|
||||||
|
{ key = 'bong', label = 'Bong benutzen', type = 'bong', coords = { x = 337.95, y = -997.45, z = -99.20 } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
high = {
|
||||||
|
label = 'Luxus',
|
||||||
|
premium = 65000,
|
||||||
|
ipls = {},
|
||||||
|
spawn = { x = -17.86, y = -589.22, z = 79.43, heading = 337.0 },
|
||||||
|
exit = { x = -17.86, y = -589.22, z = 79.43 },
|
||||||
|
loadAt = { x = -18.07856, y = -583.6725, z = 79.46569 },
|
||||||
|
cameraShots = {
|
||||||
|
{ cam = { x = -20.65, y = -586.10, z = 81.10 }, target = { x = -16.10, y = -582.90, z = 80.05 } },
|
||||||
|
{ cam = { x = -13.10, y = -581.15, z = 81.05 }, target = { x = -18.70, y = -578.80, z = 80.00 } },
|
||||||
|
{ cam = { x = -22.10, y = -577.90, z = 81.05 }, target = { x = -17.00, y = -582.50, z = 80.00 } }
|
||||||
|
},
|
||||||
|
companionSpawn = { x = -15.20, y = -583.20, z = 79.47, heading = 155.0 },
|
||||||
|
interactions = {
|
||||||
|
{ key = 'whiskey', label = 'Whiskey trinken', type = 'whiskey', coords = { x = -17.20, y = -579.75, z = 79.47 } },
|
||||||
|
{ key = 'beer', label = 'Bier trinken', type = 'beer', coords = { x = -13.90, y = -582.30, z = 79.47 } },
|
||||||
|
{ key = 'sleep', label = 'Im Bett schlafen', type = 'sleep', coords = { x = -20.45, y = -570.55, z = 79.47 }, heading = 180.0 },
|
||||||
|
{ key = 'bong', label = 'Bong benutzen', type = 'bong', coords = { x = -10.85, y = -583.20, z = 79.47 } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
penthouse = {
|
||||||
|
label = 'Penthouse',
|
||||||
|
premium = 118000,
|
||||||
|
ipls = {},
|
||||||
|
spawn = { x = -781.95, y = 339.78, z = 211.20, heading = 88.0 },
|
||||||
|
exit = { x = -781.95, y = 339.78, z = 211.20 },
|
||||||
|
loadAt = { x = -773.407, y = 341.766, z = 211.397 },
|
||||||
|
cameraShots = {
|
||||||
|
{ cam = { x = -779.30, y = 342.10, z = 213.10 }, target = { x = -773.40, y = 341.75, z = 211.90 } },
|
||||||
|
{ cam = { x = -771.90, y = 338.45, z = 213.05 }, target = { x = -768.20, y = 344.20, z = 211.95 } },
|
||||||
|
{ cam = { x = -768.80, y = 348.30, z = 213.00 }, target = { x = -774.10, y = 343.25, z = 211.95 } }
|
||||||
|
},
|
||||||
|
companionSpawn = { x = -773.10, y = 343.25, z = 211.40, heading = 260.0 },
|
||||||
|
interactions = {
|
||||||
|
{ key = 'whiskey', label = 'Whiskey trinken', type = 'whiskey', coords = { x = -768.15, y = 341.80, z = 211.40 } },
|
||||||
|
{ key = 'beer', label = 'Bier trinken', type = 'beer', coords = { x = -771.60, y = 338.80, z = 211.40 } },
|
||||||
|
{ key = 'sleep', label = 'Im Bett schlafen', type = 'sleep', coords = { x = -765.15, y = 329.30, z = 211.40 }, heading = 90.0 },
|
||||||
|
{ key = 'bong', label = 'Bong benutzen', type = 'bong', coords = { x = -778.10, y = 338.35, z = 211.40 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
-- The floor is a private, always-loaded 10-car garage scene. Local doors,
|
||||||
|
-- signs and decoration turn it into a stable virtual corridor. No
|
||||||
|
-- apartment IPL is requested or removed here.
|
||||||
|
FloorShell = {
|
||||||
|
ipls = {},
|
||||||
|
loadAt = { x = 229.9559, y = -981.7928, z = -99.66071 },
|
||||||
|
spawn = { x = 239.70, y = -1004.65, z = -99.00, heading = 88.5 },
|
||||||
|
elevator = { x = 239.55, y = -1004.55, z = -99.00 },
|
||||||
|
exit = { x = 239.55, y = -1001.30, z = -99.00 },
|
||||||
|
doorModel = 'v_ilev_fib_door1',
|
||||||
|
doors = {
|
||||||
|
{ x = 237.55, y = -1007.05, z = -99.00, heading = 0.0, sign = { x = 238.32, y = -1006.82, z = -97.78 } },
|
||||||
|
{ x = 234.35, y = -1007.05, z = -99.00, heading = 0.0, sign = { x = 235.12, y = -1006.82, z = -97.78 } },
|
||||||
|
{ x = 231.15, y = -1007.05, z = -99.00, heading = 0.0, sign = { x = 231.92, y = -1006.82, z = -97.78 } },
|
||||||
|
{ x = 227.95, y = -1007.05, z = -99.00, heading = 0.0, sign = { x = 228.72, y = -1006.82, z = -97.78 } }
|
||||||
|
},
|
||||||
|
setDressing = {
|
||||||
|
{ model = 'prop_plant_int_03a', coords = { x = 240.65, y = -1005.80, z = -99.65, heading = 0.0 } },
|
||||||
|
{ model = 'prop_plant_int_03b', coords = { x = 225.95, y = -1005.80, z = -99.65, heading = 0.0 } },
|
||||||
|
{ model = 'prop_off_chair_04', coords = { x = 239.55, y = -1003.10, z = -99.65, heading = 180.0 } },
|
||||||
|
{ model = 'prop_table_03', coords = { x = 237.85, y = -1003.10, z = -99.65, heading = 0.0 } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
Buildings = {
|
||||||
|
{ key = 'eclipse', label = 'Eclipse Towers', exterior = { x = -773.92, y = 312.09, z = 85.70, heading = 178.0 }, floors = 5, unitsPerFloor = 4, basePrice = 110000, floorPrice = 8500 },
|
||||||
|
{ key = 'integrity', label = '4 Integrity Way', exterior = { x = -47.32, y = -585.99, z = 37.95, heading = 69.0 }, floors = 5, unitsPerFloor = 4, basePrice = 76000, floorPrice = 7000 },
|
||||||
|
{ key = 'del_perro', label = 'Del Perro Heights', exterior = { x = -1447.49, y = -537.49, z = 34.74, heading = 212.0 }, floors = 5, unitsPerFloor = 4, basePrice = 59000, floorPrice = 6000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
local legacyKeys = {
|
||||||
|
eclipse = { ['3:1'] = 'eclipse_31' },
|
||||||
|
integrity = { ['2:1'] = 'integrity_28' },
|
||||||
|
del_perro = { ['1:1'] = 'del_perro_7' }
|
||||||
|
}
|
||||||
|
|
||||||
|
local function styleFor(floor, unitNumber)
|
||||||
|
if floor == 1 then return unitNumber <= 2 and 'low' or 'mid' end
|
||||||
|
if floor == 2 then return 'mid' end
|
||||||
|
if floor == 3 then return unitNumber == 4 and 'high' or 'mid' end
|
||||||
|
if floor == 4 then return 'high' end
|
||||||
|
return unitNumber == 4 and 'penthouse' or 'high'
|
||||||
|
end
|
||||||
|
|
||||||
|
for buildingIndex, building in ipairs(DevXHousing.Buildings) do
|
||||||
|
building.index = buildingIndex
|
||||||
|
building.units = {}
|
||||||
|
for floor = 1, building.floors do
|
||||||
|
for unitNumber = 1, building.unitsPerFloor do
|
||||||
|
local legacy = legacyKeys[building.key] and legacyKeys[building.key][('%s:%s'):format(floor, unitNumber)]
|
||||||
|
local key = legacy or ('%s_f%02d_u%02d'):format(building.key, floor, unitNumber)
|
||||||
|
local style = styleFor(floor, unitNumber)
|
||||||
|
local premium = DevXHousing.Interiors[style].premium or 0
|
||||||
|
building.units[#building.units + 1] = {
|
||||||
|
key = key,
|
||||||
|
buildingKey = building.key,
|
||||||
|
buildingLabel = building.label,
|
||||||
|
floor = floor,
|
||||||
|
unitNumber = unitNumber,
|
||||||
|
doorNumber = ('%s%02d'):format(floor, unitNumber),
|
||||||
|
label = ('%s – Etage %s, Wohnung %s'):format(building.label, floor, unitNumber),
|
||||||
|
price = building.basePrice + (floor - 1) * building.floorPrice + (unitNumber - 1) * 1750 + premium,
|
||||||
|
style = style,
|
||||||
|
door = DevXHousing.FloorShell.doors[unitNumber]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
const resource = typeof GetParentResourceName === 'function' ? GetParentResourceName() : 'devx_housing';
|
||||||
|
const preview = document.querySelector('#preview');
|
||||||
|
const panel = document.querySelector('#panel');
|
||||||
|
const building = document.querySelector('#building');
|
||||||
|
const label = document.querySelector('#label');
|
||||||
|
const meta = document.querySelector('#meta');
|
||||||
|
const price = document.querySelector('#price');
|
||||||
|
const buy = document.querySelector('#buy');
|
||||||
|
const buyText = document.querySelector('#buyText');
|
||||||
|
const panelTitle = document.querySelector('#panelTitle');
|
||||||
|
const panelText = document.querySelector('#panelText');
|
||||||
|
const panelBody = document.querySelector('#panelBody');
|
||||||
|
let currentDoor = null;
|
||||||
|
|
||||||
|
async function post(name, data = {}) {
|
||||||
|
const response = await fetch(`https://${resource}/${name}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value){return `${new Intl.NumberFormat('de-DE').format(Number(value)||0)} $`}
|
||||||
|
function closePanel(){panel.classList.add('hidden');post('uiClose')}
|
||||||
|
|
||||||
|
document.querySelector('#previous').onclick = () => post('previewPrevious');
|
||||||
|
document.querySelector('#next').onclick = () => post('previewNext');
|
||||||
|
document.querySelector('#previewClose').onclick = () => post('previewClose');
|
||||||
|
document.querySelector('#panelClose').onclick = closePanel;
|
||||||
|
buy.onclick = async () => {
|
||||||
|
buy.disabled = true;
|
||||||
|
const result = await post('previewBuy');
|
||||||
|
if (!result.ok) {
|
||||||
|
buyText.textContent = result.error || 'KAUF FEHLGESCHLAGEN';
|
||||||
|
setTimeout(() => buyText.textContent = 'KAUFEN', 2200);
|
||||||
|
buy.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keyup', event => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (!preview.classList.contains('hidden')) post('previewClose');
|
||||||
|
else if (!panel.classList.contains('hidden')) closePanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function elevator(data){
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
panelTitle.textContent = data.building;
|
||||||
|
panelText.textContent = `Aufzug · aktuelle Etage ${data.currentFloor || 'Lobby'}`;
|
||||||
|
panelBody.innerHTML = '';
|
||||||
|
for(let floor=1;floor<=Number(data.floors||1);floor++){
|
||||||
|
const button=document.createElement('button');button.className='panel-action';
|
||||||
|
button.innerHTML=`<b>Etage ${floor}</b><span>Wohnungen ${floor}01–${floor}04</span>`;
|
||||||
|
button.onclick=()=>post('elevatorFloor',{floor});
|
||||||
|
panelBody.appendChild(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function invite(data){
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
panelTitle.textContent=data.title||'Spieler einladen';
|
||||||
|
panelText.textContent='Der ausgewählte Spieler erhält eine Einladung und wird erst nach der Annahme teleportiert.';
|
||||||
|
panelBody.innerHTML='';
|
||||||
|
const players=Array.isArray(data.players)?data.players:[];
|
||||||
|
if(players.length===0){
|
||||||
|
const empty=document.createElement('div');empty.className='empty-state';empty.textContent='Aktuell ist kein anderer Spieler online.';panelBody.appendChild(empty);return;
|
||||||
|
}
|
||||||
|
for(const player of players){
|
||||||
|
const button=document.createElement('button');button.className='panel-action';
|
||||||
|
button.innerHTML=`<b>${player.name||'Spieler'}</b><span>Server-ID ${player.source}</span>`;
|
||||||
|
button.onclick=async()=>{button.disabled=true;const result=await post('invitePlayer',{target:player.source});if(!result.ok)button.disabled=false;};
|
||||||
|
panelBody.appendChild(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function door(data){
|
||||||
|
currentDoor=data;
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
panelTitle.textContent=data.label;
|
||||||
|
panelText.textContent=data.sold?`Bewohner: ${data.ownerName||'Unbekannt'}`:'Diese Wohnung ist noch frei.';
|
||||||
|
panelBody.innerHTML='';
|
||||||
|
const actions=document.createElement('div');actions.className='door-actions';
|
||||||
|
if(data.sold){
|
||||||
|
const ring=document.createElement('button');ring.className='panel-action';ring.innerHTML='<b>Klingeln</b>';ring.onclick=()=>post('doorRing',{propertyKey:data.propertyKey});
|
||||||
|
const knock=document.createElement('button');knock.className='panel-action';knock.innerHTML='<b>Klopfen</b>';knock.onclick=()=>post('doorKnock',{propertyKey:data.propertyKey});
|
||||||
|
actions.append(ring,knock);
|
||||||
|
const enter=document.createElement('button');enter.className='panel-action enter';enter.innerHTML='<b>Mit Einladung eintreten</b>';enter.onclick=()=>post('doorEnter',{propertyKey:data.propertyKey});actions.appendChild(enter);
|
||||||
|
}
|
||||||
|
panelBody.appendChild(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', event => {
|
||||||
|
const data=event.data||{};
|
||||||
|
if(data.action==='previewOpen'){preview.classList.remove('hidden');panel.classList.add('hidden')}
|
||||||
|
if(data.action==='previewData'){
|
||||||
|
building.textContent=data.building||'DEVX IMMOBILIEN';
|
||||||
|
label.textContent=data.label||'Wohnung';
|
||||||
|
meta.textContent=`Etage ${data.floor} · Wohnung ${data.unitNumber} · ${data.style} · ${data.index}/${data.total}`;
|
||||||
|
price.textContent=money(data.price);
|
||||||
|
buy.disabled=Boolean(data.sold);
|
||||||
|
buyText.textContent=data.ownedByMe?'DEINE WOHNUNG':data.sold?`VERKAUFT · ${data.ownerName||'Bewohnt'}`:'KAUFEN';
|
||||||
|
}
|
||||||
|
if(data.action==='elevatorOpen')elevator(data);
|
||||||
|
if(data.action==='doorOpen')door(data);
|
||||||
|
if(data.action==='inviteOpen')invite(data);
|
||||||
|
if(data.action==='close'){preview.classList.add('hidden');panel.classList.add('hidden')}
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="preview" class="hidden">
|
||||||
|
<section class="preview-bar">
|
||||||
|
<button id="previous" class="arrow" aria-label="Vorherige Wohnung">‹</button>
|
||||||
|
<div class="preview-copy">
|
||||||
|
<small id="building">DEVX IMMOBILIEN</small>
|
||||||
|
<strong id="label">Wohnung</strong>
|
||||||
|
<span id="meta">Etage · Stil</span>
|
||||||
|
</div>
|
||||||
|
<button id="buy" class="buy">
|
||||||
|
<span id="buyText">KAUFEN</span>
|
||||||
|
<b id="price">0 $</b>
|
||||||
|
</button>
|
||||||
|
<button id="next" class="arrow" aria-label="Nächste Wohnung">›</button>
|
||||||
|
<button id="previewClose" class="close">×</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main id="panel" class="hidden overlay">
|
||||||
|
<section class="panel-card">
|
||||||
|
<button id="panelClose" class="close panel-close">×</button>
|
||||||
|
<small id="panelEyebrow">DEVX-CITY</small>
|
||||||
|
<h1 id="panelTitle">Aufzug</h1>
|
||||||
|
<p id="panelText"></p>
|
||||||
|
<div id="panelBody" class="panel-body"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#fff}*{box-sizing:border-box}body{margin:0;background:transparent;overflow:hidden}.hidden{display:none!important}button{font:inherit}.preview-bar{position:fixed;left:50%;bottom:42px;transform:translateX(-50%);display:grid;grid-template-columns:64px minmax(260px,1fr) 190px 64px;align-items:stretch;width:min(860px,calc(100vw - 80px));min-height:98px;border:1px solid rgba(255,255,255,.14);border-radius:18px;background:linear-gradient(180deg,rgba(12,17,28,.96),rgba(7,10,18,.97));box-shadow:0 25px 80px rgba(0,0,0,.55);backdrop-filter:blur(14px);overflow:visible}.preview-copy{display:flex;flex-direction:column;justify-content:center;padding:14px 22px}.preview-copy small,#panelEyebrow{font-size:10px;font-weight:900;letter-spacing:.2em;color:#79a7ff}.preview-copy strong{font-size:20px;margin-top:4px}.preview-copy span{font-size:12px;color:rgba(255,255,255,.58);margin-top:4px}.arrow,.buy{border:0;cursor:pointer;color:#fff}.arrow{font-size:42px;background:rgba(255,255,255,.035);transition:.15s}.arrow:hover{background:rgba(90,136,255,.22)}.buy{display:flex;flex-direction:column;align-items:center;justify-content:center;background:linear-gradient(135deg,#4977f3,#6e95ff);font-weight:900}.buy span{font-size:13px;letter-spacing:.1em}.buy b{font-size:18px;margin-top:4px}.buy:disabled{cursor:not-allowed;background:#2b303c;color:rgba(255,255,255,.5)}.close{border:1px solid rgba(255,255,255,.16);background:#111722;color:white;border-radius:999px;width:34px;height:34px;cursor:pointer;font-size:22px;line-height:1}.preview-bar>.close{position:absolute;right:-14px;top:-14px}.overlay{position:fixed;inset:0;display:grid;place-items:center;background:rgba(2,5,11,.62);backdrop-filter:blur(8px)}.panel-card{position:relative;width:min(520px,calc(100vw - 60px));max-height:76vh;padding:30px;border:1px solid rgba(255,255,255,.14);border-radius:22px;background:linear-gradient(180deg,#101725,#080d16);box-shadow:0 35px 100px rgba(0,0,0,.65)}.panel-close{position:absolute;right:18px;top:18px}.panel-card h1{margin:6px 0 8px;font-size:28px}.panel-card p{margin:0 0 20px;color:rgba(255,255,255,.62)}.panel-body{display:grid;gap:10px;max-height:52vh;overflow:auto}.panel-action{display:flex;align-items:center;justify-content:space-between;width:100%;padding:14px 16px;border:1px solid rgba(255,255,255,.11);border-radius:12px;background:rgba(255,255,255,.04);color:white;cursor:pointer;text-align:left}.panel-action:hover{border-color:#6e95ff;background:rgba(80,120,235,.15)}.panel-action b{font-size:15px}.panel-action span{font-size:12px;color:rgba(255,255,255,.55)}.door-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.door-actions .panel-action{justify-content:center;text-align:center}.door-actions .enter{grid-column:1/-1;background:linear-gradient(135deg,#4977f3,#6e95ff);border:0}
|
||||||
|
.empty-state{padding:18px;border:1px dashed rgba(255,255,255,.16);border-radius:12px;color:rgba(255,255,255,.62);text-align:center}.panel-action:disabled{opacity:.55;cursor:wait}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
local radarEnabled = false
|
||||||
|
local playerLoaded = false
|
||||||
|
|
||||||
|
AddEventHandler('devx_core:client:playerLoaded', function()
|
||||||
|
playerLoaded = true
|
||||||
|
radarEnabled = true
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_hud:client:setVisible', function(state)
|
||||||
|
radarEnabled = state == true
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterCommand('hud', function()
|
||||||
|
radarEnabled = not radarEnabled
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while true do
|
||||||
|
Wait(0)
|
||||||
|
DisplayRadar(playerLoaded and radarEnabled)
|
||||||
|
end
|
||||||
|
end)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
fx_version 'cerulean'
|
||||||
|
game 'gta5'
|
||||||
|
|
||||||
|
author 'Colin-Joel / DevX Studios'
|
||||||
|
description 'DevX RP radar controller without a permanent money overlay'
|
||||||
|
version '0.4.3'
|
||||||
|
|
||||||
|
client_script 'client/main.lua'
|
||||||
|
|
||||||
|
dependency 'devx_core'
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
local resourceName = GetCurrentResourceName()
|
||||||
|
local lastRestore = 0
|
||||||
|
|
||||||
|
local function restoreWorldStreaming(reason)
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
if ped == 0 or not DoesEntityExist(ped) then return false end
|
||||||
|
|
||||||
|
-- A stale scripted focus/load-scene is one of the most common causes for
|
||||||
|
-- half-loaded streets after leaving an instanced interior.
|
||||||
|
NewLoadSceneStop()
|
||||||
|
ClearFocus()
|
||||||
|
ClearHdArea()
|
||||||
|
|
||||||
|
local coords = GetEntityCoords(ped)
|
||||||
|
RequestCollisionAtCoord(coords.x, coords.y, coords.z)
|
||||||
|
RequestAdditionalCollisionAtCoord(coords.x, coords.y, coords.z)
|
||||||
|
SetFocusEntity(ped)
|
||||||
|
|
||||||
|
local deadline = GetGameTimer() + 5000
|
||||||
|
while not HasCollisionLoadedAroundEntity(ped) and GetGameTimer() < deadline do
|
||||||
|
RequestCollisionAtCoord(coords.x, coords.y, coords.z)
|
||||||
|
RequestAdditionalCollisionAtCoord(coords.x, coords.y, coords.z)
|
||||||
|
Wait(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
ClearFocus()
|
||||||
|
lastRestore = GetGameTimer()
|
||||||
|
if reason then
|
||||||
|
print(('[devx_ipl] Weltstreaming wiederhergestellt (%s).'):format(reason))
|
||||||
|
end
|
||||||
|
return HasCollisionLoadedAroundEntity(ped)
|
||||||
|
end
|
||||||
|
|
||||||
|
exports('RestoreWorldStreaming', restoreWorldStreaming)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_ipl:client:restoreWorld', function(reason)
|
||||||
|
CreateThread(function()
|
||||||
|
restoreWorldStreaming(reason or 'event')
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterCommand('fixworld', function()
|
||||||
|
CreateThread(function()
|
||||||
|
local ok = restoreWorldStreaming('command')
|
||||||
|
if GetResourceState('devx_core') == 'started' then
|
||||||
|
exports.devx_core:Notify(ok and 'Weltstreaming wurde neu aufgebaut.' or 'Kollision lädt noch. Bewege dich kurz oder verbinde dich neu.')
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
-- Do not globally request/remove apartment or mission IPLs here. Those IPLs
|
||||||
|
-- share hidden coordinates and were the source of overlapping geometry in the
|
||||||
|
-- earlier versions. Each feature now loads only what it needs, on demand.
|
||||||
|
CreateThread(function()
|
||||||
|
Wait(1500)
|
||||||
|
restoreWorldStreaming('resource-start')
|
||||||
|
print('[devx_ipl] DevX Streaming-Guard v0.4.3 aktiv. Keine globalen Apartment-/Warehouse-IPLs.')
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('onClientResourceStart', function(resource)
|
||||||
|
if resource == resourceName and GetGameTimer() - lastRestore > 1000 then
|
||||||
|
CreateThread(function()
|
||||||
|
Wait(800)
|
||||||
|
restoreWorldStreaming('client-resource-start')
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('playerSpawned', function()
|
||||||
|
CreateThread(function()
|
||||||
|
Wait(1200)
|
||||||
|
restoreWorldStreaming('player-spawned')
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('devx_core:client:playerLoaded', function()
|
||||||
|
CreateThread(function()
|
||||||
|
Wait(800)
|
||||||
|
restoreWorldStreaming('character-loaded')
|
||||||
|
end)
|
||||||
|
end)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fx_version 'cerulean'
|
||||||
|
game 'gta5'
|
||||||
|
|
||||||
|
author 'Colin-Joel / DevX Studios'
|
||||||
|
description 'DevX RP IPL and interior bootstrap for mission, casino and apartment interiors'
|
||||||
|
version '0.4.3'
|
||||||
|
lua54 'yes'
|
||||||
|
|
||||||
|
client_script 'client/main.lua'
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
local open = false
|
||||||
|
local phoneObject = nil
|
||||||
|
local lastToggleAt = 0
|
||||||
|
|
||||||
|
local function loadModel(model)
|
||||||
|
local hash = GetHashKey(model)
|
||||||
|
RequestModel(hash)
|
||||||
|
local deadline = GetGameTimer() + 5000
|
||||||
|
while not HasModelLoaded(hash) and GetGameTimer() < deadline do Wait(0) end
|
||||||
|
return HasModelLoaded(hash) and hash or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function loadAnim(dict)
|
||||||
|
RequestAnimDict(dict)
|
||||||
|
local deadline = GetGameTimer() + 5000
|
||||||
|
while not HasAnimDictLoaded(dict) and GetGameTimer() < deadline do Wait(0) end
|
||||||
|
return HasAnimDictLoaded(dict)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function removePhoneProp()
|
||||||
|
ClearPedSecondaryTask(PlayerPedId())
|
||||||
|
if phoneObject and DoesEntityExist(phoneObject) then DeleteEntity(phoneObject) end
|
||||||
|
phoneObject = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function createPhoneProp()
|
||||||
|
removePhoneProp()
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
local model = loadModel('prop_npc_phone_02')
|
||||||
|
if model then
|
||||||
|
phoneObject = CreateObject(model, 0.0, 0.0, 0.0, false, false, false)
|
||||||
|
AttachEntityToEntity(phoneObject, ped, GetPedBoneIndex(ped, 28422), 0.0, 0.0, 0.0,
|
||||||
|
0.0, 0.0, 0.0, true, true, false, true, 1, true)
|
||||||
|
SetModelAsNoLongerNeeded(model)
|
||||||
|
end
|
||||||
|
if loadAnim('cellphone@') then
|
||||||
|
TaskPlayAnim(ped, 'cellphone@', 'cellphone_text_read_base', 3.0, -3.0, -1, 49, 0.0, false, false, false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sendState()
|
||||||
|
local player = exports.devx_core:GetPlayerData()
|
||||||
|
DevXTriggerCallback('phone:state', {}, function(phoneResult)
|
||||||
|
DevXTriggerCallback('phone:bankOverview', {}, function(bankResult)
|
||||||
|
SendNUIMessage({
|
||||||
|
action = 'state',
|
||||||
|
player = player,
|
||||||
|
phone = phoneResult.ok and phoneResult or nil,
|
||||||
|
banking = bankResult.ok and bankResult.overview or nil,
|
||||||
|
error = (not phoneResult.ok and phoneResult.error) or (not bankResult.ok and bankResult.error) or nil
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setOpen(state)
|
||||||
|
if state and not exports.devx_core:IsPlayerLoaded() then return end
|
||||||
|
open = state == true
|
||||||
|
SetNuiFocus(open, open)
|
||||||
|
SendNUIMessage({ action = open and 'open' or 'close' })
|
||||||
|
if open then createPhoneProp(); sendState() else removePhoneProp() end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function togglePhone()
|
||||||
|
local now = GetGameTimer()
|
||||||
|
if now - lastToggleAt < 300 then return end
|
||||||
|
lastToggleAt = now
|
||||||
|
setOpen(not open)
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterCommand('phone', togglePhone, false)
|
||||||
|
RegisterCommand('+devxphone', togglePhone, false)
|
||||||
|
RegisterCommand('-devxphone', function() end, false)
|
||||||
|
RegisterKeyMapping('+devxphone', 'DevX Smartphone öffnen/schließen', 'keyboard', 'M')
|
||||||
|
|
||||||
|
-- No raw INPUT_INTERACTION_MENU fallback here. It used to fire again on key
|
||||||
|
-- release and immediately close the phone, which made M behave like hold-to-use.
|
||||||
|
|
||||||
|
RegisterNUICallback('close', function(_, cb) setOpen(false); cb({ ok = true }) end)
|
||||||
|
RegisterNUICallback('refresh', function(_, cb) sendState(); cb({ ok = true }) end)
|
||||||
|
RegisterNUICallback('bankTransfer', function(data, cb)
|
||||||
|
DevXTriggerCallback('phone:bankTransfer', data, function(result)
|
||||||
|
if result.ok then sendState() end
|
||||||
|
cb(result)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
RegisterNUICallback('contactCreate', function(data, cb)
|
||||||
|
DevXTriggerCallback('phone:contactCreate', data, function(result)
|
||||||
|
if result.ok then sendState() end
|
||||||
|
cb(result)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
RegisterNUICallback('contactDelete', function(data, cb)
|
||||||
|
DevXTriggerCallback('phone:contactDelete', data, function(result)
|
||||||
|
if result.ok then sendState() end
|
||||||
|
cb(result)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
RegisterNUICallback('companionInvite', function(data, cb)
|
||||||
|
if GetResourceState('devx_stripclub') ~= 'started' then
|
||||||
|
cb({ ok = false, error = 'Die Begleiterfunktion ist nicht aktiv.' })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
DevXTriggerCallback('phone:companionInvite', data, cb)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_phone:client:refresh', function()
|
||||||
|
if open then sendState() end
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('onResourceStop', function(resourceName)
|
||||||
|
if resourceName ~= GetCurrentResourceName() then return end
|
||||||
|
removePhoneProp()
|
||||||
|
SetNuiFocus(false, false)
|
||||||
|
end)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
fx_version 'cerulean'
|
||||||
|
game 'gta5'
|
||||||
|
|
||||||
|
author 'Colin-Joel / DevX Studios'
|
||||||
|
description 'DevX RP smartphone, contacts and mobile banking'
|
||||||
|
version '0.4.3'
|
||||||
|
|
||||||
|
lua54 'yes'
|
||||||
|
|
||||||
|
ui_page 'web/index.html'
|
||||||
|
|
||||||
|
files {
|
||||||
|
'web/index.html',
|
||||||
|
'web/style.css',
|
||||||
|
'web/app.js'
|
||||||
|
}
|
||||||
|
|
||||||
|
client_scripts {
|
||||||
|
'@devx_core/client/callbacks.lua',
|
||||||
|
'client/main.lua'
|
||||||
|
}
|
||||||
|
|
||||||
|
server_scripts {
|
||||||
|
'@oxmysql/lib/MySQL.lua',
|
||||||
|
'@devx_core/server/callbacks.lua',
|
||||||
|
'server/main.lua'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
'devx_core',
|
||||||
|
'devx_banking'
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
local function cleanText(value, minimum, maximum)
|
||||||
|
if type(value) ~= 'string' then return nil end
|
||||||
|
value = value:gsub('^%s+', ''):gsub('%s+$', '')
|
||||||
|
if #value < minimum or #value > maximum then return nil end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensureColumn(name, definition)
|
||||||
|
local exists = MySQL.scalar.await([[
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'devx_phone_contacts' AND COLUMN_NAME = ?
|
||||||
|
]], { name })
|
||||||
|
if tonumber(exists) == 0 then
|
||||||
|
MySQL.query.await(('ALTER TABLE devx_phone_contacts ADD COLUMN `%s` %s'):format(name, definition))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
MySQL.ready(function()
|
||||||
|
ensureColumn('contact_type', "VARCHAR(32) NOT NULL DEFAULT 'player' AFTER phone_number")
|
||||||
|
ensureColumn('metadata', 'LONGTEXT NULL AFTER contact_type')
|
||||||
|
end)
|
||||||
|
|
||||||
|
local function addSystemContact(characterId, name, number, contactType, metadata)
|
||||||
|
characterId = tonumber(characterId)
|
||||||
|
name = cleanText(name, 2, 64)
|
||||||
|
number = cleanText(number, 4, 24)
|
||||||
|
contactType = cleanText(contactType or 'system', 2, 32)
|
||||||
|
if not characterId or not name or not number or not contactType then return false, 'Ungültiger Systemkontakt.' end
|
||||||
|
|
||||||
|
local existing = MySQL.scalar.await([[
|
||||||
|
SELECT id FROM devx_phone_contacts
|
||||||
|
WHERE character_id = ? AND phone_number = ? AND contact_type = ? LIMIT 1
|
||||||
|
]], { characterId, number, contactType })
|
||||||
|
if existing then return true, tonumber(existing) end
|
||||||
|
|
||||||
|
local id = MySQL.insert.await([[
|
||||||
|
INSERT INTO devx_phone_contacts
|
||||||
|
(character_id, contact_name, phone_number, contact_type, metadata)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
]], { characterId, name, number, contactType, metadata and json.encode(metadata) or nil })
|
||||||
|
return id ~= nil, id
|
||||||
|
end
|
||||||
|
|
||||||
|
exports('AddSystemContact', addSystemContact)
|
||||||
|
|
||||||
|
DevXRegisterCallback('phone:state', function(source)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||||
|
|
||||||
|
local contacts = MySQL.query.await([[
|
||||||
|
SELECT id, contact_name AS contactName, phone_number AS phoneNumber,
|
||||||
|
contact_type AS contactType, metadata
|
||||||
|
FROM devx_phone_contacts
|
||||||
|
WHERE character_id = ?
|
||||||
|
ORDER BY contact_name ASC
|
||||||
|
]], { player.characterId }) or {}
|
||||||
|
|
||||||
|
for _, contact in ipairs(contacts) do
|
||||||
|
contact.id = tonumber(contact.id)
|
||||||
|
if type(contact.metadata) == 'string' and contact.metadata ~= '' then
|
||||||
|
local ok, decoded = pcall(json.decode, contact.metadata)
|
||||||
|
contact.metadata = ok and decoded or {}
|
||||||
|
else
|
||||||
|
contact.metadata = {}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok = true,
|
||||||
|
phoneNumber = player.phoneNumber,
|
||||||
|
rpXp = player.rpXp or 0,
|
||||||
|
contacts = contacts
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('phone:contactCreate', function(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||||
|
|
||||||
|
local name = cleanText(payload.contactName, 2, 64)
|
||||||
|
local number = cleanText(payload.phoneNumber, 4, 16)
|
||||||
|
if not name or not number or not number:match('^[%d%-]+$') then
|
||||||
|
return { ok = false, error = 'Name oder Telefonnummer ist ungültig.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
local exists = MySQL.scalar.await('SELECT id FROM devx_characters WHERE phone_number = ? LIMIT 1', { number })
|
||||||
|
if not exists then return { ok = false, error = 'Diese Telefonnummer existiert nicht.' } end
|
||||||
|
|
||||||
|
local duplicate = MySQL.scalar.await([[
|
||||||
|
SELECT id FROM devx_phone_contacts WHERE character_id = ? AND phone_number = ? LIMIT 1
|
||||||
|
]], { player.characterId, number })
|
||||||
|
if duplicate then return { ok = false, error = 'Diese Nummer ist bereits gespeichert.' } end
|
||||||
|
|
||||||
|
local id = MySQL.insert.await([[
|
||||||
|
INSERT INTO devx_phone_contacts (character_id, contact_name, phone_number, contact_type)
|
||||||
|
VALUES (?, ?, ?, 'player')
|
||||||
|
]], { player.characterId, name, number })
|
||||||
|
|
||||||
|
return id and { ok = true } or { ok = false, error = 'Kontakt konnte nicht gespeichert werden.' }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('phone:contactDelete', function(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
local contactId = tonumber(payload.contactId)
|
||||||
|
if not player or not contactId then return { ok = false, error = 'Ungültige Anfrage.' } end
|
||||||
|
|
||||||
|
local contact = MySQL.single.await([[
|
||||||
|
SELECT contact_type AS contactType FROM devx_phone_contacts
|
||||||
|
WHERE id = ? AND character_id = ? LIMIT 1
|
||||||
|
]], { contactId, player.characterId })
|
||||||
|
if not contact then return { ok = false, error = 'Kontakt wurde nicht gefunden.' } end
|
||||||
|
if contact.contactType ~= 'player' then return { ok = false, error = 'Dieser Kontakt gehört zu einer freigeschalteten Beziehung.' } end
|
||||||
|
|
||||||
|
local changed = MySQL.update.await('DELETE FROM devx_phone_contacts WHERE id = ? AND character_id = ?', { contactId, player.characterId })
|
||||||
|
return changed and changed > 0 and { ok = true } or { ok = false, error = 'Kontakt wurde nicht gefunden.' }
|
||||||
|
end)
|
||||||
|
|
||||||
|
|
||||||
|
DevXRegisterCallback('phone:companionInvite', function(source)
|
||||||
|
if GetResourceState('devx_stripclub') ~= 'started' then
|
||||||
|
return { ok = false, error = 'Die Begleiterfunktion ist nicht aktiv.' }
|
||||||
|
end
|
||||||
|
return exports.devx_stripclub:InviteCompanion(source)
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('phone:bankOverview', function(source)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||||
|
local overview = exports.devx_banking:GetOverview(player.characterId)
|
||||||
|
return overview and { ok = true, overview = overview } or { ok = false, error = 'Kein Bankkonto gefunden.' }
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('phone:bankTransfer', function(source, payload)
|
||||||
|
local success, result = exports.devx_banking:TransferForSource(source, payload)
|
||||||
|
return success and { ok = true, balance = result } or { ok = false, error = result }
|
||||||
|
end)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
const resource = typeof GetParentResourceName === 'function' ? GetParentResourceName() : 'devx_phone';
|
||||||
|
const phone = document.querySelector('#phone');
|
||||||
|
const screens = [...document.querySelectorAll('.screen')];
|
||||||
|
const format = new Intl.NumberFormat('de-DE');
|
||||||
|
let state = { player: {}, phone: {}, banking: {} };
|
||||||
|
|
||||||
|
async function post(event, data = {}) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://${resource}/${event}`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json; charset=UTF-8' }, body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
return text ? JSON.parse(text) : { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: 'Die App hat keine Antwort erhalten.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>"']/g, char => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
}[char]));
|
||||||
|
}
|
||||||
|
function show(id) { screens.forEach(screen => screen.classList.toggle('hidden', screen.id !== id)); }
|
||||||
|
function setError(id, text = '') {
|
||||||
|
const el = document.querySelector(id); el.textContent = text; el.classList.toggle('hidden', !text);
|
||||||
|
}
|
||||||
|
function close() { post('close'); }
|
||||||
|
function refresh() { post('refresh'); }
|
||||||
|
|
||||||
|
function renderTransactions(entries = []) {
|
||||||
|
const list = document.querySelector('#transactions');
|
||||||
|
list.innerHTML = entries.length ? '' : '<div class="empty-state compact-empty"><p>Noch keine Buchungen.</p></div>';
|
||||||
|
entries.slice(0, 12).forEach(entry => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'transaction';
|
||||||
|
const credit = entry.direction === 'credit';
|
||||||
|
row.innerHTML = `<div><strong>${escapeHtml(entry.reference)}</strong><small>${escapeHtml(entry.counterparty || '')}</small></div><b class="${credit ? 'credit' : 'debit'}">${credit ? '+' : '−'}${format.format(Number(entry.amount) || 0)} $</b>`;
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContacts(entries = []) {
|
||||||
|
const list = document.querySelector('#contactList');
|
||||||
|
list.innerHTML = entries.length ? '' : '<div class="empty-state compact-empty"><p>Noch keine Kontakte.</p></div>';
|
||||||
|
entries.forEach(entry => {
|
||||||
|
const row = document.createElement('div'); row.className = 'contact';
|
||||||
|
const system = entry.contactType && entry.contactType !== 'player';
|
||||||
|
row.innerHTML = `<div><strong>${escapeHtml(entry.contactName)}</strong><small>${escapeHtml(entry.phoneNumber)}</small></div><div class="contact-actions"></div>`;
|
||||||
|
const actions = row.querySelector('.contact-actions');
|
||||||
|
if (entry.contactType === 'npc_companion') {
|
||||||
|
const invite = document.createElement('button'); invite.className = 'invite-contact'; invite.textContent = 'Einladen';
|
||||||
|
invite.onclick = async () => {
|
||||||
|
invite.disabled = true;
|
||||||
|
const result = await post('companionInvite', { contactId: Number(entry.id), phoneNumber: entry.phoneNumber });
|
||||||
|
setError('#contactError', result.ok ? (result.message || 'Einladung gesendet.') : (result.error || 'Einladung fehlgeschlagen.'));
|
||||||
|
setTimeout(() => setError('#contactError'), 3500); invite.disabled = false;
|
||||||
|
};
|
||||||
|
actions.appendChild(invite);
|
||||||
|
}
|
||||||
|
if (!system) {
|
||||||
|
const remove = document.createElement('button'); remove.className = 'delete-contact'; remove.textContent = '×'; remove.ariaLabel = 'Löschen';
|
||||||
|
remove.onclick = async () => {
|
||||||
|
const result = await post('contactDelete', { contactId: Number(entry.id) });
|
||||||
|
if (!result.ok) setError('#contactError', result.error || 'Löschen fehlgeschlagen.');
|
||||||
|
};
|
||||||
|
actions.appendChild(remove);
|
||||||
|
}
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const player = state.player || {}; const phoneState = state.phone || {}; const account = state.banking?.account;
|
||||||
|
document.querySelector('#firstName').textContent = player.firstName || 'Spieler';
|
||||||
|
document.querySelector('#ownNumber').textContent = phoneState.phoneNumber || player.phoneNumber || '–';
|
||||||
|
document.querySelector('#profileName').textContent = [player.firstName, player.lastName].filter(Boolean).join(' ') || 'Spieler';
|
||||||
|
document.querySelector('#profileNumber').textContent = phoneState.phoneNumber || player.phoneNumber || '–';
|
||||||
|
document.querySelector('#rpXp').textContent = `${format.format(Number(phoneState.rpXp || player.rpXp) || 0)} XP`;
|
||||||
|
document.querySelector('#characterId').textContent = player.characterId || '–';
|
||||||
|
document.querySelector('#balance').textContent = `${format.format(Number(account?.balance) || 0)} $`;
|
||||||
|
document.querySelector('#accountNumber').textContent = account?.accountNumber || 'Kein Konto';
|
||||||
|
renderTransactions(state.banking?.transactions || []);
|
||||||
|
renderContacts(phoneState.contacts || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const button of document.querySelectorAll('[data-app]')) button.onclick = () => show(button.dataset.app);
|
||||||
|
for (const button of document.querySelectorAll('.back')) button.onclick = () => show('home');
|
||||||
|
for (const button of document.querySelectorAll('.refresh')) button.onclick = refresh;
|
||||||
|
document.querySelector('#homeButton').onclick = close;
|
||||||
|
document.addEventListener('keyup', event => { if (event.key === 'Escape') close(); });
|
||||||
|
setInterval(() => { document.querySelector('#time').textContent = new Date().toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'}); }, 1000);
|
||||||
|
|
||||||
|
const transferForm = document.querySelector('#transferForm');
|
||||||
|
transferForm.addEventListener('submit', async event => {
|
||||||
|
event.preventDefault(); setError('#bankError');
|
||||||
|
const button = event.currentTarget.querySelector('button'); button.disabled = true;
|
||||||
|
const result = await post('bankTransfer', Object.fromEntries(new FormData(event.currentTarget)));
|
||||||
|
if (!result.ok) setError('#bankError', result.error || 'Überweisung fehlgeschlagen.');
|
||||||
|
else { event.currentTarget.reset(); await post('refresh'); }
|
||||||
|
button.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#contactForm').addEventListener('submit', async event => {
|
||||||
|
event.preventDefault(); setError('#contactError');
|
||||||
|
const result = await post('contactCreate', Object.fromEntries(new FormData(event.currentTarget)));
|
||||||
|
if (!result.ok) setError('#contactError', result.error || 'Kontakt konnte nicht gespeichert werden.');
|
||||||
|
else { event.currentTarget.reset(); await post('refresh'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('message', event => {
|
||||||
|
const data = event.data || {};
|
||||||
|
if (data.action === 'open') { phone.classList.remove('hidden'); show('home'); }
|
||||||
|
if (data.action === 'close') phone.classList.add('hidden');
|
||||||
|
if (data.action === 'state') {
|
||||||
|
state = { player: data.player || {}, phone: data.phone || {}, banking: data.banking || {} };
|
||||||
|
render();
|
||||||
|
if (data.error) setError('#bankError', data.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section id="phone" class="phone hidden">
|
||||||
|
<div class="camera-dot"></div>
|
||||||
|
<header class="status"><span id="time">12:00</span><span>DEVX LTE ▰</span></header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section id="home" class="screen">
|
||||||
|
<div class="home-heading">
|
||||||
|
<small>DEVX-CITY</small>
|
||||||
|
<h1>Hallo, <span id="firstName">Spieler</span>.</h1>
|
||||||
|
<p id="ownNumber">555-0000</p>
|
||||||
|
</div>
|
||||||
|
<div class="apps">
|
||||||
|
<button data-app="bank"><span class="bank-icon">$</span><b>Bank</b></button>
|
||||||
|
<button data-app="contacts"><span class="contacts-icon">●</span><b>Kontakte</b></button>
|
||||||
|
<button data-app="messages"><span class="messages-icon">✉</span><b>Nachrichten</b></button>
|
||||||
|
<button data-app="profile"><span class="profile-icon">DX</span><b>Profil</b></button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="bank" class="screen hidden">
|
||||||
|
<div class="app-header"><button class="back">‹</button><h2>DevX Bank</h2><button class="refresh">↻</button></div>
|
||||||
|
<article class="bank-card">
|
||||||
|
<small>VERFÜGBAR</small>
|
||||||
|
<strong id="balance">0 $</strong>
|
||||||
|
<span id="accountNumber">–</span>
|
||||||
|
</article>
|
||||||
|
<div id="bankError" class="inline-error hidden"></div>
|
||||||
|
<form id="transferForm" class="phone-form">
|
||||||
|
<h3>Überweisen</h3>
|
||||||
|
<input name="destination" placeholder="Empfängerkonto" required>
|
||||||
|
<input name="amount" type="number" min="1" step="1" placeholder="Betrag" required>
|
||||||
|
<input name="reference" maxlength="140" placeholder="Verwendungszweck" value="Überweisung">
|
||||||
|
<button type="submit">Überweisen</button>
|
||||||
|
</form>
|
||||||
|
<h3 class="history-title">Letzte Buchungen</h3>
|
||||||
|
<div id="transactions" class="transactions"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="contacts" class="screen hidden">
|
||||||
|
<div class="app-header"><button class="back">‹</button><h2>Kontakte</h2><span></span></div>
|
||||||
|
<form id="contactForm" class="phone-form compact">
|
||||||
|
<input name="contactName" placeholder="Name" required>
|
||||||
|
<input name="phoneNumber" placeholder="555-0000" required>
|
||||||
|
<button type="submit">Kontakt speichern</button>
|
||||||
|
</form>
|
||||||
|
<div id="contactError" class="inline-error hidden"></div>
|
||||||
|
<div id="contactList" class="contact-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="profile" class="screen hidden">
|
||||||
|
<div class="app-header"><button class="back">‹</button><h2>Profil</h2><span></span></div>
|
||||||
|
<div class="profile-card"><span>DX</span><h2 id="profileName">Spieler</h2><p id="profileNumber">555-0000</p></div>
|
||||||
|
<div class="profile-row"><span>RP-Erfahrung</span><strong id="rpXp">0 XP</strong></div>
|
||||||
|
<div class="profile-row"><span>Charakter-ID</span><strong id="characterId">–</strong></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="messages" class="screen hidden">
|
||||||
|
<div class="app-header"><button class="back">‹</button><h2>Nachrichten</h2><span></span></div>
|
||||||
|
<div class="empty-state"><strong>Noch keine Nachrichten</strong><p>SMS und Anrufe folgen in der nächsten Telefon-Ausbaustufe.</p></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<button id="homeButton" aria-label="Handy schließen"></button>
|
||||||
|
</section>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #f7f8fc; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; overflow: hidden; background: transparent; user-select: none; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
button, input { font: inherit; }
|
||||||
|
.phone {
|
||||||
|
position: fixed; right: 36px; bottom: 25px; width: 348px; height: 690px;
|
||||||
|
border: 9px solid #111319; border-radius: 44px;
|
||||||
|
background: linear-gradient(160deg, #244b9b 0%, #182a51 30%, #10141d 70%);
|
||||||
|
box-shadow: 0 30px 85px rgba(0,0,0,.58); overflow: hidden;
|
||||||
|
}
|
||||||
|
.camera-dot { position: absolute; top: 14px; left: 50%; transform: translateX(-50%); width: 72px; height: 7px; border-radius: 10px; background: #050608; z-index: 4; }
|
||||||
|
.status { height: 52px; padding: 27px 19px 0; display: flex; justify-content: space-between; font-size: 11px; font-weight: 750; }
|
||||||
|
main { height: 586px; padding: 8px 17px 16px; overflow: hidden; }
|
||||||
|
.screen { height: 100%; overflow-y: auto; padding: 4px 2px 18px; scrollbar-width: none; }
|
||||||
|
.screen::-webkit-scrollbar { display: none; }
|
||||||
|
.home-heading { padding-top: 25px; }
|
||||||
|
.home-heading small { color: #9db7ff; font-size: 9px; letter-spacing: .19em; font-weight: 850; }
|
||||||
|
h1 { margin: 8px 0 4px; font-size: 29px; letter-spacing: -.035em; }
|
||||||
|
.home-heading p { margin: 0; color: rgba(255,255,255,.62); font-size: 12px; }
|
||||||
|
.apps { display: grid; grid-template-columns: repeat(2,1fr); gap: 13px; margin-top: 30px; }
|
||||||
|
.apps button { height: 120px; padding: 13px; display: grid; place-items: center; gap: 7px; color: white; background: rgba(255,255,255,.095); border: 1px solid rgba(255,255,255,.105); border-radius: 21px; cursor: pointer; }
|
||||||
|
.apps button:hover { background: rgba(255,255,255,.15); }
|
||||||
|
.apps button span { width: 51px; height: 51px; border-radius: 15px; display: grid; place-items: center; font-size: 22px; font-weight: 850; box-shadow: 0 8px 20px rgba(0,0,0,.22); }
|
||||||
|
.apps button b { font-size: 12px; }
|
||||||
|
.bank-icon { background: linear-gradient(145deg,#4fb881,#27835a); }
|
||||||
|
.contacts-icon { background: linear-gradient(145deg,#f0a64b,#d86d38); }
|
||||||
|
.messages-icon { background: linear-gradient(145deg,#57a7ff,#426ce8); }
|
||||||
|
.profile-icon { background: linear-gradient(145deg,#7c86ff,#554dcc); font-size: 13px !important; }
|
||||||
|
.app-header { min-height: 45px; display: grid; grid-template-columns: 38px 1fr 38px; align-items: center; }
|
||||||
|
.app-header h2 { margin: 0; text-align: center; font-size: 17px; }
|
||||||
|
.back, .refresh { border: 0; background: transparent; color: white; font-size: 27px; cursor: pointer; }
|
||||||
|
.refresh { font-size: 19px; }
|
||||||
|
.bank-card { margin-top: 10px; min-height: 142px; padding: 20px; display: flex; flex-direction: column; justify-content: flex-end; border-radius: 20px; background: linear-gradient(145deg,#547cf3,#2546a0); box-shadow: 0 16px 35px rgba(16,34,92,.35); }
|
||||||
|
.bank-card small { font-size: 9px; letter-spacing: .14em; opacity: .72; }
|
||||||
|
.bank-card strong { margin: 5px 0 12px; font-size: 35px; letter-spacing: -.04em; }
|
||||||
|
.bank-card span { font-size: 11px; opacity: .75; }
|
||||||
|
.phone-form { display: grid; gap: 8px; margin-top: 18px; }
|
||||||
|
.phone-form h3, .history-title { margin: 0 0 3px; font-size: 13px; }
|
||||||
|
.phone-form input { width: 100%; padding: 11px; border: 1px solid rgba(255,255,255,.11); border-radius: 10px; outline: 0; color: white; background: rgba(0,0,0,.22); }
|
||||||
|
.phone-form input:focus { border-color: #6f96ff; }
|
||||||
|
.phone-form button { border: 0; border-radius: 10px; padding: 11px; color: white; background: #557df3; font-weight: 800; cursor: pointer; }
|
||||||
|
.phone-form.compact { margin-top: 12px; }
|
||||||
|
.history-title { margin-top: 20px; }
|
||||||
|
.transactions, .contact-list { display: grid; gap: 7px; margin-top: 8px; }
|
||||||
|
.transaction, .contact { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; padding: 10px 11px; border-radius: 11px; background: rgba(255,255,255,.06); }
|
||||||
|
.transaction strong, .contact strong { display: block; font-size: 11px; }
|
||||||
|
.transaction small, .contact small { color: rgba(255,255,255,.55); font-size: 9px; }
|
||||||
|
.transaction .credit { color: #7ce6a9; }
|
||||||
|
.transaction .debit { color: #ff8d9b; }
|
||||||
|
.contact button { width: 27px; height: 27px; border: 0; border-radius: 8px; color: #ff9daa; background: rgba(210,70,88,.17); cursor: pointer; }
|
||||||
|
.inline-error { margin-top: 9px; padding: 9px; border-radius: 9px; color: #ffadb7; background: rgba(179,47,66,.22); font-size: 10px; }
|
||||||
|
.profile-card { margin-top: 22px; text-align: center; }
|
||||||
|
.profile-card > span { width: 68px; height: 68px; margin: auto; display: grid; place-items: center; border-radius: 20px; background: linear-gradient(145deg,#7294ff,#3d5ed0); font-weight: 900; }
|
||||||
|
.profile-card h2 { margin: 13px 0 4px; }
|
||||||
|
.profile-card p { margin: 0 0 25px; color: rgba(255,255,255,.6); }
|
||||||
|
.profile-row { display: flex; justify-content: space-between; padding: 13px; border-bottom: 1px solid rgba(255,255,255,.08); font-size: 12px; }
|
||||||
|
.empty-state { margin-top: 90px; text-align: center; color: rgba(255,255,255,.62); }
|
||||||
|
.empty-state strong { color: white; }
|
||||||
|
.empty-state p { font-size: 11px; line-height: 1.5; }
|
||||||
|
#homeButton { position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); width: 112px; height: 5px; border: 0; border-radius: 10px; background: rgba(255,255,255,.82); cursor: pointer; }
|
||||||
|
.contact-actions{display:flex;align-items:center;gap:6px}.contact-actions button{height:29px;border:0;border-radius:8px;color:white;cursor:pointer;font-size:10px;font-weight:800}.delete-contact{width:29px;background:rgba(210,70,88,.2);color:#ff9daa!important}.invite-contact{padding:0 9px;background:#557df3}.contact-actions button:disabled{opacity:.45}.compact-empty{margin-top:18px}
|
||||||
|
.invite-contact{width:auto!important;min-width:64px}.delete-contact{width:29px!important}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
local playerBlips = {}
|
||||||
|
|
||||||
|
local function removePlayerBlip(serverId)
|
||||||
|
local blip = playerBlips[serverId]
|
||||||
|
if blip and DoesBlipExist(blip) then RemoveBlip(blip) end
|
||||||
|
playerBlips[serverId] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensureBlip(entry)
|
||||||
|
local serverId = tonumber(entry.serverId)
|
||||||
|
if not serverId or serverId == GetPlayerServerId(PlayerId()) then return end
|
||||||
|
|
||||||
|
local blip = playerBlips[serverId]
|
||||||
|
if not blip or not DoesBlipExist(blip) then
|
||||||
|
blip = AddBlipForCoord(entry.x + 0.0, entry.y + 0.0, entry.z + 0.0)
|
||||||
|
SetBlipSprite(blip, 1)
|
||||||
|
SetBlipColour(blip, 3)
|
||||||
|
SetBlipScale(blip, 0.82)
|
||||||
|
SetBlipDisplay(blip, 2)
|
||||||
|
SetBlipAsShortRange(blip, false)
|
||||||
|
ShowHeadingIndicatorOnBlip(blip, true)
|
||||||
|
playerBlips[serverId] = blip
|
||||||
|
end
|
||||||
|
|
||||||
|
SetBlipCoords(blip, entry.x + 0.0, entry.y + 0.0, entry.z + 0.0)
|
||||||
|
SetBlipRotation(blip, math.floor(tonumber(entry.heading) or 0.0))
|
||||||
|
BeginTextCommandSetBlipName('STRING')
|
||||||
|
AddTextComponentString(('%s [%s]'):format(entry.name or 'Spieler', serverId))
|
||||||
|
EndTextCommandSetBlipName(blip)
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_players:client:sync', function(entries)
|
||||||
|
local seen = {}
|
||||||
|
for _, entry in ipairs(entries or {}) do
|
||||||
|
local serverId = tonumber(entry.serverId)
|
||||||
|
if serverId and serverId ~= GetPlayerServerId(PlayerId()) then
|
||||||
|
seen[serverId] = true
|
||||||
|
ensureBlip(entry)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for serverId in pairs(playerBlips) do
|
||||||
|
if not seen[serverId] then removePlayerBlip(serverId) end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('onResourceStop', function(resourceName)
|
||||||
|
if resourceName ~= GetCurrentResourceName() then return end
|
||||||
|
for serverId in pairs(playerBlips) do removePlayerBlip(serverId) end
|
||||||
|
end)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
fx_version 'cerulean'
|
||||||
|
game 'gta5'
|
||||||
|
|
||||||
|
author 'Colin-Joel / DevX Studios'
|
||||||
|
description 'DevX RP player visibility and GTA Online-style map blips'
|
||||||
|
version '0.4.3'
|
||||||
|
|
||||||
|
lua54 'yes'
|
||||||
|
|
||||||
|
client_script 'client/main.lua'
|
||||||
|
server_script 'server/main.lua'
|
||||||
|
|
||||||
|
dependency 'devx_core'
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
local function buildSnapshots()
|
||||||
|
local byBucket = {}
|
||||||
|
local activePlayers = exports.devx_core:GetActivePlayers()
|
||||||
|
|
||||||
|
for source, player in pairs(activePlayers) do
|
||||||
|
source = tonumber(source)
|
||||||
|
local ped = GetPlayerPed(source)
|
||||||
|
if ped and ped > 0 and DoesEntityExist(ped) then
|
||||||
|
local coords = GetEntityCoords(ped)
|
||||||
|
local bucket = GetPlayerRoutingBucket(source)
|
||||||
|
byBucket[bucket] = byBucket[bucket] or {}
|
||||||
|
byBucket[bucket][#byBucket[bucket] + 1] = {
|
||||||
|
serverId = source,
|
||||||
|
name = player.fullName or GetPlayerName(source) or ('Spieler %s'):format(source),
|
||||||
|
x = coords.x,
|
||||||
|
y = coords.y,
|
||||||
|
z = coords.z,
|
||||||
|
heading = GetEntityHeading(ped)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return byBucket, activePlayers
|
||||||
|
end
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while true do
|
||||||
|
Wait(1500)
|
||||||
|
local snapshots, activePlayers = buildSnapshots()
|
||||||
|
|
||||||
|
for source in pairs(activePlayers) do
|
||||||
|
source = tonumber(source)
|
||||||
|
local bucket = GetPlayerRoutingBucket(source)
|
||||||
|
TriggerClientEvent('devx_players:client:sync', source, snapshots[bucket] or {})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
local enabled = false
|
||||||
|
local station = 'RADIO_02_POP'
|
||||||
|
|
||||||
|
local function notify(message) exports.devx_core:Notify(message) end
|
||||||
|
|
||||||
|
local function apply(vehicle)
|
||||||
|
if not vehicle or vehicle == 0 then return end
|
||||||
|
if enabled then
|
||||||
|
SetVehicleRadioEnabled(vehicle, true)
|
||||||
|
SetVehRadioStation(vehicle, station)
|
||||||
|
SetRadioToStationName(station)
|
||||||
|
else
|
||||||
|
SetVehRadioStation(vehicle, 'OFF')
|
||||||
|
SetVehicleRadioEnabled(vehicle, false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function toggle()
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
local vehicle = GetVehiclePedIsIn(ped, false)
|
||||||
|
if vehicle == 0 then notify('Das Radio ist nur in einem Fahrzeug verfügbar.'); return end
|
||||||
|
enabled = not enabled
|
||||||
|
apply(vehicle)
|
||||||
|
notify(enabled and 'Radio: Non-Stop-Pop FM' or 'Radio ausgeschaltet.')
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterCommand('radio', toggle, false)
|
||||||
|
RegisterCommand('+devxradio', toggle, false)
|
||||||
|
RegisterCommand('-devxradio', function() end, false)
|
||||||
|
RegisterKeyMapping('+devxradio', 'Fahrzeugradio: Non-Stop-Pop FM / Aus', 'keyboard', 'R')
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
local lastVehicle = 0
|
||||||
|
while true do
|
||||||
|
local ped = PlayerPedId()
|
||||||
|
local vehicle = GetVehiclePedIsIn(ped, false)
|
||||||
|
if vehicle ~= 0 then
|
||||||
|
-- Hide the standard station wheel and keep the allowed state enforced.
|
||||||
|
DisableControlAction(0, 81, true)
|
||||||
|
DisableControlAction(0, 82, true)
|
||||||
|
DisableControlAction(0, 85, true)
|
||||||
|
if vehicle ~= lastVehicle then apply(vehicle); lastVehicle = vehicle end
|
||||||
|
if enabled and GetPlayerRadioStationName() ~= station then apply(vehicle) end
|
||||||
|
Wait(0)
|
||||||
|
else
|
||||||
|
lastVehicle = 0
|
||||||
|
Wait(500)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fx_version 'cerulean'
|
||||||
|
game 'gta5'
|
||||||
|
|
||||||
|
author 'Colin-Joel / DevX Studios'
|
||||||
|
description 'DevX RP restricted vehicle radio: Non-Stop-Pop FM or off'
|
||||||
|
version '0.4.3'
|
||||||
|
lua54 'yes'
|
||||||
|
client_script 'client/main.lua'
|
||||||
|
dependency 'devx_core'
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
local peds = {}
|
||||||
|
local companion = nil
|
||||||
|
local busy = false
|
||||||
|
|
||||||
|
local function notify(message) exports.devx_core:Notify(message) end
|
||||||
|
local function help(text)
|
||||||
|
BeginTextCommandDisplayHelp('STRING'); AddTextComponentSubstringPlayerName(text); EndTextCommandDisplayHelp(0, false, true, -1)
|
||||||
|
end
|
||||||
|
local function subtitle(text, duration)
|
||||||
|
BeginTextCommandPrint('STRING'); AddTextComponentSubstringPlayerName(text); EndTextCommandPrint(duration or 3000, true)
|
||||||
|
end
|
||||||
|
local function loadModel(name)
|
||||||
|
local hash = GetHashKey(name); RequestModel(hash)
|
||||||
|
local deadline = GetGameTimer() + 6000
|
||||||
|
while not HasModelLoaded(hash) and GetGameTimer() < deadline do Wait(0) end
|
||||||
|
return HasModelLoaded(hash) and hash or nil
|
||||||
|
end
|
||||||
|
local function loadAnim(dict)
|
||||||
|
RequestAnimDict(dict); local deadline = GetGameTimer() + 5000
|
||||||
|
while not HasAnimDictLoaded(dict) and GetGameTimer() < deadline do Wait(0) end
|
||||||
|
return HasAnimDictLoaded(dict)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function spawnPed(modelName, pos, scenario)
|
||||||
|
local model = loadModel(modelName); if not model then return nil end
|
||||||
|
local ped = CreatePed(4, model, pos.x, pos.y, pos.z, pos.w or 0.0, false, false)
|
||||||
|
SetEntityInvincible(ped, true); SetBlockingOfNonTemporaryEvents(ped, true); FreezeEntityPosition(ped, true)
|
||||||
|
if scenario then TaskStartScenarioInPlace(ped, scenario, 0, true) end
|
||||||
|
SetModelAsNoLongerNeeded(model); return ped
|
||||||
|
end
|
||||||
|
|
||||||
|
local function updateBar(result, message)
|
||||||
|
SendNUIMessage({ action = 'relationship', name = DevXStripclub.Nikki.name,
|
||||||
|
value = result.satisfaction or 0, maximum = result.threshold or 100, message = message })
|
||||||
|
end
|
||||||
|
|
||||||
|
local function runTalk(action)
|
||||||
|
if busy then return end
|
||||||
|
busy = true
|
||||||
|
DevXTriggerCallback('stripclub:interact', { action = action }, function(result)
|
||||||
|
if not result.ok then notify(result.error or 'Interaktion fehlgeschlagen.'); busy = false; return end
|
||||||
|
local player = PlayerPedId(); local nikki = peds.nikki
|
||||||
|
if nikki and DoesEntityExist(nikki) then
|
||||||
|
ClearPedTasksImmediately(nikki); FreezeEntityPosition(player, true)
|
||||||
|
TaskTurnPedToFaceEntity(player, nikki, 900); TaskTurnPedToFaceEntity(nikki, player, 900); Wait(900)
|
||||||
|
if action == 'lounge' then
|
||||||
|
local lounge = DevXStripclub.Nikki.lounge
|
||||||
|
SetEntityCoordsNoOffset(player, lounge.x + 1.25, lounge.y, lounge.z, false, false, false)
|
||||||
|
SetEntityHeading(player, lounge.w)
|
||||||
|
SetEntityCoordsNoOffset(nikki, lounge.x, lounge.y, lounge.z, false, false, false)
|
||||||
|
SetEntityHeading(nikki, lounge.w + 180.0)
|
||||||
|
if loadAnim('mini@strip_club@idles@stripper') then
|
||||||
|
TaskPlayAnim(nikki, 'mini@strip_club@idles@stripper', 'stripper_idle_02', 2.0, -2.0, 9000, 1, 0.0, false, false, false)
|
||||||
|
end
|
||||||
|
TaskStartScenarioInPlace(player, 'WORLD_HUMAN_SEAT_WALL', 0, true)
|
||||||
|
subtitle('~p~Nikki:~s~ Schön, dass du wieder da bist.', 4000); Wait(9000)
|
||||||
|
else
|
||||||
|
if loadAnim('gestures@f@standing@casual') then
|
||||||
|
TaskPlayAnim(nikki, 'gestures@f@standing@casual', 'gesture_hello', 2.0, -2.0, 3200, 49, 0.0, false, false, false)
|
||||||
|
end
|
||||||
|
subtitle('~p~Nikki:~s~ Hey, wie läuft dein neues Leben in DevX-City?', 3500); Wait(3600)
|
||||||
|
end
|
||||||
|
ClearPedTasksImmediately(player); FreezeEntityPosition(player, false)
|
||||||
|
FreezeEntityPosition(nikki, true); TaskStartScenarioInPlace(nikki, 'WORLD_HUMAN_STAND_MOBILE', 0, true)
|
||||||
|
end
|
||||||
|
updateBar(result, result.newlyUnlocked and 'Kontakt freigeschaltet – Nikki wurde im Handy gespeichert.' or 'Zufriedenheit gestiegen.')
|
||||||
|
busy = false
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function spawnClub()
|
||||||
|
for _, ped in pairs(peds) do if DoesEntityExist(ped) then DeleteEntity(ped) end end
|
||||||
|
peds = {}
|
||||||
|
peds.nikki = spawnPed(DevXStripclub.Nikki.model, DevXStripclub.Nikki.position, 'WORLD_HUMAN_STAND_MOBILE')
|
||||||
|
for index, definition in ipairs(DevXStripclub.Dancers) do
|
||||||
|
peds['ambient' .. index] = spawnPed(definition.model, definition.position, definition.scenario)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterNetEvent('devx_stripclub:client:spawnCompanion', function(data)
|
||||||
|
if companion and DoesEntityExist(companion) then DeleteEntity(companion) end
|
||||||
|
local pos = data.spawn
|
||||||
|
companion = spawnPed(data.model, vector4(pos.x, pos.y, pos.z, pos.heading or pos.w or 0.0), 'WORLD_HUMAN_STAND_MOBILE')
|
||||||
|
if companion then notify(('%s ist in deinem Apartment angekommen.'):format(data.name or 'Dein Gast')) end
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('devx_housing:client:leftApartment', function()
|
||||||
|
if companion and DoesEntityExist(companion) then DeleteEntity(companion) end
|
||||||
|
companion = nil
|
||||||
|
end)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while not exports.devx_core:IsPlayerLoaded() do Wait(500) end
|
||||||
|
spawnClub()
|
||||||
|
local blip = AddBlipForCoord(DevXStripclub.Entry.x, DevXStripclub.Entry.y, DevXStripclub.Entry.z)
|
||||||
|
SetBlipSprite(blip, 121); SetBlipScale(blip, .7); SetBlipColour(blip, 27); SetBlipAsShortRange(blip, true)
|
||||||
|
BeginTextCommandSetBlipName('STRING'); AddTextComponentString('Vanilla Unicorn'); EndTextCommandSetBlipName(blip)
|
||||||
|
end)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while true do
|
||||||
|
local sleep = 700
|
||||||
|
if exports.devx_core:IsPlayerLoaded() and peds.nikki and DoesEntityExist(peds.nikki) then
|
||||||
|
local coords = GetEntityCoords(PlayerPedId()); local npcCoords = GetEntityCoords(peds.nikki)
|
||||||
|
local distance = #(coords - npcCoords)
|
||||||
|
if distance < 8.0 then
|
||||||
|
sleep = 0
|
||||||
|
DrawMarker(2, npcCoords.x, npcCoords.y, npcCoords.z + 1.0, 0,0,0,0,180.0,0, .18,.18,.18, 220,90,180,150,false,false,2,false,nil,nil,false)
|
||||||
|
if distance < 1.8 then
|
||||||
|
help(('~INPUT_CONTEXT~ mit Nikki reden | ~INPUT_DETONATE~ VIP-Lounge (%s $)'):format(DevXStripclub.LoungePrice))
|
||||||
|
if IsControlJustReleased(0, 38) then runTalk('talk')
|
||||||
|
elseif IsControlJustReleased(0, 47) then runTalk('lounge') end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Wait(sleep)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
AddEventHandler('onResourceStop', function(resource)
|
||||||
|
if resource ~= GetCurrentResourceName() then return end
|
||||||
|
for _, ped in pairs(peds) do if DoesEntityExist(ped) then DeleteEntity(ped) end end
|
||||||
|
if companion and DoesEntityExist(companion) then DeleteEntity(companion) end
|
||||||
|
end)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
fx_version 'cerulean'
|
||||||
|
game 'gta5'
|
||||||
|
|
||||||
|
author 'Colin-Joel / DevX Studios'
|
||||||
|
description 'DevX RP Vanilla Unicorn social relationship and apartment companion system'
|
||||||
|
version '0.4.3'
|
||||||
|
lua54 'yes'
|
||||||
|
shared_script 'shared/config.lua'
|
||||||
|
ui_page 'web/index.html'
|
||||||
|
files { 'web/index.html', 'web/style.css', 'web/app.js' }
|
||||||
|
client_scripts { '@devx_core/client/callbacks.lua', 'client/main.lua' }
|
||||||
|
server_scripts { '@oxmysql/lib/MySQL.lua', '@devx_core/server/callbacks.lua', 'server/main.lua' }
|
||||||
|
dependencies { 'devx_core', 'devx_banking', 'devx_phone', 'devx_housing' }
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
local actionCooldowns = {}
|
||||||
|
|
||||||
|
MySQL.ready(function()
|
||||||
|
MySQL.query.await([[
|
||||||
|
CREATE TABLE IF NOT EXISTS devx_club_relations (
|
||||||
|
character_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
relation_key VARCHAR(32) NOT NULL,
|
||||||
|
satisfaction TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
contact_unlocked TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (character_id, relation_key),
|
||||||
|
CONSTRAINT fk_devx_club_character FOREIGN KEY (character_id)
|
||||||
|
REFERENCES devx_characters (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
]])
|
||||||
|
end)
|
||||||
|
|
||||||
|
local function relation(characterId)
|
||||||
|
local row = MySQL.single.await([[
|
||||||
|
SELECT satisfaction, contact_unlocked AS contactUnlocked
|
||||||
|
FROM devx_club_relations WHERE character_id = ? AND relation_key = ? LIMIT 1
|
||||||
|
]], { characterId, DevXStripclub.Nikki.key })
|
||||||
|
return {
|
||||||
|
satisfaction = row and tonumber(row.satisfaction) or 0,
|
||||||
|
contactUnlocked = row and tonumber(row.contactUnlocked) == 1 or false
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function saveRelation(characterId, satisfaction, unlocked)
|
||||||
|
MySQL.query.await([[
|
||||||
|
INSERT INTO devx_club_relations (character_id, relation_key, satisfaction, contact_unlocked)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE satisfaction = VALUES(satisfaction), contact_unlocked = VALUES(contact_unlocked)
|
||||||
|
]], { characterId, DevXStripclub.Nikki.key, satisfaction, unlocked and 1 or 0 })
|
||||||
|
end
|
||||||
|
|
||||||
|
DevXRegisterCallback('stripclub:state', function(source)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||||
|
local current = relation(player.characterId)
|
||||||
|
current.ok = true
|
||||||
|
current.name = DevXStripclub.Nikki.name
|
||||||
|
current.threshold = DevXStripclub.Nikki.contactThreshold
|
||||||
|
return current
|
||||||
|
end)
|
||||||
|
|
||||||
|
DevXRegisterCallback('stripclub:interact', function(source, payload)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
local action = payload and payload.action
|
||||||
|
if not player or (action ~= 'talk' and action ~= 'lounge') then return { ok = false, error = 'Ungültige Anfrage.' } end
|
||||||
|
|
||||||
|
local now = os.time()
|
||||||
|
local cooldown = actionCooldowns[source] or 0
|
||||||
|
if cooldown > now then return { ok = false, error = ('Warte noch %s Sekunden.'):format(cooldown - now) } end
|
||||||
|
|
||||||
|
local gain = DevXStripclub.TalkGain
|
||||||
|
if action == 'lounge' then
|
||||||
|
local paid, result = exports.devx_banking:ChargeCharacter(
|
||||||
|
player.characterId, DevXStripclub.LoungePrice, 'VIP-Lounge', 'Vanilla Unicorn'
|
||||||
|
)
|
||||||
|
if not paid then return { ok = false, error = result } end
|
||||||
|
exports.devx_core:SetBankCache(source, result)
|
||||||
|
gain = DevXStripclub.LoungeGain
|
||||||
|
actionCooldowns[source] = now + 45
|
||||||
|
else
|
||||||
|
actionCooldowns[source] = now + 15
|
||||||
|
end
|
||||||
|
|
||||||
|
local current = relation(player.characterId)
|
||||||
|
current.satisfaction = math.min(DevXStripclub.Nikki.contactThreshold, current.satisfaction + gain)
|
||||||
|
local newlyUnlocked = not current.contactUnlocked and current.satisfaction >= DevXStripclub.Nikki.contactThreshold
|
||||||
|
if newlyUnlocked then current.contactUnlocked = true end
|
||||||
|
saveRelation(player.characterId, current.satisfaction, current.contactUnlocked)
|
||||||
|
|
||||||
|
if current.contactUnlocked then
|
||||||
|
exports.devx_phone:AddSystemContact(
|
||||||
|
player.characterId, DevXStripclub.Nikki.name, DevXStripclub.Nikki.phone,
|
||||||
|
'npc_companion', { relationKey = DevXStripclub.Nikki.key }
|
||||||
|
)
|
||||||
|
TriggerClientEvent('devx_phone:client:refresh', source)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok = true,
|
||||||
|
satisfaction = current.satisfaction,
|
||||||
|
threshold = DevXStripclub.Nikki.contactThreshold,
|
||||||
|
contactUnlocked = current.contactUnlocked,
|
||||||
|
newlyUnlocked = newlyUnlocked,
|
||||||
|
action = action
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
local function inviteCompanion(source)
|
||||||
|
local player = exports.devx_core:GetPlayer(source)
|
||||||
|
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||||
|
local current = relation(player.characterId)
|
||||||
|
if not current.contactUnlocked then return { ok = false, error = 'Dieser Kontakt ist noch nicht freigeschaltet.' } end
|
||||||
|
|
||||||
|
local location = exports.devx_housing:GetPlayerHousingLocation(source)
|
||||||
|
if not location or location.type ~= 'unit' then
|
||||||
|
return { ok = false, error = 'Du musst dich in deinem Apartment befinden.' }
|
||||||
|
end
|
||||||
|
if tonumber(location.ownerCharacterId) ~= tonumber(player.characterId) then
|
||||||
|
return { ok = false, error = 'Du kannst Nikki nur in dein eigenes Apartment einladen.' }
|
||||||
|
end
|
||||||
|
local spawn = location.interior and location.interior.companionSpawn
|
||||||
|
if not spawn then return { ok = false, error = 'Für dieses Apartment ist kein Treffpunkt eingerichtet.' } end
|
||||||
|
|
||||||
|
TriggerClientEvent('devx_stripclub:client:spawnCompanion', source, {
|
||||||
|
name = DevXStripclub.Nikki.name,
|
||||||
|
model = DevXStripclub.Nikki.model,
|
||||||
|
spawn = spawn
|
||||||
|
})
|
||||||
|
return { ok = true, message = 'Nikki ist auf dem Weg zu deinem Apartment.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
exports('InviteCompanion', inviteCompanion)
|
||||||
|
|
||||||
|
DevXRegisterCallback('stripclub:inviteCompanion', function(source)
|
||||||
|
return inviteCompanion(source)
|
||||||
|
end)
|
||||||
|
|
||||||
|
|
||||||
|
RegisterCommand('resetnikki', function(source, args)
|
||||||
|
if source ~= 0 and not IsPlayerAceAllowed(source, 'devx.admin') then return end
|
||||||
|
local target = tonumber(args[1]) or source
|
||||||
|
local player = exports.devx_core:GetPlayer(target)
|
||||||
|
if not player then return end
|
||||||
|
MySQL.query.await('DELETE FROM devx_club_relations WHERE character_id = ? AND relation_key = ?', {
|
||||||
|
player.characterId, DevXStripclub.Nikki.key
|
||||||
|
})
|
||||||
|
MySQL.query.await("DELETE FROM devx_phone_contacts WHERE character_id = ? AND contact_type = 'npc_companion'", {
|
||||||
|
player.characterId
|
||||||
|
})
|
||||||
|
TriggerClientEvent('devx_phone:client:refresh', target)
|
||||||
|
TriggerClientEvent('devx_housing:client:notify', target, 'Nikki-Beziehung wurde zurückgesetzt.')
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
AddEventHandler('playerDropped', function() actionCooldowns[source] = nil end)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
DevXStripclub = {
|
||||||
|
Entry = vector3(127.40, -1283.12, 29.27),
|
||||||
|
Nikki = {
|
||||||
|
key = 'nikki',
|
||||||
|
name = 'Nikki',
|
||||||
|
phone = '555-0199',
|
||||||
|
model = 'a_f_y_bevhills_02',
|
||||||
|
position = vector4(117.18, -1302.15, 28.77, 303.0),
|
||||||
|
lounge = vector4(120.70, -1300.05, 28.77, 210.0),
|
||||||
|
contactThreshold = 100
|
||||||
|
},
|
||||||
|
Dancers = {
|
||||||
|
{ model = 's_f_y_stripper_01', position = vector4(112.75, -1287.35, 28.46, 195.0), scenario = 'WORLD_HUMAN_MUSICIAN' },
|
||||||
|
{ model = 's_f_y_stripper_02', position = vector4(104.85, -1294.10, 28.26, 25.0), scenario = 'WORLD_HUMAN_PARTYING' },
|
||||||
|
{ model = 'a_f_y_bevhills_01', position = vector4(128.25, -1284.25, 29.28, 125.0), scenario = 'WORLD_HUMAN_STAND_MOBILE' }
|
||||||
|
},
|
||||||
|
TalkGain = 10,
|
||||||
|
LoungeGain = 25,
|
||||||
|
LoungePrice = 500
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
const box=document.querySelector('#relation'),fill=document.querySelector('#fill'),value=document.querySelector('#value'),nameEl=document.querySelector('#name'),message=document.querySelector('#message');let timer;
|
||||||
|
window.addEventListener('message',event=>{const d=event.data||{};if(d.action!=='relationship')return;const max=Number(d.maximum)||100,current=Math.max(0,Math.min(max,Number(d.value)||0));nameEl.textContent=d.name||'Kontakt';message.textContent=d.message||'Zufriedenheit';value.textContent=`${current} / ${max}`;fill.style.width=`${current/max*100}%`;box.classList.remove('hidden');clearTimeout(timer);timer=setTimeout(()=>box.classList.add('hidden'),6500)});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="de"><head><meta charset="utf-8"><link rel="stylesheet" href="style.css"></head><body><section id="relation" class="hidden"><div><span id="name">Nikki</span><b id="message"></b><div class="track"><i id="fill"></i></div><small id="value">0 / 100</small></div></section><script src="app.js"></script></body></html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
:root{font-family:Inter,system-ui,sans-serif;color:white}*{box-sizing:border-box}body{margin:0;background:transparent;overflow:hidden}.hidden{display:none!important}#relation{position:fixed;left:50%;bottom:84px;transform:translateX(-50%);width:430px;padding:14px 18px;border:1px solid rgba(255,255,255,.14);border-radius:14px;background:rgba(8,10,16,.9);box-shadow:0 18px 50px rgba(0,0,0,.45)}#name{display:block;color:#f097ca;font-weight:900;font-size:12px;letter-spacing:.12em;text-transform:uppercase}#message{display:block;margin:4px 0 9px;font-size:13px}.track{height:8px;border-radius:20px;background:rgba(255,255,255,.1);overflow:hidden}.track i{display:block;height:100%;width:0;background:linear-gradient(90deg,#d958a8,#ff9ed5);transition:width .45s}small{display:block;margin-top:5px;text-align:right;color:rgba(255,255,255,.6)}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
# ================================================================
|
||||||
|
# DevX RP Framework v0.4.3
|
||||||
|
# Eigenständiges Framework: kein ESX, QBCore oder QBox
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
sv_hostname "{{serverName}}"
|
||||||
|
sets sv_projectName "{{serverName}}"
|
||||||
|
sets sv_projectDesc "{{recipeDescription}}"
|
||||||
|
sets tags "roleplay, custom-framework, devx, german"
|
||||||
|
sets locale "de-DE"
|
||||||
|
|
||||||
|
sv_licenseKey "{{svLicense}}"
|
||||||
|
sv_maxclients {{maxClients}}
|
||||||
|
{{serverEndpoints}}
|
||||||
|
|
||||||
|
sv_enforceGameBuild 3751
|
||||||
|
set onesync on
|
||||||
|
set mysql_connection_string "{{dbConnectionString}}"
|
||||||
|
set steam_webApiKey "none"
|
||||||
|
set resources_useSystemChat true
|
||||||
|
|
||||||
|
# Standardressourcen / txAdmin monitor
|
||||||
|
ensure mapmanager
|
||||||
|
ensure chat
|
||||||
|
ensure spawnmanager
|
||||||
|
ensure hardcap
|
||||||
|
|
||||||
|
# Infrastruktur
|
||||||
|
ensure oxmysql
|
||||||
|
ensure devx_ipl
|
||||||
|
|
||||||
|
# DevX-Framework: Reihenfolge nicht verändern
|
||||||
|
ensure devx_core
|
||||||
|
ensure devx_characters
|
||||||
|
ensure devx_hud
|
||||||
|
ensure devx_players
|
||||||
|
ensure devx_banking
|
||||||
|
ensure devx_housing
|
||||||
|
ensure devx_phone
|
||||||
|
ensure devx_admin
|
||||||
|
ensure devx_crime
|
||||||
|
ensure devx_casino
|
||||||
|
ensure devx_stripclub
|
||||||
|
ensure devx_radio
|
||||||
|
|
||||||
|
# DevX-Administration. Der txAdmin-Ingame-Admin muss zusätzlich im
|
||||||
|
# txAdmin Admin Manager mit Cfx.re- oder Discord-Identifier angelegt sein.
|
||||||
|
add_ace group.admin command allow
|
||||||
|
add_ace group.admin command.quit deny
|
||||||
|
add_ace group.admin devx.admin allow
|
||||||
|
{{addPrincipalsMaster}}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
ALTER TABLE `devx_characters`
|
||||||
|
ADD COLUMN IF NOT EXISTS `phone_number` VARCHAR(16) NULL AFTER `model`;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS `uq_devx_character_phone`
|
||||||
|
ON `devx_characters` (`phone_number`);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `devx_bans` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`license` VARCHAR(80) NOT NULL,
|
||||||
|
`player_name` VARCHAR(96) NULL,
|
||||||
|
`reason` VARCHAR(255) NOT NULL,
|
||||||
|
`banned_by` VARCHAR(96) NOT NULL,
|
||||||
|
`expires_at` DATETIME NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`revoked_at` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_devx_bans_license` (`license`),
|
||||||
|
KEY `idx_devx_bans_active` (`license`, `revoked_at`, `expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `devx_heist_progress` (
|
||||||
|
`character_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`drill_done` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`card_done` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`vehicle_done` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`character_id`),
|
||||||
|
CONSTRAINT `fk_devx_heist_character`
|
||||||
|
FOREIGN KEY (`character_id`) REFERENCES `devx_characters` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `devx_casino_daily` (
|
||||||
|
`character_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`last_spin` DATETIME NULL,
|
||||||
|
`total_spins` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`character_id`),
|
||||||
|
CONSTRAINT `fk_devx_casino_character`
|
||||||
|
FOREIGN KEY (`character_id`) REFERENCES `devx_characters` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- DevX RP v0.4.0
|
||||||
|
-- Keine neue Tabelle erforderlich.
|
||||||
|
-- devx_housing synchronisiert die zusätzlichen Apartmentdatensätze beim Start.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- DevX RP v0.4.1 migration. Resources also create these structures at runtime.
|
||||||
|
SET @has_contact_type := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'devx_phone_contacts' AND COLUMN_NAME = 'contact_type'
|
||||||
|
);
|
||||||
|
SET @sql_contact_type := IF(@has_contact_type = 0,
|
||||||
|
"ALTER TABLE devx_phone_contacts ADD COLUMN contact_type VARCHAR(32) NOT NULL DEFAULT 'player' AFTER phone_number",
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE stmt_contact_type FROM @sql_contact_type; EXECUTE stmt_contact_type; DEALLOCATE PREPARE stmt_contact_type;
|
||||||
|
|
||||||
|
SET @has_metadata := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'devx_phone_contacts' AND COLUMN_NAME = 'metadata'
|
||||||
|
);
|
||||||
|
SET @sql_metadata := IF(@has_metadata = 0,
|
||||||
|
'ALTER TABLE devx_phone_contacts ADD COLUMN metadata LONGTEXT NULL AFTER contact_type',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE stmt_metadata FROM @sql_metadata; EXECUTE stmt_metadata; DEALLOCATE PREPARE stmt_metadata;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS devx_casino_wallets (
|
||||||
|
character_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
chips BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (character_id),
|
||||||
|
CONSTRAINT fk_devx_casino_wallet_character FOREIGN KEY (character_id)
|
||||||
|
REFERENCES devx_characters (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS devx_club_relations (
|
||||||
|
character_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
relation_key VARCHAR(32) NOT NULL,
|
||||||
|
satisfaction TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
contact_unlocked TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (character_id, relation_key),
|
||||||
|
CONSTRAINT fk_devx_club_character FOREIGN KEY (character_id)
|
||||||
|
REFERENCES devx_characters (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- DevX RP v0.4.3
|
||||||
|
-- Entfernt ausschließlich Apartment-Eigentümer, deren Charakter fehlt oder
|
||||||
|
-- bereits gelöscht wurde. Aktive Charaktere und deren Apartments bleiben erhalten.
|
||||||
|
DELETE po
|
||||||
|
FROM devx_property_owners po
|
||||||
|
LEFT JOIN devx_characters c ON c.id = po.character_id
|
||||||
|
WHERE c.id IS NULL OR c.deleted_at IS NOT NULL;
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET time_zone = '+00:00';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `devx_characters` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`owner_license` VARCHAR(80) NOT NULL,
|
||||||
|
`slot` TINYINT UNSIGNED NOT NULL,
|
||||||
|
`first_name` VARCHAR(32) NOT NULL,
|
||||||
|
`last_name` VARCHAR(32) NOT NULL,
|
||||||
|
`date_of_birth` DATE NOT NULL,
|
||||||
|
`sex` ENUM('m','f','x') NOT NULL DEFAULT 'x',
|
||||||
|
`model` VARCHAR(64) NOT NULL DEFAULT 'mp_m_freemode_01',
|
||||||
|
`phone_number` VARCHAR(16) NULL,
|
||||||
|
`cash` BIGINT NOT NULL DEFAULT 150,
|
||||||
|
`position` LONGTEXT NULL,
|
||||||
|
`metadata` LONGTEXT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`deleted_at` TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_devx_character_slot` (`owner_license`, `slot`),
|
||||||
|
UNIQUE KEY `uq_devx_character_phone` (`phone_number`),
|
||||||
|
KEY `idx_devx_character_owner` (`owner_license`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `devx_bank_accounts` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`character_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`account_number` VARCHAR(24) NOT NULL,
|
||||||
|
`account_name` VARCHAR(80) NOT NULL,
|
||||||
|
`account_type` ENUM('personal','business','government') NOT NULL DEFAULT 'personal',
|
||||||
|
`balance` BIGINT NOT NULL DEFAULT 500,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_devx_account_number` (`account_number`),
|
||||||
|
KEY `idx_devx_account_character` (`character_id`),
|
||||||
|
CONSTRAINT `fk_devx_account_character`
|
||||||
|
FOREIGN KEY (`character_id`) REFERENCES `devx_characters` (`id`)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `devx_bank_transactions` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`account_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`direction` ENUM('credit','debit') NOT NULL,
|
||||||
|
`amount` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`balance_after` BIGINT NOT NULL,
|
||||||
|
`counterparty` VARCHAR(80) NULL,
|
||||||
|
`reference` VARCHAR(140) NOT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_devx_transaction_account_date` (`account_id`, `created_at`),
|
||||||
|
CONSTRAINT `fk_devx_transaction_account`
|
||||||
|
FOREIGN KEY (`account_id`) REFERENCES `devx_bank_accounts` (`id`)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `devx_properties` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`property_key` VARCHAR(64) NOT NULL,
|
||||||
|
`label` VARCHAR(80) NOT NULL,
|
||||||
|
`price` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`property_type` ENUM('apartment','house','business') NOT NULL DEFAULT 'apartment',
|
||||||
|
`entry_data` LONGTEXT NOT NULL,
|
||||||
|
`interior_data` LONGTEXT NOT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_devx_property_key` (`property_key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `devx_property_owners` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`property_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`character_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`purchase_price` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`purchased_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_devx_property_owner_property` (`property_id`),
|
||||||
|
KEY `idx_devx_property_owner_character` (`character_id`),
|
||||||
|
CONSTRAINT `fk_devx_owner_property`
|
||||||
|
FOREIGN KEY (`property_id`) REFERENCES `devx_properties` (`id`)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_devx_owner_character`
|
||||||
|
FOREIGN KEY (`character_id`) REFERENCES `devx_characters` (`id`)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `devx_phone_contacts` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`character_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`contact_name` VARCHAR(64) NOT NULL,
|
||||||
|
`phone_number` VARCHAR(24) NOT NULL,
|
||||||
|
`contact_type` VARCHAR(32) NOT NULL DEFAULT 'player',
|
||||||
|
`metadata` LONGTEXT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_devx_phone_contact_character` (`character_id`),
|
||||||
|
CONSTRAINT `fk_devx_contact_character`
|
||||||
|
FOREIGN KEY (`character_id`) REFERENCES `devx_characters` (`id`)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `devx_bans` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`license` VARCHAR(80) NOT NULL,
|
||||||
|
`player_name` VARCHAR(96) NULL,
|
||||||
|
`reason` VARCHAR(255) NOT NULL,
|
||||||
|
`banned_by` VARCHAR(96) NOT NULL,
|
||||||
|
`expires_at` DATETIME NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`revoked_at` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_devx_bans_license` (`license`),
|
||||||
|
KEY `idx_devx_bans_active` (`license`, `revoked_at`, `expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `devx_heist_progress` (
|
||||||
|
`character_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`drill_done` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`card_done` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`vehicle_done` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`character_id`),
|
||||||
|
CONSTRAINT `fk_devx_heist_character`
|
||||||
|
FOREIGN KEY (`character_id`) REFERENCES `devx_characters` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `devx_casino_daily` (
|
||||||
|
`character_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`last_spin` DATETIME NULL,
|
||||||
|
`total_spins` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`character_id`),
|
||||||
|
CONSTRAINT `fk_devx_casino_character`
|
||||||
|
FOREIGN KEY (`character_id`) REFERENCES `devx_characters` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `devx_casino_wallets` (
|
||||||
|
`character_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`chips` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`character_id`),
|
||||||
|
CONSTRAINT `fk_devx_casino_wallet_character`
|
||||||
|
FOREIGN KEY (`character_id`) REFERENCES `devx_characters` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `devx_club_relations` (
|
||||||
|
`character_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`relation_key` VARCHAR(32) NOT NULL,
|
||||||
|
`satisfaction` TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`contact_unlocked` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`character_id`, `relation_key`),
|
||||||
|
CONSTRAINT `fk_devx_club_character`
|
||||||
|
FOREIGN KEY (`character_id`) REFERENCES `devx_characters` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
param(
|
||||||
|
[string]$Version = "0.4.3"
|
||||||
|
)
|
||||||
|
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$Release = Join-Path $Root "release"
|
||||||
|
$Output = Join-Path $Release "devx-rp-server-v$Version.zip"
|
||||||
|
$Staging = Join-Path $env:TEMP "devx-rp-release-$Version"
|
||||||
|
|
||||||
|
if (Test-Path $Staging) { Remove-Item $Staging -Recurse -Force }
|
||||||
|
New-Item $Staging -ItemType Directory | Out-Null
|
||||||
|
New-Item $Release -ItemType Directory -Force | Out-Null
|
||||||
|
|
||||||
|
Copy-Item (Join-Path $Root "server.cfg") $Staging
|
||||||
|
Copy-Item (Join-Path $Root "sql") $Staging -Recurse
|
||||||
|
Copy-Item (Join-Path $Root "resources") $Staging -Recurse
|
||||||
|
|
||||||
|
if (Test-Path $Output) { Remove-Item $Output -Force }
|
||||||
|
Compress-Archive -Path (Join-Path $Staging "*") -DestinationPath $Output
|
||||||
|
|
||||||
|
Remove-Item $Staging -Recurse -Force
|
||||||
|
Write-Host "Created: $Output"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${1:-0.4.3}"
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
OUT="$ROOT/release/devx-rp-server-v${VERSION}.zip"
|
||||||
|
|
||||||
|
mkdir -p "$ROOT/release"
|
||||||
|
rm -f "$OUT"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
zip -r "$OUT" server.cfg sql resources \
|
||||||
|
-x "*/.DS_Store" "*/Thumbs.db" "*/node_modules/*"
|
||||||
|
|
||||||
|
echo "Created: $OUT"
|
||||||
Reference in New Issue
Block a user