From 86d4bcdc37300de2e24e56b3ae202a733fbf9e48 Mon Sep 17 00:00:00 2001 From: Colin-Joel Scupin Date: Wed, 5 Aug 2026 01:29:39 +0200 Subject: [PATCH] first commit --- .gitignore | 20 + CHANGELOG.md | 70 ++ LICENSE.md | 10 + README.md | 55 + docs/ARCHITECTURE.md | 60 ++ docs/GITEA_TXADMIN.md | 16 + docs/ROADMAP.md | 40 + recipe.yaml | 49 + release/INSTALL-v0.4.3.txt | 18 + release/devx-rp-hotfix-v0.4.3.zip | Bin 0 -> 34307 bytes release/devx-rp-server-v0.4.3.zip | Bin 0 -> 116129 bytes release/v0.4.3-server.cfg-snippet.txt | 15 + resources/[devx]/devx_admin/client/main.lua | 61 ++ resources/[devx]/devx_admin/fxmanifest.lua | 14 + resources/[devx]/devx_admin/server/main.lua | 147 +++ resources/[devx]/devx_admin/web/app.js | 21 + resources/[devx]/devx_admin/web/index.html | 11 + resources/[devx]/devx_admin/web/style.css | 5 + resources/[devx]/devx_banking/client/main.lua | 80 ++ resources/[devx]/devx_banking/fxmanifest.lua | 31 + resources/[devx]/devx_banking/server/main.lua | 218 ++++ resources/[devx]/devx_banking/web/app.js | 73 ++ resources/[devx]/devx_banking/web/index.html | 44 + resources/[devx]/devx_banking/web/style.css | 47 + resources/[devx]/devx_casino/client/main.lua | 155 +++ resources/[devx]/devx_casino/fxmanifest.lua | 13 + resources/[devx]/devx_casino/server/main.lua | 231 ++++ .../[devx]/devx_casino/shared/config.lua | 25 + resources/[devx]/devx_casino/web/app.js | 11 + resources/[devx]/devx_casino/web/index.html | 9 + resources/[devx]/devx_casino/web/style.css | 1 + .../[devx]/devx_characters/client/main.lua | 678 ++++++++++++ .../[devx]/devx_characters/fxmanifest.lua | 37 + .../[devx]/devx_characters/server/main.lua | 253 +++++ .../[devx]/devx_characters/shared/config.lua | 39 + resources/[devx]/devx_characters/web/app.js | 275 +++++ .../[devx]/devx_characters/web/index.html | 107 ++ .../[devx]/devx_characters/web/style.css | 144 +++ .../[devx]/devx_core/client/callbacks.lua | 41 + resources/[devx]/devx_core/client/main.lua | 63 ++ resources/[devx]/devx_core/fxmanifest.lua | 23 + .../[devx]/devx_core/server/callbacks.lua | 79 ++ resources/[devx]/devx_core/server/main.lua | 381 +++++++ resources/[devx]/devx_core/shared/config.lua | 28 + resources/[devx]/devx_crime/client/main.lua | 385 +++++++ resources/[devx]/devx_crime/fxmanifest.lua | 10 + resources/[devx]/devx_crime/server/main.lua | 165 +++ resources/[devx]/devx_crime/shared/config.lua | 90 ++ resources/[devx]/devx_housing/client/main.lua | 989 ++++++++++++++++++ resources/[devx]/devx_housing/fxmanifest.lua | 34 + resources/[devx]/devx_housing/server/main.lua | 566 ++++++++++ .../[devx]/devx_housing/shared/config.lua | 164 +++ resources/[devx]/devx_housing/web/app.js | 110 ++ resources/[devx]/devx_housing/web/index.html | 38 + resources/[devx]/devx_housing/web/style.css | 2 + resources/[devx]/devx_hud/client/main.lua | 22 + resources/[devx]/devx_hud/fxmanifest.lua | 10 + resources/[devx]/devx_ipl/client/main.lua | 81 ++ resources/[devx]/devx_ipl/fxmanifest.lua | 9 + resources/[devx]/devx_phone/client/main.lua | 115 ++ resources/[devx]/devx_phone/fxmanifest.lua | 32 + resources/[devx]/devx_phone/server/main.lua | 136 +++ resources/[devx]/devx_phone/web/app.js | 120 +++ resources/[devx]/devx_phone/web/index.html | 74 ++ resources/[devx]/devx_phone/web/style.css | 63 ++ resources/[devx]/devx_players/client/main.lua | 50 + resources/[devx]/devx_players/fxmanifest.lua | 13 + resources/[devx]/devx_players/server/main.lua | 37 + resources/[devx]/devx_radio/client/main.lua | 50 + resources/[devx]/devx_radio/fxmanifest.lua | 9 + .../[devx]/devx_stripclub/client/main.lua | 124 +++ .../[devx]/devx_stripclub/fxmanifest.lua | 13 + .../[devx]/devx_stripclub/server/main.lua | 139 +++ .../[devx]/devx_stripclub/shared/config.lua | 20 + resources/[devx]/devx_stripclub/web/app.js | 2 + .../[devx]/devx_stripclub/web/index.html | 1 + resources/[devx]/devx_stripclub/web/style.css | 1 + server.cfg | 51 + sql/migrations/v0.3.0.sql | 39 + sql/migrations/v0.4.0.sql | 3 + sql/migrations/v0.4.1.sql | 38 + sql/migrations/v0.4.3.sql | 7 + sql/schema.sql | 159 +++ tools/build-release.ps1 | 22 + tools/build-release.sh | 15 + 85 files changed, 7706 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/GITEA_TXADMIN.md create mode 100644 docs/ROADMAP.md create mode 100644 recipe.yaml create mode 100644 release/INSTALL-v0.4.3.txt create mode 100644 release/devx-rp-hotfix-v0.4.3.zip create mode 100644 release/devx-rp-server-v0.4.3.zip create mode 100644 release/v0.4.3-server.cfg-snippet.txt create mode 100644 resources/[devx]/devx_admin/client/main.lua create mode 100644 resources/[devx]/devx_admin/fxmanifest.lua create mode 100644 resources/[devx]/devx_admin/server/main.lua create mode 100644 resources/[devx]/devx_admin/web/app.js create mode 100644 resources/[devx]/devx_admin/web/index.html create mode 100644 resources/[devx]/devx_admin/web/style.css create mode 100644 resources/[devx]/devx_banking/client/main.lua create mode 100644 resources/[devx]/devx_banking/fxmanifest.lua create mode 100644 resources/[devx]/devx_banking/server/main.lua create mode 100644 resources/[devx]/devx_banking/web/app.js create mode 100644 resources/[devx]/devx_banking/web/index.html create mode 100644 resources/[devx]/devx_banking/web/style.css create mode 100644 resources/[devx]/devx_casino/client/main.lua create mode 100644 resources/[devx]/devx_casino/fxmanifest.lua create mode 100644 resources/[devx]/devx_casino/server/main.lua create mode 100644 resources/[devx]/devx_casino/shared/config.lua create mode 100644 resources/[devx]/devx_casino/web/app.js create mode 100644 resources/[devx]/devx_casino/web/index.html create mode 100644 resources/[devx]/devx_casino/web/style.css create mode 100644 resources/[devx]/devx_characters/client/main.lua create mode 100644 resources/[devx]/devx_characters/fxmanifest.lua create mode 100644 resources/[devx]/devx_characters/server/main.lua create mode 100644 resources/[devx]/devx_characters/shared/config.lua create mode 100644 resources/[devx]/devx_characters/web/app.js create mode 100644 resources/[devx]/devx_characters/web/index.html create mode 100644 resources/[devx]/devx_characters/web/style.css create mode 100644 resources/[devx]/devx_core/client/callbacks.lua create mode 100644 resources/[devx]/devx_core/client/main.lua create mode 100644 resources/[devx]/devx_core/fxmanifest.lua create mode 100644 resources/[devx]/devx_core/server/callbacks.lua create mode 100644 resources/[devx]/devx_core/server/main.lua create mode 100644 resources/[devx]/devx_core/shared/config.lua create mode 100644 resources/[devx]/devx_crime/client/main.lua create mode 100644 resources/[devx]/devx_crime/fxmanifest.lua create mode 100644 resources/[devx]/devx_crime/server/main.lua create mode 100644 resources/[devx]/devx_crime/shared/config.lua create mode 100644 resources/[devx]/devx_housing/client/main.lua create mode 100644 resources/[devx]/devx_housing/fxmanifest.lua create mode 100644 resources/[devx]/devx_housing/server/main.lua create mode 100644 resources/[devx]/devx_housing/shared/config.lua create mode 100644 resources/[devx]/devx_housing/web/app.js create mode 100644 resources/[devx]/devx_housing/web/index.html create mode 100644 resources/[devx]/devx_housing/web/style.css create mode 100644 resources/[devx]/devx_hud/client/main.lua create mode 100644 resources/[devx]/devx_hud/fxmanifest.lua create mode 100644 resources/[devx]/devx_ipl/client/main.lua create mode 100644 resources/[devx]/devx_ipl/fxmanifest.lua create mode 100644 resources/[devx]/devx_phone/client/main.lua create mode 100644 resources/[devx]/devx_phone/fxmanifest.lua create mode 100644 resources/[devx]/devx_phone/server/main.lua create mode 100644 resources/[devx]/devx_phone/web/app.js create mode 100644 resources/[devx]/devx_phone/web/index.html create mode 100644 resources/[devx]/devx_phone/web/style.css create mode 100644 resources/[devx]/devx_players/client/main.lua create mode 100644 resources/[devx]/devx_players/fxmanifest.lua create mode 100644 resources/[devx]/devx_players/server/main.lua create mode 100644 resources/[devx]/devx_radio/client/main.lua create mode 100644 resources/[devx]/devx_radio/fxmanifest.lua create mode 100644 resources/[devx]/devx_stripclub/client/main.lua create mode 100644 resources/[devx]/devx_stripclub/fxmanifest.lua create mode 100644 resources/[devx]/devx_stripclub/server/main.lua create mode 100644 resources/[devx]/devx_stripclub/shared/config.lua create mode 100644 resources/[devx]/devx_stripclub/web/app.js create mode 100644 resources/[devx]/devx_stripclub/web/index.html create mode 100644 resources/[devx]/devx_stripclub/web/style.css create mode 100644 server.cfg create mode 100644 sql/migrations/v0.3.0.sql create mode 100644 sql/migrations/v0.4.0.sql create mode 100644 sql/migrations/v0.4.1.sql create mode 100644 sql/migrations/v0.4.3.sql create mode 100644 sql/schema.sql create mode 100644 tools/build-release.ps1 create mode 100644 tools/build-release.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0917c77 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b8b7093 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..f942d93 --- /dev/null +++ b/LICENSE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..96df347 --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..dd27ace --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/GITEA_TXADMIN.md b/docs/GITEA_TXADMIN.md new file mode 100644 index 0000000..3c4fb63 --- /dev/null +++ b/docs/GITEA_TXADMIN.md @@ -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 +``` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..aff4dd0 --- /dev/null +++ b/docs/ROADMAP.md @@ -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 diff --git a/recipe.yaml b/recipe.yaml new file mode 100644 index 0000000..02f13a0 --- /dev/null +++ b/recipe.yaml @@ -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 diff --git a/release/INSTALL-v0.4.3.txt b/release/INSTALL-v0.4.3.txt new file mode 100644 index 0000000..b166096 --- /dev/null +++ b/release/INSTALL-v0.4.3.txt @@ -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. diff --git a/release/devx-rp-hotfix-v0.4.3.zip b/release/devx-rp-hotfix-v0.4.3.zip new file mode 100644 index 0000000000000000000000000000000000000000..e7b5ef5637e8942e764d64f4903385223a972754 GIT binary patch literal 34307 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 KXutXP+y4N=ku#$J literal 0 HcmV?d00001 diff --git a/release/devx-rp-server-v0.4.3.zip b/release/devx-rp-server-v0.4.3.zip new file mode 100644 index 0000000000000000000000000000000000000000..15ae803e611fb5379492a23fc6fd10c4880ea5a6 GIT binary patch literal 116129 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%0D@53#+SrJ=h$)5H3>??Ta`xoSpg6n!raV< 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 Qyn%nToG>uPF{eWPf3#F5$^ZZW literal 0 HcmV?d00001 diff --git a/release/v0.4.3-server.cfg-snippet.txt b/release/v0.4.3-server.cfg-snippet.txt new file mode 100644 index 0000000..cda9dd9 --- /dev/null +++ b/release/v0.4.3-server.cfg-snippet.txt @@ -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 diff --git a/resources/[devx]/devx_admin/client/main.lua b/resources/[devx]/devx_admin/client/main.lua new file mode 100644 index 0000000..2c7f48e --- /dev/null +++ b/resources/[devx]/devx_admin/client/main.lua @@ -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) diff --git a/resources/[devx]/devx_admin/fxmanifest.lua b/resources/[devx]/devx_admin/fxmanifest.lua new file mode 100644 index 0000000..2e35762 --- /dev/null +++ b/resources/[devx]/devx_admin/fxmanifest.lua @@ -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' } diff --git a/resources/[devx]/devx_admin/server/main.lua b/resources/[devx]/devx_admin/server/main.lua new file mode 100644 index 0000000..ff98a2c --- /dev/null +++ b/resources/[devx]/devx_admin/server/main.lua @@ -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) diff --git a/resources/[devx]/devx_admin/web/app.js b/resources/[devx]/devx_admin/web/app.js new file mode 100644 index 0000000..5651b72 --- /dev/null +++ b/resources/[devx]/devx_admin/web/app.js @@ -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=`${p.source}
${esc(p.fullName)}${esc(p.connectionName)}
`;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='

Spieler auswählen

Wähle links einen Online-Spieler aus.

';return}editor.className='editor';editor.innerHTML=` +

${esc(p.fullName)}

Server-ID ${p.source} · Charakter ${p.characterId??'–'} · ${esc(p.phoneNumber||'keine Nummer')}

${p.cash} $ bar${p.bank} $ Bank${p.rpXp} XP
+
+

Geld bearbeiten

+

Identität

+

Waffe geben

+

Spieleraktionen

+

Verwarnung / Trennung

+
`; +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()}}); diff --git a/resources/[devx]/devx_admin/web/index.html b/resources/[devx]/devx_admin/web/index.html new file mode 100644 index 0000000..6808cce --- /dev/null +++ b/resources/[devx]/devx_admin/web/index.html @@ -0,0 +1,11 @@ + +
+
+
DEVX-CITY

Administration

+ +
+ +

Spieler auswählen

Wähle links einen Online-Spieler aus.

+
+
+
diff --git a/resources/[devx]/devx_admin/web/style.css b/resources/[devx]/devx_admin/web/style.css new file mode 100644 index 0000000..db8247a --- /dev/null +++ b/resources/[devx]/devx_admin/web/style.css @@ -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} diff --git a/resources/[devx]/devx_banking/client/main.lua b/resources/[devx]/devx_banking/client/main.lua new file mode 100644 index 0000000..dadd803 --- /dev/null +++ b/resources/[devx]/devx_banking/client/main.lua @@ -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) diff --git a/resources/[devx]/devx_banking/fxmanifest.lua b/resources/[devx]/devx_banking/fxmanifest.lua new file mode 100644 index 0000000..93c3577 --- /dev/null +++ b/resources/[devx]/devx_banking/fxmanifest.lua @@ -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' diff --git a/resources/[devx]/devx_banking/server/main.lua b/resources/[devx]/devx_banking/server/main.lua new file mode 100644 index 0000000..5576fba --- /dev/null +++ b/resources/[devx]/devx_banking/server/main.lua @@ -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) diff --git a/resources/[devx]/devx_banking/web/app.js b/resources/[devx]/devx_banking/web/app.js new file mode 100644 index 0000000..4ea1834 --- /dev/null +++ b/resources/[devx]/devx_banking/web/app.js @@ -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 = ` +
${escapeHtml(entry.reference)}
+ ${escapeHtml(entry.counterparty || '')} · ${escapeHtml(String(entry.createdAt))}
+ ${sign}${format.format(entry.amount)} $`; + 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); +}); diff --git a/resources/[devx]/devx_banking/web/index.html b/resources/[devx]/devx_banking/web/index.html new file mode 100644 index 0000000..f19abc2 --- /dev/null +++ b/resources/[devx]/devx_banking/web/index.html @@ -0,0 +1,44 @@ + + + + + + + +
+
+
+
+

LOS SANTOS FINANCIAL

+

Banking

+
+ +
+ + + + + +
+
+

Überweisung

+ + + + +
+ +
+

Letzte Buchungen

+
+
+
+
+
+ + + diff --git a/resources/[devx]/devx_banking/web/style.css b/resources/[devx]/devx_banking/web/style.css new file mode 100644 index 0000000..618d57e --- /dev/null +++ b/resources/[devx]/devx_banking/web/style.css @@ -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; } +} diff --git a/resources/[devx]/devx_casino/client/main.lua b/resources/[devx]/devx_casino/client/main.lua new file mode 100644 index 0000000..a34eddb --- /dev/null +++ b/resources/[devx]/devx_casino/client/main.lua @@ -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) diff --git a/resources/[devx]/devx_casino/fxmanifest.lua b/resources/[devx]/devx_casino/fxmanifest.lua new file mode 100644 index 0000000..41d3f01 --- /dev/null +++ b/resources/[devx]/devx_casino/fxmanifest.lua @@ -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' } diff --git a/resources/[devx]/devx_casino/server/main.lua b/resources/[devx]/devx_casino/server/main.lua new file mode 100644 index 0000000..93f793b --- /dev/null +++ b/resources/[devx]/devx_casino/server/main.lua @@ -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) diff --git a/resources/[devx]/devx_casino/shared/config.lua b/resources/[devx]/devx_casino/shared/config.lua new file mode 100644 index 0000000..1ebfd45 --- /dev/null +++ b/resources/[devx]/devx_casino/shared/config.lua @@ -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' } + } +} diff --git a/resources/[devx]/devx_casino/web/app.js b/resources/[devx]/devx_casino/web/app.js new file mode 100644 index 0000000..9f2b734 --- /dev/null +++ b/resources/[devx]/devx_casino/web/app.js @@ -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')}); diff --git a/resources/[devx]/devx_casino/web/index.html b/resources/[devx]/devx_casino/web/index.html new file mode 100644 index 0000000..65aa3e4 --- /dev/null +++ b/resources/[devx]/devx_casino/web/index.html @@ -0,0 +1,9 @@ + +
THE DIAMOND CASINO & RESORT

Casino

CHIPS0
+ +

+

Casino-Kasse

+ + + +
diff --git a/resources/[devx]/devx_casino/web/style.css b/resources/[devx]/devx_casino/web/style.css new file mode 100644 index 0000000..03a7b5b --- /dev/null +++ b/resources/[devx]/devx_casino/web/style.css @@ -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} diff --git a/resources/[devx]/devx_characters/client/main.lua b/resources/[devx]/devx_characters/client/main.lua new file mode 100644 index 0000000..33d9f3f --- /dev/null +++ b/resources/[devx]/devx_characters/client/main.lua @@ -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) diff --git a/resources/[devx]/devx_characters/fxmanifest.lua b/resources/[devx]/devx_characters/fxmanifest.lua new file mode 100644 index 0000000..9a99eb6 --- /dev/null +++ b/resources/[devx]/devx_characters/fxmanifest.lua @@ -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' +} diff --git a/resources/[devx]/devx_characters/server/main.lua b/resources/[devx]/devx_characters/server/main.lua new file mode 100644 index 0000000..432046b --- /dev/null +++ b/resources/[devx]/devx_characters/server/main.lua @@ -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) diff --git a/resources/[devx]/devx_characters/shared/config.lua b/resources/[devx]/devx_characters/shared/config.lua new file mode 100644 index 0000000..933b79d --- /dev/null +++ b/resources/[devx]/devx_characters/shared/config.lua @@ -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 + } +} diff --git a/resources/[devx]/devx_characters/web/app.js b/resources/[devx]/devx_characters/web/app.js new file mode 100644 index 0000000..02a50c0 --- /dev/null +++ b/resources/[devx]/devx_characters/web/app.js @@ -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 = ` + ${String(slot).padStart(2, '0')} + + ${escapeHtml(character.firstName)} ${escapeHtml(character.lastName)} + ${character.introCompleted ? 'Einwohner von DevX-City' : 'Neue Ankunft'} + + `; + card.addEventListener('click', () => selectCharacter(character.id)); + } else { + card.innerHTML = ` + ${String(slot).padStart(2, '0')} + Freier CharakterplatzNeue Identität erstellen + +`; + 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}`)); diff --git a/resources/[devx]/devx_characters/web/index.html b/resources/[devx]/devx_characters/web/index.html new file mode 100644 index 0000000..f47dd32 --- /dev/null +++ b/resources/[devx]/devx_characters/web/index.html @@ -0,0 +1,107 @@ + + + + + + + + +
+ + +
+ DEVX-CITY + Wähle dein neues Leben. +
+ + + + +
+ + + + + + diff --git a/resources/[devx]/devx_characters/web/style.css b/resources/[devx]/devx_characters/web/style.css new file mode 100644 index 0000000..67708ab --- /dev/null +++ b/resources/[devx]/devx_characters/web/style.css @@ -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; } +} diff --git a/resources/[devx]/devx_core/client/callbacks.lua b/resources/[devx]/devx_core/client/callbacks.lua new file mode 100644 index 0000000..8303eb3 --- /dev/null +++ b/resources/[devx]/devx_core/client/callbacks.lua @@ -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) diff --git a/resources/[devx]/devx_core/client/main.lua b/resources/[devx]/devx_core/client/main.lua new file mode 100644 index 0000000..4a88d83 --- /dev/null +++ b/resources/[devx]/devx_core/client/main.lua @@ -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) diff --git a/resources/[devx]/devx_core/fxmanifest.lua b/resources/[devx]/devx_core/fxmanifest.lua new file mode 100644 index 0000000..3aeb360 --- /dev/null +++ b/resources/[devx]/devx_core/fxmanifest.lua @@ -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' +} diff --git a/resources/[devx]/devx_core/server/callbacks.lua b/resources/[devx]/devx_core/server/callbacks.lua new file mode 100644 index 0000000..03109bf --- /dev/null +++ b/resources/[devx]/devx_core/server/callbacks.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 diff --git a/resources/[devx]/devx_core/server/main.lua b/resources/[devx]/devx_core/server/main.lua new file mode 100644 index 0000000..ca7636e --- /dev/null +++ b/resources/[devx]/devx_core/server/main.lua @@ -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) diff --git a/resources/[devx]/devx_core/shared/config.lua b/resources/[devx]/devx_core/shared/config.lua new file mode 100644 index 0000000..5ce5bfd --- /dev/null +++ b/resources/[devx]/devx_core/shared/config.lua @@ -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' + } +} diff --git a/resources/[devx]/devx_crime/client/main.lua b/resources/[devx]/devx_crime/client/main.lua new file mode 100644 index 0000000..d5b533d --- /dev/null +++ b/resources/[devx]/devx_crime/client/main.lua @@ -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) diff --git a/resources/[devx]/devx_crime/fxmanifest.lua b/resources/[devx]/devx_crime/fxmanifest.lua new file mode 100644 index 0000000..5702d23 --- /dev/null +++ b/resources/[devx]/devx_crime/fxmanifest.lua @@ -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' } diff --git a/resources/[devx]/devx_crime/server/main.lua b/resources/[devx]/devx_crime/server/main.lua new file mode 100644 index 0000000..b525340 --- /dev/null +++ b/resources/[devx]/devx_crime/server/main.lua @@ -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) diff --git a/resources/[devx]/devx_crime/shared/config.lua b/resources/[devx]/devx_crime/shared/config.lua new file mode 100644 index 0000000..292f498 --- /dev/null +++ b/resources/[devx]/devx_crime/shared/config.lua @@ -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 +} diff --git a/resources/[devx]/devx_housing/client/main.lua b/resources/[devx]/devx_housing/client/main.lua new file mode 100644 index 0000000..c60e80d --- /dev/null +++ b/resources/[devx]/devx_housing/client/main.lua @@ -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) diff --git a/resources/[devx]/devx_housing/fxmanifest.lua b/resources/[devx]/devx_housing/fxmanifest.lua new file mode 100644 index 0000000..e8afd1c --- /dev/null +++ b/resources/[devx]/devx_housing/fxmanifest.lua @@ -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' +} diff --git a/resources/[devx]/devx_housing/server/main.lua b/resources/[devx]/devx_housing/server/main.lua new file mode 100644 index 0000000..8895d0c --- /dev/null +++ b/resources/[devx]/devx_housing/server/main.lua @@ -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) diff --git a/resources/[devx]/devx_housing/shared/config.lua b/resources/[devx]/devx_housing/shared/config.lua new file mode 100644 index 0000000..971a838 --- /dev/null +++ b/resources/[devx]/devx_housing/shared/config.lua @@ -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 diff --git a/resources/[devx]/devx_housing/web/app.js b/resources/[devx]/devx_housing/web/app.js new file mode 100644 index 0000000..8f84cba --- /dev/null +++ b/resources/[devx]/devx_housing/web/app.js @@ -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=`Etage ${floor}Wohnungen ${floor}01–${floor}04`; + 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=`${player.name||'Spieler'}Server-ID ${player.source}`; + 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='Klingeln';ring.onclick=()=>post('doorRing',{propertyKey:data.propertyKey}); + const knock=document.createElement('button');knock.className='panel-action';knock.innerHTML='Klopfen';knock.onclick=()=>post('doorKnock',{propertyKey:data.propertyKey}); + actions.append(ring,knock); + const enter=document.createElement('button');enter.className='panel-action enter';enter.innerHTML='Mit Einladung eintreten';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')} +}); diff --git a/resources/[devx]/devx_housing/web/index.html b/resources/[devx]/devx_housing/web/index.html new file mode 100644 index 0000000..c2e779f --- /dev/null +++ b/resources/[devx]/devx_housing/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + +
+
+ +
+ DEVX IMMOBILIEN + Wohnung + Etage · Stil +
+ + + +
+
+ +
+
+ + DEVX-CITY +

Aufzug

+

+
+
+
+ + + + diff --git a/resources/[devx]/devx_housing/web/style.css b/resources/[devx]/devx_housing/web/style.css new file mode 100644 index 0000000..6c7bab7 --- /dev/null +++ b/resources/[devx]/devx_housing/web/style.css @@ -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} diff --git a/resources/[devx]/devx_hud/client/main.lua b/resources/[devx]/devx_hud/client/main.lua new file mode 100644 index 0000000..40a09d4 --- /dev/null +++ b/resources/[devx]/devx_hud/client/main.lua @@ -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) diff --git a/resources/[devx]/devx_hud/fxmanifest.lua b/resources/[devx]/devx_hud/fxmanifest.lua new file mode 100644 index 0000000..c738a3a --- /dev/null +++ b/resources/[devx]/devx_hud/fxmanifest.lua @@ -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' diff --git a/resources/[devx]/devx_ipl/client/main.lua b/resources/[devx]/devx_ipl/client/main.lua new file mode 100644 index 0000000..7283064 --- /dev/null +++ b/resources/[devx]/devx_ipl/client/main.lua @@ -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) diff --git a/resources/[devx]/devx_ipl/fxmanifest.lua b/resources/[devx]/devx_ipl/fxmanifest.lua new file mode 100644 index 0000000..c389175 --- /dev/null +++ b/resources/[devx]/devx_ipl/fxmanifest.lua @@ -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' diff --git a/resources/[devx]/devx_phone/client/main.lua b/resources/[devx]/devx_phone/client/main.lua new file mode 100644 index 0000000..d1e6740 --- /dev/null +++ b/resources/[devx]/devx_phone/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) diff --git a/resources/[devx]/devx_phone/fxmanifest.lua b/resources/[devx]/devx_phone/fxmanifest.lua new file mode 100644 index 0000000..4ccd28d --- /dev/null +++ b/resources/[devx]/devx_phone/fxmanifest.lua @@ -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' +} diff --git a/resources/[devx]/devx_phone/server/main.lua b/resources/[devx]/devx_phone/server/main.lua new file mode 100644 index 0000000..04e7af1 --- /dev/null +++ b/resources/[devx]/devx_phone/server/main.lua @@ -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) diff --git a/resources/[devx]/devx_phone/web/app.js b/resources/[devx]/devx_phone/web/app.js new file mode 100644 index 0000000..baf7153 --- /dev/null +++ b/resources/[devx]/devx_phone/web/app.js @@ -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 ? '' : '

Noch keine Buchungen.

'; + entries.slice(0, 12).forEach(entry => { + const row = document.createElement('div'); + row.className = 'transaction'; + const credit = entry.direction === 'credit'; + row.innerHTML = `
${escapeHtml(entry.reference)}${escapeHtml(entry.counterparty || '')}
${credit ? '+' : '−'}${format.format(Number(entry.amount) || 0)} $`; + list.appendChild(row); + }); +} + +function renderContacts(entries = []) { + const list = document.querySelector('#contactList'); + list.innerHTML = entries.length ? '' : '

Noch keine Kontakte.

'; + entries.forEach(entry => { + const row = document.createElement('div'); row.className = 'contact'; + const system = entry.contactType && entry.contactType !== 'player'; + row.innerHTML = `
${escapeHtml(entry.contactName)}${escapeHtml(entry.phoneNumber)}
`; + 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); + } +}); diff --git a/resources/[devx]/devx_phone/web/index.html b/resources/[devx]/devx_phone/web/index.html new file mode 100644 index 0000000..68f4176 --- /dev/null +++ b/resources/[devx]/devx_phone/web/index.html @@ -0,0 +1,74 @@ + + + + + + + + + + + + diff --git a/resources/[devx]/devx_phone/web/style.css b/resources/[devx]/devx_phone/web/style.css new file mode 100644 index 0000000..ec2f939 --- /dev/null +++ b/resources/[devx]/devx_phone/web/style.css @@ -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} diff --git a/resources/[devx]/devx_players/client/main.lua b/resources/[devx]/devx_players/client/main.lua new file mode 100644 index 0000000..1829b93 --- /dev/null +++ b/resources/[devx]/devx_players/client/main.lua @@ -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) diff --git a/resources/[devx]/devx_players/fxmanifest.lua b/resources/[devx]/devx_players/fxmanifest.lua new file mode 100644 index 0000000..d58d595 --- /dev/null +++ b/resources/[devx]/devx_players/fxmanifest.lua @@ -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' diff --git a/resources/[devx]/devx_players/server/main.lua b/resources/[devx]/devx_players/server/main.lua new file mode 100644 index 0000000..d277409 --- /dev/null +++ b/resources/[devx]/devx_players/server/main.lua @@ -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) diff --git a/resources/[devx]/devx_radio/client/main.lua b/resources/[devx]/devx_radio/client/main.lua new file mode 100644 index 0000000..de92f73 --- /dev/null +++ b/resources/[devx]/devx_radio/client/main.lua @@ -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) diff --git a/resources/[devx]/devx_radio/fxmanifest.lua b/resources/[devx]/devx_radio/fxmanifest.lua new file mode 100644 index 0000000..7096f10 --- /dev/null +++ b/resources/[devx]/devx_radio/fxmanifest.lua @@ -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' diff --git a/resources/[devx]/devx_stripclub/client/main.lua b/resources/[devx]/devx_stripclub/client/main.lua new file mode 100644 index 0000000..79f76ba --- /dev/null +++ b/resources/[devx]/devx_stripclub/client/main.lua @@ -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) diff --git a/resources/[devx]/devx_stripclub/fxmanifest.lua b/resources/[devx]/devx_stripclub/fxmanifest.lua new file mode 100644 index 0000000..31af2d7 --- /dev/null +++ b/resources/[devx]/devx_stripclub/fxmanifest.lua @@ -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' } diff --git a/resources/[devx]/devx_stripclub/server/main.lua b/resources/[devx]/devx_stripclub/server/main.lua new file mode 100644 index 0000000..5992c5f --- /dev/null +++ b/resources/[devx]/devx_stripclub/server/main.lua @@ -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) diff --git a/resources/[devx]/devx_stripclub/shared/config.lua b/resources/[devx]/devx_stripclub/shared/config.lua new file mode 100644 index 0000000..dcdc9c0 --- /dev/null +++ b/resources/[devx]/devx_stripclub/shared/config.lua @@ -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 +} diff --git a/resources/[devx]/devx_stripclub/web/app.js b/resources/[devx]/devx_stripclub/web/app.js new file mode 100644 index 0000000..386d78a --- /dev/null +++ b/resources/[devx]/devx_stripclub/web/app.js @@ -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)}); diff --git a/resources/[devx]/devx_stripclub/web/index.html b/resources/[devx]/devx_stripclub/web/index.html new file mode 100644 index 0000000..731217f --- /dev/null +++ b/resources/[devx]/devx_stripclub/web/index.html @@ -0,0 +1 @@ + diff --git a/resources/[devx]/devx_stripclub/web/style.css b/resources/[devx]/devx_stripclub/web/style.css new file mode 100644 index 0000000..1f1b03d --- /dev/null +++ b/resources/[devx]/devx_stripclub/web/style.css @@ -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)} diff --git a/server.cfg b/server.cfg new file mode 100644 index 0000000..6226e93 --- /dev/null +++ b/server.cfg @@ -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}} diff --git a/sql/migrations/v0.3.0.sql b/sql/migrations/v0.3.0.sql new file mode 100644 index 0000000..55599c8 --- /dev/null +++ b/sql/migrations/v0.3.0.sql @@ -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; diff --git a/sql/migrations/v0.4.0.sql b/sql/migrations/v0.4.0.sql new file mode 100644 index 0000000..12cbc02 --- /dev/null +++ b/sql/migrations/v0.4.0.sql @@ -0,0 +1,3 @@ +-- DevX RP v0.4.0 +-- Keine neue Tabelle erforderlich. +-- devx_housing synchronisiert die zusätzlichen Apartmentdatensätze beim Start. diff --git a/sql/migrations/v0.4.1.sql b/sql/migrations/v0.4.1.sql new file mode 100644 index 0000000..ca1d30a --- /dev/null +++ b/sql/migrations/v0.4.1.sql @@ -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; diff --git a/sql/migrations/v0.4.3.sql b/sql/migrations/v0.4.3.sql new file mode 100644 index 0000000..f59d6d2 --- /dev/null +++ b/sql/migrations/v0.4.3.sql @@ -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; diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 0000000..be3667f --- /dev/null +++ b/sql/schema.sql @@ -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; diff --git a/tools/build-release.ps1 b/tools/build-release.ps1 new file mode 100644 index 0000000..9a788d4 --- /dev/null +++ b/tools/build-release.ps1 @@ -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" diff --git a/tools/build-release.sh b/tools/build-release.sh new file mode 100644 index 0000000..b4fa41d --- /dev/null +++ b/tools/build-release.sh @@ -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"