commit 86d4bcdc37300de2e24e56b3ae202a733fbf9e48 Author: Colin-Joel Scupin Date: Wed Aug 5 01:29:39 2026 +0200 first commit 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 0000000..e7b5ef5 Binary files /dev/null and b/release/devx-rp-hotfix-v0.4.3.zip differ 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 0000000..15ae803 Binary files /dev/null and b/release/devx-rp-server-v0.4.3.zip differ 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"