first commit

This commit is contained in:
Colin-Joel Scupin
2026-08-05 01:29:39 +02:00
commit 86d4bcdc37
85 changed files with 7706 additions and 0 deletions
+20
View File
@@ -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/
+70
View File
@@ -0,0 +1,70 @@
# Changelog
## 0.4.3
- Apartment- und Warehouse-IPLs werden nicht mehr global entfernt oder übereinander geladen.
- `devx_ipl` stellt Fokus, HD-Area und Kollision nach Interiorwechseln wieder her.
- Bankvorbereitungen nutzen eine No-IPL-Garage mit Missionsprops, Fahrzeugen und NPCs.
- Apartmentflur auf stabile No-IPL-Szene umgestellt.
- Freie Wohnungen werden im Flur nicht mehr als bewohnt dargestellt.
- Nur verkaufte Wohnungen erhalten Türschild, Klingeln und Klopfen.
- Eigentümer gelöschter Charaktere werden automatisch aus der Belegung entfernt.
- Apartment-Einladung über Spielerliste und Annahme/Ablehnung per E/X.
- Gast wird erst nach Annahme in die Wohnung teleportiert.
- Welt-Streaming wird beim Verlassen von Mission, Vorschau und Gebäude repariert.
## 0.4.2
- Apartment-IPLs werden nicht mehr global und übereinander geladen.
- Der gemeinsame Flur nutzt nur noch eine exklusive, exakt positionierte IPL-Variante.
- Interior-Streaming akzeptiert bei GTA-Online-IPLs auch den zuverlässigen IPL-/Scene-Status, wenn FiveM keine Interior-ID liefert.
- Kollisionsprüfung und Diagnoseausgaben für Lobby, Flur und Apartments erweitert.
## 0.4.1
- Mission-, Casino- und Apartment-IPLs werden durch `devx_ipl` zuverlässig vorgeladen
- Warehouse-Vorbereitungsmissionen nutzen das eingerichtete Import/Export-Lager und Planungscutscenes
- Apartmentvorschau verwendet feste Kamerapunkte innerhalb der vier tatsächlichen Interior-Tiers
- Lobby/Etagenflure auf einen stabil geladenen Innenraumbereich verschoben
- genau ein Apartment pro Charakter; nach Kauf wird am passenden Gebäude direkt „Apartment betreten“ angeboten
- Whiskey-, Bier-, Schlaf- und Bong-Interaktionen in den Apartments
- Smartphone-Taste M ist ein echter Ein/Aus-Schalter ohne zweites Release-Event
- Casino mit Ambient-NPCs, originalem Glücksrad ohne Duplikat, Chips, Kasse, Slots, Roulette und Blackjack
- Vanilla-Unicorn-Beziehungssystem mit nicht-expliziter VIP-Lounge, Zufriedenheitsbalken, Telefonkontakt und Apartmenteinladung
- Fahrzeugradio auf Non-Stop-Pop FM oder Aus beschränkt
## 0.4.0
- Intro-Kamera folgt dem Spieler; Fahrzeug wartet bereits am Straßenrand
- globale Spielerkarte mit serverseitig synchronisierten Blips
- Schutz gegen übrig gebliebene Routing Buckets und neuer `/world`-Befehl
- Heist-Vorbereitungen in sichere Warehouse-Missionsinstanzen verschoben
- physisch animiertes Casino-Glücksrad
- Smartphone-Keymapping und M-Tasten-Fallback repariert
- mehrstöckige Apartmenthäuser mit Lobby, Fluren und Aufzug
- gemeinsame Etagen- und Wohnungsinstanzen für mehrere Spieler
- Klingeln, Klopfen, Besucherfreigabe und Einladungen
- drehende Wohnungsvorschau mit Pfeilen und Kaufleiste
## 0.3.0
- Charakterkopf und Freemode-Aussehen korrigiert
- Charakterdatum normalisiert und Löschdialog neu gebaut
- Flughafen-Intro als animierte Ankunftssequenz überarbeitet
- Fahrerfahrzeug auf Straßennavigation umgestellt
- dauerhaftes Geld-HUD entfernt und Untertitel bereinigt
- Smartphone auf Taste M mit Prop, Kontakten und vollständiger Bank-App
- eindeutige Telefonnummern pro Charakter
- eigenes DevX-Admininterface mit Geld, Waffen, Teleport, Kick und Ban
- persistente Bans
- Bankraub mit drei Vorbereitungsmissionen
- Casinozugang und tägliches Glücksrad
## 0.2.0
- klassische Charakterauswahl und 3D-Vorschau
- erste Flughafen-Begrüßungsfahrt
## 0.1.5
- Client-Callbackhelper in das Resource-Packfile aufgenommen
+10
View File
@@ -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.
+55
View File
@@ -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.
+60
View File
@@ -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.
+16
View File
@@ -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
```
+40
View File
@@ -0,0 +1,40 @@
# Roadmap
## v0.2 Charakter und Einstieg
- [x] klassische Charakterauswahl und 3D-Vorschau
- [x] einmalige Begrüßungsfahrt mit NPC
- [ ] Aussehen-/Kleidungseditor
- [ ] Hunger, Durst und Gesundheit
- [ ] Inventar, Items und Shops
- [ ] vollständiges Todes-/Respawn-System
- [ ] Ausweis, Führerschein und Dokumente
## v0.3 Fahrzeuge und Wohnungen
- Fahrzeugbesitz, Schlüssel und Garagen
- Mietwohnungen und Nebenkosten
- Wohnungslager, Möbel und Gäste
- mehrere Apartment-Innenraumtypen
- Makler- und Verwaltungsoberfläche
## v0.4 Smartphone
- Telefonnummern
- Kontakte und Chatnachrichten
- Banking-App
- Firmen-App
- native Telefonanimation und Prop
- optionale Sprach-/Anrufintegration
## v0.5 Wirtschaft und Jobs
- eigenes Jobsystem
- IT-Systemhaus-Karriere
- Firmenkonten, Angestellte und Gehälter
- Finanzamt, Steuern, Rechnungen und Verträge
- legale und illegale Aufträge
## Vor öffentlichem Betrieb
- Integrationstests
- Migrationen mit Versionsnummern
- Rate-Limits und Audit-Log
- Backups und Wiederherstellungstest
- Performanceprofiling mit vielen Spielern
- Datenschutz- und Regelwerksprüfung
+49
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
DevX RP v0.4.3 Streaming-, Missions- und Apartment-Reparatur
1. FXServer in txAdmin vollständig stoppen.
2. Eine Sicherung von server.cfg, resources/[devx] und der Datenbank erstellen.
3. devx-rp-hotfix-v0.4.3.zip im Server-Datenordner entpacken.
4. Vorhandene Dateien überschreiben.
5. Prüfen, dass devx_ipl vor devx_crime und devx_housing startet.
6. Server und FiveM vollständig neu starten.
Keine Pflichtmigration: devx_housing bereinigt beim Start verwaiste
Apartment-Eigentümer. Optional kann sql/migrations/v0.4.3.sql einmal manuell
importiert werden.
Wichtig:
- Keine Apartment-/Warehouse-IPLs aus älteren DevX-Versionen zusätzlich laden.
- /fixworld baut Weltfokus und Kollision neu auf.
- Im Apartment an der Tür: E verlassen, G Spieler auswählen/einladen.
- Der Gast wird erst nach E-Annahme der Einladung teleportiert.
Binary file not shown.
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
# DevX RP v0.4.3 relevante Reihenfolge
ensure oxmysql
ensure devx_ipl
ensure devx_core
ensure devx_characters
ensure devx_hud
ensure devx_players
ensure devx_banking
ensure devx_housing
ensure devx_phone
ensure devx_admin
ensure devx_crime
ensure devx_casino
ensure devx_stripclub
ensure devx_radio
@@ -0,0 +1,61 @@
local open = false
local function setOpen(state)
open = state
SetNuiFocus(state, state)
SendNUIMessage({ action = state and 'open' or 'close' })
if state then
DevXTriggerCallback('admin:bootstrap', {}, function(result)
if not result.ok then
open = false
SetNuiFocus(false, false)
SendNUIMessage({ action = 'close' })
exports.devx_core:Notify(result.error or 'Kein Adminzugriff.')
return
end
SendNUIMessage({ action = 'state', players = result.players, weapons = result.weapons })
end)
end
end
RegisterCommand('admin', function()
setOpen(not open)
end, false)
RegisterNUICallback('close', function(_, cb)
setOpen(false)
cb({ ok = true })
end)
RegisterNUICallback('action', function(data, cb)
DevXTriggerCallback('admin:action', data, function(result)
if result.players then SendNUIMessage({ action = 'players', players = result.players }) end
cb(result)
end)
end)
RegisterNetEvent('devx_admin:client:giveWeapon', function(weapon, ammo)
GiveWeaponToPed(PlayerPedId(), GetHashKey(weapon), tonumber(ammo) or 250, false, true)
exports.devx_core:Notify(('Adminwaffe erhalten: %s'):format(weapon))
end)
RegisterNetEvent('devx_admin:client:heal', function()
local ped = PlayerPedId()
SetEntityHealth(ped, GetEntityMaxHealth(ped))
SetPedArmour(ped, 100)
ClearPedBloodDamage(ped)
end)
RegisterNetEvent('devx_admin:client:freeze', function(state)
FreezeEntityPosition(PlayerPedId(), state == true)
exports.devx_core:Notify(state and 'Du wurdest eingefroren.' or 'Du wurdest freigegeben.')
end)
RegisterNetEvent('devx_admin:client:teleport', function(coords)
local ped = PlayerPedId()
SetEntityCoordsNoOffset(ped, coords.x, coords.y, coords.z, false, false, false)
end)
AddEventHandler('onResourceStop', function(resourceName)
if resourceName == GetCurrentResourceName() then SetNuiFocus(false, false) end
end)
@@ -0,0 +1,14 @@
fx_version 'cerulean'
game 'gta5'
author 'Colin-Joel / DevX Studios'
description 'DevX RP ACE-secured administration interface'
version '0.4.3'
lua54 'yes'
shared_script '@devx_core/shared/config.lua'
ui_page 'web/index.html'
files { 'web/index.html', 'web/style.css', 'web/app.js' }
client_scripts { '@devx_core/client/callbacks.lua', 'client/main.lua' }
server_scripts { '@oxmysql/lib/MySQL.lua', '@devx_core/server/callbacks.lua', 'server/main.lua' }
dependencies { 'devx_core', 'devx_banking' }
+147
View File
@@ -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)
+21
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]))}
function error(t=''){errorEl.textContent=t;errorEl.classList.toggle('hidden',!t)}
function selected(){return players.find(p=>Number(p.source)===Number(selectedId))}
function renderPlayers(){const q=document.querySelector('#search').value.toLowerCase();playersEl.innerHTML='';players.filter(p=>`${p.source} ${p.fullName} ${p.connectionName}`.toLowerCase().includes(q)).forEach(p=>{const b=document.createElement('button');b.className=`player ${Number(p.source)===Number(selectedId)?'active':''}`;b.innerHTML=`<span>${p.source}</span><div><strong>${esc(p.fullName)}</strong><small>${esc(p.connectionName)}</small></div>`;b.onclick=()=>{selectedId=p.source;renderPlayers();renderEditor()};playersEl.appendChild(b)})}
function action(action,data={}){error('');return post('action',{action,target:selectedId,...data}).then(r=>{if(!r.ok)error(r.error||'Aktion fehlgeschlagen.');if(r.players){players=r.players;renderPlayers();renderEditor()}return r})}
function renderEditor(){const p=selected();if(!p){editor.className='editor empty';editor.innerHTML='<div><h2>Spieler auswählen</h2><p>Wähle links einen Online-Spieler aus.</p></div>';return}editor.className='editor';editor.innerHTML=`
<div class="editor-header"><div><h2>${esc(p.fullName)}</h2><p>Server-ID ${p.source} · Charakter ${p.characterId??''} · ${esc(p.phoneNumber||'keine Nummer')}</p></div><div class="badges"><span class="badge">${p.cash} $ bar</span><span class="badge">${p.bank} $ Bank</span><span class="badge">${p.rpXp} XP</span></div></div>
<div class="grid">
<div class="card"><h3>Geld bearbeiten</h3><div class="form-row"><input id="cash" type="number" min="0" value="${p.cash}"><button class="primary" data-act="setcash">Bargeld setzen</button></div><div class="form-row"><input id="bank" type="number" min="0" value="${p.bank}"><button class="primary" data-act="setbank">Bank setzen</button></div></div>
<div class="card"><h3>Identität</h3><div class="form-row"><input id="phone" value="${esc(p.phoneNumber||'')}"><button class="primary" data-act="setphone">Nummer setzen</button></div></div>
<div class="card"><h3>Waffe geben</h3><div class="form-row"><select id="weapon">${weapons.map(w=>`<option value="${w.name}">${esc(w.label)}</option>`).join('')}</select><button class="primary" data-act="giveweapon">Geben</button></div></div>
<div class="card"><h3>Spieleraktionen</h3><div class="button-grid"><button data-act="heal">Heilen</button><button data-act="freeze">${p.frozen?'Freigeben':'Einfrieren'}</button><button data-act="goto">Zu Spieler</button><button data-act="bring">Herholen</button></div></div>
<div class="card punish"><h3>Verwarnung / Trennung</h3><div class="punish-fields"><input id="reason" class="full-input" placeholder="Begründung"><input id="minutes" class="full-input" type="number" min="0" placeholder="Minuten (0 = permanent)"></div><div class="button-grid"><button class="danger" data-act="kick">Kicken</button><button class="danger" data-act="ban">Bannen</button></div></div>
</div>`;
editor.querySelectorAll('[data-act]').forEach(b=>b.onclick=()=>{const a=b.dataset.act;if(a==='setcash')action(a,{amount:document.querySelector('#cash').value});else if(a==='setbank')action(a,{amount:document.querySelector('#bank').value});else if(a==='setphone')action(a,{phoneNumber:document.querySelector('#phone').value});else if(a==='giveweapon')action(a,{weapon:document.querySelector('#weapon').value});else if(a==='kick'||a==='ban')action(a,{reason:document.querySelector('#reason').value,minutes:document.querySelector('#minutes').value});else action(a)})}
document.querySelector('#search').oninput=renderPlayers;document.querySelector('#refresh').onclick=()=>action('refresh');document.querySelector('#close').onclick=()=>post('close');document.addEventListener('keyup',e=>{if(e.key==='Escape')post('close')});
window.addEventListener('message',e=>{const d=e.data||{};if(d.action==='open')app.classList.remove('hidden');if(d.action==='close')app.classList.add('hidden');if(d.action==='state'){players=d.players||[];weapons=d.weapons||[];selectedId=players[0]?.source??null;renderPlayers();renderEditor()}if(d.action==='players'){players=d.players||[];renderPlayers();renderEditor()}});
@@ -0,0 +1,11 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><link rel="stylesheet" href="style.css"></head>
<body><main id="app" class="hidden">
<section class="admin-shell">
<header><div><small>DEVX-CITY</small><h1>Administration</h1></div><div class="header-actions"><button id="refresh">Aktualisieren</button><button id="close">×</button></div></header>
<div id="error" class="error hidden"></div>
<div class="layout">
<aside><input id="search" placeholder="Spieler suchen"><div id="players" class="players"></div></aside>
<section id="editor" class="editor empty"><h2>Spieler auswählen</h2><p>Wähle links einen Online-Spieler aus.</p></section>
</div>
</section>
</main><script src="app.js"></script></body></html>
@@ -0,0 +1,5 @@
:root{font-family:Inter,system-ui,sans-serif;color:#f6f7fb;--line:rgba(255,255,255,.1);--muted:#929baa;--accent:#5e82f4}
*{box-sizing:border-box}body{margin:0;background:transparent;overflow:hidden}.hidden{display:none!important}button,input,select{font:inherit}
#app{position:fixed;inset:0;display:grid;place-items:center;background:rgba(2,4,8,.62)}
.admin-shell{width:min(1180px,94vw);height:min(760px,90vh);padding:25px;border:1px solid var(--line);border-radius:16px;background:#0d1119;box-shadow:0 35px 110px rgba(0,0,0,.62)}
header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}header small{color:#84a2ff;letter-spacing:.2em;font-weight:850}h1{margin:4px 0 0}.header-actions{display:flex;gap:8px}button{border:0;border-radius:8px;padding:10px 13px;color:white;background:rgba(255,255,255,.08);cursor:pointer}#close{font-size:22px}.layout{height:calc(100% - 78px);display:grid;grid-template-columns:320px 1fr;gap:18px}aside,.editor{border:1px solid var(--line);border-radius:12px;background:#111721;padding:15px;overflow:auto}#search{width:100%;padding:11px;border:1px solid var(--line);border-radius:8px;background:#090d13;color:white;outline:0}.players{display:grid;gap:7px;margin-top:12px}.player{width:100%;display:grid;grid-template-columns:42px 1fr;gap:10px;text-align:left;padding:11px;background:rgba(255,255,255,.04);border:1px solid transparent}.player.active{border-color:#6689f4;background:rgba(72,106,215,.18)}.player span{display:grid;place-items:center;width:38px;height:38px;border-radius:9px;background:#27375d;font-weight:850}.player strong{display:block}.player small{color:var(--muted)}.editor.empty{display:grid;place-items:center;text-align:center;color:var(--muted)}.editor-header{display:flex;justify-content:space-between;align-items:flex-start}.editor-header h2{margin:0}.editor-header p{margin:5px 0;color:var(--muted)}.badges{display:flex;gap:7px}.badge{padding:7px 9px;border-radius:7px;background:rgba(255,255,255,.06);font-size:11px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:13px;margin-top:20px}.card{padding:15px;border:1px solid var(--line);border-radius:10px;background:rgba(255,255,255,.025)}.card h3{margin:0 0 12px;font-size:14px}.form-row{display:grid;grid-template-columns:1fr auto;gap:7px;margin-top:8px}.form-row input,.form-row select,.full-input{width:100%;padding:10px;border:1px solid var(--line);border-radius:8px;background:#090d13;color:white}.primary{background:#557af0}.danger{background:rgba(197,62,81,.23);color:#ffabb5}.button-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}.error{margin-bottom:12px;padding:10px;border-radius:8px;background:rgba(184,48,67,.25);color:#ffadb7}.punish{grid-column:1/-1}.punish-fields{display:grid;grid-template-columns:1fr 140px;gap:8px;margin-bottom:8px}
@@ -0,0 +1,80 @@
local open = false
local bankLocations = {
vector3(149.77, -1040.72, 29.37),
vector3(-1212.98, -330.84, 37.78),
vector3(-2962.58, 482.63, 15.70),
vector3(314.19, -278.62, 54.17)
}
local function setOpen(state)
open = state
SetNuiFocus(state, state)
SendNUIMessage({ action = state and 'open' or 'close' })
if state then
DevXTriggerCallback('banking:overview', {}, function(result)
SendNUIMessage({
action = result.ok and 'overview' or 'error',
overview = result.overview,
message = result.error
})
end)
end
end
RegisterCommand('bank', function()
if exports.devx_core:IsPlayerLoaded() then setOpen(not open) end
end, false)
RegisterNUICallback('close', function(_, cb)
setOpen(false)
cb({ ok = true })
end)
RegisterNUICallback('refresh', function(_, cb)
DevXTriggerCallback('banking:overview', {}, cb)
end)
RegisterNUICallback('transfer', function(data, cb)
DevXTriggerCallback('banking:transfer', data, cb)
end)
CreateThread(function()
for _, location in ipairs(bankLocations) do
local blip = AddBlipForCoord(location.x, location.y, location.z)
SetBlipSprite(blip, 108)
SetBlipScale(blip, 0.65)
SetBlipColour(blip, 2)
SetBlipAsShortRange(blip, true)
BeginTextCommandSetBlipName('STRING')
AddTextComponentString('Bank')
EndTextCommandSetBlipName(blip)
end
end)
CreateThread(function()
while true do
local sleep = 1000
if exports.devx_core:IsPlayerLoaded() then
local coords = GetEntityCoords(PlayerPedId())
for _, location in ipairs(bankLocations) do
local distance = #(coords - location)
if distance < 15.0 then
sleep = 0
DrawMarker(
1, location.x, location.y, location.z - 1.0,
0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
1.1, 1.1, 0.35, 70, 120, 255, 120,
false, false, 2, false, nil, nil, false
)
if distance < 1.5 then
BeginTextCommandDisplayHelp('STRING')
AddTextComponentSubstringPlayerName('Drücke ~INPUT_CONTEXT~, um die Bank zu öffnen.')
EndTextCommandDisplayHelp(0, false, true, -1)
if IsControlJustReleased(0, 38) then setOpen(true) end
end
end
end
end
Wait(sleep)
end
end)
@@ -0,0 +1,31 @@
fx_version 'cerulean'
game 'gta5'
author 'Colin-Joel / DevX Studios'
description 'DevX RP banking'
version '0.4.3'
lua54 'yes'
shared_script '@devx_core/shared/config.lua'
ui_page 'web/index.html'
files {
'web/index.html',
'web/style.css',
'web/app.js'
}
client_scripts {
'@devx_core/client/callbacks.lua',
'client/main.lua'
}
server_scripts {
'@oxmysql/lib/MySQL.lua',
'@devx_core/server/callbacks.lua',
'server/main.lua'
}
dependency 'devx_core'
@@ -0,0 +1,218 @@
local accountLocks = {}
local function money(value, allowZero)
local amount = tonumber(value)
if not amount then return nil end
amount = math.floor(amount)
if amount < (allowZero and 0 or 1) or amount > DevXConfig.MaxMoneyOperation then return nil end
return amount
end
local function withLocks(accountIds, callback)
local unique = {}
for _, id in ipairs(accountIds) do
id = tonumber(id)
if id and not unique[id] then unique[id] = true end
end
for id in pairs(unique) do
if accountLocks[id] then return false, 'Ein beteiligtes Konto verarbeitet gerade eine andere Buchung.' end
end
for id in pairs(unique) do accountLocks[id] = true end
local ok, resultA, resultB = pcall(callback)
for id in pairs(unique) do accountLocks[id] = nil end
if not ok then
print(('[devx_banking] Buchungsfehler: %s'):format(tostring(resultA)))
return false, 'Interner Buchungsfehler.'
end
return resultA, resultB
end
local function getOverview(characterId)
local account = MySQL.single.await([[
SELECT id, account_number AS accountNumber, account_name AS accountName,
account_type AS accountType, balance
FROM devx_bank_accounts
WHERE character_id = ? AND account_type = 'personal'
LIMIT 1
]], { characterId })
if not account then return nil end
account.id = tonumber(account.id)
account.balance = tonumber(account.balance) or 0
local transactions = MySQL.query.await([[
SELECT id, direction, amount, balance_after AS balanceAfter,
counterparty, reference, created_at AS createdAt
FROM devx_bank_transactions
WHERE account_id = ?
ORDER BY id DESC
LIMIT 25
]], { account.id }) or {}
for _, transaction in ipairs(transactions) do
transaction.id = tonumber(transaction.id)
transaction.amount = tonumber(transaction.amount) or 0
transaction.balanceAfter = tonumber(transaction.balanceAfter) or 0
end
return { account = account, transactions = transactions }
end
local function addTransaction(accountId, direction, amount, balanceAfter, counterparty, reference)
return MySQL.insert.await([[
INSERT INTO devx_bank_transactions
(account_id, direction, amount, balance_after, counterparty, reference)
VALUES (?, ?, ?, ?, ?, ?)
]], { accountId, direction, amount, balanceAfter, counterparty, reference })
end
local function chargeCharacter(characterId, amount, reference, counterparty)
amount = money(amount)
if not amount then return false, 'Ungültiger Betrag.' end
local account = MySQL.single.await([[
SELECT id, balance FROM devx_bank_accounts
WHERE character_id = ? AND account_type = 'personal' LIMIT 1
]], { characterId })
if not account then return false, 'Kein Bankkonto gefunden.' end
return withLocks({ account.id }, function()
account = MySQL.single.await('SELECT id, balance FROM devx_bank_accounts WHERE id = ? LIMIT 1', { account.id })
if not account or tonumber(account.balance) < amount then return false, 'Das Kontoguthaben reicht nicht aus.' end
local newBalance = tonumber(account.balance) - amount
local changed = MySQL.update.await('UPDATE devx_bank_accounts SET balance = ? WHERE id = ?', { newBalance, account.id })
if not changed or changed < 1 then return false, 'Belastung fehlgeschlagen.' end
addTransaction(account.id, 'debit', amount, newBalance, counterparty or 'Unbekannt', reference or 'Zahlung')
return true, newBalance
end)
end
local function creditCharacter(characterId, amount, reference, counterparty)
amount = money(amount)
if not amount then return false, 'Ungültiger Betrag.' end
local account = MySQL.single.await([[
SELECT id, balance FROM devx_bank_accounts
WHERE character_id = ? AND account_type = 'personal' LIMIT 1
]], { characterId })
if not account then return false, 'Kein Bankkonto gefunden.' end
return withLocks({ account.id }, function()
account = MySQL.single.await('SELECT id, balance FROM devx_bank_accounts WHERE id = ? LIMIT 1', { account.id })
if not account then return false, 'Kein Bankkonto gefunden.' end
local newBalance = tonumber(account.balance) + amount
local changed = MySQL.update.await('UPDATE devx_bank_accounts SET balance = ? WHERE id = ?', { newBalance, account.id })
if not changed or changed < 1 then return false, 'Gutschrift fehlgeschlagen.' end
addTransaction(account.id, 'credit', amount, newBalance, counterparty or 'Unbekannt', reference or 'Gutschrift')
return true, newBalance
end)
end
local function setPlayerBalance(source, amount, reason)
local player = exports.devx_core:GetPlayer(source)
amount = money(amount, true)
if not player or not player.accountId or amount == nil then return false, 'Ungültige Anfrage.' end
return withLocks({ player.accountId }, function()
local changed = MySQL.update.await('UPDATE devx_bank_accounts SET balance = ? WHERE id = ?', { amount, player.accountId })
if not changed or changed < 1 then return false, 'Kontostand konnte nicht geändert werden.' end
addTransaction(player.accountId, 'credit', 0, amount, 'Administration', reason or 'Kontostand gesetzt')
exports.devx_core:SetBankCache(source, amount)
return true, amount
end)
end
local function transferForSource(source, payload)
local player = exports.devx_core:GetPlayer(source)
local amount = money(payload.amount)
local destinationNumber = type(payload.destination) == 'string' and payload.destination:gsub('%s+', ''):upper() or nil
local reference = type(payload.reference) == 'string' and payload.reference:sub(1, 140) or 'Überweisung'
if not player or not player.accountId then return false, 'Kein Charakter aktiv.' end
if not amount or not destinationNumber or #destinationNumber < 4 then return false, 'Betrag oder Empfängerkonto ist ungültig.' end
if destinationNumber == player.accountNumber then return false, 'Du kannst nicht auf dasselbe Konto überweisen.' end
local destination = MySQL.single.await([[
SELECT id, account_number, account_name, balance
FROM devx_bank_accounts WHERE account_number = ? LIMIT 1
]], { destinationNumber })
if not destination then return false, 'Empfängerkonto wurde nicht gefunden.' end
return withLocks({ player.accountId, destination.id }, function()
local sourceAccount = MySQL.single.await([[
SELECT id, account_number, account_name, balance
FROM devx_bank_accounts WHERE id = ? AND character_id = ? LIMIT 1
]], { player.accountId, player.characterId })
destination = MySQL.single.await([[
SELECT id, account_number, account_name, balance
FROM devx_bank_accounts WHERE id = ? LIMIT 1
]], { destination.id })
if not sourceAccount or not destination then return false, 'Ein Konto wurde nicht gefunden.' end
if tonumber(sourceAccount.balance) < amount then return false, 'Das Kontoguthaben reicht nicht aus.' end
local sourceBalance = tonumber(sourceAccount.balance) - amount
local destinationBalance = tonumber(destination.balance) + amount
local transactionOk = MySQL.transaction.await({
{
query = 'UPDATE devx_bank_accounts SET balance = ? WHERE id = ?',
values = { sourceBalance, sourceAccount.id }
},
{
query = 'UPDATE devx_bank_accounts SET balance = ? WHERE id = ?',
values = { destinationBalance, destination.id }
},
{
query = [[INSERT INTO devx_bank_transactions
(account_id, direction, amount, balance_after, counterparty, reference)
VALUES (?, 'debit', ?, ?, ?, ?)]],
values = { sourceAccount.id, amount, sourceBalance, destination.account_name, reference }
},
{
query = [[INSERT INTO devx_bank_transactions
(account_id, direction, amount, balance_after, counterparty, reference)
VALUES (?, 'credit', ?, ?, ?, ?)]],
values = { destination.id, amount, destinationBalance, sourceAccount.account_name, reference }
}
})
if not transactionOk then return false, 'Überweisung fehlgeschlagen.' end
exports.devx_core:SetBankCache(source, sourceBalance)
return true, sourceBalance
end)
end
exports('GetOverview', getOverview)
exports('ChargeCharacter', chargeCharacter)
exports('CreditCharacter', creditCharacter)
exports('SetPlayerBalance', setPlayerBalance)
exports('TransferForSource', transferForSource)
DevXRegisterCallback('banking:overview', function(source)
local player = exports.devx_core:GetPlayer(source)
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
local overview = getOverview(player.characterId)
if not overview then return { ok = false, error = 'Kein Bankkonto gefunden.' } end
exports.devx_core:SetBankCache(source, overview.account.balance)
return { ok = true, overview = overview }
end)
DevXRegisterCallback('banking:transfer', function(source, payload)
local success, result = transferForSource(source, payload)
return success and { ok = true, balance = result } or { ok = false, error = result }
end)
RegisterCommand('setbank', function(source, args)
if source ~= 0 and not IsPlayerAceAllowed(source, 'devx.admin') then return end
local target = tonumber(args[1])
local amount = money(args[2], true)
if not target or amount == nil then return end
setPlayerBalance(target, amount, ('Admin %s'):format(source))
end, false)
+73
View File
@@ -0,0 +1,73 @@
const resource = typeof GetParentResourceName === 'function'
? GetParentResourceName()
: 'devx_banking';
const app = document.querySelector('#app');
const form = document.querySelector('#transferForm');
const transactions = document.querySelector('#transactions');
const errorEl = document.querySelector('#error');
const format = new Intl.NumberFormat('de-DE');
async function post(event, data = {}) {
const response = await fetch(`https://${resource}/${event}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data)
});
return response.json();
}
function showError(text = '') {
errorEl.textContent = text;
errorEl.classList.toggle('hidden', !text);
}
function render(overview) {
const account = overview.account;
document.querySelector('#accountName').textContent = account.accountName;
document.querySelector('#accountNumber').textContent = account.accountNumber;
document.querySelector('#balance').textContent = `${format.format(account.balance)} $`;
transactions.innerHTML = '';
for (const entry of overview.transactions || []) {
const row = document.createElement('div');
row.className = 'transaction';
const sign = entry.direction === 'credit' ? '+' : '';
row.innerHTML = `
<div><strong>${escapeHtml(entry.reference)}</strong><br>
<small>${escapeHtml(entry.counterparty || '')} · ${escapeHtml(String(entry.createdAt))}</small></div>
<strong class="${entry.direction}">${sign}${format.format(entry.amount)} $</strong>`;
transactions.appendChild(row);
}
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
}[char]));
}
document.querySelector('#close').addEventListener('click', () => post('close'));
document.addEventListener('keyup', event => {
if (event.key === 'Escape') post('close');
});
form.addEventListener('submit', async event => {
event.preventDefault();
showError('');
const result = await post('transfer', Object.fromEntries(new FormData(form)));
if (!result.ok) {
showError(result.error || 'Überweisung fehlgeschlagen.');
return;
}
form.reset();
const refreshed = await post('refresh');
if (refreshed.ok) render(refreshed.overview);
});
window.addEventListener('message', event => {
const data = event.data;
if (data.action === 'open') app.classList.remove('hidden');
if (data.action === 'close') app.classList.add('hidden');
if (data.action === 'overview') render(data.overview);
if (data.action === 'error') showError(data.message);
});
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="style.css">
</head>
<body>
<main id="app" class="hidden">
<section class="bank">
<header>
<div>
<p>LOS SANTOS FINANCIAL</p>
<h1>Banking</h1>
</div>
<button id="close" aria-label="Schließen">×</button>
</header>
<div id="error" class="error hidden"></div>
<section class="account">
<div><span>KONTO</span><strong id="accountName"></strong></div>
<div><span>KONTONUMMER</span><strong id="accountNumber"></strong></div>
<div class="balance"><span>VERFÜGBAR</span><strong id="balance">0 $</strong></div>
</section>
<section class="columns">
<form id="transferForm">
<h2>Überweisung</h2>
<label>Empfängerkonto<input name="destination" placeholder="LS0000000000" required></label>
<label>Betrag<input name="amount" type="number" min="1" step="1" required></label>
<label>Verwendungszweck<input name="reference" maxlength="140" value="Überweisung"></label>
<button type="submit">Überweisen</button>
</form>
<div>
<h2>Letzte Buchungen</h2>
<div id="transactions" class="transactions"></div>
</div>
</section>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,47 @@
:root { font-family: Inter, Arial, sans-serif; color: #f7f8fb; }
* { box-sizing: border-box; }
body { margin: 0; overflow: hidden; background: transparent; }
.hidden { display: none !important; }
#app {
min-height: 100vh; display: grid; place-items: center;
background: rgba(2,5,9,.55); backdrop-filter: blur(5px);
}
.bank {
width: min(980px, 92vw); max-height: 86vh; overflow: auto;
background: #10151e; border: 1px solid rgba(255,255,255,.1);
border-radius: 22px; padding: 28px; box-shadow: 0 30px 100px rgba(0,0,0,.55);
}
header { display: flex; justify-content: space-between; align-items: flex-start; }
header p, .account span { color: #8f9aae; font-size: 11px; letter-spacing: .15em; }
h1 { margin: 4px 0 22px; font-size: 38px; }
h2 { font-size: 18px; }
#close { background: transparent; color: white; border: 0; font-size: 32px; cursor: pointer; }
.account {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;
background: linear-gradient(135deg, #2857dd, #1e3e98);
border-radius: 16px; padding: 20px;
}
.account div { display: grid; gap: 7px; }
.account .balance strong { font-size: 28px; }
.columns { display: grid; grid-template-columns: .8fr 1.2fr; gap: 24px; margin-top: 22px; }
form { display: grid; gap: 12px; align-content: start; }
label { display: grid; gap: 6px; color: #aeb7c7; font-size: 13px; }
input {
border: 1px solid rgba(255,255,255,.11); background: #0a0e15;
color: white; border-radius: 10px; padding: 12px;
}
button[type=submit] {
border: 0; background: #416cf5; color: white; font-weight: 800;
border-radius: 10px; padding: 12px; cursor: pointer;
}
.transaction {
display: grid; grid-template-columns: 1fr auto; gap: 6px;
padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,.08);
}
.transaction small { color: #8f9aae; }
.credit { color: #6ee7a5; }
.debit { color: #ff8592; }
.error { background: rgba(180,45,65,.28); padding: 12px; border-radius: 10px; margin-bottom: 14px; }
@media (max-width: 760px) {
.account, .columns { grid-template-columns: 1fr; }
}
@@ -0,0 +1,155 @@
local wheelBusy = false
local casinoPeds = {}
local uiOpen = false
local function notify(message) exports.devx_core:Notify(message) end
local function help(text)
BeginTextCommandDisplayHelp('STRING'); AddTextComponentSubstringPlayerName(text); EndTextCommandDisplayHelp(0, false, true, -1)
end
local function loadModel(name)
local hash = GetHashKey(name); RequestModel(hash)
local deadline = GetGameTimer() + 6000
while not HasModelLoaded(hash) and GetGameTimer() < deadline do Wait(0) end
return HasModelLoaded(hash) and hash or nil
end
local function ensureCasinoInterior()
for _, ipl in ipairs(DevXCasinoConfig.Ipls or {}) do RequestIpl(ipl) end
local p = DevXCasinoConfig.InteriorEntry
SetFocusPosAndVel(p.x, p.y, p.z, 0.0, 0.0, 0.0)
RequestCollisionAtCoord(p.x, p.y, p.z)
NewLoadSceneStartSphere(p.x, p.y, p.z, 150.0, 0)
local deadline = GetGameTimer() + 9000
while not IsNewLoadSceneLoaded() and GetGameTimer() < deadline do Wait(0) end
NewLoadSceneStop(); ClearFocus()
end
local function teleport(position)
DoScreenFadeOut(250); while not IsScreenFadedOut() do Wait(0) end
local ped = PlayerPedId(); FreezeEntityPosition(ped, true)
RequestCollisionAtCoord(position.x, position.y, position.z)
SetEntityCoordsNoOffset(ped, position.x, position.y, position.z, false, false, false)
SetEntityHeading(ped, position.w or position.heading or 0.0)
local deadline = GetGameTimer() + 5000
while not HasCollisionLoadedAroundEntity(ped) and GetGameTimer() < deadline do RequestCollisionAtCoord(position.x, position.y, position.z); Wait(0) end
FreezeEntityPosition(ped, false); Wait(150); DoScreenFadeIn(350)
end
local function spawnCasinoPeds()
for _, ped in ipairs(casinoPeds) do if DoesEntityExist(ped) then DeleteEntity(ped) end end
casinoPeds = {}
for _, definition in ipairs(DevXCasinoConfig.Npcs or {}) do
local model = loadModel(definition.model)
if model then
local c = definition.coords
local ped = CreatePed(4, model, c.x, c.y, c.z, c.w, false, false)
SetEntityInvincible(ped, true); SetBlockingOfNonTemporaryEvents(ped, true); FreezeEntityPosition(ped, true)
TaskStartScenarioInPlace(ped, definition.scenario or 'WORLD_HUMAN_STAND_MOBILE', 0, true)
casinoPeds[#casinoPeds + 1] = ped; SetModelAsNoLongerNeeded(model)
end
end
end
local function getWheelObject()
for _, modelName in ipairs(DevXCasinoConfig.WheelModels or {}) do
local object = GetClosestObjectOfType(DevXCasinoConfig.Wheel.x, DevXCasinoConfig.Wheel.y, DevXCasinoConfig.Wheel.z, 3.0, GetHashKey(modelName), false, false, false)
if object and object ~= 0 and DoesEntityExist(object) then return object end
end
return nil
end
local function animateWheel(result)
local wheel = getWheelObject()
if not wheel then notify('Das originale Casino-Glücksrad ist noch nicht geladen. Betritt das Casino erneut.'); return false end
local duration, start = 6200, GetGameTimer()
local base = GetEntityRotation(wheel, 2).y
local segment = 360.0 / tonumber(result.totalSegments or 8)
local target = 1440.0 + (360.0 - ((tonumber(result.segment or 0) * segment) + segment * .5))
FreezeEntityPosition(wheel, true)
while GetGameTimer() - start < duration do
local progress = (GetGameTimer() - start) / duration
local eased = 1.0 - ((1.0 - progress) ^ 4)
SetEntityRotation(wheel, 0.0, (base + target * eased) % 360.0, 0.0, 2, true)
help('Das Glücksrad dreht sich …'); Wait(0)
end
notify(('Glücksrad: %s gewonnen!'):format(result.reward and result.reward.label or 'Gewinn'))
return true
end
local function spinWheel()
if #(GetEntityCoords(PlayerPedId()) - DevXCasinoConfig.Wheel) > 3.0 then
notify('Du musst direkt am Glücksrad stehen.')
return
end
if wheelBusy then return end
wheelBusy = true
DevXTriggerCallback('casino:spin', {}, function(result)
if not result.ok then notify(result.error or 'Das Glücksrad konnte nicht gedreht werden.'); wheelBusy = false; return end
CreateThread(function() animateWheel(result); wheelBusy = false end)
end)
end
local function openUi(mode)
uiOpen = true; SetNuiFocus(true, true)
DevXTriggerCallback('casino:status', {}, function(status)
SendNUIMessage({ action = 'open', mode = mode, status = status })
end)
end
local function closeUi()
uiOpen = false; SetNuiFocus(false, false); SendNUIMessage({ action = 'close' })
end
RegisterNetEvent('devx_casino:client:notify', notify)
RegisterCommand('dailywheel', spinWheel, false)
RegisterNUICallback('close', function(_, cb) closeUi(); cb({ ok = true }) end)
RegisterNUICallback('refresh', function(_, cb) DevXTriggerCallback('casino:status', {}, cb) end)
RegisterNUICallback('exchange', function(data, cb) DevXTriggerCallback('casino:exchange', data, cb) end)
RegisterNUICallback('slots', function(data, cb) DevXTriggerCallback('casino:slots', data, cb) end)
RegisterNUICallback('roulette', function(data, cb) DevXTriggerCallback('casino:roulette', data, cb) end)
RegisterNUICallback('blackjackStart', function(data, cb) DevXTriggerCallback('casino:blackjackStart', data, cb) end)
RegisterNUICallback('blackjackAction', function(data, cb) DevXTriggerCallback('casino:blackjackAction', data, cb) end)
CreateThread(function()
ensureCasinoInterior(); Wait(1000); spawnCasinoPeds()
local blip = AddBlipForCoord(DevXCasinoConfig.ExteriorEntry.x, DevXCasinoConfig.ExteriorEntry.y, DevXCasinoConfig.ExteriorEntry.z)
SetBlipSprite(blip, 679); SetBlipScale(blip, .8); SetBlipColour(blip, 27); SetBlipAsShortRange(blip, true)
BeginTextCommandSetBlipName('STRING'); AddTextComponentString('Diamond Casino'); EndTextCommandSetBlipName(blip)
end)
local points = {
{ key = 'cashier', position = DevXCasinoConfig.Cashier, label = '~INPUT_CONTEXT~ Casino-Kasse und Chips', mode = 'cashier' },
{ key = 'slots', position = DevXCasinoConfig.Slots, label = '~INPUT_CONTEXT~ Spielautomaten', mode = 'slots' },
{ key = 'roulette', position = DevXCasinoConfig.Roulette, label = '~INPUT_CONTEXT~ Roulette', mode = 'roulette' },
{ key = 'blackjack', position = DevXCasinoConfig.Blackjack, label = '~INPUT_CONTEXT~ Blackjack', mode = 'blackjack' }
}
CreateThread(function()
while true do
local sleep = 700
if exports.devx_core:IsPlayerLoaded() and not uiOpen then
local coords = GetEntityCoords(PlayerPedId())
local exterior = vector3(DevXCasinoConfig.ExteriorEntry.x, DevXCasinoConfig.ExteriorEntry.y, DevXCasinoConfig.ExteriorEntry.z)
local interiorExit = vector3(DevXCasinoConfig.InteriorExit.x, DevXCasinoConfig.InteriorExit.y, DevXCasinoConfig.InteriorExit.z)
if #(coords - exterior) < 12.0 then
sleep = 0; DrawMarker(1, exterior.x, exterior.y, exterior.z - 1.0,0,0,0,0,0,0,1.1,1.1,.3,190,110,255,130,false,false,2,false,nil,nil,false)
if #(coords - exterior) < 1.5 then help('~INPUT_CONTEXT~ Casino betreten'); if IsControlJustReleased(0,38) then ensureCasinoInterior(); teleport(DevXCasinoConfig.InteriorEntry); spawnCasinoPeds() end end
elseif #(coords - interiorExit) < 8.0 then
sleep = 0; DrawMarker(1, interiorExit.x, interiorExit.y, interiorExit.z - 1.0,0,0,0,0,0,0,1.0,1.0,.25,190,110,255,130,false,false,2,false,nil,nil,false)
if #(coords - interiorExit) < 1.5 then help('~INPUT_CONTEXT~ Casino verlassen'); if IsControlJustReleased(0,38) then teleport(DevXCasinoConfig.ExteriorEntry) end end
else
local wheelDistance = #(coords - DevXCasinoConfig.Wheel)
if wheelDistance < 10.0 then
sleep = 0; DrawMarker(2, DevXCasinoConfig.Wheel.x,DevXCasinoConfig.Wheel.y,DevXCasinoConfig.Wheel.z+.15,0,0,0,0,180,0,.25,.25,.25,255,190,70,150,false,false,2,false,nil,nil,false)
if wheelDistance < 1.8 then help(wheelBusy and 'Das Glücksrad dreht sich …' or '~INPUT_CONTEXT~ täglichen Glücksrad-Dreh starten'); if not wheelBusy and IsControlJustReleased(0,38) then spinWheel() end end
end
for _, point in ipairs(points) do
local distance = #(coords - point.position)
if distance < 8.0 then
sleep = 0; DrawMarker(2,point.position.x,point.position.y,point.position.z+.15,0,0,0,0,180,0,.22,.22,.22,210,160,70,145,false,false,2,false,nil,nil,false)
if distance < 1.6 then help(point.label); if IsControlJustReleased(0,38) then openUi(point.mode) end end
end
end
end
end
Wait(sleep)
end
end)
AddEventHandler('onResourceStop', function(resource)
if resource ~= GetCurrentResourceName() then return end
closeUi()
for _, ped in ipairs(casinoPeds) do if DoesEntityExist(ped) then DeleteEntity(ped) end end
end)
@@ -0,0 +1,13 @@
fx_version 'cerulean'
game 'gta5'
author 'Colin-Joel / DevX Studios'
description 'DevX RP Diamond Casino with NPC ambience, lucky wheel, chips, slots, roulette and blackjack'
version '0.4.3'
lua54 'yes'
shared_script 'shared/config.lua'
ui_page 'web/index.html'
files { 'web/index.html', 'web/style.css', 'web/app.js' }
client_scripts { '@devx_core/client/callbacks.lua', 'client/main.lua' }
server_scripts { '@oxmysql/lib/MySQL.lua', '@devx_core/server/callbacks.lua', 'server/main.lua' }
dependencies { 'devx_core', 'devx_banking', 'devx_ipl' }
@@ -0,0 +1,231 @@
local spinLocks = {}
local gameLocks = {}
local blackjackSessions = {}
local rewards = {
{ type = 'cash', amount = 500, label = '500 $ Bargeld' },
{ type = 'xp', amount = 25, label = '25 RP-XP' },
{ type = 'bank', amount = 1000, label = '1.000 $ Bankguthaben' },
{ type = 'cash', amount = 750, label = '750 $ Bargeld' },
{ type = 'xp', amount = 50, label = '50 RP-XP' },
{ type = 'bank', amount = 1500, label = '1.500 $ Bankguthaben' },
{ type = 'cash', amount = 1250, label = '1.250 $ Bargeld' },
{ type = 'xp', amount = 100, label = '100 RP-XP' }
}
MySQL.ready(function()
MySQL.query.await([[
CREATE TABLE IF NOT EXISTS devx_casino_daily (
character_id BIGINT UNSIGNED NOT NULL,
last_spin DATETIME NULL,
total_spins INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (character_id),
CONSTRAINT fk_devx_casino_character FOREIGN KEY (character_id)
REFERENCES devx_characters (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
]])
MySQL.query.await([[
CREATE TABLE IF NOT EXISTS devx_casino_wallets (
character_id BIGINT UNSIGNED NOT NULL,
chips BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (character_id),
CONSTRAINT fk_devx_casino_wallet_character FOREIGN KEY (character_id)
REFERENCES devx_characters (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
]])
end)
local function amount(value, min, max)
local n = math.floor(tonumber(value) or 0)
if n < (min or 1) or n > (max or 100000) then return nil end
return n
end
local function wallet(characterId)
MySQL.query.await('INSERT IGNORE INTO devx_casino_wallets (character_id, chips) VALUES (?, 0)', { characterId })
return tonumber(MySQL.scalar.await('SELECT chips FROM devx_casino_wallets WHERE character_id = ?', { characterId })) or 0
end
local function addChips(characterId, chips)
MySQL.query.await([[
INSERT INTO devx_casino_wallets (character_id, chips) VALUES (?, ?)
ON DUPLICATE KEY UPDATE chips = chips + VALUES(chips)
]], { characterId, chips })
return wallet(characterId)
end
local function removeChips(characterId, chips)
local changed = MySQL.update.await([[
UPDATE devx_casino_wallets SET chips = chips - ? WHERE character_id = ? AND chips >= ?
]], { chips, characterId, chips })
return changed and changed > 0, wallet(characterId)
end
local function spinStatus(characterId)
local row = MySQL.single.await([[
SELECT UNIX_TIMESTAMP(last_spin) AS lastSpin, total_spins AS totalSpins
FROM devx_casino_daily WHERE character_id = ? LIMIT 1
]], { characterId })
local lastSpin = row and tonumber(row.lastSpin) or 0
local nextSpin = lastSpin + DevXCasinoConfig.CooldownSeconds
return { available = os.time() >= nextSpin, remainingSeconds = math.max(0, nextSpin - os.time()), totalSpins = row and tonumber(row.totalSpins) or 0 }
end
local function card()
local rank = math.random(1, 13)
local labels = { 'A','2','3','4','5','6','7','8','9','10','J','Q','K' }
return { rank = rank, label = labels[rank] }
end
local function handValue(cards)
local total, aces = 0, 0
for _, item in ipairs(cards) do
if item.rank == 1 then total = total + 11; aces = aces + 1
elseif item.rank >= 10 then total = total + 10
else total = total + item.rank end
end
while total > 21 and aces > 0 do total = total - 10; aces = aces - 1 end
return total
end
local function blackjackPayload(session, reveal, result)
local dealer = {}
for index, item in ipairs(session.dealer) do
dealer[index] = (reveal or index == 1) and item or { label = '?' }
end
return {
ok = true, active = result == nil, result = result,
playerCards = session.player, dealerCards = dealer,
playerValue = handValue(session.player), dealerValue = reveal and handValue(session.dealer) or nil,
chips = wallet(session.characterId)
}
end
local function settleBlackjack(source, session, outcome)
local result = outcome
if outcome == 'win' then addChips(session.characterId, session.bet * 2)
elseif outcome == 'blackjack' then addChips(session.characterId, math.floor(session.bet * 2.5))
elseif outcome == 'push' then addChips(session.characterId, session.bet) end
blackjackSessions[source] = nil
return blackjackPayload(session, true, result)
end
DevXRegisterCallback('casino:status', function(source)
local player = exports.devx_core:GetPlayer(source)
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
local status = spinStatus(player.characterId)
status.ok = true; status.chips = wallet(player.characterId)
return status
end)
DevXRegisterCallback('casino:exchange', function(source, payload)
local player = exports.devx_core:GetPlayer(source)
local value = amount(payload.amount, 100, 100000)
local direction = payload.direction
if not player or not value or (direction ~= 'buy' and direction ~= 'sell') then return { ok = false, error = 'Ungültige Wechselanfrage.' } end
if gameLocks[source] then return { ok = false, error = 'Eine Casino-Buchung läuft bereits.' } end
gameLocks[source] = true
local response
if direction == 'buy' then
local paid, balance = exports.devx_banking:ChargeCharacter(player.characterId, value, 'Casino-Chips', 'Diamond Casino')
if paid then exports.devx_core:SetBankCache(source, balance); response = { ok = true, chips = addChips(player.characterId, value) }
else response = { ok = false, error = balance } end
else
local removed = removeChips(player.characterId, value)
if removed then
local credited, balance = exports.devx_banking:CreditCharacter(player.characterId, value, 'Casino-Chips eingelöst', 'Diamond Casino')
if credited then exports.devx_core:SetBankCache(source, balance); response = { ok = true, chips = wallet(player.characterId) }
else addChips(player.characterId, value); response = { ok = false, error = balance } end
else response = { ok = false, error = 'Du besitzt nicht genügend Chips.' } end
end
gameLocks[source] = nil
return response
end)
DevXRegisterCallback('casino:spin', function(source)
local player = exports.devx_core:GetPlayer(source)
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
if spinLocks[source] then return { ok = false, error = 'Dein Glücksrad-Dreh läuft bereits.' } end
spinLocks[source] = true
local status = spinStatus(player.characterId)
if not status.available then spinLocks[source] = nil; return { ok = false, error = ('Nächster Dreh in %s Stunden.'):format(math.ceil(status.remainingSeconds / 3600)) } end
local index = math.random(1, #rewards); local reward = rewards[index]; local success = false
if reward.type == 'cash' then success = exports.devx_core:AddCash(source, reward.amount, 'Casino-Glücksrad') == true
elseif reward.type == 'bank' then
local credited, newBalance = exports.devx_banking:CreditCharacter(player.characterId, reward.amount, 'Casino-Glücksrad', 'Diamond Casino')
success = credited == true; if success then exports.devx_core:SetBankCache(source, newBalance) end
elseif reward.type == 'xp' then success = exports.devx_core:AddMetadataNumber(source, 'rpXp', reward.amount) == true end
if not success then spinLocks[source] = nil; return { ok = false, error = 'Gewinn konnte nicht gutgeschrieben werden.' } end
MySQL.query.await([[
INSERT INTO devx_casino_daily (character_id, last_spin, total_spins) VALUES (?, NOW(), 1)
ON DUPLICATE KEY UPDATE last_spin = NOW(), total_spins = total_spins + 1
]], { player.characterId })
spinLocks[source] = nil
return { ok = true, reward = reward, segment = index - 1, totalSegments = #rewards, nextSpinSeconds = DevXCasinoConfig.CooldownSeconds }
end)
DevXRegisterCallback('casino:slots', function(source, payload)
local player = exports.devx_core:GetPlayer(source); local bet = amount(payload.bet, 100, 5000)
if not player or not bet then return { ok = false, error = 'Ungültiger Einsatz.' } end
local removed = removeChips(player.characterId, bet)
if not removed then return { ok = false, error = 'Nicht genügend Chips.' } end
local symbols = { '7', 'BAR', 'KIRSCHE', 'DIAMANT', 'GLOCKE', 'ZITRONE' }
local roll = { symbols[math.random(#symbols)], symbols[math.random(#symbols)], symbols[math.random(#symbols)] }
local payout = 0
if roll[1] == roll[2] and roll[2] == roll[3] then payout = bet * (roll[1] == '7' and 12 or 7)
elseif roll[1] == roll[2] or roll[2] == roll[3] or roll[1] == roll[3] then payout = bet * 2 end
if payout > 0 then addChips(player.characterId, payout) end
return { ok = true, symbols = roll, payout = payout, chips = wallet(player.characterId) }
end)
local redNumbers = { [1]=true,[3]=true,[5]=true,[7]=true,[9]=true,[12]=true,[14]=true,[16]=true,[18]=true,[19]=true,[21]=true,[23]=true,[25]=true,[27]=true,[30]=true,[32]=true,[34]=true,[36]=true }
DevXRegisterCallback('casino:roulette', function(source, payload)
local player = exports.devx_core:GetPlayer(source); local bet = amount(payload.bet, 100, 10000); local choice = payload.choice
if not player or not bet or (choice ~= 'red' and choice ~= 'black' and choice ~= 'green') then return { ok = false, error = 'Ungültiger Einsatz.' } end
local removed = removeChips(player.characterId, bet); if not removed then return { ok = false, error = 'Nicht genügend Chips.' } end
local number = math.random(0, 36); local color = number == 0 and 'green' or (redNumbers[number] and 'red' or 'black')
local payout = color == choice and bet * (color == 'green' and 14 or 2) or 0
if payout > 0 then addChips(player.characterId, payout) end
return { ok = true, number = number, color = color, payout = payout, chips = wallet(player.characterId) }
end)
DevXRegisterCallback('casino:blackjackStart', function(source, payload)
local player = exports.devx_core:GetPlayer(source); local bet = amount(payload.bet, 100, 10000)
if not player or not bet then return { ok = false, error = 'Ungültiger Einsatz.' } end
if blackjackSessions[source] then return { ok = false, error = 'Du spielst bereits eine Runde.' } end
local removed = removeChips(player.characterId, bet); if not removed then return { ok = false, error = 'Nicht genügend Chips.' } end
local session = { characterId = player.characterId, bet = bet, player = { card(), card() }, dealer = { card(), card() } }
blackjackSessions[source] = session
local pv, dv = handValue(session.player), handValue(session.dealer)
if pv == 21 and dv == 21 then return settleBlackjack(source, session, 'push') end
if pv == 21 then return settleBlackjack(source, session, 'blackjack') end
if dv == 21 then return settleBlackjack(source, session, 'lose') end
return blackjackPayload(session, false, nil)
end)
DevXRegisterCallback('casino:blackjackAction', function(source, payload)
local session = blackjackSessions[source]
if not session then return { ok = false, error = 'Keine aktive Blackjack-Runde.' } end
if payload.action == 'hit' then
session.player[#session.player + 1] = card()
local value = handValue(session.player)
if value > 21 then return settleBlackjack(source, session, 'bust') end
if value == 21 then payload.action = 'stand' else return blackjackPayload(session, false, nil) end
end
if payload.action == 'stand' then
while handValue(session.dealer) < 17 do session.dealer[#session.dealer + 1] = card() end
local pv, dv = handValue(session.player), handValue(session.dealer)
if dv > 21 or pv > dv then return settleBlackjack(source, session, 'win')
elseif pv == dv then return settleBlackjack(source, session, 'push')
else return settleBlackjack(source, session, 'lose') end
end
return { ok = false, error = 'Ungültige Aktion.' }
end)
RegisterCommand('resetwheel', function(source, args)
if source ~= 0 and not IsPlayerAceAllowed(source, 'devx.admin') then return end
local target = tonumber(args[1]) or source
local player = exports.devx_core:GetPlayer(target)
if not player then return end
MySQL.query.await('DELETE FROM devx_casino_daily WHERE character_id = ?', { player.characterId })
TriggerClientEvent('devx_casino:client:notify', target, 'Dein täglicher Glücksrad-Dreh wurde zurückgesetzt.')
end, false)
AddEventHandler('playerDropped', function()
spinLocks[source] = nil; gameLocks[source] = nil; blackjackSessions[source] = nil
end)
@@ -0,0 +1,25 @@
DevXCasinoConfig = {
ExteriorEntry = vector4(935.72, 46.86, 81.10, 146.0),
InteriorEntry = vector4(1089.70, 206.12, -49.00, 350.0),
InteriorExit = vector4(1085.83, 214.41, -49.20, 130.0),
Wheel = vector3(1111.12, 229.84, -49.64),
WheelModels = { 'vw_prop_vw_luckywheel_02a', 'vw_prop_vw_luckywheel_01a' },
Cashier = vector3(1117.48, 219.63, -49.44),
Slots = vector3(1114.65, 235.85, -49.64),
Roulette = vector3(1143.82, 264.58, -51.84),
Blackjack = vector3(1148.76, 269.20, -51.84),
CooldownSeconds = 86400,
Ipls = {
'hei_dlc_windows_casino', 'hei_dlc_casino_aircon', 'vw_dlc_casino_door',
'hei_dlc_casino_door', 'vw_casino_main', 'vw_casino_garage', 'vw_casino_carpark'
},
Npcs = {
{ model = 's_f_y_casino_01', coords = vector4(1117.52, 220.48, -49.44, 90.0), scenario = 'WORLD_HUMAN_CLIPBOARD' },
{ model = 's_m_y_casino_01', coords = vector4(1144.10, 264.25, -51.84, 180.0), scenario = 'WORLD_HUMAN_STAND_IMPATIENT' },
{ model = 's_m_y_casino_01', coords = vector4(1148.95, 268.82, -51.84, 180.0), scenario = 'WORLD_HUMAN_STAND_IMPATIENT' },
{ model = 'a_f_y_business_04', coords = vector4(1116.15, 233.75, -49.64, 110.0), scenario = 'WORLD_HUMAN_STAND_MOBILE' },
{ model = 'a_m_y_business_03', coords = vector4(1107.65, 229.25, -49.64, 280.0), scenario = 'WORLD_HUMAN_DRINKING' },
{ model = 'a_f_y_bevhills_02', coords = vector4(1126.85, 241.30, -49.64, 205.0), scenario = 'WORLD_HUMAN_PARTYING' },
{ model = 'a_m_m_bevhills_02', coords = vector4(1136.25, 250.90, -51.04, 50.0), scenario = 'WORLD_HUMAN_STAND_MOBILE' }
}
}
+11
View File
@@ -0,0 +1,11 @@
const resource=typeof GetParentResourceName==='function'?GetParentResourceName():'devx_casino';const app=document.querySelector('#app'),chips=document.querySelector('#chips'),message=document.querySelector('#message');const views=[...document.querySelectorAll('.view')];let blackjackActive=false;
async function post(name,data={}){try{const r=await fetch(`https://${resource}/${name}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});return await r.json()}catch{return{ok:false,error:'Keine Antwort vom Casino.'}}}
function money(v){return new Intl.NumberFormat('de-DE').format(Number(v)||0)}function setMessage(t){message.textContent=t||''}function setChips(v){chips.textContent=money(v)}function show(id){views.forEach(v=>v.classList.toggle('hidden',v.id!==id));document.querySelector('#title').textContent={cashier:'Casino-Kasse',slots:'Spielautomaten',roulette:'Roulette',blackjack:'Blackjack'}[id]||'Casino'}
async function refresh(){const r=await post('refresh');if(r.ok)setChips(r.chips);else setMessage(r.error)}
document.querySelector('#close').onclick=()=>post('close');document.addEventListener('keyup',e=>{if(e.key==='Escape')post('close')});document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>show(b.dataset.view));
async function exchange(direction){const amount=document.querySelector('#exchangeAmount').value;const r=await post('exchange',{direction,amount});setMessage(r.ok?'Buchung abgeschlossen.':r.error);if(r.ok)setChips(r.chips)}document.querySelector('#buyChips').onclick=()=>exchange('buy');document.querySelector('#sellChips').onclick=()=>exchange('sell');
document.querySelector('#slotPlay').onclick=async()=>{setMessage('Walzen drehen …');const r=await post('slots',{bet:document.querySelector('#slotBet').value});if(!r.ok){setMessage(r.error);return}document.querySelectorAll('#reels b').forEach((el,i)=>el.textContent=r.symbols[i]);setChips(r.chips);setMessage(r.payout>0?`Gewinn: ${money(r.payout)} Chips`:'Leider kein Gewinn.')};
document.querySelectorAll('[data-color]').forEach(b=>b.onclick=async()=>{const r=await post('roulette',{choice:b.dataset.color,bet:document.querySelector('#rouletteBet').value});if(!r.ok){setMessage(r.error);return}const ball=document.querySelector('#rouletteResult');ball.textContent=r.number;ball.style.background=r.color==='red'?'#9d2b37':r.color==='black'?'#17191e':'#26804a';setChips(r.chips);setMessage(r.payout>0?`Gewinn: ${money(r.payout)} Chips`:`${r.number} · ${r.color}`)});
function cards(list){return (list||[]).map(c=>c.label).join(' ')}function renderBlackjack(r){document.querySelector('#dealerCards').textContent=cards(r.dealerCards);document.querySelector('#playerCards').textContent=cards(r.playerCards);document.querySelector('#playerValue').textContent=r.playerValue?`${r.playerValue} Punkte`:'';setChips(r.chips);blackjackActive=Boolean(r.active);document.querySelector('#blackjackActions').classList.toggle('hidden',!blackjackActive);document.querySelector('#blackjackStart').disabled=blackjackActive;if(r.result)setMessage({win:'Gewonnen!',blackjack:'Blackjack!',push:'Unentschieden.',lose:'Dealer gewinnt.',bust:'Überkauft.'}[r.result]||r.result)}
document.querySelector('#blackjackStart').onclick=async()=>{const r=await post('blackjackStart',{bet:document.querySelector('#blackjackBet').value});if(!r.ok){setMessage(r.error);return}setMessage('');renderBlackjack(r)};document.querySelectorAll('[data-action]').forEach(b=>b.onclick=async()=>{const r=await post('blackjackAction',{action:b.dataset.action});if(!r.ok){setMessage(r.error);return}renderBlackjack(r)});
window.addEventListener('message',e=>{const d=e.data||{};if(d.action==='open'){app.classList.remove('hidden');setChips(d.status?.chips||0);show(d.mode==='lobby'?'cashier':d.mode||'cashier');setMessage('')}if(d.action==='close')app.classList.add('hidden')});
@@ -0,0 +1,9 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="style.css"></head><body>
<main id="app" class="hidden"><section class="card"><button id="close">×</button><small>THE DIAMOND CASINO & RESORT</small><h1 id="title">Casino</h1><div class="wallet"><span>CHIPS</span><strong id="chips">0</strong></div>
<nav><button data-view="cashier">Kasse</button><button data-view="slots">Slots</button><button data-view="roulette">Roulette</button><button data-view="blackjack">Blackjack</button></nav>
<p id="message"></p>
<section id="cashier" class="view"><h2>Casino-Kasse</h2><input id="exchangeAmount" type="number" min="100" step="100" value="1000"><div class="row"><button id="buyChips">Chips kaufen</button><button id="sellChips">Chips einlösen</button></div></section>
<section id="slots" class="view hidden"><h2>Spielautomat</h2><div id="reels" class="reels"><b>?</b><b>?</b><b>?</b></div><input id="slotBet" type="number" min="100" max="5000" step="100" value="500"><button id="slotPlay">Drehen</button></section>
<section id="roulette" class="view hidden"><h2>Roulette</h2><div id="rouletteResult" class="roulette-result"></div><input id="rouletteBet" type="number" min="100" max="10000" step="100" value="500"><div class="row three"><button data-color="red">Rot</button><button data-color="black">Schwarz</button><button data-color="green">Grün</button></div></section>
<section id="blackjack" class="view hidden"><h2>Blackjack</h2><div class="hands"><div><span>Dealer</span><strong id="dealerCards"></strong></div><div><span>Du</span><strong id="playerCards"></strong><small id="playerValue"></small></div></div><input id="blackjackBet" type="number" min="100" max="10000" step="100" value="500"><button id="blackjackStart">Runde starten</button><div id="blackjackActions" class="row hidden"><button data-action="hit">Karte</button><button data-action="stand">Halten</button></div></section>
</section></main><script src="app.js"></script></body></html>
@@ -0,0 +1 @@
:root{font-family:Inter,system-ui,sans-serif;color:#fff}*{box-sizing:border-box}body{margin:0;background:transparent;overflow:hidden}.hidden{display:none!important}button,input{font:inherit}#app{position:fixed;inset:0;display:grid;place-items:center;background:rgba(3,4,8,.52);backdrop-filter:blur(4px)}.card{position:relative;width:min(680px,calc(100vw - 70px));padding:28px;border:1px solid rgba(255,255,255,.14);border-radius:22px;background:linear-gradient(160deg,#171421,#0c0d13);box-shadow:0 35px 100px rgba(0,0,0,.65)}#close{position:absolute;right:18px;top:16px;border:0;background:transparent;color:white;font-size:28px;cursor:pointer}.card>small{color:#d8b66e;font-weight:900;letter-spacing:.18em}.card h1{margin:5px 0 16px}.wallet{position:absolute;right:58px;top:22px;text-align:right}.wallet span{display:block;font-size:9px;color:rgba(255,255,255,.55);letter-spacing:.13em}.wallet strong{font-size:22px;color:#f0cc80}nav{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:15px 0}button{border:1px solid rgba(255,255,255,.11);border-radius:10px;padding:11px;color:white;background:rgba(255,255,255,.06);cursor:pointer;font-weight:800}button:hover{background:rgba(213,176,101,.17);border-color:#d6b267}button:disabled{opacity:.45;cursor:not-allowed}.view{min-height:290px;padding:18px;border-radius:15px;background:rgba(255,255,255,.035)}.view h2{margin:0 0 18px}input{width:100%;padding:12px;margin-bottom:10px;border:1px solid rgba(255,255,255,.12);border-radius:10px;color:white;background:rgba(0,0,0,.25);outline:0}.row{display:grid;grid-template-columns:1fr 1fr;gap:10px}.row.three{grid-template-columns:repeat(3,1fr)}#message{min-height:22px;color:#f0cc80}.reels{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:18px 0}.reels b{height:90px;display:grid;place-items:center;border-radius:14px;background:#f7f1e6;color:#191720;font-size:22px}.roulette-result{height:90px;margin:18px 0;display:grid;place-items:center;border-radius:50%;width:90px;background:#333;font-size:28px;font-weight:900}.hands{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:15px}.hands>div{padding:15px;border-radius:12px;background:rgba(255,255,255,.06)}.hands span,.hands small{display:block;color:rgba(255,255,255,.55);font-size:10px}.hands strong{display:block;margin:8px 0;font-size:20px}
@@ -0,0 +1,678 @@
local selectorOpen = false
local selectorInitialized = false
local previewPed = nil
local previewCamera = nil
local previewModel = nil
local introRunning = false
local introCamera = nil
local introDriver = nil
local introVehicle = nil
local function notify(message)
BeginTextCommandThefeedPost('STRING')
AddTextComponentSubstringPlayerName(message)
EndTextCommandThefeedPostTicker(false, false)
end
local function runNuiRpc(name, data, nuiCallback, afterResponse)
local replied = false
local function reply(result)
if replied then return end
replied = true
result = result or { ok = false, error = 'Leere Antwort vom Clientscript.' }
if afterResponse then afterResponse(result) end
nuiCallback(result)
end
local ok, errorMessage = pcall(function()
DevXTriggerCallback(name, data or {}, reply)
end)
if not ok then
print(('[devx_characters] Client-RPC %s konnte nicht gestartet werden: %s'):format(
name, tostring(errorMessage)
))
reply({ ok = false, error = ('Clientscriptfehler bei %s. Öffne F8.'):format(name) })
return
end
SetTimeout(24000, function()
reply({ ok = false, error = ('Keine Antwort auf %s. Prüfe F8 und txAdmin.'):format(name) })
end)
end
local function loadModel(model)
local hash = type(model) == 'number' and model or GetHashKey(model)
if not IsModelInCdimage(hash) or not IsModelValid(hash) then return nil end
RequestModel(hash)
local deadline = GetGameTimer() + 10000
while not HasModelLoaded(hash) and GetGameTimer() < deadline do Wait(0) end
return HasModelLoaded(hash) and hash or nil
end
local function loadAnimDict(dict)
RequestAnimDict(dict)
local deadline = GetGameTimer() + 7000
while not HasAnimDictLoaded(dict) and GetGameTimer() < deadline do Wait(0) end
return HasAnimDictLoaded(dict)
end
local function safeDeleteEntity(entity)
if entity and DoesEntityExist(entity) then
SetEntityAsMissionEntity(entity, true, true)
DeleteEntity(entity)
end
end
local function applyBasicAppearance(ped, modelName)
if not ped or not DoesEntityExist(ped) then return end
SetPedDefaultComponentVariation(ped)
ClearAllPedProps(ped)
if modelName == 'mp_m_freemode_01' or modelName == 'mp_f_freemode_01' then
SetPedHeadBlendData(ped, 0, 0, 0, 0, 0, 0, 0.5, 0.5, 0.0, false)
SetPedComponentVariation(ped, 0, 0, 0, 2)
SetPedHairColor(ped, 1, 1)
SetPedEyeColor(ped, 2)
end
end
local function destroyPreviewScene()
if previewCamera and DoesCamExist(previewCamera) then
RenderScriptCams(false, true, 350, true, true)
DestroyCam(previewCamera, false)
end
previewCamera = nil
safeDeleteEntity(previewPed)
previewPed = nil
previewModel = nil
ClearFocus()
end
local function createPreviewCharacter(modelName)
if not selectorOpen then return end
modelName = modelName or 'mp_m_freemode_01'
if previewPed and DoesEntityExist(previewPed) and previewModel == modelName then return end
local model = loadModel(modelName)
if not model then
modelName = 'mp_m_freemode_01'
model = loadModel(modelName)
end
if not model then
SendNUIMessage({ action = 'error', message = 'Vorschaumodell konnte nicht geladen werden.' })
return
end
safeDeleteEntity(previewPed)
local position = DevXCharacterConfig.Preview.pedPosition
SetFocusPosAndVel(position.x, position.y, position.z, 0.0, 0.0, 0.0)
previewPed = CreatePed(4, model, position.x, position.y, position.z, position.heading, false, false)
if not DoesEntityExist(previewPed) then
SetModelAsNoLongerNeeded(model)
SendNUIMessage({ action = 'error', message = 'Charaktervorschau konnte nicht erzeugt werden.' })
return
end
previewModel = modelName
SetEntityInvincible(previewPed, true)
SetEntityCollision(previewPed, false, false)
FreezeEntityPosition(previewPed, true)
SetBlockingOfNonTemporaryEvents(previewPed, true)
SetPedCanRagdoll(previewPed, false)
applyBasicAppearance(previewPed, modelName)
if loadAnimDict('anim@amb@business@bgen@bgen_no_work@') then
TaskPlayAnim(previewPed, 'anim@amb@business@bgen@bgen_no_work@', 'stand_phone_phoneputdown_idle_nowork',
3.0, -3.0, -1, 1, 0.0, false, false, false)
end
local cameraPosition = DevXCharacterConfig.Preview.cameraPosition
if not previewCamera or not DoesCamExist(previewCamera) then
previewCamera = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
SetCamCoord(previewCamera, cameraPosition.x, cameraPosition.y, cameraPosition.z)
SetCamFov(previewCamera, DevXCharacterConfig.Preview.cameraFov or 32.0)
SetCamActive(previewCamera, true)
RenderScriptCams(true, true, 450, true, true)
end
PointCamAtEntity(previewCamera, previewPed, 0.0, 0.0, 0.72, true)
SetModelAsNoLongerNeeded(model)
end
local function prepareSelectorScene()
local stream = DevXCharacterConfig.Preview.streamPosition
local ped = PlayerPedId()
SetEntityCoordsNoOffset(ped, stream.x, stream.y, stream.z, false, false, false)
SetEntityHeading(ped, stream.heading)
SetEntityVisible(ped, false, false)
SetEntityCollision(ped, false, false)
FreezeEntityPosition(ped, true)
createPreviewCharacter('mp_m_freemode_01')
end
local function setSelector(open)
selectorOpen = open
SetNuiFocus(open, open)
SendNUIMessage({ action = open and 'open' or 'close' })
local ped = PlayerPedId()
if open then
DisplayRadar(false)
prepareSelectorScene()
else
destroyPreviewScene()
if DoesEntityExist(ped) then
SetEntityVisible(ped, true, false)
SetEntityCollision(ped, true, true)
FreezeEntityPosition(ped, false)
end
end
end
local function requestCharacters()
DevXTriggerCallback('characters:list', {}, function(result)
if not result.ok then
SendNUIMessage({ action = 'error', message = result.error })
return
end
SendNUIMessage({
action = 'characters',
characters = result.characters,
maxCharacters = result.maxCharacters
})
end)
end
local function showDialogue(speaker, text)
SendNUIMessage({ action = 'dialogue', speaker = speaker, text = text })
end
local function hideDialogue()
SendNUIMessage({ action = 'dialogueHide' })
end
local function showSkipHelp()
BeginTextCommandDisplayHelp('STRING')
AddTextComponentSubstringPlayerName('Begrüßung überspringen: ~INPUT_JUMP~')
EndTextCommandDisplayHelp(0, false, true, -1)
end
local function introControls()
showSkipHelp()
DisableControlAction(0, 24, true)
DisableControlAction(0, 25, true)
DisableControlAction(0, 37, true)
DisableControlAction(0, 75, true)
return IsControlJustReleased(0, 22)
end
local function waitIntro(milliseconds)
local deadline = GetGameTimer() + milliseconds
while introRunning and GetGameTimer() < deadline do
if introControls() then return false end
Wait(0)
end
return introRunning
end
local function waitUntilIntro(predicate, timeoutMs)
local deadline = GetGameTimer() + timeoutMs
while introRunning and GetGameTimer() < deadline do
if introControls() then return 'skip' end
if predicate() then return 'ready' end
Wait(0)
end
return 'timeout'
end
local function setIntroCamera(position, target, fov, transition)
if introCamera and DoesCamExist(introCamera) then
DestroyCam(introCamera, false)
end
introCamera = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
SetCamCoord(introCamera, position.x, position.y, position.z)
PointCamAtCoord(introCamera, target.x, target.y, target.z)
SetCamFov(introCamera, fov or 48.0)
SetCamActive(introCamera, true)
RenderScriptCams(true, transition == true, transition and 700 or 0, true, true)
end
local function resolveRoadPosition(point)
local ok, found, node, heading = pcall(function()
local success, position, roadHeading = GetClosestVehicleNodeWithHeading(
point.x, point.y, point.z, 1, 3.0, 0
)
return success, position, roadHeading
end)
if ok and found and node then
return { x = node.x, y = node.y, z = node.z, heading = heading or point.heading }
end
return point
end
local function playGesture(ped, animation)
local dict = 'gestures@m@standing@casual'
if not loadAnimDict(dict) then return end
TaskPlayAnim(ped, dict, animation, 4.0, -4.0, 2600, 48, 0.0, false, false, false)
end
local function walkTo(ped, target, timeoutMs)
TaskFollowNavMeshToCoord(ped, target.x, target.y, target.z, 1.0, timeoutMs, 0.3, false, target.heading or 0.0)
local result = waitUntilIntro(function()
return #(GetEntityCoords(ped) - vector3(target.x, target.y, target.z)) < 1.4
end, timeoutMs)
if result == 'timeout' and DoesEntityExist(ped) then
SetEntityCoordsNoOffset(ped, target.x, target.y, target.z, false, false, false)
SetEntityHeading(ped, target.heading or GetEntityHeading(ped))
end
return result
end
local function finishWelcomeIntro(markCompleted)
introRunning = false
hideDialogue()
DoScreenFadeOut(350)
local fadeDeadline = GetGameTimer() + 1400
while not IsScreenFadedOut() and GetGameTimer() < fadeDeadline do Wait(0) end
if introCamera and DoesCamExist(introCamera) then
RenderScriptCams(false, false, 0, true, true)
DestroyCam(introCamera, false)
end
introCamera = nil
local playerPed = PlayerPedId()
local finalSpawn = DevXCharacterConfig.Intro.finalSpawn
SetEntityCoordsNoOffset(playerPed, finalSpawn.x, finalSpawn.y, finalSpawn.z, false, false, false)
SetEntityHeading(playerPed, finalSpawn.heading)
SetEntityVisible(playerPed, true, false)
SetEntityCollision(playerPed, true, true)
SetEntityInvincible(playerPed, false)
FreezeEntityPosition(playerPed, false)
ClearPedTasksImmediately(playerPed)
safeDeleteEntity(introDriver)
safeDeleteEntity(introVehicle)
introDriver = nil
introVehicle = nil
DisplayRadar(true)
ClearFocus()
TriggerEvent('devx_hud:client:setVisible', true)
TriggerServerEvent('devx_core:server:updatePosition', finalSpawn)
if markCompleted then
DevXTriggerCallback('characters:completeIntro', {}, function(result)
if not result.ok then
print(('[devx_characters] Introstatus konnte nicht gespeichert werden: %s'):format(
tostring(result.error)
))
end
end)
end
DoScreenFadeIn(600)
notify('Willkommen in DevX-City. Dein neues Leben beginnt jetzt.')
end
local function startFollowCamera(ped)
local config = DevXCharacterConfig.Intro.followCamera
if introCamera and DoesCamExist(introCamera) then
DestroyCam(introCamera, false)
end
introCamera = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
AttachCamToEntity(
introCamera,
ped,
config.offset.x, config.offset.y, config.offset.z,
true
)
PointCamAtEntity(
introCamera,
ped,
config.lookAtOffset.x, config.lookAtOffset.y, config.lookAtOffset.z,
true
)
SetCamFov(introCamera, config.fov or 48.0)
SetCamActive(introCamera, true)
RenderScriptCams(true, true, 600, true, true)
end
local function parkIntroVehicle(vehicle, position)
SetEntityCoordsNoOffset(vehicle, position.x, position.y, position.z, false, false, false)
SetEntityHeading(vehicle, position.heading)
SetVehicleOnGroundProperly(vehicle)
SetVehicleEngineOn(vehicle, false, true, false)
SetVehicleHandbrake(vehicle, true)
FreezeEntityPosition(vehicle, true)
end
local function startWelcomeIntro()
if introRunning then return end
introRunning = true
local config = DevXCharacterConfig.Intro
local playerPed = PlayerPedId()
local driverModel = loadModel(config.driverModel)
local vehicleModel = loadModel(config.vehicleModel)
if not driverModel or not vehicleModel then
finishWelcomeIntro(true)
return
end
TriggerEvent('devx_hud:client:setVisible', false)
SetNuiFocus(false, false)
DisplayRadar(false)
SetEntityInvincible(playerPed, true)
FreezeEntityPosition(playerPed, false)
local parked = config.vehicleParked
introVehicle = CreateVehicle(
vehicleModel, parked.x, parked.y, parked.z,
parked.heading, false, false
)
introDriver = CreatePed(
4, driverModel,
config.driverWait.x, config.driverWait.y, config.driverWait.z,
config.driverWait.heading, false, false
)
SetModelAsNoLongerNeeded(driverModel)
SetModelAsNoLongerNeeded(vehicleModel)
if not DoesEntityExist(introVehicle) or not DoesEntityExist(introDriver) then
finishWelcomeIntro(true)
return
end
SetEntityAsMissionEntity(introVehicle, true, true)
SetEntityAsMissionEntity(introDriver, true, true)
SetEntityInvincible(introVehicle, true)
SetEntityInvincible(introDriver, true)
SetBlockingOfNonTemporaryEvents(introDriver, true)
SetPedCanRagdoll(introDriver, false)
SetVehicleDoorsLocked(introVehicle, 1)
parkIntroVehicle(introVehicle, parked)
startFollowCamera(playerPed)
DoScreenFadeIn(700)
if not waitIntro(900) then finishWelcomeIntro(true) return end
TaskFollowNavMeshToCoord(
playerPed,
config.terminalExit.x, config.terminalExit.y, config.terminalExit.z,
1.0, 17000, 0.2, false, config.terminalExit.heading
)
local walkResult = waitUntilIntro(function()
return #(GetEntityCoords(playerPed) - vector3(
config.terminalExit.x, config.terminalExit.y, config.terminalExit.z
)) < 1.5
end, 17000)
if walkResult == 'skip' then finishWelcomeIntro(true) return end
if walkResult == 'timeout' then
SetEntityCoordsNoOffset(
playerPed,
config.terminalExit.x, config.terminalExit.y, config.terminalExit.z,
false, false, false
)
SetEntityHeading(playerPed, config.terminalExit.heading)
end
local playerWalk = walkTo(playerPed, config.meetingPoint, 9000)
if playerWalk == 'skip' then finishWelcomeIntro(true) return end
TaskGoStraightToCoord(
introDriver,
config.meetingPoint.x + 1.30,
config.meetingPoint.y - 0.10,
config.meetingPoint.z,
1.0, 7000, config.meetingPoint.heading + 180.0, 0.15
)
local driverResult = waitUntilIntro(function()
return #(GetEntityCoords(introDriver) - vector3(
config.meetingPoint.x + 1.30,
config.meetingPoint.y - 0.10,
config.meetingPoint.z
)) < 1.4
end, 7000)
if driverResult == 'skip' then finishWelcomeIntro(true) return end
if driverResult == 'timeout' then
SetEntityCoordsNoOffset(
introDriver,
config.meetingPoint.x + 1.30,
config.meetingPoint.y - 0.10,
config.meetingPoint.z,
false, false, false
)
end
local greetingCamera = config.greetingCamera
setIntroCamera(greetingCamera, {
x = config.meetingPoint.x,
y = config.meetingPoint.y,
z = config.meetingPoint.z + 0.90
}, 43.0, true)
TaskTurnPedToFaceEntity(playerPed, introDriver, 1200)
TaskTurnPedToFaceEntity(introDriver, playerPed, 1200)
if not waitIntro(1200) then finishWelcomeIntro(true) return end
PlayAmbientSpeech1(introDriver, 'GENERIC_HI', 'SPEECH_PARAMS_FORCE')
playGesture(introDriver, 'gesture_hello')
showDialogue(config.driverName, 'Hey! Du musst neu hier sein. Willkommen in DevX-City.')
if not waitIntro(4300) then finishWelcomeIntro(true) return end
playGesture(introDriver, 'gesture_hand_right')
showDialogue(config.driverName, 'Ich bin Mason. Mein Wagen steht schon bereit ich bringe dich direkt ins Zentrum.')
if not waitIntro(5000) then finishWelcomeIntro(true) return end
playGesture(playerPed, 'gesture_nod_yes_hard')
showDialogue('Du', 'Danke. Ich kenne mich hier noch überhaupt nicht aus.')
if not waitIntro(3700) then finishWelcomeIntro(true) return end
playGesture(introDriver, 'gesture_point')
showDialogue(config.driverName, 'Dann komm. Unterwegs erkläre ich dir die wichtigsten Dinge.')
if not waitIntro(4100) then finishWelcomeIntro(true) return end
hideDialogue()
ClearPedTasks(playerPed)
ClearPedTasks(introDriver)
FreezeEntityPosition(introVehicle, false)
SetVehicleHandbrake(introVehicle, false)
SetVehicleEngineOn(introVehicle, true, true, false)
SetVehicleDoorsLocked(introVehicle, 1)
TaskEnterVehicle(introDriver, introVehicle, 12000, -1, 1.0, 1, 0)
TaskEnterVehicle(playerPed, introVehicle, 12000, 0, 1.0, 1, 0)
local enterResult = waitUntilIntro(function()
return IsPedInVehicle(introDriver, introVehicle, false)
and IsPedInVehicle(playerPed, introVehicle, false)
end, 12000)
if enterResult == 'skip' then finishWelcomeIntro(true) return end
if enterResult ~= 'ready' then
SetPedIntoVehicle(introDriver, introVehicle, -1)
SetPedIntoVehicle(playerPed, introVehicle, 0)
end
if introCamera and DoesCamExist(introCamera) then
RenderScriptCams(false, true, 900, true, true)
DestroyCam(introCamera, false)
introCamera = nil
end
TaskVehicleDriveToCoordLongrange(
introDriver, introVehicle,
config.destinationRoad.x, config.destinationRoad.y, config.destinationRoad.z,
config.driveSpeed, config.drivingStyle, 10.0
)
showDialogue(config.driverName, 'Am Anfang startest du klein. Arbeit, Kontakte und ein eigenes Zuhause kommen Schritt für Schritt.')
if not waitIntro(6200) then finishWelcomeIntro(true) return end
showDialogue(config.driverName, 'Mit M öffnest du dein Handy. Dort findest du deine Telefonnummer und dein Bankkonto.')
if not waitIntro(5600) then finishWelcomeIntro(true) return end
hideDialogue()
local driveDeadline = GetGameTimer() + config.maxDriveTimeMs
while introRunning and GetGameTimer() < driveDeadline do
if introControls() then finishWelcomeIntro(true) return end
if not DoesEntityExist(introVehicle) or not DoesEntityExist(introDriver) then break end
local coords = GetEntityCoords(introVehicle)
if #(coords - vector3(config.destinationRoad.x, config.destinationRoad.y, config.destinationRoad.z)) < 28.0 then
break
end
Wait(0)
end
if not introRunning then return end
TaskVehicleTempAction(introDriver, introVehicle, 27, 2500)
showDialogue(config.driverName, 'Da wären wir. Willkommen im Zentrum von DevX-City und viel Erfolg!')
if not waitIntro(4700) then finishWelcomeIntro(true) return end
finishWelcomeIntro(true)
end
local function spawnCharacter(player)
local shouldPlayIntro = DevXCharacterConfig.Intro.enabled and player.introCompleted ~= true
local position = shouldPlayIntro
and DevXCharacterConfig.Intro.terminalInside
or player.position
or DevXConfig.StreetSpawn
local modelName = player.model or 'mp_m_freemode_01'
local model = loadModel(modelName)
if not model then
SendNUIMessage({ action = 'error', message = 'Charaktermodell konnte nicht geladen werden.' })
return
end
DoScreenFadeOut(250)
while not IsScreenFadedOut() do Wait(0) end
exports.spawnmanager:spawnPlayer({
x = position.x,
y = position.y,
z = position.z,
heading = position.heading or 0.0,
model = model,
skipFade = true
}, function()
SetModelAsNoLongerNeeded(model)
setSelector(false)
local ped = PlayerPedId()
applyBasicAppearance(ped, modelName)
SetEntityCollision(ped, true, true)
SetEntityVisible(ped, true, false)
FreezeEntityPosition(ped, false)
TriggerEvent('devx_characters:client:spawned', player)
if shouldPlayIntro then
CreateThread(startWelcomeIntro)
else
DisplayRadar(true)
DoScreenFadeIn(500)
end
end)
end
local function initializeSelector()
if selectorInitialized then return end
selectorInitialized = true
exports.spawnmanager:setAutoSpawn(false)
while not NetworkIsSessionStarted() do Wait(100) end
local spawn = DevXCharacterConfig.Preview.streamPosition
local model = loadModel('mp_m_freemode_01') or loadModel('a_m_y_business_01')
if not model then return end
DoScreenFadeOut(0)
local initialSpawnFinished = false
local function finishInitialization()
if initialSpawnFinished then return end
initialSpawnFinished = true
SetModelAsNoLongerNeeded(model)
setSelector(true)
requestCharacters()
ShutdownLoadingScreen()
ShutdownLoadingScreenNui()
DoScreenFadeIn(500)
end
exports.spawnmanager:spawnPlayer({
x = spawn.x,
y = spawn.y,
z = spawn.z,
heading = spawn.heading,
model = model,
skipFade = true
}, finishInitialization)
SetTimeout(12000, finishInitialization)
end
RegisterNUICallback('ready', function(_, cb)
requestCharacters()
cb({ ok = true })
end)
RegisterNUICallback('preview', function(data, cb)
createPreviewCharacter(data.model or (data.sex == 'f' and 'mp_f_freemode_01' or 'mp_m_freemode_01'))
cb({ ok = true })
end)
RegisterNUICallback('create', function(data, cb)
runNuiRpc('characters:create', data, cb, function(result)
if result.ok then requestCharacters() end
end)
end)
RegisterNUICallback('select', function(data, cb)
runNuiRpc('characters:select', data, cb, function(result)
if result.ok then spawnCharacter(result.player) end
end)
end)
RegisterNUICallback('delete', function(data, cb)
runNuiRpc('characters:delete', data, cb, function(result)
if result.ok then requestCharacters() end
end)
end)
CreateThread(initializeSelector)
AddEventHandler('onClientResourceStart', function(resourceName)
if resourceName ~= GetCurrentResourceName() then return end
exports.spawnmanager:setAutoSpawn(false)
CreateThread(initializeSelector)
end)
AddEventHandler('onResourceStop', function(resourceName)
if resourceName ~= GetCurrentResourceName() then return end
introRunning = false
destroyPreviewScene()
safeDeleteEntity(introDriver)
safeDeleteEntity(introVehicle)
SetNuiFocus(false, false)
end)
@@ -0,0 +1,37 @@
fx_version 'cerulean'
game 'gta5'
author 'Colin-Joel / DevX Studios'
description 'DevX RP character selection, preview and first-arrival intro'
version '0.4.3'
lua54 'yes'
shared_scripts {
'@devx_core/shared/config.lua',
'shared/config.lua'
}
ui_page 'web/index.html'
files {
'web/index.html',
'web/style.css',
'web/app.js'
}
client_scripts {
'@devx_core/client/callbacks.lua',
'client/main.lua'
}
server_scripts {
'@oxmysql/lib/MySQL.lua',
'@devx_core/server/callbacks.lua',
'server/main.lua'
}
dependencies {
'devx_core',
'spawnmanager'
}
@@ -0,0 +1,253 @@
local function cleanName(value)
if type(value) ~= 'string' then return nil end
value = value:gsub('^%s+', ''):gsub('%s+$', '')
if #value < 2 or #value > 32 then return nil end
if not value:match("^[%aÀ-ÿ'%- ]+$") then return nil end
return value
end
local function validDate(value)
if type(value) ~= 'string' then return false end
local year, month, day = value:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$')
year, month, day = tonumber(year), tonumber(month), tonumber(day)
if not year or year < 1900 or year > 2010 then return false end
if not month or month < 1 or month > 12 then return false end
if not day or day < 1 or day > 31 then return false end
local daysInMonth = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
local leapYear = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0)
if leapYear then daysInMonth[2] = 29 end
return day <= daysInMonth[month]
end
local function normalizeDate(value)
if type(value) == 'number' then
local seconds = value > 100000000000 and math.floor(value / 1000) or math.floor(value)
return os.date('!%Y-%m-%d', seconds)
end
local text = tostring(value or '')
local numeric = tonumber(text)
if numeric then
local seconds = numeric > 100000000000 and math.floor(numeric / 1000) or math.floor(numeric)
return os.date('!%Y-%m-%d', seconds)
end
return text:match('^(%d%d%d%d%-%d%d%-%d%d)') or text
end
local function accountNumberFor(characterId)
return ('LS%010d'):format(tonumber(characterId))
end
local function phoneNumberFor(characterId)
return ('%s-%04d'):format(DevXConfig.PhonePrefix or '555', tonumber(characterId))
end
local function decodeMetadata(value)
if type(value) ~= 'string' or value == '' then return {} end
local ok, result = pcall(json.decode, value)
return ok and type(result) == 'table' and result or {}
end
local function listCharacters(license)
local rows = MySQL.query.await([[
SELECT id, slot, first_name AS firstName, last_name AS lastName,
date_of_birth AS dateOfBirth, sex, model, phone_number AS phoneNumber, metadata
FROM devx_characters
WHERE owner_license = ? AND deleted_at IS NULL
ORDER BY slot ASC
]], { license }) or {}
for _, character in ipairs(rows) do
local metadata = decodeMetadata(character.metadata)
character.id = tonumber(character.id)
character.slot = tonumber(character.slot)
character.introCompleted = metadata.introCompleted == true
character.metadata = nil
character.dateOfBirth = normalizeDate(character.dateOfBirth)
if not character.phoneNumber or character.phoneNumber == '' then
character.phoneNumber = phoneNumberFor(character.id)
MySQL.update.await(
'UPDATE devx_characters SET phone_number = ? WHERE id = ?',
{ character.phoneNumber, character.id }
)
end
end
return rows
end
DevXRegisterCallback('characters:list', function(source)
local license = exports.devx_core:GetLicense(source)
if not license then return { ok = false, error = 'Keine Lizenzkennung.' } end
return {
ok = true,
characters = listCharacters(license),
maxCharacters = DevXConfig.MaxCharacters
}
end)
DevXRegisterCallback('characters:create', function(source, payload)
local license = exports.devx_core:GetLicense(source)
if not license then return { ok = false, error = 'Keine Lizenzkennung.' } end
local firstName = cleanName(payload.firstName)
local lastName = cleanName(payload.lastName)
local sex = payload.sex
local dateOfBirth = payload.dateOfBirth
if not firstName or not lastName then
return { ok = false, error = 'Vor- und Nachname sind ungültig.' }
end
if sex ~= 'm' and sex ~= 'f' and sex ~= 'x' then
return { ok = false, error = 'Ungültige Geschlechtsangabe.' }
end
if not validDate(dateOfBirth) then
return { ok = false, error = 'Geburtsdatum ist ungültig.' }
end
local existing = listCharacters(license)
if #existing >= DevXConfig.MaxCharacters then
return { ok = false, error = 'Alle Charakterplätze sind belegt.' }
end
local used = {}
for _, character in ipairs(existing) do used[tonumber(character.slot)] = true end
local slot = 1
while used[slot] do slot = slot + 1 end
local model = sex == 'f' and 'mp_f_freemode_01' or 'mp_m_freemode_01'
local metadata = json.encode({ introCompleted = false, rpXp = 0 })
local characterId = tonumber(MySQL.insert.await([[
INSERT INTO devx_characters
(owner_license, slot, first_name, last_name, date_of_birth, sex, model, cash, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
]], {
license, slot, firstName, lastName, dateOfBirth, sex, model,
DevXConfig.StartCash, metadata
}))
if not characterId or characterId < 1 then
return { ok = false, error = 'Charakter konnte nicht in der Datenbank erstellt werden.' }
end
local phoneNumber = phoneNumberFor(characterId)
MySQL.update.await('UPDATE devx_characters SET phone_number = ? WHERE id = ?', {
phoneNumber, characterId
})
local accountNumber = accountNumberFor(characterId)
local accountId = tonumber(MySQL.insert.await([[
INSERT INTO devx_bank_accounts
(character_id, account_number, account_name, account_type, balance)
VALUES (?, ?, ?, 'personal', ?)
]], {
characterId, accountNumber, ('%s %s'):format(firstName, lastName), DevXConfig.StartBank
}))
if not accountId or accountId < 1 then
MySQL.update.await('DELETE FROM devx_characters WHERE id = ?', { characterId })
return { ok = false, error = 'Bankkonto konnte nicht erstellt werden.' }
end
MySQL.insert.await([[
INSERT INTO devx_bank_transactions
(account_id, direction, amount, balance_after, counterparty, reference)
VALUES (?, 'credit', ?, ?, 'Stadtverwaltung', 'Startguthaben')
]], { accountId, DevXConfig.StartBank, DevXConfig.StartBank })
return {
ok = true,
characterId = characterId,
phoneNumber = phoneNumber,
characters = listCharacters(license)
}
end)
DevXRegisterCallback('characters:select', function(source, payload)
local characterId = tonumber(payload.characterId)
if not characterId then return { ok = false, error = 'Ungültiger Charakter.' } end
local player, errorMessage = exports.devx_core:ActivateCharacter(source, characterId)
if not player then return { ok = false, error = errorMessage } end
return { ok = true, player = player }
end)
DevXRegisterCallback('characters:completeIntro', function(source)
local player = exports.devx_core:GetPlayer(source)
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
local saved = exports.devx_core:SetMetadataValue(source, 'introCompleted', true)
if not saved then
return { ok = false, error = 'Introstatus konnte nicht gespeichert werden.' }
end
local finalSpawn = DevXCharacterConfig.Intro.finalSpawn
player.position = {
x = finalSpawn.x,
y = finalSpawn.y,
z = finalSpawn.z,
heading = finalSpawn.heading
}
MySQL.update.await(
'UPDATE devx_characters SET position = ? WHERE id = ?',
{ json.encode(player.position), player.characterId }
)
return { ok = true }
end)
DevXRegisterCallback('characters:delete', function(source, payload)
local license = exports.devx_core:GetLicense(source)
local characterId = tonumber(payload.characterId)
if not license or not characterId then
return { ok = false, error = 'Ungültige Anfrage.' }
end
local character = MySQL.single.await([[
SELECT id, first_name, last_name
FROM devx_characters
WHERE id = ? AND owner_license = ? AND deleted_at IS NULL
LIMIT 1
]], { characterId, license })
if not character then return { ok = false, error = 'Charakter wurde nicht gefunden.' } end
local ownedProperties = tonumber(MySQL.scalar.await([[
SELECT COUNT(*) FROM devx_property_owners WHERE character_id = ?
]], { characterId })) or 0
if ownedProperties > 0 then
return { ok = false, error = 'Verkaufe zuerst alle Wohnungen dieses Charakters.' }
end
local changed = MySQL.update.await([[
DELETE FROM devx_characters
WHERE id = ? AND owner_license = ?
]], { characterId, license })
if not changed or changed < 1 then
return { ok = false, error = 'Charakter konnte nicht gelöscht werden.' }
end
print(('[devx_characters] %s %s (ID %s) wurde von Lizenz %s gelöscht.'):format(
character.first_name, character.last_name, characterId, license
))
return { ok = true, characters = listCharacters(license) }
end)
RegisterCommand('resetintro', function(source, args)
if source ~= 0 and not IsPlayerAceAllowed(source, 'devx.admin') then return end
local target = tonumber(args[1]) or source
if not target or target == 0 then return end
local player = exports.devx_core:GetPlayer(target)
if not player then return end
exports.devx_core:SetMetadataValue(target, 'introCompleted', false)
end, false)
@@ -0,0 +1,39 @@
DevXCharacterConfig = {
Preview = {
streamPosition = { x = 402.90, y = -1001.05, z = -99.00, heading = 180.0 },
pedPosition = { x = 402.90, y = -997.05, z = -99.00, heading = 180.0 },
cameraPosition = { x = 402.90, y = -1000.05, z = -98.15 },
cameraFov = 32.0
},
Intro = {
enabled = true,
driverName = 'Mason',
driverModel = 'a_m_m_business_01',
vehicleModel = 'schafter2',
-- Der Spieler startet im Terminal und läuft automatisch nach draußen.
terminalInside = { x = -1042.75, y = -2745.65, z = 21.36, heading = 329.0 },
terminalExit = { x = -1038.10, y = -2738.20, z = 20.17, heading = 329.0 },
meetingPoint = { x = -1032.80, y = -2733.10, z = 20.17, heading = 238.0 },
-- Das Fahrzeug steht bereits am Straßenrand. Es fährt nicht erst auf das Flughafengelände.
vehicleParked = { x = -1024.72, y = -2729.45, z = 19.90, heading = 239.0 },
driverWait = { x = -1029.35, y = -2731.20, z = 20.05, heading = 61.0 },
-- Die Kamera ist während des Laufens am Spieler befestigt und folgt ihm.
followCamera = {
offset = { x = 2.35, y = -4.20, z = 1.65 },
lookAtOffset = { x = 0.0, y = 0.0, z = 0.75 },
fov = 48.0
},
greetingCamera = { x = -1035.65, y = -2729.15, z = 22.05 },
destinationRoad = { x = 231.52, y = -872.71, z = 29.95 },
finalSpawn = { x = 216.80, y = -884.15, z = 30.69, heading = 337.0 },
driveSpeed = 18.0,
drivingStyle = 786603,
maxDriveTimeMs = 120000
}
}
+275
View File
@@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
}[char]));
}
function message(text = '', type = 'error') {
messageEl.textContent = text;
messageEl.dataset.type = type;
messageEl.classList.toggle('hidden', !text);
}
function selectedCharacter() {
return characters.find(character => Number(character.id) === Number(selectedId)) || null;
}
function formatDate(value) {
if (typeof value === 'number' || /^\d{11,}$/.test(String(value || ''))) {
const date = new Date(Number(value));
if (!Number.isNaN(date.getTime())) return date.toLocaleDateString('de-DE');
}
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
return match ? `${match[3]}.${match[2]}.${match[1]}` : String(value || '');
}
function previewCharacter(character) {
const payload = character
? { model: character.model, sex: character.sex }
: { model: sexSelect.value === 'f' ? 'mp_f_freemode_01' : 'mp_m_freemode_01', sex: sexSelect.value };
post('preview', payload).catch(error => message(`Vorschaufehler: ${error.message}`));
}
function updateDetails() {
const character = selectedCharacter();
const hasCharacter = Boolean(character);
selectedName.textContent = hasCharacter ? `${character.firstName} ${character.lastName}` : 'Kein Charakter';
selectedDob.textContent = hasCharacter ? formatDate(character.dateOfBirth) : '';
selectedSlot.textContent = hasCharacter ? `Slot ${character.slot}` : '';
selectedPhone.textContent = hasCharacter ? (character.phoneNumber || 'Wird erstellt') : '';
selectedStatus.textContent = hasCharacter ? (character.introCompleted ? 'Einwohner' : 'Neue Ankunft') : '';
playButton.disabled = !hasCharacter;
deleteButton.disabled = !hasCharacter;
}
function selectCharacter(characterId, shouldPreview = true) {
selectedId = characterId == null ? null : Number(characterId);
document.querySelectorAll('.character-slot').forEach(card => {
card.classList.toggle('active', Number(card.dataset.characterId) === Number(selectedId));
});
updateDetails();
if (shouldPreview) previewCharacter(selectedCharacter());
}
function openCreator() {
if (characters.length >= maxCharacters) return;
message('');
createForm.reset();
createOverlay.classList.remove('hidden');
previewCharacter(null);
setTimeout(() => createForm.querySelector('input')?.focus(), 50);
}
function closeCreator() {
createOverlay.classList.add('hidden');
createForm.reset();
previewCharacter(selectedCharacter());
}
function openDelete() {
deleteTarget = selectedCharacter();
if (!deleteTarget) return;
document.querySelector('#deleteDescription').textContent = `${deleteTarget.firstName} ${deleteTarget.lastName} wird mit Bankkonto, Telefonnummer und Fortschritt dauerhaft gelöscht.`;
deleteConfirmInput.value = '';
confirmDeleteButton.disabled = true;
deleteOverlay.classList.remove('hidden');
setTimeout(() => deleteConfirmInput.focus(), 50);
}
function closeDelete() {
deleteOverlay.classList.add('hidden');
deleteTarget = null;
deleteConfirmInput.value = '';
}
function render() {
charactersEl.innerHTML = '';
slotCounter.textContent = `${characters.length} / ${maxCharacters}`;
for (let slot = 1; slot <= maxCharacters; slot += 1) {
const character = characters.find(entry => Number(entry.slot) === slot);
const card = document.createElement('button');
card.type = 'button';
card.className = `character-slot ${character ? '' : 'vacant'}`;
if (character) {
card.dataset.characterId = character.id;
card.innerHTML = `
<span class="slot-number">${String(slot).padStart(2, '0')}</span>
<span class="slot-content">
<strong>${escapeHtml(character.firstName)} ${escapeHtml(character.lastName)}</strong>
<small>${character.introCompleted ? 'Einwohner von DevX-City' : 'Neue Ankunft'}</small>
</span>
<span class="slot-arrow"></span>`;
card.addEventListener('click', () => selectCharacter(character.id));
} else {
card.innerHTML = `
<span class="slot-number">${String(slot).padStart(2, '0')}</span>
<span class="slot-content"><strong>Freier Charakterplatz</strong><small>Neue Identität erstellen</small></span>
<span class="slot-plus">+</span>`;
card.addEventListener('click', openCreator);
}
charactersEl.appendChild(card);
}
newButton.disabled = characters.length >= maxCharacters;
if (!selectedCharacter()) selectedId = characters[0]?.id ?? null;
selectCharacter(selectedId, true);
}
playButton.addEventListener('click', async () => {
const character = selectedCharacter();
if (!character) return;
playButton.disabled = true;
message('Charakter wird geladen …', 'info');
try {
const result = await post('select', { characterId: Number(character.id) });
if (!result.ok) {
message(result.error || 'Charakter konnte nicht geladen werden.');
playButton.disabled = false;
}
} catch (error) {
message(`Verbindungsfehler: ${error.message}`);
playButton.disabled = false;
}
});
deleteButton.addEventListener('click', openDelete);
document.querySelector('#cancelDelete').addEventListener('click', closeDelete);
deleteConfirmInput.addEventListener('input', () => {
confirmDeleteButton.disabled = deleteConfirmInput.value.trim().toUpperCase() !== 'LÖSCHEN';
});
confirmDeleteButton.addEventListener('click', async () => {
if (!deleteTarget || deleteConfirmInput.value.trim().toUpperCase() !== 'LÖSCHEN') return;
confirmDeleteButton.disabled = true;
const targetId = Number(deleteTarget.id);
closeDelete();
message('Charakter wird gelöscht …', 'info');
try {
const result = await post('delete', { characterId: targetId });
if (!result.ok) {
message(result.error || 'Löschen fehlgeschlagen.');
return;
}
characters = Array.isArray(result.characters) ? result.characters : characters.filter(entry => Number(entry.id) !== targetId);
selectedId = characters[0]?.id ?? null;
render();
message('Charakter wurde dauerhaft gelöscht.', 'success');
} catch (error) {
message(`Verbindungsfehler: ${error.message}`);
}
});
newButton.addEventListener('click', openCreator);
document.querySelector('#cancelCreate').addEventListener('click', closeCreator);
document.querySelector('#cancelCreateBottom').addEventListener('click', closeCreator);
sexSelect.addEventListener('change', () => previewCharacter(null));
createForm.addEventListener('submit', async event => {
event.preventDefault();
message('Charakter wird erstellt …', 'info');
createButton.disabled = true;
const oldLabel = createButton.textContent;
createButton.textContent = 'Wird erstellt …';
try {
const result = await post('create', Object.fromEntries(new FormData(createForm)));
if (!result.ok) {
message(result.error || 'Erstellung fehlgeschlagen.');
return;
}
characters = Array.isArray(result.characters) ? result.characters : characters;
selectedId = Number(result.characterId);
closeCreator();
render();
message(`Charakter erstellt. Telefonnummer: ${result.phoneNumber || 'wird geladen'}`, 'success');
} catch (error) {
message(`Verbindungsfehler: ${error.message}`);
} finally {
createButton.disabled = false;
createButton.textContent = oldLabel;
}
});
window.addEventListener('keydown', event => {
if (event.key !== 'Escape') return;
if (!deleteOverlay.classList.contains('hidden')) closeDelete();
else if (!createOverlay.classList.contains('hidden')) closeCreator();
});
window.addEventListener('message', event => {
const data = event.data || {};
if (data.action === 'open') app.classList.remove('hidden');
if (data.action === 'close') {
app.classList.add('hidden');
createOverlay.classList.add('hidden');
deleteOverlay.classList.add('hidden');
message('');
}
if (data.action === 'characters') {
characters = data.characters || [];
maxCharacters = Number(data.maxCharacters) || 3;
render();
}
if (data.action === 'error') message(data.message || 'Unbekannter Fehler.');
if (data.action === 'dialogue') {
dialogueSpeaker.textContent = data.speaker || 'Unbekannt';
dialogueText.textContent = data.text || '';
dialogue.classList.remove('hidden');
}
if (data.action === 'dialogueHide') dialogue.classList.add('hidden');
});
post('ready').catch(error => message(`Startfehler: ${error.message}`));
@@ -0,0 +1,107 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="style.css">
</head>
<body>
<main id="app" class="hidden">
<aside class="selector-panel">
<header class="brand">
<div class="brand-mark">DX</div>
<div>
<p>DEVX-CITY ROLEPLAY</p>
<h1>Charakterauswahl</h1>
</div>
</header>
<div id="message" class="message hidden"></div>
<section class="character-section">
<div class="section-heading">
<span>DEINE CHARAKTERE</span>
<small id="slotCounter">0 / 3</small>
</div>
<div id="characters" class="characters"></div>
</section>
<section class="details">
<p class="details-kicker">AUSGEWÄHLTER CHARAKTER</p>
<h2 id="selectedName">Kein Charakter</h2>
<div class="detail-grid">
<div><span>Geburtsdatum</span><strong id="selectedDob"></strong></div>
<div><span>Charakterplatz</span><strong id="selectedSlot"></strong></div>
<div><span>Telefon</span><strong id="selectedPhone"></strong></div>
<div><span>Status</span><strong id="selectedStatus"></strong></div>
</div>
</section>
<footer class="main-actions">
<button id="deleteCharacter" class="danger" disabled>Löschen</button>
<button id="newCharacter" class="secondary">Neuer Charakter</button>
<button id="playCharacter" class="primary" disabled>Stadt betreten</button>
</footer>
</aside>
<div class="preview-hint">
<span>DEVX-CITY</span>
<strong>Wähle dein neues Leben.</strong>
</div>
<section id="createOverlay" class="modal-overlay hidden">
<form id="createForm" class="modal-card create-card">
<div class="modal-header">
<div>
<p>NEUE IDENTITÄT</p>
<h2>Charakter erstellen</h2>
</div>
<button type="button" id="cancelCreate" class="close-button" aria-label="Schließen">×</button>
</div>
<div class="form-grid">
<label><span>Vorname</span><input name="firstName" minlength="2" maxlength="32" autocomplete="off" required></label>
<label><span>Nachname</span><input name="lastName" minlength="2" maxlength="32" autocomplete="off" required></label>
<label><span>Geburtsdatum</span><input name="dateOfBirth" type="date" min="1900-01-01" max="2010-12-31" required></label>
<label><span>Geschlecht</span>
<select name="sex" id="sexSelect">
<option value="m">Männlich</option>
<option value="f">Weiblich</option>
<option value="x">Divers</option>
</select>
</label>
</div>
<p class="form-note">Deine Telefonnummer und dein persönliches Bankkonto werden automatisch erstellt.</p>
<div class="modal-actions">
<button type="button" id="cancelCreateBottom" class="secondary">Abbrechen</button>
<button type="submit" class="primary">Charakter erstellen</button>
</div>
</form>
</section>
<section id="deleteOverlay" class="modal-overlay hidden">
<div class="modal-card delete-card">
<div class="warning-icon">!</div>
<h2>Charakter dauerhaft löschen?</h2>
<p id="deleteDescription">Dieser Vorgang kann nicht rückgängig gemacht werden.</p>
<label class="confirm-label">
<span>Tippe <strong id="deleteConfirmWord">LÖSCHEN</strong> zur Bestätigung</span>
<input id="deleteConfirmInput" autocomplete="off" placeholder="LÖSCHEN">
</label>
<div class="modal-actions">
<button id="cancelDelete" class="secondary">Abbrechen</button>
<button id="confirmDelete" class="danger" disabled>Endgültig löschen</button>
</div>
</div>
</section>
</main>
<section id="dialogue" class="dialogue hidden" aria-live="polite">
<strong id="dialogueSpeaker">Mason</strong>
<p id="dialogueText"></p>
</section>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,144 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #f7f8fc;
--panel: rgba(9, 12, 18, .975);
--panel-soft: rgba(16, 20, 29, .985);
--line: rgba(255, 255, 255, .11);
--muted: #939cab;
--accent: #4d7cff;
--accent-bright: #7b9cff;
--danger: #d65464;
}
* { box-sizing: border-box; }
body { margin: 0; overflow: hidden; background: transparent; user-select: none; }
button, input, select { font: inherit; }
button { color: inherit; }
.hidden { display: none !important; }
#app {
position: fixed;
inset: 0;
background: linear-gradient(90deg, rgba(0, 2, 7, .45) 0, rgba(0, 2, 7, .08) 46%, transparent 72%);
}
.selector-panel {
width: min(455px, 36vw);
min-width: 390px;
height: 100vh;
padding: 34px 30px 26px;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(13, 17, 25, .99), rgba(6, 8, 13, .985));
border-right: 1px solid var(--line);
box-shadow: 26px 0 70px rgba(0, 0, 0, .35);
}
.brand { display: flex; align-items: center; gap: 14px; margin-bottom: 28px; }
.brand-mark {
width: 46px; height: 46px; border-radius: 11px;
display: grid; place-items: center;
background: linear-gradient(145deg, var(--accent-bright), #345bd6);
box-shadow: 0 10px 28px rgba(67, 108, 234, .3);
font-weight: 900; letter-spacing: -.06em;
}
.brand p, .modal-header p { margin: 0 0 4px; color: var(--accent-bright); font-size: 9px; letter-spacing: .2em; font-weight: 850; }
.brand h1 { margin: 0; font-size: 24px; line-height: 1.1; letter-spacing: -.025em; }
.section-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.section-heading span { color: #c6cbd5; font-size: 9px; font-weight: 850; letter-spacing: .16em; }
.section-heading small { color: var(--muted); font-size: 12px; }
.characters { display: grid; gap: 8px; }
.character-slot {
width: 100%; min-height: 68px; padding: 0 14px;
border: 1px solid var(--line); border-radius: 8px;
display: grid; grid-template-columns: 36px 1fr 22px; align-items: center; gap: 11px;
text-align: left; cursor: pointer;
background: rgba(255, 255, 255, .034);
transition: border-color .16s ease, background .16s ease, transform .16s ease;
}
.character-slot:hover { background: rgba(255, 255, 255, .064); transform: translateX(3px); }
.character-slot.active {
border-color: rgba(83, 126, 255, .82);
background: linear-gradient(90deg, rgba(70, 111, 241, .22), rgba(255, 255, 255, .035));
box-shadow: inset 3px 0 0 var(--accent);
}
.character-slot.vacant { border-style: dashed; opacity: .7; }
.character-slot.vacant:hover { opacity: 1; }
.slot-number { color: #697386; font-size: 12px; font-weight: 850; }
.slot-content { display: grid; gap: 3px; }
.slot-content strong { font-size: 14px; font-weight: 780; }
.slot-content small { color: var(--muted); font-size: 10px; }
.slot-arrow, .slot-plus { justify-self: end; color: var(--accent-bright); font-size: 24px; font-weight: 300; }
.details {
margin-top: 20px; padding: 17px;
border: 1px solid var(--line); border-radius: 9px;
background: rgba(255, 255, 255, .027);
}
.details-kicker { margin: 0 0 7px; color: var(--muted); font-size: 8px; letter-spacing: .18em; font-weight: 850; }
.details h2 { margin: 0 0 15px; font-size: 21px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.detail-grid div { display: grid; gap: 4px; }
.detail-grid span { color: var(--muted); font-size: 8px; text-transform: uppercase; letter-spacing: .08em; }
.detail-grid strong { font-size: 11px; font-weight: 680; }
.main-actions { margin-top: auto; padding-top: 20px; display: grid; grid-template-columns: .72fr 1fr 1.18fr; gap: 8px; }
button {
border: 0; border-radius: 7px; padding: 12px 13px;
font-weight: 780; cursor: pointer;
transition: filter .15s ease, transform .15s ease, opacity .15s ease;
}
button:hover:not(:disabled) { filter: brightness(1.1); transform: translateY(-1px); }
button:disabled { opacity: .36; cursor: not-allowed; }
.primary { background: linear-gradient(135deg, var(--accent-bright), #3b65dd); }
.secondary { background: rgba(255, 255, 255, .085); border: 1px solid var(--line); }
.danger { background: rgba(198, 69, 85, .14); border: 1px solid rgba(214, 84, 100, .3); color: #ff9ca8; }
.preview-hint {
position: absolute; right: 42px; bottom: 36px;
display: grid; justify-items: end; gap: 5px;
text-shadow: 0 3px 16px rgba(0,0,0,.9); pointer-events: none;
}
.preview-hint span { color: var(--accent-bright); font-size: 10px; letter-spacing: .28em; font-weight: 900; }
.preview-hint strong { font-size: 16px; font-weight: 650; }
.message { margin: -10px 0 15px; padding: 10px 12px; border-radius: 7px; border: 1px solid rgba(214, 84, 100, .25); background: rgba(186, 52, 69, .13); color: #ffadb6; font-size: 11px; }
.message[data-type="info"] { border-color: rgba(80, 126, 255, .3); background: rgba(67, 108, 234, .14); color: #b8c9ff; }
.message[data-type="success"] { border-color: rgba(64, 181, 124, .3); background: rgba(51, 153, 104, .14); color: #a9ebc9; }
.modal-overlay { position: fixed; inset: 0; display: grid; place-items: center; background: rgba(3, 5, 9, .68); }
.modal-card { width: min(610px, 88vw); padding: 27px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel-soft); box-shadow: 0 30px 100px rgba(0,0,0,.6); }
.modal-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 23px; }
.modal-header h2, .delete-card h2 { margin: 0; font-size: 26px; }
.close-button { width: 38px; height: 38px; padding: 0; border: 1px solid var(--line); background: rgba(255,255,255,.06); font-size: 23px; font-weight: 400; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
label { display: grid; gap: 7px; }
label span { color: #b8bfcb; font-size: 10px; font-weight: 700; }
input, select { width: 100%; padding: 12px 13px; border: 1px solid var(--line); border-radius: 7px; outline: none; background: rgba(0,0,0,.27); color: white; }
input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(77,124,255,.12); }
option { color: black; }
.form-note { color: var(--muted); font-size: 11px; line-height: 1.55; }
.modal-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 21px; }
.delete-card { width: min(480px, 86vw); text-align: center; }
.warning-icon { width: 48px; height: 48px; margin: 0 auto 16px; display: grid; place-items: center; border-radius: 50%; background: rgba(214,84,100,.16); border: 1px solid rgba(214,84,100,.34); color: #ff9ca8; font-size: 24px; font-weight: 900; }
.delete-card > p { color: var(--muted); line-height: 1.55; }
.confirm-label { margin-top: 18px; text-align: left; }
.dialogue {
position: fixed; left: 50%; bottom: 6.5vh; transform: translateX(-50%);
width: min(940px, 84vw); text-align: center;
pointer-events: none;
text-shadow: 0 2px 4px #000, 0 0 14px rgba(0,0,0,.95);
}
.dialogue strong {
display: block; margin-bottom: 6px;
color: #79a0ff; font-size: 13px; letter-spacing: .09em; text-transform: uppercase;
}
.dialogue p { margin: 0; font-size: 20px; line-height: 1.38; font-weight: 650; }
@media (max-width: 1050px) {
.selector-panel { width: 420px; min-width: 420px; }
.preview-hint { display: none; }
}
@@ -0,0 +1,41 @@
local pendingCallbacks = {}
local callbackCounter = 0
local resourceName = GetCurrentResourceName()
local responseEvent = ('devx_core:client:callback:%s'):format(resourceName)
function DevXTriggerCallback(name, payload, callback)
assert(type(name) == 'string' and name:match('^[%w_%-]+:[%w_%-]+$'),
'Callbackname ist ungültig.')
assert(type(callback) == 'function', 'Clientcallback muss eine Funktion sein.')
callbackCounter = callbackCounter + 1
local requestId = ('%s:%s:%s'):format(resourceName, GetGameTimer(), callbackCounter)
pendingCallbacks[requestId] = callback
TriggerServerEvent(
('devx_core:server:callback:%s:%s'):format(resourceName, name),
requestId,
payload or {}
)
SetTimeout(22000, function()
local pending = pendingCallbacks[requestId]
if not pending then return end
pendingCallbacks[requestId] = nil
pending({
ok = false,
error = ('Keine Antwort auf %s. Prüfe die txAdmin- und F8-Konsole.'):format(name)
})
end)
end
RegisterNetEvent(responseEvent)
AddEventHandler(responseEvent, function(requestId, result)
local callback = pendingCallbacks[requestId]
if not callback then return end
pendingCallbacks[requestId] = nil
callback(result or { ok = false, error = 'Leere Serverantwort.' })
end)
@@ -0,0 +1,63 @@
local PlayerData = nil
local function notify(message)
BeginTextCommandThefeedPost('STRING')
AddTextComponentSubstringPlayerName(message)
EndTextCommandThefeedPostTicker(false, false)
end
exports('GetPlayerData', function()
return PlayerData
end)
exports('IsPlayerLoaded', function()
return PlayerData ~= nil
end)
exports('Notify', notify)
RegisterNetEvent('devx_core:client:playerData', function(data)
PlayerData = data
LocalPlayer.state:set('devxCharacterId', data.characterId, true)
TriggerEvent('devx_core:client:playerLoaded', data)
end)
CreateThread(function()
while true do
Wait(DevXConfig.PositionSaveIntervalMs)
if PlayerData then
local ped = PlayerPedId()
if DoesEntityExist(ped) and not IsEntityDead(ped) then
local coords = GetEntityCoords(ped)
TriggerServerEvent('devx_core:server:updatePosition', {
x = coords.x,
y = coords.y,
z = coords.z,
heading = GetEntityHeading(ped)
})
end
end
end
end)
RegisterCommand('coords', function()
local ped = PlayerPedId()
local coords = GetEntityCoords(ped)
local text = ('vector4(%.2f, %.2f, %.2f, %.2f)'):format(
coords.x, coords.y, coords.z, GetEntityHeading(ped)
)
print(text)
notify(text)
end, false)
RegisterCommand('unstuck', function()
if not PlayerData then return end
local spawn = DevXConfig.StreetSpawn
SetEntityCoords(PlayerPedId(), spawn.x, spawn.y, spawn.z, false, false, false, false)
SetEntityHeading(PlayerPedId(), spawn.heading)
notify('Du wurdest zum Straßenstart teleportiert.')
end, false)
RegisterNetEvent('devx_core:client:returnedToWorld', function()
notify('Du wurdest in die öffentliche Welt zurückgesetzt.')
end)
+23
View File
@@ -0,0 +1,23 @@
fx_version 'cerulean'
game 'gta5'
author 'Colin-Joel / DevX Studios'
description 'DevX RP core sessions, callbacks and player state'
version '0.4.3'
lua54 'yes'
shared_script 'shared/config.lua'
-- This helper is loaded by dependent resources using
-- @devx_core/client/callbacks.lua. It must be part of the client packfile.
files {
'client/callbacks.lua'
}
client_script 'client/main.lua'
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/main.lua'
}
@@ -0,0 +1,79 @@
local registeredCallbacks = {}
local pendingExecutions = {}
local resourceName = GetCurrentResourceName()
print(('[%s] DevX RPC v0.4.1 aktiv.'):format(resourceName))
local function callbackEventName(name)
return ('devx_core:server:callback:%s:%s'):format(resourceName, name)
end
local function responseEventName()
return ('devx_core:client:callback:%s'):format(resourceName)
end
local function sendResult(requestSource, requestId, result)
TriggerClientEvent(responseEventName(), requestSource, requestId, result)
end
function DevXRegisterCallback(name, handler)
assert(type(name) == 'string' and name:match('^[%w_%-]+:[%w_%-]+$'),
'Callbackname ist ungültig.')
assert(type(handler) == 'function', 'Callbackhandler muss eine Funktion sein.')
assert(not registeredCallbacks[name], ('Callback %s wurde doppelt registriert.'):format(name))
registeredCallbacks[name] = true
local eventName = callbackEventName(name)
RegisterNetEvent(eventName)
AddEventHandler(eventName, function(requestId, payload)
local requestSource = source
if type(requestId) ~= 'string' or #requestId > 160 or type(payload) ~= 'table' then
sendResult(requestSource, tostring(requestId or ''), {
ok = false,
error = 'Ungültige Serveranfrage.'
})
return
end
local executionKey = ('%s:%s'):format(requestSource, requestId)
if pendingExecutions[executionKey] then
sendResult(requestSource, requestId, {
ok = false,
error = 'Diese Serveranfrage wird bereits verarbeitet.'
})
return
end
pendingExecutions[executionKey] = true
local completed = false
local function finish(result)
if completed then return end
completed = true
pendingExecutions[executionKey] = nil
if result == nil then result = { ok = true } end
sendResult(requestSource, requestId, result)
end
print(('[%s] RPC empfangen: %s von Spieler %s.'):format(resourceName, name, requestSource))
SetTimeout(20000, function()
if completed then return end
print(('[%s] RPC %s für Spieler %s hat nach 20 Sekunden nicht geantwortet.'):format(
resourceName, name, requestSource
))
finish({
ok = false,
error = ('Serververarbeitung für %s abgelaufen. Prüfe die txAdmin-Konsole.'):format(name)
})
end)
CreateThread(function()
local result = handler(requestSource, payload)
finish(result)
end)
end)
end
+381
View File
@@ -0,0 +1,381 @@
local Players = {}
local positionRateLimit = {}
local function getLicense(source)
for _, identifier in ipairs(GetPlayerIdentifiers(source)) do
if identifier:sub(1, 8) == 'license:' then
return identifier
end
end
return nil
end
local function sanitizeInteger(value, maximum)
local number = tonumber(value)
if not number then return nil end
number = math.floor(number)
if number < 0 then return nil end
if maximum and number > maximum then return nil end
return number
end
local function normalizeDateOfBirth(value)
if type(value) == 'number' then
local seconds = value > 100000000000 and math.floor(value / 1000) or math.floor(value)
return os.date('!%Y-%m-%d', seconds)
end
local text = tostring(value or '')
local numeric = tonumber(text)
if numeric then
local seconds = numeric > 100000000000 and math.floor(numeric / 1000) or math.floor(numeric)
return os.date('!%Y-%m-%d', seconds)
end
local date = text:match('^(%d%d%d%d%-%d%d%-%d%d)')
return date or text
end
local function phoneNumberFor(characterId)
return ('%s-%04d'):format(DevXConfig.PhonePrefix or '555', tonumber(characterId))
end
local function ensurePhoneNumber(character)
if character.phone_number and character.phone_number ~= '' then
return character.phone_number
end
local phoneNumber = phoneNumberFor(character.id)
MySQL.update.await(
'UPDATE devx_characters SET phone_number = ? WHERE id = ? AND (phone_number IS NULL OR phone_number = "")',
{ phoneNumber, character.id }
)
return phoneNumber
end
local function emitPlayerData(source)
local player = Players[source]
if not player then return end
TriggerClientEvent('devx_core:client:playerData', source, player)
end
local function loadCharacter(source, characterId)
local license = getLicense(source)
characterId = sanitizeInteger(characterId)
if not license or not characterId then
return nil, 'Ungültige Sitzung.'
end
local character = MySQL.single.await([[
SELECT id, slot, first_name, last_name, date_of_birth, sex, model,
phone_number, cash, position, metadata
FROM devx_characters
WHERE id = ? AND owner_license = ? AND deleted_at IS NULL
LIMIT 1
]], { characterId, license })
if not character then
return nil, 'Charakter wurde nicht gefunden.'
end
local account = MySQL.single.await([[
SELECT id, account_number, account_name, account_type, balance
FROM devx_bank_accounts
WHERE character_id = ? AND account_type = 'personal'
LIMIT 1
]], { character.id })
local metadata = {}
if character.metadata and character.metadata ~= '' then
local ok, decoded = pcall(json.decode, character.metadata)
if ok and type(decoded) == 'table' then metadata = decoded end
end
local position = nil
if character.position and character.position ~= '' then
local ok, decoded = pcall(json.decode, character.position)
if ok and type(decoded) == 'table' then position = decoded end
end
local phoneNumber = ensurePhoneNumber(character)
Players[source] = {
source = source,
license = license,
characterId = tonumber(character.id),
slot = tonumber(character.slot),
firstName = character.first_name,
lastName = character.last_name,
fullName = ('%s %s'):format(character.first_name, character.last_name),
dateOfBirth = normalizeDateOfBirth(character.date_of_birth),
sex = character.sex,
model = character.model,
phoneNumber = phoneNumber,
cash = tonumber(character.cash) or 0,
bank = account and tonumber(account.balance) or 0,
accountId = account and tonumber(account.id) or nil,
accountNumber = account and account.account_number or nil,
position = position,
metadata = metadata,
rpXp = tonumber(metadata.rpXp) or 0,
introCompleted = metadata.introCompleted == true
}
-- Jeder frisch ausgewählte Charakter startet sicher in der öffentlichen Welt.
-- So bleiben keine alten Apartment-/Missions-Buckets nach einem Reconnect hängen.
SetPlayerRoutingBucket(source, 0)
Player(source).state:set('devxCharacterId', Players[source].characterId, true)
Player(source).state:set('devxCharacterName', Players[source].fullName, true)
Player(source).state:set('devxPhoneNumber', Players[source].phoneNumber, true)
emitPlayerData(source)
return Players[source]
end
local function savePosition(source)
local player = Players[source]
if not player or not player.position then return end
MySQL.update.await(
'UPDATE devx_characters SET position = ? WHERE id = ?',
{ json.encode(player.position), player.characterId }
)
end
local function persistMetadata(source)
local player = Players[source]
if not player then return false end
local changed = MySQL.update.await(
'UPDATE devx_characters SET metadata = ? WHERE id = ?',
{ json.encode(player.metadata or {}), player.characterId }
)
return changed ~= nil
end
exports('GetLicense', getLicense)
exports('GetPlayer', function(source)
return Players[tonumber(source)]
end)
exports('GetActivePlayers', function()
return Players
end)
exports('ActivateCharacter', function(source, characterId)
return loadCharacter(tonumber(source), characterId)
end)
exports('SetMetadataValue', function(source, key, value)
source = tonumber(source)
local player = Players[source]
if not player or type(key) ~= 'string' or #key < 1 or #key > 64 then
return false
end
player.metadata = player.metadata or {}
player.metadata[key] = value
if not persistMetadata(source) then return false end
if key == 'introCompleted' then player.introCompleted = value == true end
if key == 'rpXp' then player.rpXp = tonumber(value) or 0 end
emitPlayerData(source)
return true
end)
exports('AddMetadataNumber', function(source, key, amount)
source = tonumber(source)
amount = tonumber(amount)
local player = Players[source]
if not player or not amount or type(key) ~= 'string' then return false end
player.metadata = player.metadata or {}
local newValue = math.floor((tonumber(player.metadata[key]) or 0) + amount)
player.metadata[key] = newValue
if not persistMetadata(source) then return false end
if key == 'rpXp' then player.rpXp = newValue end
emitPlayerData(source)
return true, newValue
end)
exports('SetBankCache', function(source, balance)
local player = Players[tonumber(source)]
balance = sanitizeInteger(balance)
if not player or balance == nil then return false end
player.bank = balance
emitPlayerData(tonumber(source))
return true
end)
exports('SetCash', function(source, amount, reason)
source = tonumber(source)
amount = sanitizeInteger(amount, DevXConfig.MaxMoneyOperation)
local player = Players[source]
if not player or amount == nil then return false end
local changed = MySQL.update.await(
'UPDATE devx_characters SET cash = ? WHERE id = ?',
{ amount, player.characterId }
)
if not changed or changed < 1 then return false end
player.cash = amount
emitPlayerData(source)
TriggerEvent('devx_core:server:moneyAudit', source, 'cash_set', amount, reason or 'Unbekannt')
return true
end)
exports('AddCash', function(source, amount, reason)
source = tonumber(source)
amount = sanitizeInteger(amount, DevXConfig.MaxMoneyOperation)
local player = Players[source]
if not player or not amount or amount == 0 then return false end
local newCash = player.cash + amount
local changed = MySQL.update.await(
'UPDATE devx_characters SET cash = ? WHERE id = ?',
{ newCash, player.characterId }
)
if not changed or changed < 1 then return false end
player.cash = newCash
emitPlayerData(source)
TriggerEvent('devx_core:server:moneyAudit', source, 'cash_credit', amount, reason or 'Unbekannt')
return true, newCash
end)
exports('RemoveCash', function(source, amount, reason)
source = tonumber(source)
amount = sanitizeInteger(amount, DevXConfig.MaxMoneyOperation)
local player = Players[source]
if not player or not amount or amount == 0 or player.cash < amount then return false end
local newCash = player.cash - amount
local changed = MySQL.update.await(
'UPDATE devx_characters SET cash = ? WHERE id = ? AND cash >= ?',
{ newCash, player.characterId, amount }
)
if not changed or changed < 1 then return false end
player.cash = newCash
emitPlayerData(source)
TriggerEvent('devx_core:server:moneyAudit', source, 'cash_debit', amount, reason or 'Unbekannt')
return true, newCash
end)
RegisterNetEvent('devx_core:server:updatePosition', function(position)
local source = source
local player = Players[source]
if not player or type(position) ~= 'table' then return end
local now = os.time()
if positionRateLimit[source] and now - positionRateLimit[source] < 10 then return end
positionRateLimit[source] = now
local x, y, z, heading = tonumber(position.x), tonumber(position.y),
tonumber(position.z), tonumber(position.heading)
if not x or not y or not z or not heading then return end
if math.abs(x) > 10000 or math.abs(y) > 10000 or math.abs(z) > 3000 then return end
player.position = { x = x, y = y, z = z, heading = heading }
savePosition(source)
end)
AddEventHandler('playerDropped', function()
local source = source
savePosition(source)
Players[source] = nil
positionRateLimit[source] = nil
end)
AddEventHandler('onResourceStop', function(resourceName)
if resourceName ~= GetCurrentResourceName() then return end
for source in pairs(Players) do savePosition(source) end
end)
MySQL.ready(function()
MySQL.query.await([[
ALTER TABLE devx_characters
ADD COLUMN IF NOT EXISTS phone_number VARCHAR(16) NULL AFTER model
]])
MySQL.query.await([[
CREATE UNIQUE INDEX IF NOT EXISTS uq_devx_character_phone
ON devx_characters (phone_number)
]])
MySQL.query.await([[
CREATE TABLE IF NOT EXISTS devx_bans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
license VARCHAR(80) NOT NULL,
player_name VARCHAR(96) NULL,
reason VARCHAR(255) NOT NULL,
banned_by VARCHAR(96) NOT NULL,
expires_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at DATETIME NULL,
PRIMARY KEY (id),
KEY idx_devx_bans_license (license),
KEY idx_devx_bans_active (license, revoked_at, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
]])
print('[devx_core] Datenbankschema v0.4.1 geprüft.')
end)
AddEventHandler('playerConnecting', function(_, _, deferrals)
local source = source
deferrals.defer()
Wait(0)
local license = getLicense(source)
if not license then
deferrals.done('FiveM konnte keine license:-Kennung ermitteln.')
return
end
local ok, ban = pcall(function()
return MySQL.single.await([[
SELECT reason, expires_at
FROM devx_bans
WHERE license = ?
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY id DESC
LIMIT 1
]], { license })
end)
if ok and ban then
local expiry = ban.expires_at and ('\nAblauf: %s'):format(tostring(ban.expires_at)) or '\nDauer: permanent'
deferrals.done(('Du bist von DevX-City gesperrt.\nGrund: %s%s'):format(ban.reason, expiry))
return
end
deferrals.done()
end)
RegisterNetEvent('devx_core:server:returnToWorld', function()
local source = source
if not Players[source] then return end
SetPlayerRoutingBucket(source, 0)
end)
RegisterCommand('world', function(source)
if source == 0 then return end
if not Players[source] then return end
SetPlayerRoutingBucket(source, 0)
TriggerClientEvent('devx_core:client:returnedToWorld', source)
end, false)
RegisterCommand('setcash', function(source, args)
if source ~= 0 and not IsPlayerAceAllowed(source, 'devx.admin') then return end
local target = tonumber(args[1])
local amount = sanitizeInteger(args[2], DevXConfig.MaxMoneyOperation)
if not target or amount == nil or not Players[target] then return end
exports.devx_core:SetCash(target, amount, ('Admin %s'):format(source))
end, false)
@@ -0,0 +1,28 @@
DevXConfig = {
MaxCharacters = 3,
StartCash = 150,
StartBank = 500,
StreetSpawn = {
x = -1037.72,
y = -2737.88,
z = 20.17,
heading = 329.0
},
PhonePrefix = '555',
PositionSaveIntervalMs = 30000,
MaxMoneyOperation = 100000000,
AdminWeapons = {
WEAPON_PISTOL = 'Pistole',
WEAPON_COMBATPISTOL = 'Kampfpistole',
WEAPON_SMG = 'MP',
WEAPON_CARBINERIFLE = 'Karabiner',
WEAPON_PUMPSHOTGUN = 'Pumpgun',
WEAPON_STUNGUN = 'Taser',
WEAPON_FLASHLIGHT = 'Taschenlampe',
WEAPON_NIGHTSTICK = 'Schlagstock',
WEAPON_KNIFE = 'Messer'
}
}
+385
View File
@@ -0,0 +1,385 @@
local progress = { drill = false, card = false, vehicle = false }
local robberyRunning = false
local activePrep = nil
local objectiveProp = nil
local plannerPeds = {}
local warehousePeds = {}
local missionProps = {}
local missionVehicles = {}
local cutsceneRunning = false
local function notify(message) exports.devx_core:Notify(message) end
local function help(message)
BeginTextCommandDisplayHelp('STRING')
AddTextComponentSubstringPlayerName(message)
EndTextCommandDisplayHelp(0, false, true, -1)
end
local function subtitle(message, duration)
BeginTextCommandPrint('STRING')
AddTextComponentSubstringPlayerName(message)
EndTextCommandPrint(duration or 3500, true)
end
local function drawMarkerAt(coords, red, green, blue)
DrawMarker(1, coords.x, coords.y, coords.z - 1.0, 0.0,0.0,0.0,0.0,0.0,0.0,
1.2,1.2,0.35,red,green,blue,130,false,false,2,false,nil,nil,false)
end
local function loadModel(name)
local hash = GetHashKey(name)
RequestModel(hash)
local deadline = GetGameTimer() + 7000
while not HasModelLoaded(hash) and GetGameTimer() < deadline do Wait(0) end
return HasModelLoaded(hash) and hash or nil
end
local function loadAnim(dict)
RequestAnimDict(dict)
local deadline = GetGameTimer() + 5000
while not HasAnimDictLoaded(dict) and GetGameTimer() < deadline do Wait(0) end
return HasAnimDictLoaded(dict)
end
local function teleport(coords)
DoScreenFadeOut(250)
local deadline = GetGameTimer() + 1500
while not IsScreenFadedOut() and GetGameTimer() < deadline do Wait(0) end
local ped = PlayerPedId()
FreezeEntityPosition(ped, true)
if coords.z < -10.0 then SetFocusPosAndVel(coords.x, coords.y, coords.z, 0.0, 0.0, 0.0) end
RequestCollisionAtCoord(coords.x, coords.y, coords.z)
SetEntityCoordsNoOffset(ped, coords.x, coords.y, coords.z, false, false, false)
SetEntityHeading(ped, coords.w or coords.heading or 0.0)
local collisionDeadline = GetGameTimer() + 5000
while not HasCollisionLoadedAroundEntity(ped) and GetGameTimer() < collisionDeadline do
RequestCollisionAtCoord(coords.x, coords.y, coords.z)
Wait(0)
end
ClearPedTasksImmediately(ped)
FreezeEntityPosition(ped, false)
Wait(180)
ClearFocus()
DoScreenFadeIn(350)
end
local function ensurePrepInterior()
local interior = DevXCrimeConfig.PrepInterior
local position = interior.loadAt or interior.spawn
SetFocusPosAndVel(position.x, position.y, position.z, 0.0, 0.0, 0.0)
RequestCollisionAtCoord(position.x, position.y, position.z)
RequestAdditionalCollisionAtCoord(position.x, position.y, position.z)
NewLoadSceneStartSphere(position.x, position.y, position.z, 110.0, 0)
local interiorId = GetInteriorAtCoords(position.x, position.y, position.z)
if interiorId ~= 0 then
PinInteriorInMemory(interiorId)
RefreshInterior(interiorId)
end
local deadline = GetGameTimer() + 12000
local ready = false
while GetGameTimer() < deadline do
local sceneReady = IsNewLoadSceneLoaded()
local interiorReady = interiorId == 0 or IsInteriorReady(interiorId)
if sceneReady and interiorReady then
ready = true
break
end
RequestCollisionAtCoord(position.x, position.y, position.z)
Wait(0)
end
NewLoadSceneStop()
ClearFocus()
if not ready then
print(('[devx_crime] Sicheres Garagen-Interior nicht bereit. interior=%s'):format(interiorId))
end
return ready
end
local function deletePeds(list)
for _, ped in pairs(list) do
if ped and DoesEntityExist(ped) then DeleteEntity(ped) end
end
for key in pairs(list) do list[key] = nil end
end
local function deleteProps(list)
for _, object in pairs(list) do
if object and DoesEntityExist(object) then DeleteEntity(object) end
end
for key in pairs(list) do list[key] = nil end
end
local function deleteVehicles(list)
for _, vehicle in pairs(list) do
if vehicle and DoesEntityExist(vehicle) then DeleteEntity(vehicle) end
end
for key in pairs(list) do list[key] = nil end
end
local function spawnMissionVehicles()
deleteVehicles(missionVehicles)
for index, entry in ipairs(DevXCrimeConfig.PrepInterior.vehicles or {}) do
local model = loadModel(entry.model)
if model then
local c = entry.coords
local vehicle = CreateVehicle(model, c.x, c.y, c.z, c.w or 0.0, false, false)
SetVehicleOnGroundProperly(vehicle)
SetVehicleDoorsLocked(vehicle, 2)
SetEntityInvincible(vehicle, true)
FreezeEntityPosition(vehicle, true)
missionVehicles[index] = vehicle
SetModelAsNoLongerNeeded(model)
end
end
end
local function spawnMissionSetDressing()
deleteProps(missionProps)
for index, entry in ipairs(DevXCrimeConfig.PrepInterior.setDressing or {}) do
local model = loadModel(entry.model)
if model then
local c = entry.coords
local object = CreateObject(model, c.x, c.y, c.z, false, false, false)
SetEntityHeading(object, c.w or 0.0)
FreezeEntityPosition(object, true)
SetEntityCollision(object, true, true)
missionProps[index] = object
SetModelAsNoLongerNeeded(model)
end
end
end
local function spawnPlanners()
deletePeds(plannerPeds)
local model = loadModel(DevXCrimeConfig.PlannerModel)
if not model then return end
for _, prep in ipairs(DevXCrimeConfig.PrepLocations) do
local c = prep.planner
local ped = CreatePed(4, model, c.x, c.y, c.z - 1.0, c.w, false, false)
SetEntityInvincible(ped, true)
SetBlockingOfNonTemporaryEvents(ped, true)
FreezeEntityPosition(ped, true)
TaskStartScenarioInPlace(ped, 'WORLD_HUMAN_CLIPBOARD', 0, true)
plannerPeds[prep.key] = ped
end
SetModelAsNoLongerNeeded(model)
end
local function spawnWarehousePeds()
deletePeds(warehousePeds)
for index, worker in ipairs(DevXCrimeConfig.WarehouseWorkers or {}) do
local model = loadModel(worker.model)
if model then
local c = worker.coords
local ped = CreatePed(4, model, c.x, c.y, c.z, c.w, false, false)
SetEntityInvincible(ped, true)
SetBlockingOfNonTemporaryEvents(ped, true)
FreezeEntityPosition(ped, true)
TaskStartScenarioInPlace(ped, worker.scenario or 'WORLD_HUMAN_CLIPBOARD', 0, true)
warehousePeds[index] = ped
SetModelAsNoLongerNeeded(model)
end
end
end
local function removeObjectiveProp()
if objectiveProp and DoesEntityExist(objectiveProp) then DeleteEntity(objectiveProp) end
objectiveProp = nil
end
local function spawnObjectiveProp(prep)
removeObjectiveProp()
local model = loadModel(prep.prop)
if not model then return end
objectiveProp = CreateObject(model, prep.objective.x, prep.objective.y, prep.objective.z + 0.05, false, false, false)
SetEntityHeading(objectiveProp, prep.objective.w or 0.0)
FreezeEntityPosition(objectiveProp, true)
SetModelAsNoLongerNeeded(model)
end
local function timedAction(label, seconds)
local endTime = GetGameTimer() + seconds * 1000
FreezeEntityPosition(PlayerPedId(), true)
while GetGameTimer() < endTime do
local left = math.ceil((endTime - GetGameTimer()) / 1000)
help(('%s ~b~%ss'):format(label, left))
DisableAllControlActions(0)
EnableControlAction(0, 200, true)
Wait(0)
end
FreezeEntityPosition(PlayerPedId(), false)
end
local function refreshStatus()
DevXTriggerCallback('crime:status', {}, function(result)
if result.ok then progress = result.progress or progress end
end)
end
local function leavePrep()
if not activePrep then return end
DevXTriggerCallback('crime:leavePrep', {}, function(result)
if result.ok then
teleport(result.exterior)
TriggerEvent('devx_ipl:client:restoreWorld', 'crime-leave')
end
end)
activePrep = nil
removeObjectiveProp()
deletePeds(warehousePeds)
deleteProps(missionProps)
deleteVehicles(missionVehicles)
end
local function runPlanningScene(prep, callback)
if cutsceneRunning then return end
cutsceneRunning = true
local planner = plannerPeds[prep.key]
if not planner or not DoesEntityExist(planner) then
cutsceneRunning = false
callback()
return
end
local player = PlayerPedId()
FreezeEntityPosition(player, true)
ClearPedTasksImmediately(planner)
TaskTurnPedToFaceEntity(player, planner, 1500)
TaskTurnPedToFaceEntity(planner, player, 1500)
Wait(1000)
local playerCoords = GetEntityCoords(player)
local plannerCoords = GetEntityCoords(planner)
local midpoint = (playerCoords + plannerCoords) / 2.0
local cam = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
SetCamCoord(cam, midpoint.x + 2.4, midpoint.y + 2.4, midpoint.z + 1.45)
PointCamAtCoord(cam, midpoint.x, midpoint.y, midpoint.z + 0.85)
SetCamFov(cam, 43.0)
SetCamActive(cam, true)
RenderScriptCams(true, true, 600, true, true)
if loadAnim('gestures@m@standing@casual') then
for _, line in ipairs(prep.dialogue or {}) do
TaskPlayAnim(planner, 'gestures@m@standing@casual', 'gesture_hand_down', 2.0, -2.0, 2800, 49, 0.0, false, false, false)
subtitle(('~b~%s:~s~ %s'):format(DevXCrimeConfig.PlannerName, line), 3300)
Wait(3400)
end
else
for _, line in ipairs(prep.dialogue or {}) do
subtitle(('~b~%s:~s~ %s'):format(DevXCrimeConfig.PlannerName, line), 3300)
Wait(3400)
end
end
RenderScriptCams(false, true, 550, true, true)
DestroyCam(cam, false)
FreezeEntityPosition(player, false)
TaskStartScenarioInPlace(planner, 'WORLD_HUMAN_CLIPBOARD', 0, true)
cutsceneRunning = false
callback()
end
local function beginPrep(prep)
runPlanningScene(prep, function()
DevXTriggerCallback('crime:enterPrep', { key = prep.key }, function(result)
if not result.ok then notify(result.error); return end
if not ensurePrepInterior() then
notify('Das Missionslager konnte nicht geladen werden. Die Mission wurde sicher abgebrochen.')
DevXTriggerCallback('crime:leavePrep', {}, function() end)
return
end
activePrep = result.prep
teleport(result.interior.spawn)
spawnMissionSetDressing()
spawnMissionVehicles()
spawnObjectiveProp(activePrep)
spawnWarehousePeds()
subtitle(('~b~MISSION:~s~ %s'):format(activePrep.objectiveLabel), 4500)
end)
end)
end
CreateThread(function()
ensurePrepInterior()
while not exports.devx_core:IsPlayerLoaded() do Wait(500) end
refreshStatus()
spawnPlanners()
for _, entry in ipairs(DevXCrimeConfig.PrepLocations) do
local blip = AddBlipForCoord(entry.exterior.x, entry.exterior.y, entry.exterior.z)
SetBlipSprite(blip,478);SetBlipScale(blip,.65);SetBlipColour(blip,47);SetBlipAsShortRange(blip,true)
BeginTextCommandSetBlipName('STRING');AddTextComponentString('Bankraub-Vorbereitung');EndTextCommandSetBlipName(blip)
end
end)
CreateThread(function()
while true do
local sleep = 700
if exports.devx_core:IsPlayerLoaded() and not robberyRunning and not cutsceneRunning then
local coords = GetEntityCoords(PlayerPedId())
if activePrep then
local objective = activePrep.objective
local objectiveDistance = #(coords - vector3(objective.x, objective.y, objective.z))
local exit = DevXCrimeConfig.PrepInterior.exit
local exitDistance = #(coords - exit)
if objectiveDistance < 15.0 then
sleep = 0;drawMarkerAt(objective,70,150,245)
if objectiveDistance < 1.6 then
help(('~INPUT_CONTEXT~ %s'):format(activePrep.objectiveLabel))
if IsControlJustReleased(0,38) then
timedAction(activePrep.objectiveLabel,7)
DevXTriggerCallback('crime:completePrep',{key=activePrep.key},function(result)
if result.ok then
progress=result.progress;removeObjectiveProp();deletePeds(warehousePeds);deleteProps(missionProps);deleteVehicles(missionVehicles);activePrep=nil;teleport(result.exterior);TriggerEvent('devx_ipl:client:restoreWorld', 'crime-complete')
notify('Vorbereitungsmission abgeschlossen.')
else notify(result.error or 'Mission fehlgeschlagen.') end
end)
end
end
end
if exitDistance < 8.0 then
sleep=0;drawMarkerAt(exit,230,230,230)
if exitDistance < 1.6 then
help('~INPUT_CONTEXT~ Lager verlassen')
if IsControlJustReleased(0,38) then leavePrep() end
end
end
else
for _, entry in ipairs(DevXCrimeConfig.PrepLocations) do
local distance = #(coords - vector3(entry.exterior.x,entry.exterior.y,entry.exterior.z))
if distance < 18.0 then
sleep=0;drawMarkerAt(entry.exterior,progress[entry.key] and 70 or 230,progress[entry.key] and 190 or 150,90)
if distance < 1.8 then
if progress[entry.key] then help(('Vorbereitung abgeschlossen: %s'):format(entry.label))
else
help(('~INPUT_CONTEXT~ mit %s planen: %s'):format(DevXCrimeConfig.PlannerName, entry.label))
if IsControlJustReleased(0,38) then beginPrep(entry) end
end
end
end
end
local bankDistance=#(coords-DevXCrimeConfig.Bank)
if bankDistance<18.0 then
sleep=0;drawMarkerAt(DevXCrimeConfig.Bank,210,55,75)
if bankDistance<1.8 then
help('~INPUT_CONTEXT~ vorbereiteten Bankraub starten')
if IsControlJustReleased(0,38) then
DevXTriggerCallback('crime:startRobbery',{},function(result)
if not result.ok then notify(result.error or 'Bankraub konnte nicht gestartet werden.');return end
robberyRunning=true
CreateThread(function()
timedAction('Tresor wird geöffnet …',result.duration)
DevXTriggerCallback('crime:finishRobbery',{},function(finish)
robberyRunning=false
if finish.ok then progress={drill=false,card=false,vehicle=false};notify(('Bankraub erfolgreich: %s $ und %s RP-XP.'):format(finish.reward,finish.xp))
else notify(finish.error or 'Bankraub fehlgeschlagen.') end
end)
end)
end)
end
end
end
end
end
Wait(sleep)
end
end)
AddEventHandler('onResourceStop',function(resourceName)
if resourceName~=GetCurrentResourceName() then return end
removeObjectiveProp();deletePeds(plannerPeds);deletePeds(warehousePeds);deleteProps(missionProps);deleteVehicles(missionVehicles);TriggerServerEvent('devx_core:server:returnToWorld');TriggerEvent('devx_ipl:client:restoreWorld', 'crime-stop')
end)
@@ -0,0 +1,10 @@
fx_version 'cerulean'
game 'gta5'
author 'Colin-Joel / DevX Studios'
description 'DevX RP bank robbery with preparation missions'
version '0.4.3'
lua54 'yes'
shared_scripts { '@devx_core/shared/config.lua', 'shared/config.lua' }
client_scripts { '@devx_core/client/callbacks.lua', 'client/main.lua' }
server_scripts { '@oxmysql/lib/MySQL.lua', '@devx_core/server/callbacks.lua', 'server/main.lua' }
dependencies { 'devx_core', 'devx_banking', 'devx_ipl' }
+165
View File
@@ -0,0 +1,165 @@
local activeRobberies = {}
local prepSessions = {}
local globalCooldownUntil = 0
local function nearLocation(source, coords, maximum)
local ped = GetPlayerPed(source)
if not ped or ped <= 0 then return false end
local playerCoords = GetEntityCoords(ped)
return #(playerCoords - vector3(coords.x, coords.y, coords.z)) <= maximum
end
local function findPrep(key)
for _, entry in ipairs(DevXCrimeConfig.PrepLocations) do
if entry.key == key then return entry end
end
return nil
end
local function getProgress(characterId)
local row = MySQL.single.await([[
SELECT drill_done AS drillDone, card_done AS cardDone, vehicle_done AS vehicleDone
FROM devx_heist_progress WHERE character_id = ? LIMIT 1
]], { characterId })
if not row then
MySQL.insert.await('INSERT IGNORE INTO devx_heist_progress (character_id) VALUES (?)', { characterId })
row = { drillDone = 0, cardDone = 0, vehicleDone = 0 }
end
return {
drill = tonumber(row.drillDone) == 1,
card = tonumber(row.cardDone) == 1,
vehicle = tonumber(row.vehicleDone) == 1
}
end
local function leavePrep(source)
prepSessions[source] = nil
SetPlayerRoutingBucket(source, 0)
end
MySQL.ready(function()
MySQL.query.await([[
CREATE TABLE IF NOT EXISTS devx_heist_progress (
character_id BIGINT UNSIGNED NOT NULL,
drill_done TINYINT(1) NOT NULL DEFAULT 0,
card_done TINYINT(1) NOT NULL DEFAULT 0,
vehicle_done TINYINT(1) NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (character_id),
CONSTRAINT fk_devx_heist_character FOREIGN KEY (character_id)
REFERENCES devx_characters (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
]])
end)
DevXRegisterCallback('crime:status', function(source)
local player = exports.devx_core:GetPlayer(source)
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
return {
ok = true,
progress = getProgress(player.characterId),
cooldownSeconds = math.max(0, globalCooldownUntil - os.time()),
active = activeRobberies[source] ~= nil
}
end)
DevXRegisterCallback('crime:enterPrep', function(source, payload)
local player = exports.devx_core:GetPlayer(source)
local prep = findPrep(tostring(payload.key or ''))
if not player or not prep then return { ok = false, error = 'Ungültige Vorbereitungsmission.' } end
if not nearLocation(source, prep.exterior, 6.0) then return { ok = false, error = 'Du bist nicht am Lagereingang.' } end
local bucket = DevXCrimeConfig.MissionBucketBase + tonumber(source)
prepSessions[source] = { key = prep.key, exterior = prep.exterior, bucket = bucket }
SetRoutingBucketPopulationEnabled(bucket, false)
SetRoutingBucketEntityLockdownMode(bucket, 'relaxed')
SetPlayerRoutingBucket(source, bucket)
return { ok = true, interior = DevXCrimeConfig.PrepInterior, prep = prep }
end)
DevXRegisterCallback('crime:leavePrep', function(source)
local session = prepSessions[source]
if not session then return { ok = false, error = 'Keine Vorbereitungsmission aktiv.' } end
local exterior = session.exterior
leavePrep(source)
return { ok = true, exterior = exterior }
end)
DevXRegisterCallback('crime:completePrep', function(source, payload)
local player = exports.devx_core:GetPlayer(source)
local key = tostring(payload.key or '')
local prep = findPrep(key)
local session = prepSessions[source]
if not player or not prep or not session or session.key ~= key then
return { ok = false, error = 'Keine passende Vorbereitungsmission aktiv.' }
end
if not nearLocation(source, prep.objective, 5.0) then return { ok = false, error = 'Du bist nicht am Missionsziel.' } end
local column = ({ drill = 'drill_done', card = 'card_done', vehicle = 'vehicle_done' })[key]
MySQL.query.await('INSERT IGNORE INTO devx_heist_progress (character_id) VALUES (?)', { player.characterId })
MySQL.update.await(('UPDATE devx_heist_progress SET %s = 1 WHERE character_id = ?'):format(column), { player.characterId })
local exterior = session.exterior
leavePrep(source)
return { ok = true, progress = getProgress(player.characterId), exterior = exterior }
end)
DevXRegisterCallback('crime:startRobbery', function(source)
local player = exports.devx_core:GetPlayer(source)
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
if not nearLocation(source, DevXCrimeConfig.Bank, 6.0) then return { ok = false, error = 'Du bist nicht im Tresorbereich.' } end
if activeRobberies[source] then return { ok = false, error = 'Der Raub läuft bereits.' } end
if os.time() < globalCooldownUntil then
return { ok = false, error = ('Die Bank ist noch %s Minuten gesperrt.'):format(math.ceil((globalCooldownUntil - os.time()) / 60)) }
end
local progress = getProgress(player.characterId)
if not progress.drill or not progress.card or not progress.vehicle then
return { ok = false, error = 'Schließe zuerst alle drei Vorbereitungsmissionen ab.' }
end
local duration = DevXCrimeConfig.RobberyDurationSeconds
activeRobberies[source] = { characterId = player.characterId, completeAt = os.time() + duration }
globalCooldownUntil = os.time() + DevXCrimeConfig.GlobalCooldownSeconds
return { ok = true, duration = duration }
end)
DevXRegisterCallback('crime:finishRobbery', function(source)
local robbery = activeRobberies[source]
local player = exports.devx_core:GetPlayer(source)
if not robbery or not player or robbery.characterId ~= player.characterId then
return { ok = false, error = 'Kein aktiver Bankraub.' }
end
if os.time() < robbery.completeAt then return { ok = false, error = 'Der Tresor ist noch nicht geöffnet.' } end
if not nearLocation(source, DevXCrimeConfig.Bank, 12.0) then
activeRobberies[source] = nil
return { ok = false, error = 'Du hast den Bankraubbereich verlassen.' }
end
local reward = math.random(DevXCrimeConfig.MinimumReward, DevXCrimeConfig.MaximumReward)
exports.devx_core:AddCash(source, reward, 'Fleeca-Bankraub')
exports.devx_core:AddMetadataNumber(source, 'rpXp', 75)
MySQL.update.await([[
UPDATE devx_heist_progress
SET drill_done = 0, card_done = 0, vehicle_done = 0
WHERE character_id = ?
]], { player.characterId })
activeRobberies[source] = nil
return { ok = true, reward = reward, xp = 75 }
end)
AddEventHandler('playerDropped', function()
activeRobberies[source] = nil
leavePrep(source)
end)
AddEventHandler('onResourceStop', function(resourceName)
if resourceName ~= GetCurrentResourceName() then return end
for _, playerId in ipairs(GetPlayers()) do SetPlayerRoutingBucket(tonumber(playerId), 0) end
end)
RegisterCommand('resetheist', function(source)
if source ~= 0 and not IsPlayerAceAllowed(source, 'devx.admin') then return end
globalCooldownUntil = 0
activeRobberies = {}
end, false)
@@ -0,0 +1,90 @@
DevXCrimeConfig = {
MissionBucketBase = 20000,
-- v0.4.3 deliberately uses the always-available 10-car garage interior.
-- It needs no IPL and therefore cannot appear as an empty warehouse when a
-- DLC placement fails to stream. Mission props/NPCs create the warehouse set.
PrepInterior = {
ipls = {},
spawn = vector4(239.86, -1004.71, -99.00, 88.5),
exit = vector3(239.55, -1004.55, -99.00),
loadAt = vector3(229.96, -981.79, -99.66),
setDressing = {
{ model = 'prop_boxpile_07d', coords = vector4(233.60, -1003.00, -99.65, 90.0) },
{ model = 'prop_boxpile_06b', coords = vector4(233.55, -999.55, -99.65, 90.0) },
{ model = 'prop_tool_bench02', coords = vector4(224.25, -1001.50, -99.65, 270.0) },
{ model = 'prop_crate_11e', coords = vector4(225.90, -998.80, -99.65, 0.0) },
{ model = 'prop_generator_03b', coords = vector4(228.20, -997.30, -99.65, 180.0) },
{ model = 'prop_worklight_03b', coords = vector4(229.70, -1003.20, -99.65, 90.0) },
{ model = 'prop_shelves_02', coords = vector4(222.40, -998.10, -99.65, 270.0) },
{ model = 'prop_shelves_02', coords = vector4(222.40, -1001.20, -99.65, 270.0) },
{ model = 'prop_box_wood05a', coords = vector4(236.40, -998.20, -99.65, 0.0) },
{ model = 'prop_cratepile_07a', coords = vector4(236.25, -1001.60, -99.65, 180.0) },
{ model = 'prop_toolchest_05', coords = vector4(225.10, -996.65, -99.65, 90.0) },
{ model = 'prop_barrel_02a', coords = vector4(238.00, -995.80, -99.65, 0.0) },
{ model = 'prop_barrel_02a', coords = vector4(238.00, -994.90, -99.65, 0.0) },
{ model = 'prop_worklight_04b', coords = vector4(222.95, -1003.55, -99.65, 270.0) },
{ model = 'prop_folding_chair_01', coords = vector4(228.40, -1003.80, -99.65, 180.0) },
{ model = 'prop_table_03', coords = vector4(230.00, -1003.85, -99.65, 0.0) }
},
vehicles = {
{ model = 'burrito3', coords = vector4(235.25, -988.10, -99.45, 180.0) },
{ model = 'sultan', coords = vector4(229.80, -988.10, -99.45, 180.0) }
}
},
PlannerModel = 's_m_m_highsec_01',
PlannerName = 'Vincent',
PrepLocations = {
{
key = 'drill',
label = 'Bohrgerät am Hafen beschaffen',
exterior = vector4(1204.15, -3116.62, 5.54, 90.0),
planner = vector4(1202.40, -3115.15, 5.54, 178.0),
objective = vector4(224.45, -1001.20, -99.65, 270.0),
objectiveLabel = 'Industriebohrer von der Werkbank nehmen',
prop = 'prop_tool_drill',
dialogue = {
'Der Tresor braucht mehr als rohe Gewalt.',
'Unser Werkzeug liegt in einer abgeschirmten Garage.',
'Hol den Industriebohrer und verschwinde unauffällig.'
}
},
{
key = 'card',
label = 'Zugangskarte aus dem Lager holen',
exterior = vector4(707.31, -966.86, 30.41, 180.0),
planner = vector4(705.55, -966.10, 30.41, 260.0),
objective = vector4(226.10, -998.75, -99.65, 0.0),
objectiveLabel = 'Sicherheitskarte aus der Transportkiste nehmen',
prop = 'prop_cs_swipe_card',
dialogue = {
'Die Banktür prüft eine Mitarbeiterkarte.',
'Unser Kontakt hat eine Karte in einer Kiste deponiert.',
'Nimm sie und hinterlasse keine Spuren.'
}
},
{
key = 'vehicle',
label = 'Fluchtfahrzeug vorbereiten',
exterior = vector4(970.25, -1832.05, 31.11, 175.0),
planner = vector4(968.65, -1831.40, 31.11, 260.0),
objective = vector4(228.35, -997.15, -99.65, 180.0),
objectiveLabel = 'Fahrzeugsteuergerät am Laptop programmieren',
prop = 'prop_laptop_01a',
dialogue = {
'Ohne sauberes Fluchtfahrzeug endet der Plan an der ersten Kreuzung.',
'Programmiere das Steuergerät auf unsere Kennung.',
'Danach ist das Fahrzeug für das Finale vorbereitet.'
}
}
},
WarehouseWorkers = {
{ model = 's_m_m_dockwork_01', coords = vector4(232.20, -997.80, -99.65, 180.0), scenario = 'WORLD_HUMAN_CLIPBOARD' },
{ model = 's_m_y_dockwork_01', coords = vector4(225.10, -1003.35, -99.65, 270.0), scenario = 'WORLD_HUMAN_WELDING' }
},
Bank = vector3(146.74, -1044.82, 29.37),
RobberyDurationSeconds = 75,
GlobalCooldownSeconds = 3600,
MinimumReward = 4500,
MaximumReward = 8500
}
@@ -0,0 +1,989 @@
local propertyState = {}
local ownedPropertyKey = nil
local ownedPropertyLabel = nil
local locationMode = 'world'
local currentBuilding = nil
local currentFloor = nil
local currentProperty = nil
local currentInteriorStyle = nil
local previewOpen = false
local previewUnits = {}
local previewIndex = 1
local previewCamera = nil
local previewShotIndex = 1
local nextPreviewShotAt = 0
local pendingDoorbell = nil
local pendingInvite = nil
local activeInteraction = false
local hallwayObjects = {}
local lastPropertyRefresh = 0
local function notify(message)
exports.devx_core:Notify(message)
end
local function help(text)
BeginTextCommandDisplayHelp('STRING')
AddTextComponentSubstringPlayerName(text)
EndTextCommandDisplayHelp(0, false, true, -1)
end
local function drawText3D(coords, lineOne, lineTwo)
local onScreen, screenX, screenY = World3dToScreen2d(coords.x, coords.y, coords.z)
if not onScreen then return end
SetTextScale(0.0, 0.30)
SetTextFont(4)
SetTextProportional(true)
SetTextColour(255, 255, 255, 235)
SetTextCentre(true)
SetTextOutline()
BeginTextCommandDisplayText('STRING')
AddTextComponentSubstringPlayerName(lineOne or '')
EndTextCommandDisplayText(screenX, screenY)
if lineTwo and lineTwo ~= '' then
SetTextScale(0.0, 0.25)
SetTextFont(4)
SetTextProportional(true)
SetTextColour(155, 190, 255, 230)
SetTextCentre(true)
SetTextOutline()
BeginTextCommandDisplayText('STRING')
AddTextComponentSubstringPlayerName(lineTwo)
EndTextCommandDisplayText(screenX, screenY + 0.018)
end
end
local function findBuilding(key)
for _, building in ipairs(DevXHousing.Buildings) do
if building.key == key then return building end
end
return nil
end
local function findUnit(key)
for _, building in ipairs(DevXHousing.Buildings) do
for _, unit in ipairs(building.units) do
if unit.key == key then return unit, building end
end
end
return nil, nil
end
local function requestAnim(dict)
RequestAnimDict(dict)
local deadline = GetGameTimer() + 5000
while not HasAnimDictLoaded(dict) and GetGameTimer() < deadline do Wait(0) end
return HasAnimDictLoaded(dict)
end
local function requestModel(name)
local hash = GetHashKey(name)
if not IsModelInCdimage(hash) or not IsModelValid(hash) then return nil end
RequestModel(hash)
local deadline = GetGameTimer() + 5000
while not HasModelLoaded(hash) and GetGameTimer() < deadline do Wait(0) end
return HasModelLoaded(hash) and hash or nil
end
local function clearHallwayObjects()
for _, entity in ipairs(hallwayObjects) do
if entity and DoesEntityExist(entity) then DeleteEntity(entity) end
end
hallwayObjects = {}
end
local function createHallwayObject(modelName, coords, collision)
local model = requestModel(modelName)
if not model then
print(('[devx_housing] Dekorationsmodell fehlt: %s'):format(modelName))
return nil
end
local object = CreateObjectNoOffset(model, coords.x, coords.y, coords.z, false, false, false)
if object and object ~= 0 then
SetEntityHeading(object, coords.heading or coords.w or 0.0)
FreezeEntityPosition(object, true)
SetEntityCollision(object, collision ~= false, collision ~= false)
SetEntityAsMissionEntity(object, true, true)
hallwayObjects[#hallwayObjects + 1] = object
end
SetModelAsNoLongerNeeded(model)
return object
end
local function spawnHallwayObjects(showDoors)
clearHallwayObjects()
local shell = DevXHousing.FloorShell
for _, entry in ipairs(shell.setDressing or {}) do
createHallwayObject(entry.model, entry.coords, true)
end
if showDoors then
for _, door in ipairs(shell.doors or {}) do
createHallwayObject(shell.doorModel, door, true)
end
end
end
local function restoreWorldStreaming(reason)
clearHallwayObjects()
TriggerEvent('devx_ipl:client:restoreWorld', reason or 'housing')
end
local function prepareInterior(definition)
local position = definition and (definition.loadAt or definition.spawn) or nil
if not position then return true end
-- v0.4.3 never removes or swaps global apartment IPLs. It only streams the
-- already existing interior around the target coordinate.
NewLoadSceneStop()
ClearFocus()
SetFocusPosAndVel(position.x, position.y, position.z, 0.0, 0.0, 0.0)
RequestCollisionAtCoord(position.x, position.y, position.z)
RequestAdditionalCollisionAtCoord(position.x, position.y, position.z)
NewLoadSceneStartSphere(position.x, position.y, position.z, 120.0, 0)
local interiorId = GetInteriorAtCoords(position.x, position.y, position.z)
if interiorId == 0 then interiorId = GetInteriorAtCoords(position.x, position.y, position.z + 1.0) end
if interiorId ~= 0 then
PinInteriorInMemory(interiorId)
RefreshInterior(interiorId)
end
local deadline = GetGameTimer() + 15000
local ready = false
while GetGameTimer() < deadline do
local sceneReady = IsNewLoadSceneLoaded()
local interiorReady = interiorId == 0 or IsInteriorReady(interiorId)
if sceneReady and interiorReady then
ready = true
break
end
RequestCollisionAtCoord(position.x, position.y, position.z)
RequestAdditionalCollisionAtCoord(position.x, position.y, position.z)
Wait(0)
end
NewLoadSceneStop()
if not ready then
print(('[devx_housing] Streaming-Timeout bei %.4f %.4f %.4f (interior=%s)'):format(
position.x, position.y, position.z, interiorId
))
end
return ready
end
local function teleport(position, interiorDefinition)
DoScreenFadeOut(280)
local fadeDeadline = GetGameTimer() + 1500
while not IsScreenFadedOut() and GetGameTimer() < fadeDeadline do Wait(0) end
if interiorDefinition and not prepareInterior(interiorDefinition) then
ClearFocus()
DoScreenFadeIn(300)
notify('Der Innenraum konnte nicht gestreamt werden. Du wurdest nicht teleportiert.')
return false
end
local ped = PlayerPedId()
FreezeEntityPosition(ped, true)
SetEntityCollision(ped, true, true)
RequestCollisionAtCoord(position.x, position.y, position.z)
RequestAdditionalCollisionAtCoord(position.x, position.y, position.z)
SetEntityCoordsNoOffset(ped, position.x, position.y, position.z, false, false, false)
SetEntityHeading(ped, position.heading or position.w or 0.0)
ClearPedTasksImmediately(ped)
local collisionDeadline = GetGameTimer() + 10000
while not HasCollisionLoadedAroundEntity(ped) and GetGameTimer() < collisionDeadline do
RequestCollisionAtCoord(position.x, position.y, position.z)
RequestAdditionalCollisionAtCoord(position.x, position.y, position.z)
Wait(0)
end
FreezeEntityPosition(ped, false)
Wait(180)
ClearFocus()
DoScreenFadeIn(380)
return true
end
local function refreshProperties(callback)
DevXTriggerCallback('housing:list', {}, function(result)
if result.ok then
propertyState = {}
ownedPropertyKey = result.ownedPropertyKey
ownedPropertyLabel = result.ownedPropertyLabel
for _, property in ipairs(result.properties or {}) do
propertyState[property.propertyKey] = {
id = property.id,
ownerCharacterId = property.ownerCharacterId,
ownerName = property.ownerName,
characterId = result.characterId,
price = tonumber(property.price) or 0,
label = property.label
}
end
lastPropertyRefresh = GetGameTimer()
end
if callback then callback(result) end
end)
end
local function stateFor(unit)
local player = exports.devx_core:GetPlayerData()
local state = propertyState[unit.key] or {
characterId = player and player.characterId,
price = unit.price
}
local ownerName = type(state.ownerName) == 'string' and state.ownerName:gsub('^%s+', ''):gsub('%s+$', '') or ''
state.ownerName = ownerName ~= '' and ownerName or nil
state.ownedByMe = state.ownerCharacterId
and tonumber(state.ownerCharacterId) == tonumber(state.characterId)
state.sold = state.ownerCharacterId ~= nil and state.ownerName ~= nil
return state
end
local function sendUi(action, data)
data = data or {}
data.action = action
SendNUIMessage(data)
end
local function destroyPreviewCamera()
if previewCamera and DoesCamExist(previewCamera) then
RenderScriptCams(false, true, 450, true, true)
DestroyCam(previewCamera, false)
end
previewCamera = nil
end
local function destroyPreview()
previewOpen = false
destroyPreviewCamera()
SetNuiFocus(false, false)
local ped = PlayerPedId()
SetEntityVisible(ped, true, false)
SetEntityCollision(ped, true, true)
FreezeEntityPosition(ped, false)
ClearFocus()
end
local function currentPreviewUnit()
return previewUnits[previewIndex]
end
local function updatePreviewUi()
local unit = currentPreviewUnit()
if not unit then return end
local state = stateFor(unit)
sendUi('previewData', {
label = unit.label,
building = unit.buildingLabel,
floor = unit.floor,
unitNumber = unit.unitNumber,
style = DevXHousing.Interiors[unit.style].label,
price = state.price or unit.price,
sold = state.sold,
ownedByMe = state.ownedByMe,
ownerName = state.ownerName,
index = previewIndex,
total = #previewUnits
})
end
local function usePreviewShot(instant)
local unit = currentPreviewUnit()
if not unit then return end
local interior = DevXHousing.Interiors[unit.style]
local shots = interior.cameraShots or {}
if #shots == 0 then return end
if previewShotIndex > #shots then previewShotIndex = 1 end
local shot = shots[previewShotIndex]
local newCamera = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
SetCamCoord(newCamera, shot.cam.x, shot.cam.y, shot.cam.z)
PointCamAtCoord(newCamera, shot.target.x, shot.target.y, shot.target.z)
SetCamFov(newCamera, 48.0)
SetCamActive(newCamera, true)
if previewCamera and DoesCamExist(previewCamera) and not instant then
SetCamActiveWithInterp(newCamera, previewCamera, 1450, true, true)
local oldCamera = previewCamera
CreateThread(function()
Wait(1550)
if DoesCamExist(oldCamera) then DestroyCam(oldCamera, false) end
end)
else
RenderScriptCams(true, true, 450, true, true)
if previewCamera and DoesCamExist(previewCamera) then DestroyCam(previewCamera, false) end
end
previewCamera = newCamera
nextPreviewShotAt = GetGameTimer() + 6000
end
local function showPreviewUnit()
local unit = currentPreviewUnit()
if not unit then return end
local interior = DevXHousing.Interiors[unit.style]
local ped = PlayerPedId()
DoScreenFadeOut(180)
while not IsScreenFadedOut() do Wait(0) end
if not prepareInterior(interior) then
DoScreenFadeIn(250)
notify('Diese Apartmentansicht konnte nicht geladen werden.')
return
end
SetEntityCoordsNoOffset(ped, interior.spawn.x, interior.spawn.y, interior.spawn.z, false, false, false)
SetEntityHeading(ped, interior.spawn.heading or 0.0)
SetEntityVisible(ped, false, false)
SetEntityCollision(ped, false, false)
FreezeEntityPosition(ped, true)
destroyPreviewCamera()
previewShotIndex = 1
usePreviewShot(true)
updatePreviewUi()
Wait(120)
DoScreenFadeIn(260)
end
local function closePreview(returnToWorld)
local building = currentBuilding
destroyPreview()
sendUi('close')
DevXTriggerCallback('housing:previewEnd', {}, function() end)
locationMode = 'world'
if returnToWorld ~= false and building then
teleport(building.exterior)
restoreWorldStreaming('housing-preview-end')
end
end
local function startPreview(building)
if previewOpen then return end
clearHallwayObjects()
refreshProperties(function(result)
if not result.ok then notify(result.error or 'Wohnungen konnten nicht geladen werden.'); return end
if ownedPropertyKey then
notify(('Du besitzt bereits %s. Pro Charakter ist nur ein Apartment möglich.'):format(ownedPropertyLabel or 'ein Apartment'))
return
end
previewUnits = {}
for _, unit in ipairs(building.units) do
if not stateFor(unit).sold then previewUnits[#previewUnits + 1] = unit end
end
table.sort(previewUnits, function(a, b) return a.price < b.price end)
if #previewUnits == 0 then notify('In diesem Gebäude sind derzeit keine Apartments frei.'); return end
previewIndex = 1
currentBuilding = building
DevXTriggerCallback('housing:previewStart', { buildingKey = building.key }, function(startResult)
if not startResult.ok then notify(startResult.error or 'Vorschau konnte nicht gestartet werden.'); return end
previewOpen = true
locationMode = 'preview'
SetNuiFocus(true, true)
sendUi('previewOpen')
showPreviewUnit()
end)
end)
end
local function applyUnitResult(result, unit, building)
clearHallwayObjects()
locationMode = 'unit'
currentProperty = unit.key
currentBuilding = building
currentFloor = unit.floor
currentInteriorStyle = unit.style
if teleport(result.interior.spawn, result.interior) then
notify(('Du bist in %s.'):format(result.label or unit.label))
else
locationMode = 'world'
currentProperty = nil
currentInteriorStyle = nil
TriggerServerEvent('devx_core:server:returnToWorld')
teleport(building.exterior)
restoreWorldStreaming('housing-unit-failed')
end
end
local function enterUnit(unit)
DevXTriggerCallback('housing:enterUnit', { propertyKey = unit.key }, function(result)
if not result.ok then notify(result.error or 'Wohnung konnte nicht betreten werden.'); return end
local _, building = findUnit(unit.key)
applyUnitResult(result, unit, building)
end)
end
local function enterOwned(building)
DevXTriggerCallback('housing:enterOwned', { buildingKey = building.key }, function(result)
if not result.ok then notify(result.error or 'Dein Apartment konnte nicht betreten werden.'); return end
local unit, unitBuilding = findUnit(result.propertyKey)
if unit then applyUnitResult(result, unit, unitBuilding) end
end)
end
local function enterLobby(building)
DevXTriggerCallback('housing:enterLobby', { buildingKey = building.key }, function(result)
if not result.ok then notify(result.error); return end
currentBuilding = building
currentFloor = 0
currentProperty = nil
currentInteriorStyle = nil
locationMode = 'lobby'
if teleport(result.position, DevXHousing.FloorShell) then
spawnHallwayObjects(false)
notify(('Lobby von %s betreten. Am Aufzug kannst du eine Etage wählen.'):format(building.label))
else
TriggerServerEvent('devx_core:server:returnToWorld')
locationMode = 'world'
teleport(building.exterior)
restoreWorldStreaming('housing-lobby-failed')
end
end)
end
local function openDoorMenu(unit)
local state = stateFor(unit)
if not state.sold then return end
SetNuiFocus(true, true)
sendUi('doorOpen', {
propertyKey = unit.key,
label = ('Wohnung %s'):format(unit.doorNumber),
ownerName = state.ownerName,
sold = true,
ownedByMe = state.ownedByMe
})
end
local function openInviteMenu()
DevXTriggerCallback('housing:inviteCandidates', {}, function(result)
if not result.ok then notify(result.error or 'Spielerliste konnte nicht geladen werden.'); return end
SetNuiFocus(true, true)
sendUi('inviteOpen', {
title = 'Spieler einladen',
players = result.players or {}
})
end)
end
local function playDrink(kind)
if activeInteraction then return end
activeInteraction = true
local ped = PlayerPedId()
local modelName = kind == 'whiskey' and 'prop_drink_whisky' or 'prop_amb_beer_bottle'
local model = requestModel(modelName)
local prop = nil
if model then
prop = CreateObject(model, 0.0, 0.0, 0.0, false, false, false)
AttachEntityToEntity(prop, ped, GetPedBoneIndex(ped, 28422), 0.0, -0.01, -0.03, 0.0, 0.0, 0.0, true, true, false, true, 1, true)
end
if requestAnim('amb@world_human_drinking@beer@male@idle_a') then
TaskPlayAnim(ped, 'amb@world_human_drinking@beer@male@idle_a', 'idle_c', 3.0, -3.0, 6500, 49, 0.0, false, false, false)
end
Wait(6500)
ClearPedSecondaryTask(ped)
if prop and DoesEntityExist(prop) then DeleteEntity(prop) end
if model then SetModelAsNoLongerNeeded(model) end
notify(kind == 'whiskey' and 'Du hast einen Whiskey getrunken.' or 'Du hast ein Bier getrunken.')
activeInteraction = false
end
local function playSleep(point)
if activeInteraction then return end
activeInteraction = true
local ped = PlayerPedId()
SetEntityCoordsNoOffset(ped, point.coords.x, point.coords.y, point.coords.z + 0.15, false, false, false)
SetEntityHeading(ped, point.heading or 0.0)
if requestAnim('timetable@tracy@sleep@') then
TaskPlayAnim(ped, 'timetable@tracy@sleep@', 'idle_c', 2.0, -2.0, 10000, 1, 0.0, false, false, false)
end
notify('Du schläfst kurz …')
Wait(10000)
ClearPedTasksImmediately(ped)
SetEntityHealth(ped, math.min(GetEntityMaxHealth(ped), GetEntityHealth(ped) + 15))
activeInteraction = false
end
local function playBong()
if activeInteraction then return end
activeInteraction = true
local ped = PlayerPedId()
TaskStartScenarioInPlace(ped, 'WORLD_HUMAN_SMOKING_POT', 0, true)
StartScreenEffect('DrugsMichaelAliensFight', 0, false)
Wait(9000)
StopScreenEffect('DrugsMichaelAliensFight')
ClearPedTasksImmediately(ped)
activeInteraction = false
end
local function useApartmentInteraction(point)
if point.type == 'beer' or point.type == 'whiskey' then
CreateThread(function() playDrink(point.type) end)
elseif point.type == 'sleep' then
CreateThread(function() playSleep(point) end)
elseif point.type == 'bong' then
CreateThread(playBong)
end
end
CreateThread(function()
while true do
if previewOpen and GetGameTimer() >= nextPreviewShotAt then
local unit = currentPreviewUnit()
if unit then
local shots = DevXHousing.Interiors[unit.style].cameraShots or {}
previewShotIndex = previewShotIndex + 1
if previewShotIndex > #shots then previewShotIndex = 1 end
usePreviewShot(false)
end
end
Wait(250)
end
end)
CreateThread(function()
while true do
if previewOpen then
DisableAllControlActions(0)
EnableControlAction(0, 200, true)
Wait(0)
else
Wait(500)
end
end
end)
RegisterNUICallback('previewPrevious', function(_, cb)
if #previewUnits > 0 then
previewIndex = previewIndex - 1
if previewIndex < 1 then previewIndex = #previewUnits end
showPreviewUnit()
end
cb({ ok = true })
end)
RegisterNUICallback('previewNext', function(_, cb)
if #previewUnits > 0 then
previewIndex = previewIndex + 1
if previewIndex > #previewUnits then previewIndex = 1 end
showPreviewUnit()
end
cb({ ok = true })
end)
RegisterNUICallback('previewBuy', function(_, cb)
local unit = currentPreviewUnit()
if not unit then cb({ ok = false, error = 'Keine Wohnung ausgewählt.' }); return end
DevXTriggerCallback('housing:buy', { propertyKey = unit.key }, function(result)
if result.ok then
refreshProperties(function()
notify('Wohnung erfolgreich gekauft. Am Gebäudeeingang kannst du sie jetzt direkt betreten.')
closePreview(true)
end)
end
cb(result)
end)
end)
RegisterNUICallback('previewClose', function(_, cb)
closePreview(true)
cb({ ok = true })
end)
RegisterNUICallback('elevatorFloor', function(data, cb)
local building = currentBuilding
local floor = tonumber(data.floor)
if not building or not floor then cb({ ok = false }); return end
DevXTriggerCallback('housing:goFloor', { buildingKey = building.key, floor = floor }, function(result)
if result.ok then
SetNuiFocus(false, false)
sendUi('close')
locationMode = 'floor'
currentFloor = floor
refreshProperties()
if teleport(result.position, DevXHousing.FloorShell) then
spawnHallwayObjects(true)
else
locationMode = 'world'
TriggerServerEvent('devx_core:server:returnToWorld')
teleport(building.exterior)
restoreWorldStreaming('housing-floor-failed')
end
else
notify(result.error)
end
cb(result)
end)
end)
RegisterNUICallback('uiClose', function(_, cb)
SetNuiFocus(false, false)
sendUi('close')
cb({ ok = true })
end)
RegisterNUICallback('doorRing', function(data, cb)
DevXTriggerCallback('housing:ring', { propertyKey = data.propertyKey }, function(result)
notify(result.message or result.error or 'Klingelanfrage beendet.')
cb(result)
end)
end)
RegisterNUICallback('doorKnock', function(data, cb)
DevXTriggerCallback('housing:knock', { propertyKey = data.propertyKey }, function(result)
notify(result.message or result.error or 'Klopfen beendet.')
cb(result)
end)
end)
RegisterNUICallback('doorEnter', function(data, cb)
local unit = findUnit(data.propertyKey)
SetNuiFocus(false, false)
sendUi('close')
if unit then enterUnit(unit) end
cb({ ok = true })
end)
RegisterNUICallback('invitePlayer', function(data, cb)
DevXTriggerCallback('housing:invite', { target = tonumber(data.target) }, function(result)
if result.ok then
notify('Apartment-Einladung wurde gesendet. Der Spieler muss sie zuerst annehmen.')
SetNuiFocus(false, false)
sendUi('close')
else
notify(result.error or 'Einladung konnte nicht gesendet werden.')
end
cb(result)
end)
end)
RegisterNetEvent('devx_housing:client:notify', notify)
RegisterNetEvent('devx_housing:client:invite', function(data)
pendingInvite = {
propertyKey = data.propertyKey,
label = data.label or 'Apartment',
invitedBy = data.invitedBy or 'Jemand',
expiresAt = GetGameTimer() + ((tonumber(data.expiresIn) or 120) * 1000)
}
PlaySoundFrontend(-1, 'Menu_Accept', 'Phone_SoundSet_Default', true)
notify(('%s lädt dich in %s ein. Drücke E zum Annehmen oder X zum Ablehnen.'):format(
pendingInvite.invitedBy, pendingInvite.label
))
end)
RegisterNetEvent('devx_housing:client:enterGranted', function(result)
local unit, building = findUnit(result.propertyKey)
if not unit then return end
pendingInvite = nil
applyUnitResult(result, unit, building)
notify(('Zutritt zu %s erhalten.'):format(result.label or unit.label))
end)
RegisterNetEvent('devx_housing:client:doorbell', function(data)
pendingDoorbell = data
pendingDoorbell.expiresAt = GetGameTimer() + 30000
PlaySoundFrontend(-1, 'Door_Open', 'DOCKS_HEIST_FINALE_2B_SOUNDS', true)
notify(('%s klingelt an deiner Wohnung. Drücke E zum Öffnen oder X zum Ablehnen.'):format(data.visitorName))
end)
RegisterNetEvent('devx_housing:client:knock', function(visitorName)
PlaySoundFrontend(-1, 'Knuckle_Crack_Hard_Cel', 'MP_SNACKS_SOUNDSET', true)
notify(('%s klopft an der Wohnungstür.'):format(visitorName or 'Jemand'))
end)
RegisterNetEvent('devx_housing:client:visitorAccepted', function(visitorName)
notify(('%s wurde in deine Wohnung gelassen.'):format(visitorName or 'Der Besucher'))
end)
CreateThread(function()
while true do
if pendingInvite then
if GetGameTimer() > pendingInvite.expiresAt then
pendingInvite = nil
notify('Die Apartment-Einladung ist abgelaufen.')
else
help(('%s lädt dich ein: ~INPUT_CONTEXT~ annehmen | ~INPUT_VEH_DUCK~ ablehnen'):format(pendingInvite.invitedBy))
if IsControlJustReleased(0, 38) then
DevXTriggerCallback('housing:acceptInvite', {}, function(result)
if result.ok then
local unit, building = findUnit(result.propertyKey)
if unit then applyUnitResult(result, unit, building) end
else
notify(result.error or 'Einladung ist nicht mehr gültig.')
end
end)
pendingInvite = nil
elseif IsControlJustReleased(0, 73) then
DevXTriggerCallback('housing:declineInvite', {}, function() end)
pendingInvite = nil
notify('Apartment-Einladung abgelehnt.')
end
end
Wait(0)
else
Wait(400)
end
end
end)
CreateThread(function()
while true do
if pendingDoorbell then
if pendingDoorbell.expiresAt and GetGameTimer() > pendingDoorbell.expiresAt then
pendingDoorbell = nil
else
help(('%s klingelt: ~INPUT_CONTEXT~ öffnen | ~INPUT_VEH_DUCK~ ablehnen'):format(
pendingDoorbell.visitorName or 'Besucher'
))
if IsControlJustReleased(0, 38) then
DevXTriggerCallback('housing:acceptVisitor', {}, function(result)
if not result.ok then notify(result.error) end
end)
pendingDoorbell = nil
elseif IsControlJustReleased(0, 73) then
DevXTriggerCallback('housing:declineVisitor', {}, function() end)
pendingDoorbell = nil
notify('Klingelanfrage abgelehnt.')
end
end
Wait(0)
else
Wait(400)
end
end
end)
AddEventHandler('devx_core:client:playerLoaded', function()
locationMode = 'world'
currentBuilding = nil
currentFloor = nil
currentProperty = nil
currentInteriorStyle = nil
clearHallwayObjects()
refreshProperties()
end)
CreateThread(function()
while not exports.devx_core:IsPlayerLoaded() do Wait(500) end
refreshProperties()
for _, building in ipairs(DevXHousing.Buildings) do
local blip = AddBlipForCoord(building.exterior.x, building.exterior.y, building.exterior.z)
SetBlipSprite(blip, 40)
SetBlipScale(blip, 0.72)
SetBlipColour(blip, 3)
SetBlipAsShortRange(blip, true)
BeginTextCommandSetBlipName('STRING')
AddTextComponentString(building.label)
EndTextCommandSetBlipName(blip)
end
end)
CreateThread(function()
while true do
if locationMode == 'floor' and GetGameTimer() - lastPropertyRefresh >= (DevXHousing.PropertyRefreshMs or 8000) then
refreshProperties()
end
Wait(1000)
end
end)
CreateThread(function()
while true do
local sleep = 900
if exports.devx_core:IsPlayerLoaded() and not previewOpen then
local ped = PlayerPedId()
local coords = GetEntityCoords(ped)
if locationMode == 'world' then
local _, ownedBuilding = nil, nil
if ownedPropertyKey then _, ownedBuilding = findUnit(ownedPropertyKey) end
for _, building in ipairs(DevXHousing.Buildings) do
local entry = building.exterior
local distance = #(coords - vector3(entry.x, entry.y, entry.z))
if distance < DevXHousing.MarkerDistance then
sleep = 0
DrawMarker(1, entry.x, entry.y, entry.z - 1.0, 0.0,0.0,0.0,0.0,0.0,0.0,
1.0,1.0,0.25,65,110,245,125,false,false,2,false,nil,nil,false)
if distance < DevXHousing.InteractionDistance then
if ownedPropertyKey and ownedBuilding and ownedBuilding.key == building.key then
help(('~INPUT_CONTEXT~ Apartment betreten | ~INPUT_DETONATE~ Lobby von %s'):format(building.label))
if IsControlJustReleased(0, 38) then enterOwned(building)
elseif IsControlJustReleased(0, 47) then enterLobby(building) end
elseif ownedPropertyKey then
help(('Du besitzt bereits %s | ~INPUT_DETONATE~ Lobby betreten'):format(ownedPropertyLabel or 'ein Apartment'))
if IsControlJustReleased(0, 47) then enterLobby(building) end
else
help(('~INPUT_CONTEXT~ Apartments ansehen | ~INPUT_DETONATE~ Lobby von %s betreten'):format(building.label))
if IsControlJustReleased(0, 38) then startPreview(building)
elseif IsControlJustReleased(0, 47) then enterLobby(building) end
end
end
end
end
elseif locationMode == 'lobby' or locationMode == 'floor' then
local shell = DevXHousing.FloorShell
local elevatorDistance = #(coords - vector3(shell.elevator.x, shell.elevator.y, shell.elevator.z))
local exitDistance = #(coords - vector3(shell.exit.x, shell.exit.y, shell.exit.z))
if elevatorDistance < 10.0 then
sleep = 0
DrawMarker(1, shell.elevator.x, shell.elevator.y, shell.elevator.z - 1.0,
0.0,0.0,0.0,0.0,0.0,0.0,0.75,0.75,0.22,80,145,255,130,false,false,2,false,nil,nil,false)
if elevatorDistance < 1.5 then
help('Drücke ~INPUT_CONTEXT~, um den Aufzug zu benutzen.')
if IsControlJustReleased(0, 38) then
SetNuiFocus(true, true)
sendUi('elevatorOpen', {
building = currentBuilding.label,
floors = currentBuilding.floors,
currentFloor = currentFloor or 0
})
end
end
end
if exitDistance < 8.0 then
sleep = 0
DrawMarker(1, shell.exit.x, shell.exit.y, shell.exit.z - 1.0,
0.0,0.0,0.0,0.0,0.0,0.0,0.75,0.75,0.22,255,255,255,100,false,false,2,false,nil,nil,false)
if exitDistance < 1.5 then
help('Drücke ~INPUT_CONTEXT~, um das Gebäude zu verlassen.')
if IsControlJustReleased(0, 38) then
DevXTriggerCallback('housing:leaveBuilding', {}, function(result)
if result.ok then
locationMode = 'world'
currentFloor = nil
clearHallwayObjects()
teleport(result.position)
restoreWorldStreaming('housing-leave-building')
else notify(result.error) end
end)
end
end
end
if locationMode == 'floor' and currentBuilding and currentFloor then
for _, unit in ipairs(currentBuilding.units) do
if unit.floor == currentFloor then
local state = stateFor(unit)
-- Free units have no resident sign and no door action.
if state.sold then
local door = unit.door
local distance = #(coords - vector3(door.x, door.y, door.z))
if distance < 12.0 then
sleep = 0
drawText3D(door.sign or { x = door.x + 0.75, y = door.y, z = door.z + 1.2 },
('Wohnung %s'):format(unit.doorNumber),
state.ownedByMe and ('~b~%s (Deine Wohnung)'):format(state.ownerName) or state.ownerName)
if distance < 1.55 then
if state.ownedByMe then
help(('~INPUT_CONTEXT~ Wohnung %s betreten'):format(unit.doorNumber))
if IsControlJustReleased(0, 38) then enterUnit(unit) end
else
help(('~INPUT_CONTEXT~ bei %s klingeln oder klopfen'):format(state.ownerName))
if IsControlJustReleased(0, 38) then openDoorMenu(unit) end
end
end
end
end
end
end
end
elseif locationMode == 'unit' and currentProperty then
local unit = findUnit(currentProperty)
if unit then
local interior = DevXHousing.Interiors[unit.style]
local exit = interior.exit
local distance = #(coords - vector3(exit.x, exit.y, exit.z))
if distance < 10.0 then
sleep = 0
DrawMarker(1, exit.x, exit.y, exit.z - 1.0, 0.0,0.0,0.0,0.0,0.0,0.0,
0.85,0.85,0.22,255,255,255,105,false,false,2,false,nil,nil,false)
if distance < 1.55 then
local state = stateFor(unit)
if state.ownedByMe then
help('~INPUT_CONTEXT~ Apartment verlassen | ~INPUT_DETONATE~ Spieler einladen')
else
help('~INPUT_CONTEXT~ Apartment verlassen')
end
if IsControlJustReleased(0, 38) then
DevXTriggerCallback('housing:exitUnit', {}, function(result)
if result.ok then
TriggerEvent('devx_housing:client:leftApartment')
locationMode = 'floor'
currentProperty = nil
currentInteriorStyle = nil
currentFloor = result.floor
refreshProperties()
if teleport(result.position, DevXHousing.FloorShell) then
spawnHallwayObjects(true)
end
else notify(result.error) end
end)
elseif state.ownedByMe and IsControlJustReleased(0, 47) then
openInviteMenu()
end
end
end
for _, point in ipairs(interior.interactions or {}) do
local pointDistance = #(coords - vector3(point.coords.x, point.coords.y, point.coords.z))
if pointDistance < 7.0 then
sleep = 0
DrawMarker(2, point.coords.x, point.coords.y, point.coords.z + 0.15,
0.0,0.0,0.0,0.0,180.0,0.0,0.22,0.22,0.22,95,160,255,155,false,false,2,false,nil,nil,false)
if pointDistance < 1.35 then
help(('~INPUT_CONTEXT~ %s'):format(point.label))
if IsControlJustReleased(0, 38) then useApartmentInteraction(point) end
end
end
end
end
end
end
Wait(sleep)
end
end)
RegisterCommand('inviteapartment', function(_, args)
local target = tonumber(args[1])
if not target then
openInviteMenu()
return
end
DevXTriggerCallback('housing:invite', { target = target }, function(result)
notify(result.ok and 'Apartment-Einladung gesendet.' or (result.error or 'Einladung fehlgeschlagen.'))
end)
end, false)
exports('GetHousingState', function()
return {
mode = locationMode,
propertyKey = currentProperty,
buildingKey = currentBuilding and currentBuilding.key or nil,
floor = currentFloor,
ownedPropertyKey = ownedPropertyKey,
interiorStyle = currentInteriorStyle,
interior = currentInteriorStyle and DevXHousing.Interiors[currentInteriorStyle] or nil
}
end)
AddEventHandler('onResourceStop', function(resourceName)
if resourceName ~= GetCurrentResourceName() then return end
destroyPreview()
clearHallwayObjects()
TriggerServerEvent('devx_core:server:returnToWorld')
TriggerEvent('devx_ipl:client:restoreWorld', 'housing-stop')
end)
@@ -0,0 +1,34 @@
fx_version 'cerulean'
game 'gta5'
author 'Colin-Joel / DevX Studios'
description 'DevX RP multi-floor apartments, previews, elevators and visitors'
version '0.4.3'
lua54 'yes'
shared_script 'shared/config.lua'
ui_page 'web/index.html'
files {
'web/index.html',
'web/style.css',
'web/app.js'
}
client_scripts {
'@devx_core/client/callbacks.lua',
'client/main.lua'
}
server_scripts {
'@oxmysql/lib/MySQL.lua',
'@devx_core/server/callbacks.lua',
'server/main.lua'
}
dependencies {
'devx_core',
'devx_banking',
'devx_ipl'
}
@@ -0,0 +1,566 @@
local propertyLocks = {}
local characterPurchaseLocks = {}
local playerLocation = {}
local temporaryInvites = {}
local directInvites = {}
local pendingDoorbells = {}
local configuredPropertyKeys = {}
for _, building in ipairs(DevXHousing.Buildings) do
for _, unit in ipairs(building.units) do
configuredPropertyKeys[unit.key] = true
end
end
local function findBuilding(key)
for _, building in ipairs(DevXHousing.Buildings) do
if building.key == key then return building end
end
return nil
end
local function findUnit(key)
for _, building in ipairs(DevXHousing.Buildings) do
for _, unit in ipairs(building.units) do
if unit.key == key then return unit, building end
end
end
return nil, nil
end
local function unitRow(propertyKey)
return MySQL.single.await([[
SELECT p.id, p.property_key AS propertyKey, p.price,
po.character_id AS ownerCharacterId,
CONCAT(c.first_name, ' ', c.last_name) AS ownerName
FROM devx_properties p
LEFT JOIN devx_property_owners po ON po.property_id = p.id
LEFT JOIN devx_characters c ON c.id = po.character_id AND c.deleted_at IS NULL
WHERE p.property_key = ?
LIMIT 1
]], { propertyKey })
end
local function ownedUnitRow(characterId)
return MySQL.single.await([[
SELECT p.id, p.property_key AS propertyKey, p.label, p.price,
po.character_id AS ownerCharacterId,
CONCAT(c.first_name, ' ', c.last_name) AS ownerName
FROM devx_property_owners po
INNER JOIN devx_properties p ON p.id = po.property_id
LEFT JOIN devx_characters c ON c.id = po.character_id AND c.deleted_at IS NULL
WHERE po.character_id = ? AND p.property_type = 'apartment'
ORDER BY po.purchased_at ASC
LIMIT 1
]], { characterId })
end
local function floorBucket(building, floor)
return DevXHousing.FloorBucketBase + (building.index * 100) + tonumber(floor or 0)
end
local function unitBucket(propertyId)
return DevXHousing.UnitBucketBase + tonumber(propertyId)
end
local function setLocation(source, location, bucket)
playerLocation[source] = location
SetPlayerRoutingBucket(source, bucket or 0)
if bucket and bucket > 0 then
SetRoutingBucketPopulationEnabled(bucket, false)
SetRoutingBucketEntityLockdownMode(bucket, 'relaxed')
end
end
local function resetPlayer(source)
playerLocation[source] = nil
temporaryInvites[source] = nil
directInvites[source] = nil
pendingDoorbells[source] = nil
SetPlayerRoutingBucket(source, 0)
end
local function syncProperties()
for _, building in ipairs(DevXHousing.Buildings) do
for _, unit in ipairs(building.units) do
local interior = DevXHousing.Interiors[unit.style]
MySQL.query.await([[
INSERT INTO devx_properties
(property_key, label, price, property_type, entry_data, interior_data)
VALUES (?, ?, ?, 'apartment', ?, ?)
ON DUPLICATE KEY UPDATE
label = VALUES(label),
price = VALUES(price),
entry_data = VALUES(entry_data),
interior_data = VALUES(interior_data)
]], {
unit.key,
unit.label,
unit.price,
json.encode({
buildingKey = building.key,
floor = unit.floor,
unitNumber = unit.unitNumber,
exterior = building.exterior
}),
json.encode(interior)
})
end
end
-- Soft-deleted or otherwise missing characters must not keep a door sign.
-- This also repairs ownership rows left behind by older test versions.
MySQL.query.await([[
DELETE po FROM devx_property_owners po
LEFT JOIN devx_characters c ON c.id = po.character_id
WHERE c.id IS NULL OR c.deleted_at IS NOT NULL
]])
print('[devx_housing] Apartmentdaten v0.4.3 synchronisiert; verwaiste Eigentümer bereinigt.')
end
MySQL.ready(syncProperties)
DevXRegisterCallback('housing:list', function(source)
local player = exports.devx_core:GetPlayer(source)
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
local rows = MySQL.query.await([[
SELECT p.id, p.property_key AS propertyKey, p.label, p.price,
po.character_id AS ownerCharacterId,
CONCAT(c.first_name, ' ', c.last_name) AS ownerName
FROM devx_properties p
LEFT JOIN devx_property_owners po ON po.property_id = p.id
LEFT JOIN devx_characters c ON c.id = po.character_id AND c.deleted_at IS NULL
WHERE p.property_type = 'apartment'
ORDER BY p.id
]]) or {}
local filtered = {}
for _, row in ipairs(rows) do
if configuredPropertyKeys[row.propertyKey] then
-- An ownership row without a valid active character is never exposed
-- to the hallway UI as an occupied apartment.
if row.ownerCharacterId and (not row.ownerName or row.ownerName == '') then
MySQL.query.await('DELETE FROM devx_property_owners WHERE property_id = ?', { row.id })
row.ownerCharacterId = nil
row.ownerName = nil
end
filtered[#filtered + 1] = row
end
end
local owned = ownedUnitRow(player.characterId)
if owned and not configuredPropertyKeys[owned.propertyKey] then owned = nil end
return {
ok = true,
properties = filtered,
characterId = player.characterId,
ownedPropertyKey = owned and owned.propertyKey or nil,
ownedPropertyLabel = owned and owned.label or nil
}
end)
DevXRegisterCallback('housing:buy', function(source, payload)
local player = exports.devx_core:GetPlayer(source)
local unit = findUnit(payload.propertyKey)
if not player or not unit then return { ok = false, error = 'Ungültige Wohnung.' } end
local existing = ownedUnitRow(player.characterId)
if existing then
return {
ok = false,
error = ('Du besitzt bereits %s. Pro Charakter ist derzeit nur ein Apartment erlaubt.'):format(existing.label)
}
end
if characterPurchaseLocks[player.characterId] then
return { ok = false, error = 'Ein anderer Wohnungskauf wird bereits verarbeitet.' }
end
if propertyLocks[unit.key] then return { ok = false, error = 'Die Wohnung wird gerade bearbeitet.' } end
characterPurchaseLocks[player.characterId] = true
propertyLocks[unit.key] = true
local ok, response = pcall(function()
local row = unitRow(unit.key)
if not row then return { ok = false, error = 'Wohnung fehlt in der Datenbank.' } end
if row.ownerCharacterId then return { ok = false, error = 'Die Wohnung ist bereits verkauft.' } end
local charged, chargeResult = exports.devx_banking:ChargeCharacter(
player.characterId,
tonumber(row.price),
('Kauf: %s'):format(unit.label),
'Immobilienverwaltung DevX-City'
)
if not charged then return { ok = false, error = chargeResult } end
local ownerId = MySQL.insert.await([[
INSERT INTO devx_property_owners (property_id, character_id, purchase_price)
VALUES (?, ?, ?)
]], { row.id, player.characterId, row.price })
if not ownerId then
exports.devx_banking:CreditCharacter(
player.characterId,
tonumber(row.price),
('Rückbuchung: %s'):format(unit.label),
'Immobilienverwaltung DevX-City'
)
return { ok = false, error = 'Kauf wurde abgebrochen und zurückgebucht.' }
end
local overview = MySQL.single.await('SELECT balance FROM devx_bank_accounts WHERE id = ?', { player.accountId })
if overview then exports.devx_core:SetBankCache(source, tonumber(overview.balance)) end
return { ok = true, propertyKey = unit.key }
end)
propertyLocks[unit.key] = nil
characterPurchaseLocks[player.characterId] = nil
if not ok then
print(('[devx_housing] Kauf fehlgeschlagen: %s'):format(response))
return { ok = false, error = 'Interner Immobilienfehler.' }
end
return response
end)
DevXRegisterCallback('housing:previewStart', function(source, payload)
local player = exports.devx_core:GetPlayer(source)
local building = findBuilding(payload.buildingKey)
if not player or not building then return { ok = false, error = 'Ungültiges Gebäude.' } end
local owned = ownedUnitRow(player.characterId)
if owned then
return { ok = false, error = ('Du besitzt bereits %s.'):format(owned.label or 'ein Apartment') }
end
local bucket = DevXHousing.PreviewBucketBase + tonumber(source)
setLocation(source, { type = 'preview', buildingKey = building.key }, bucket)
return { ok = true }
end)
DevXRegisterCallback('housing:previewEnd', function(source)
local location = playerLocation[source]
if location and location.type == 'preview' then resetPlayer(source) end
return { ok = true }
end)
DevXRegisterCallback('housing:enterLobby', function(source, payload)
local player = exports.devx_core:GetPlayer(source)
local building = findBuilding(payload.buildingKey)
if not player or not building then return { ok = false, error = 'Ungültiges Gebäude.' } end
setLocation(source, { type = 'lobby', buildingKey = building.key, floor = 0 }, floorBucket(building, 0))
return { ok = true, position = DevXHousing.FloorShell.spawn, building = building.key }
end)
DevXRegisterCallback('housing:goFloor', function(source, payload)
local player = exports.devx_core:GetPlayer(source)
local building = findBuilding(payload.buildingKey)
local floor = tonumber(payload.floor)
if not player or not building or not floor or floor < 1 or floor > building.floors then
return { ok = false, error = 'Ungültige Etage.' }
end
setLocation(source, { type = 'floor', buildingKey = building.key, floor = floor }, floorBucket(building, floor))
return { ok = true, position = DevXHousing.FloorShell.spawn, floor = floor }
end)
DevXRegisterCallback('housing:leaveBuilding', function(source)
local location = playerLocation[source]
if not location then return { ok = false, error = 'Du bist in keinem Gebäude.' } end
local building = findBuilding(location.buildingKey)
if not building then return { ok = false, error = 'Gebäude nicht gefunden.' } end
resetPlayer(source)
return { ok = true, position = building.exterior }
end)
local function hasUnitAccess(source, row)
local player = exports.devx_core:GetPlayer(source)
if not player or not row then return false end
if tonumber(row.ownerCharacterId) == tonumber(player.characterId) then return true end
local invite = temporaryInvites[source]
return invite
and invite.propertyKey == row.propertyKey
and invite.expiresAt >= os.time()
end
local function enterUnitFor(source, propertyKey)
local unit, building = findUnit(propertyKey)
local row = unit and unitRow(propertyKey) or nil
if not unit or not building or not row or not row.ownerCharacterId then
return { ok = false, error = 'Diese Wohnung ist nicht bewohnt.' }
end
if not hasUnitAccess(source, row) then
return { ok = false, error = 'Du hast keine Einladung für diese Wohnung.' }
end
local interior = DevXHousing.Interiors[unit.style]
setLocation(source, {
type = 'unit',
propertyKey = unit.key,
propertyId = tonumber(row.id),
buildingKey = building.key,
floor = unit.floor,
ownerCharacterId = tonumber(row.ownerCharacterId)
}, unitBucket(row.id))
return {
ok = true,
interior = interior,
propertyKey = unit.key,
label = unit.label,
ownerName = row.ownerName,
floor = unit.floor,
style = unit.style,
interactions = interior.interactions or {}
}
end
DevXRegisterCallback('housing:enterUnit', function(source, payload)
return enterUnitFor(source, payload.propertyKey)
end)
DevXRegisterCallback('housing:enterOwned', function(source, payload)
local player = exports.devx_core:GetPlayer(source)
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
local owned = ownedUnitRow(player.characterId)
if not owned then return { ok = false, error = 'Du besitzt noch kein Apartment.' } end
local unit, building = findUnit(owned.propertyKey)
if not unit or not building then return { ok = false, error = 'Dein Apartment ist nicht mehr konfiguriert.' } end
if payload and payload.buildingKey and payload.buildingKey ~= building.key then
return { ok = false, error = ('Dein Apartment befindet sich in %s.'):format(building.label) }
end
return enterUnitFor(source, owned.propertyKey)
end)
DevXRegisterCallback('housing:exitUnit', function(source)
local location = playerLocation[source]
if not location or location.type ~= 'unit' then return { ok = false, error = 'Du bist in keiner Wohnung.' } end
local building = findBuilding(location.buildingKey)
setLocation(source, {
type = 'floor',
buildingKey = location.buildingKey,
floor = location.floor
}, floorBucket(building, location.floor))
return { ok = true, position = DevXHousing.FloorShell.spawn, floor = location.floor }
end)
local function activeSourceForCharacter(characterId)
for source, player in pairs(exports.devx_core:GetActivePlayers()) do
if tonumber(player.characterId) == tonumber(characterId) then return tonumber(source) end
end
return nil
end
DevXRegisterCallback('housing:ring', function(source, payload)
local visitor = exports.devx_core:GetPlayer(source)
local row = unitRow(payload.propertyKey)
local unit = findUnit(payload.propertyKey)
if not visitor or not row or not row.ownerCharacterId or not unit then
return { ok = false, error = 'Hier wohnt niemand.' }
end
local ownerSource = activeSourceForCharacter(row.ownerCharacterId)
if not ownerSource then return { ok = false, error = 'Der Bewohner ist derzeit nicht online.' } end
pendingDoorbells[ownerSource] = {
visitorSource = source,
visitorName = visitor.fullName,
propertyKey = payload.propertyKey,
expiresAt = os.time() + 30
}
TriggerClientEvent('devx_housing:client:doorbell', ownerSource, {
visitorSource = source,
visitorName = visitor.fullName,
propertyKey = payload.propertyKey,
label = unit.label
})
return { ok = true, message = 'Du hast geklingelt.' }
end)
DevXRegisterCallback('housing:knock', function(source, payload)
local visitor = exports.devx_core:GetPlayer(source)
local row = unitRow(payload.propertyKey)
if not visitor or not row or not row.ownerCharacterId then return { ok = false, error = 'Hier wohnt niemand.' } end
for target, location in pairs(playerLocation) do
if location.type == 'unit' and location.propertyKey == payload.propertyKey then
TriggerClientEvent('devx_housing:client:knock', target, visitor.fullName)
end
end
return { ok = true, message = 'Du hast angeklopft.' }
end)
DevXRegisterCallback('housing:acceptVisitor', function(source)
local owner = exports.devx_core:GetPlayer(source)
local location = playerLocation[source]
local bell = pendingDoorbells[source]
if not owner or not location or location.type ~= 'unit' or not bell or bell.expiresAt < os.time() then
return { ok = false, error = 'Keine aktuelle Klingelanfrage.' }
end
if bell.propertyKey ~= location.propertyKey then return { ok = false, error = 'Die Anfrage gehört zu einer anderen Wohnung.' } end
temporaryInvites[bell.visitorSource] = {
propertyKey = bell.propertyKey,
expiresAt = os.time() + DevXHousing.InvitationSeconds
}
pendingDoorbells[source] = nil
local result = enterUnitFor(bell.visitorSource, bell.propertyKey)
if result.ok then
TriggerClientEvent('devx_housing:client:enterGranted', bell.visitorSource, result)
TriggerClientEvent('devx_housing:client:visitorAccepted', source, bell.visitorName)
end
return result
end)
DevXRegisterCallback('housing:inviteCandidates', function(source)
local owner = exports.devx_core:GetPlayer(source)
local location = playerLocation[source]
if not owner or not location or location.type ~= 'unit' then
return { ok = false, error = 'Du musst dich in deiner Wohnung befinden.' }
end
if tonumber(location.ownerCharacterId) ~= tonumber(owner.characterId) then
return { ok = false, error = 'Nur der Eigentümer kann Spieler einladen.' }
end
local players = {}
for playerSource, player in pairs(exports.devx_core:GetActivePlayers()) do
local numericSource = tonumber(playerSource)
if numericSource and numericSource ~= tonumber(source) then
players[#players + 1] = {
source = numericSource,
name = player.fullName or GetPlayerName(numericSource) or ('Spieler %s'):format(numericSource)
}
end
end
table.sort(players, function(a, b) return a.source < b.source end)
return { ok = true, players = players }
end)
DevXRegisterCallback('housing:invite', function(source, payload)
local owner = exports.devx_core:GetPlayer(source)
local target = tonumber(payload.target)
local location = playerLocation[source]
if not owner or not location or location.type ~= 'unit' then
return { ok = false, error = 'Du musst dich in deiner Wohnung befinden.' }
end
if tonumber(location.ownerCharacterId) ~= tonumber(owner.characterId) then
return { ok = false, error = 'Nur der Eigentümer kann Gäste einladen.' }
end
if not target or not exports.devx_core:GetPlayer(target) then return { ok = false, error = 'Spieler nicht gefunden.' } end
if target == tonumber(source) then return { ok = false, error = 'Du kannst dich nicht selbst einladen.' } end
local unit = findUnit(location.propertyKey)
directInvites[target] = {
propertyKey = location.propertyKey,
invitedBy = owner.fullName,
expiresAt = os.time() + DevXHousing.InvitationSeconds
}
TriggerClientEvent('devx_housing:client:invite', target, {
propertyKey = location.propertyKey,
invitedBy = owner.fullName,
label = unit and unit.label or 'Apartment',
expiresIn = DevXHousing.InvitationSeconds
})
return { ok = true }
end)
DevXRegisterCallback('housing:acceptInvite', function(source)
local invite = directInvites[source]
if not invite or invite.expiresAt < os.time() then
directInvites[source] = nil
return { ok = false, error = 'Du hast keine gültige Apartment-Einladung.' }
end
temporaryInvites[source] = {
propertyKey = invite.propertyKey,
expiresAt = invite.expiresAt
}
directInvites[source] = nil
return enterUnitFor(source, invite.propertyKey)
end)
DevXRegisterCallback('housing:declineInvite', function(source)
directInvites[source] = nil
return { ok = true }
end)
DevXRegisterCallback('housing:declineVisitor', function(source)
pendingDoorbells[source] = nil
return { ok = true }
end)
-- /inviteapartment is handled client-side so it can open the player picker.
RegisterCommand('acceptapartment', function(source)
if source == 0 then return end
local invite = directInvites[source]
if not invite or invite.expiresAt < os.time() then
directInvites[source] = nil
TriggerClientEvent('devx_housing:client:notify', source, 'Keine gültige Apartment-Einladung.')
return
end
temporaryInvites[source] = { propertyKey = invite.propertyKey, expiresAt = invite.expiresAt }
directInvites[source] = nil
local result = enterUnitFor(source, invite.propertyKey)
if result.ok then TriggerClientEvent('devx_housing:client:enterGranted', source, result)
else TriggerClientEvent('devx_housing:client:notify', source, result.error) end
end, false)
RegisterCommand('acceptvisitor', function(source)
if source == 0 then return end
local bell = pendingDoorbells[source]
local location = playerLocation[source]
if not bell or bell.expiresAt < os.time() or not location or location.type ~= 'unit' then
TriggerClientEvent('devx_housing:client:notify', source, 'Keine aktuelle Klingelanfrage.')
return
end
temporaryInvites[bell.visitorSource] = { propertyKey = bell.propertyKey, expiresAt = os.time() + DevXHousing.InvitationSeconds }
pendingDoorbells[source] = nil
local result = enterUnitFor(bell.visitorSource, bell.propertyKey)
if result.ok then
TriggerClientEvent('devx_housing:client:enterGranted', bell.visitorSource, result)
TriggerClientEvent('devx_housing:client:visitorAccepted', source, bell.visitorName)
end
end, false)
exports('GetPlayerHousingLocation', function(source)
local location = playerLocation[tonumber(source)]
if not location then return nil end
local copy = {}
for key, value in pairs(location) do copy[key] = value end
if location.propertyKey then
local unit = findUnit(location.propertyKey)
if unit then
copy.style = unit.style
copy.interior = DevXHousing.Interiors[unit.style]
end
end
return copy
end)
exports('GetOwnedApartment', function(characterId)
return ownedUnitRow(tonumber(characterId))
end)
AddEventHandler('playerDropped', function()
resetPlayer(source)
end)
AddEventHandler('onResourceStart', function(resourceName)
if resourceName ~= GetCurrentResourceName() then return end
for _, playerId in ipairs(GetPlayers()) do SetPlayerRoutingBucket(tonumber(playerId), 0) end
end)
AddEventHandler('onResourceStop', function(resourceName)
if resourceName ~= GetCurrentResourceName() then return end
for _, playerId in ipairs(GetPlayers()) do SetPlayerRoutingBucket(tonumber(playerId), 0) end
end)
@@ -0,0 +1,164 @@
DevXHousing = {
MarkerDistance = 25.0,
InteractionDistance = 1.8,
PreviewBucketBase = 50000,
UnitBucketBase = 40000,
FloorBucketBase = 30000,
InvitationSeconds = 120,
MaxPropertiesPerCharacter = 1,
PropertyRefreshMs = 8000,
-- v0.4.3 only uses GTA Online interiors that are available without
-- RequestIpl/RemoveIpl. This avoids overlapping apartment IPLs and world
-- holes after returning to Los Santos.
Interiors = {
low = {
label = 'Klassisch',
premium = 0,
ipls = {},
spawn = { x = 266.11, y = -1007.55, z = -101.01, heading = 357.0 },
exit = { x = 266.11, y = -1007.55, z = -101.01 },
loadAt = { x = 261.4586, y = -998.8196, z = -99.00863 },
cameraShots = {
{ cam = { x = 265.20, y = -1003.85, z = -99.38 }, target = { x = 263.00, y = -1000.55, z = -99.70 } },
{ cam = { x = 262.90, y = -1001.15, z = -99.42 }, target = { x = 266.10, y = -999.90, z = -99.72 } },
{ cam = { x = 268.05, y = -999.65, z = -99.40 }, target = { x = 265.60, y = -1003.10, z = -99.72 } }
},
companionSpawn = { x = 265.10, y = -1001.20, z = -99.00, heading = 165.0 },
interactions = {
{ key = 'beer', label = 'Bier trinken', type = 'beer', coords = { x = 263.45, y = -1002.25, z = -99.00 } },
{ key = 'sleep', label = 'Im Bett schlafen', type = 'sleep', coords = { x = 262.90, y = -1004.05, z = -99.00 }, heading = 178.0 },
{ key = 'bong', label = 'Bong benutzen', type = 'bong', coords = { x = 266.90, y = -999.15, z = -99.00 } }
}
},
mid = {
label = 'Modern',
premium = 24000,
ipls = {},
spawn = { x = 346.52, y = -1012.90, z = -99.20, heading = 2.0 },
exit = { x = 346.52, y = -1012.90, z = -99.20 },
loadAt = { x = 347.2686, y = -999.2955, z = -99.19622 },
cameraShots = {
{ cam = { x = 344.80, y = -1008.00, z = -97.58 }, target = { x = 341.25, y = -1003.70, z = -97.95 } },
{ cam = { x = 340.25, y = -1003.20, z = -97.62 }, target = { x = 345.15, y = -1001.15, z = -97.95 } },
{ cam = { x = 348.55, y = -1003.20, z = -97.60 }, target = { x = 343.75, y = -1006.50, z = -97.95 } }
},
companionSpawn = { x = 342.80, y = -1004.90, z = -99.20, heading = 95.0 },
interactions = {
{ key = 'whiskey', label = 'Whiskey trinken', type = 'whiskey', coords = { x = 343.65, y = -1002.65, z = -99.20 } },
{ key = 'beer', label = 'Bier trinken', type = 'beer', coords = { x = 341.20, y = -1003.10, z = -99.20 } },
{ key = 'sleep', label = 'Im Bett schlafen', type = 'sleep', coords = { x = 350.15, y = -1007.55, z = -99.20 }, heading = 270.0 },
{ key = 'bong', label = 'Bong benutzen', type = 'bong', coords = { x = 337.95, y = -997.45, z = -99.20 } }
}
},
high = {
label = 'Luxus',
premium = 65000,
ipls = {},
spawn = { x = -17.86, y = -589.22, z = 79.43, heading = 337.0 },
exit = { x = -17.86, y = -589.22, z = 79.43 },
loadAt = { x = -18.07856, y = -583.6725, z = 79.46569 },
cameraShots = {
{ cam = { x = -20.65, y = -586.10, z = 81.10 }, target = { x = -16.10, y = -582.90, z = 80.05 } },
{ cam = { x = -13.10, y = -581.15, z = 81.05 }, target = { x = -18.70, y = -578.80, z = 80.00 } },
{ cam = { x = -22.10, y = -577.90, z = 81.05 }, target = { x = -17.00, y = -582.50, z = 80.00 } }
},
companionSpawn = { x = -15.20, y = -583.20, z = 79.47, heading = 155.0 },
interactions = {
{ key = 'whiskey', label = 'Whiskey trinken', type = 'whiskey', coords = { x = -17.20, y = -579.75, z = 79.47 } },
{ key = 'beer', label = 'Bier trinken', type = 'beer', coords = { x = -13.90, y = -582.30, z = 79.47 } },
{ key = 'sleep', label = 'Im Bett schlafen', type = 'sleep', coords = { x = -20.45, y = -570.55, z = 79.47 }, heading = 180.0 },
{ key = 'bong', label = 'Bong benutzen', type = 'bong', coords = { x = -10.85, y = -583.20, z = 79.47 } }
}
},
penthouse = {
label = 'Penthouse',
premium = 118000,
ipls = {},
spawn = { x = -781.95, y = 339.78, z = 211.20, heading = 88.0 },
exit = { x = -781.95, y = 339.78, z = 211.20 },
loadAt = { x = -773.407, y = 341.766, z = 211.397 },
cameraShots = {
{ cam = { x = -779.30, y = 342.10, z = 213.10 }, target = { x = -773.40, y = 341.75, z = 211.90 } },
{ cam = { x = -771.90, y = 338.45, z = 213.05 }, target = { x = -768.20, y = 344.20, z = 211.95 } },
{ cam = { x = -768.80, y = 348.30, z = 213.00 }, target = { x = -774.10, y = 343.25, z = 211.95 } }
},
companionSpawn = { x = -773.10, y = 343.25, z = 211.40, heading = 260.0 },
interactions = {
{ key = 'whiskey', label = 'Whiskey trinken', type = 'whiskey', coords = { x = -768.15, y = 341.80, z = 211.40 } },
{ key = 'beer', label = 'Bier trinken', type = 'beer', coords = { x = -771.60, y = 338.80, z = 211.40 } },
{ key = 'sleep', label = 'Im Bett schlafen', type = 'sleep', coords = { x = -765.15, y = 329.30, z = 211.40 }, heading = 90.0 },
{ key = 'bong', label = 'Bong benutzen', type = 'bong', coords = { x = -778.10, y = 338.35, z = 211.40 } }
}
}
},
-- The floor is a private, always-loaded 10-car garage scene. Local doors,
-- signs and decoration turn it into a stable virtual corridor. No
-- apartment IPL is requested or removed here.
FloorShell = {
ipls = {},
loadAt = { x = 229.9559, y = -981.7928, z = -99.66071 },
spawn = { x = 239.70, y = -1004.65, z = -99.00, heading = 88.5 },
elevator = { x = 239.55, y = -1004.55, z = -99.00 },
exit = { x = 239.55, y = -1001.30, z = -99.00 },
doorModel = 'v_ilev_fib_door1',
doors = {
{ x = 237.55, y = -1007.05, z = -99.00, heading = 0.0, sign = { x = 238.32, y = -1006.82, z = -97.78 } },
{ x = 234.35, y = -1007.05, z = -99.00, heading = 0.0, sign = { x = 235.12, y = -1006.82, z = -97.78 } },
{ x = 231.15, y = -1007.05, z = -99.00, heading = 0.0, sign = { x = 231.92, y = -1006.82, z = -97.78 } },
{ x = 227.95, y = -1007.05, z = -99.00, heading = 0.0, sign = { x = 228.72, y = -1006.82, z = -97.78 } }
},
setDressing = {
{ model = 'prop_plant_int_03a', coords = { x = 240.65, y = -1005.80, z = -99.65, heading = 0.0 } },
{ model = 'prop_plant_int_03b', coords = { x = 225.95, y = -1005.80, z = -99.65, heading = 0.0 } },
{ model = 'prop_off_chair_04', coords = { x = 239.55, y = -1003.10, z = -99.65, heading = 180.0 } },
{ model = 'prop_table_03', coords = { x = 237.85, y = -1003.10, z = -99.65, heading = 0.0 } }
}
},
Buildings = {
{ key = 'eclipse', label = 'Eclipse Towers', exterior = { x = -773.92, y = 312.09, z = 85.70, heading = 178.0 }, floors = 5, unitsPerFloor = 4, basePrice = 110000, floorPrice = 8500 },
{ key = 'integrity', label = '4 Integrity Way', exterior = { x = -47.32, y = -585.99, z = 37.95, heading = 69.0 }, floors = 5, unitsPerFloor = 4, basePrice = 76000, floorPrice = 7000 },
{ key = 'del_perro', label = 'Del Perro Heights', exterior = { x = -1447.49, y = -537.49, z = 34.74, heading = 212.0 }, floors = 5, unitsPerFloor = 4, basePrice = 59000, floorPrice = 6000 }
}
}
local legacyKeys = {
eclipse = { ['3:1'] = 'eclipse_31' },
integrity = { ['2:1'] = 'integrity_28' },
del_perro = { ['1:1'] = 'del_perro_7' }
}
local function styleFor(floor, unitNumber)
if floor == 1 then return unitNumber <= 2 and 'low' or 'mid' end
if floor == 2 then return 'mid' end
if floor == 3 then return unitNumber == 4 and 'high' or 'mid' end
if floor == 4 then return 'high' end
return unitNumber == 4 and 'penthouse' or 'high'
end
for buildingIndex, building in ipairs(DevXHousing.Buildings) do
building.index = buildingIndex
building.units = {}
for floor = 1, building.floors do
for unitNumber = 1, building.unitsPerFloor do
local legacy = legacyKeys[building.key] and legacyKeys[building.key][('%s:%s'):format(floor, unitNumber)]
local key = legacy or ('%s_f%02d_u%02d'):format(building.key, floor, unitNumber)
local style = styleFor(floor, unitNumber)
local premium = DevXHousing.Interiors[style].premium or 0
building.units[#building.units + 1] = {
key = key,
buildingKey = building.key,
buildingLabel = building.label,
floor = floor,
unitNumber = unitNumber,
doorNumber = ('%s%02d'):format(floor, unitNumber),
label = ('%s Etage %s, Wohnung %s'):format(building.label, floor, unitNumber),
price = building.basePrice + (floor - 1) * building.floorPrice + (unitNumber - 1) * 1750 + premium,
style = style,
door = DevXHousing.FloorShell.doors[unitNumber]
}
end
end
end
+110
View File
@@ -0,0 +1,110 @@
const resource = typeof GetParentResourceName === 'function' ? GetParentResourceName() : 'devx_housing';
const preview = document.querySelector('#preview');
const panel = document.querySelector('#panel');
const building = document.querySelector('#building');
const label = document.querySelector('#label');
const meta = document.querySelector('#meta');
const price = document.querySelector('#price');
const buy = document.querySelector('#buy');
const buyText = document.querySelector('#buyText');
const panelTitle = document.querySelector('#panelTitle');
const panelText = document.querySelector('#panelText');
const panelBody = document.querySelector('#panelBody');
let currentDoor = null;
async function post(name, data = {}) {
const response = await fetch(`https://${resource}/${name}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
return response.json();
}
function money(value){return `${new Intl.NumberFormat('de-DE').format(Number(value)||0)} $`}
function closePanel(){panel.classList.add('hidden');post('uiClose')}
document.querySelector('#previous').onclick = () => post('previewPrevious');
document.querySelector('#next').onclick = () => post('previewNext');
document.querySelector('#previewClose').onclick = () => post('previewClose');
document.querySelector('#panelClose').onclick = closePanel;
buy.onclick = async () => {
buy.disabled = true;
const result = await post('previewBuy');
if (!result.ok) {
buyText.textContent = result.error || 'KAUF FEHLGESCHLAGEN';
setTimeout(() => buyText.textContent = 'KAUFEN', 2200);
buy.disabled = false;
}
};
document.addEventListener('keyup', event => {
if (event.key === 'Escape') {
if (!preview.classList.contains('hidden')) post('previewClose');
else if (!panel.classList.contains('hidden')) closePanel();
}
});
function elevator(data){
panel.classList.remove('hidden');
panelTitle.textContent = data.building;
panelText.textContent = `Aufzug · aktuelle Etage ${data.currentFloor || 'Lobby'}`;
panelBody.innerHTML = '';
for(let floor=1;floor<=Number(data.floors||1);floor++){
const button=document.createElement('button');button.className='panel-action';
button.innerHTML=`<b>Etage ${floor}</b><span>Wohnungen ${floor}01${floor}04</span>`;
button.onclick=()=>post('elevatorFloor',{floor});
panelBody.appendChild(button);
}
}
function invite(data){
panel.classList.remove('hidden');
panelTitle.textContent=data.title||'Spieler einladen';
panelText.textContent='Der ausgewählte Spieler erhält eine Einladung und wird erst nach der Annahme teleportiert.';
panelBody.innerHTML='';
const players=Array.isArray(data.players)?data.players:[];
if(players.length===0){
const empty=document.createElement('div');empty.className='empty-state';empty.textContent='Aktuell ist kein anderer Spieler online.';panelBody.appendChild(empty);return;
}
for(const player of players){
const button=document.createElement('button');button.className='panel-action';
button.innerHTML=`<b>${player.name||'Spieler'}</b><span>Server-ID ${player.source}</span>`;
button.onclick=async()=>{button.disabled=true;const result=await post('invitePlayer',{target:player.source});if(!result.ok)button.disabled=false;};
panelBody.appendChild(button);
}
}
function door(data){
currentDoor=data;
panel.classList.remove('hidden');
panelTitle.textContent=data.label;
panelText.textContent=data.sold?`Bewohner: ${data.ownerName||'Unbekannt'}`:'Diese Wohnung ist noch frei.';
panelBody.innerHTML='';
const actions=document.createElement('div');actions.className='door-actions';
if(data.sold){
const ring=document.createElement('button');ring.className='panel-action';ring.innerHTML='<b>Klingeln</b>';ring.onclick=()=>post('doorRing',{propertyKey:data.propertyKey});
const knock=document.createElement('button');knock.className='panel-action';knock.innerHTML='<b>Klopfen</b>';knock.onclick=()=>post('doorKnock',{propertyKey:data.propertyKey});
actions.append(ring,knock);
const enter=document.createElement('button');enter.className='panel-action enter';enter.innerHTML='<b>Mit Einladung eintreten</b>';enter.onclick=()=>post('doorEnter',{propertyKey:data.propertyKey});actions.appendChild(enter);
}
panelBody.appendChild(actions);
}
window.addEventListener('message', event => {
const data=event.data||{};
if(data.action==='previewOpen'){preview.classList.remove('hidden');panel.classList.add('hidden')}
if(data.action==='previewData'){
building.textContent=data.building||'DEVX IMMOBILIEN';
label.textContent=data.label||'Wohnung';
meta.textContent=`Etage ${data.floor} · Wohnung ${data.unitNumber} · ${data.style} · ${data.index}/${data.total}`;
price.textContent=money(data.price);
buy.disabled=Boolean(data.sold);
buyText.textContent=data.ownedByMe?'DEINE WOHNUNG':data.sold?`VERKAUFT · ${data.ownerName||'Bewohnt'}`:'KAUFEN';
}
if(data.action==='elevatorOpen')elevator(data);
if(data.action==='doorOpen')door(data);
if(data.action==='inviteOpen')invite(data);
if(data.action==='close'){preview.classList.add('hidden');panel.classList.add('hidden')}
});
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="style.css">
</head>
<body>
<main id="preview" class="hidden">
<section class="preview-bar">
<button id="previous" class="arrow" aria-label="Vorherige Wohnung"></button>
<div class="preview-copy">
<small id="building">DEVX IMMOBILIEN</small>
<strong id="label">Wohnung</strong>
<span id="meta">Etage · Stil</span>
</div>
<button id="buy" class="buy">
<span id="buyText">KAUFEN</span>
<b id="price">0 $</b>
</button>
<button id="next" class="arrow" aria-label="Nächste Wohnung"></button>
<button id="previewClose" class="close">×</button>
</section>
</main>
<main id="panel" class="hidden overlay">
<section class="panel-card">
<button id="panelClose" class="close panel-close">×</button>
<small id="panelEyebrow">DEVX-CITY</small>
<h1 id="panelTitle">Aufzug</h1>
<p id="panelText"></p>
<div id="panelBody" class="panel-body"></div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,2 @@
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#fff}*{box-sizing:border-box}body{margin:0;background:transparent;overflow:hidden}.hidden{display:none!important}button{font:inherit}.preview-bar{position:fixed;left:50%;bottom:42px;transform:translateX(-50%);display:grid;grid-template-columns:64px minmax(260px,1fr) 190px 64px;align-items:stretch;width:min(860px,calc(100vw - 80px));min-height:98px;border:1px solid rgba(255,255,255,.14);border-radius:18px;background:linear-gradient(180deg,rgba(12,17,28,.96),rgba(7,10,18,.97));box-shadow:0 25px 80px rgba(0,0,0,.55);backdrop-filter:blur(14px);overflow:visible}.preview-copy{display:flex;flex-direction:column;justify-content:center;padding:14px 22px}.preview-copy small,#panelEyebrow{font-size:10px;font-weight:900;letter-spacing:.2em;color:#79a7ff}.preview-copy strong{font-size:20px;margin-top:4px}.preview-copy span{font-size:12px;color:rgba(255,255,255,.58);margin-top:4px}.arrow,.buy{border:0;cursor:pointer;color:#fff}.arrow{font-size:42px;background:rgba(255,255,255,.035);transition:.15s}.arrow:hover{background:rgba(90,136,255,.22)}.buy{display:flex;flex-direction:column;align-items:center;justify-content:center;background:linear-gradient(135deg,#4977f3,#6e95ff);font-weight:900}.buy span{font-size:13px;letter-spacing:.1em}.buy b{font-size:18px;margin-top:4px}.buy:disabled{cursor:not-allowed;background:#2b303c;color:rgba(255,255,255,.5)}.close{border:1px solid rgba(255,255,255,.16);background:#111722;color:white;border-radius:999px;width:34px;height:34px;cursor:pointer;font-size:22px;line-height:1}.preview-bar>.close{position:absolute;right:-14px;top:-14px}.overlay{position:fixed;inset:0;display:grid;place-items:center;background:rgba(2,5,11,.62);backdrop-filter:blur(8px)}.panel-card{position:relative;width:min(520px,calc(100vw - 60px));max-height:76vh;padding:30px;border:1px solid rgba(255,255,255,.14);border-radius:22px;background:linear-gradient(180deg,#101725,#080d16);box-shadow:0 35px 100px rgba(0,0,0,.65)}.panel-close{position:absolute;right:18px;top:18px}.panel-card h1{margin:6px 0 8px;font-size:28px}.panel-card p{margin:0 0 20px;color:rgba(255,255,255,.62)}.panel-body{display:grid;gap:10px;max-height:52vh;overflow:auto}.panel-action{display:flex;align-items:center;justify-content:space-between;width:100%;padding:14px 16px;border:1px solid rgba(255,255,255,.11);border-radius:12px;background:rgba(255,255,255,.04);color:white;cursor:pointer;text-align:left}.panel-action:hover{border-color:#6e95ff;background:rgba(80,120,235,.15)}.panel-action b{font-size:15px}.panel-action span{font-size:12px;color:rgba(255,255,255,.55)}.door-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.door-actions .panel-action{justify-content:center;text-align:center}.door-actions .enter{grid-column:1/-1;background:linear-gradient(135deg,#4977f3,#6e95ff);border:0}
.empty-state{padding:18px;border:1px dashed rgba(255,255,255,.16);border-radius:12px;color:rgba(255,255,255,.62);text-align:center}.panel-action:disabled{opacity:.55;cursor:wait}
+22
View File
@@ -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)
+10
View File
@@ -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'
+81
View File
@@ -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)
+9
View File
@@ -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'
+115
View File
@@ -0,0 +1,115 @@
local open = false
local phoneObject = nil
local lastToggleAt = 0
local function loadModel(model)
local hash = GetHashKey(model)
RequestModel(hash)
local deadline = GetGameTimer() + 5000
while not HasModelLoaded(hash) and GetGameTimer() < deadline do Wait(0) end
return HasModelLoaded(hash) and hash or nil
end
local function loadAnim(dict)
RequestAnimDict(dict)
local deadline = GetGameTimer() + 5000
while not HasAnimDictLoaded(dict) and GetGameTimer() < deadline do Wait(0) end
return HasAnimDictLoaded(dict)
end
local function removePhoneProp()
ClearPedSecondaryTask(PlayerPedId())
if phoneObject and DoesEntityExist(phoneObject) then DeleteEntity(phoneObject) end
phoneObject = nil
end
local function createPhoneProp()
removePhoneProp()
local ped = PlayerPedId()
local model = loadModel('prop_npc_phone_02')
if model then
phoneObject = CreateObject(model, 0.0, 0.0, 0.0, false, false, false)
AttachEntityToEntity(phoneObject, ped, GetPedBoneIndex(ped, 28422), 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, true, true, false, true, 1, true)
SetModelAsNoLongerNeeded(model)
end
if loadAnim('cellphone@') then
TaskPlayAnim(ped, 'cellphone@', 'cellphone_text_read_base', 3.0, -3.0, -1, 49, 0.0, false, false, false)
end
end
local function sendState()
local player = exports.devx_core:GetPlayerData()
DevXTriggerCallback('phone:state', {}, function(phoneResult)
DevXTriggerCallback('phone:bankOverview', {}, function(bankResult)
SendNUIMessage({
action = 'state',
player = player,
phone = phoneResult.ok and phoneResult or nil,
banking = bankResult.ok and bankResult.overview or nil,
error = (not phoneResult.ok and phoneResult.error) or (not bankResult.ok and bankResult.error) or nil
})
end)
end)
end
local function setOpen(state)
if state and not exports.devx_core:IsPlayerLoaded() then return end
open = state == true
SetNuiFocus(open, open)
SendNUIMessage({ action = open and 'open' or 'close' })
if open then createPhoneProp(); sendState() else removePhoneProp() end
end
local function togglePhone()
local now = GetGameTimer()
if now - lastToggleAt < 300 then return end
lastToggleAt = now
setOpen(not open)
end
RegisterCommand('phone', togglePhone, false)
RegisterCommand('+devxphone', togglePhone, false)
RegisterCommand('-devxphone', function() end, false)
RegisterKeyMapping('+devxphone', 'DevX Smartphone öffnen/schließen', 'keyboard', 'M')
-- No raw INPUT_INTERACTION_MENU fallback here. It used to fire again on key
-- release and immediately close the phone, which made M behave like hold-to-use.
RegisterNUICallback('close', function(_, cb) setOpen(false); cb({ ok = true }) end)
RegisterNUICallback('refresh', function(_, cb) sendState(); cb({ ok = true }) end)
RegisterNUICallback('bankTransfer', function(data, cb)
DevXTriggerCallback('phone:bankTransfer', data, function(result)
if result.ok then sendState() end
cb(result)
end)
end)
RegisterNUICallback('contactCreate', function(data, cb)
DevXTriggerCallback('phone:contactCreate', data, function(result)
if result.ok then sendState() end
cb(result)
end)
end)
RegisterNUICallback('contactDelete', function(data, cb)
DevXTriggerCallback('phone:contactDelete', data, function(result)
if result.ok then sendState() end
cb(result)
end)
end)
RegisterNUICallback('companionInvite', function(data, cb)
if GetResourceState('devx_stripclub') ~= 'started' then
cb({ ok = false, error = 'Die Begleiterfunktion ist nicht aktiv.' })
return
end
DevXTriggerCallback('phone:companionInvite', data, cb)
end)
RegisterNetEvent('devx_phone:client:refresh', function()
if open then sendState() end
end)
AddEventHandler('onResourceStop', function(resourceName)
if resourceName ~= GetCurrentResourceName() then return end
removePhoneProp()
SetNuiFocus(false, false)
end)
@@ -0,0 +1,32 @@
fx_version 'cerulean'
game 'gta5'
author 'Colin-Joel / DevX Studios'
description 'DevX RP smartphone, contacts and mobile banking'
version '0.4.3'
lua54 'yes'
ui_page 'web/index.html'
files {
'web/index.html',
'web/style.css',
'web/app.js'
}
client_scripts {
'@devx_core/client/callbacks.lua',
'client/main.lua'
}
server_scripts {
'@oxmysql/lib/MySQL.lua',
'@devx_core/server/callbacks.lua',
'server/main.lua'
}
dependencies {
'devx_core',
'devx_banking'
}
+136
View File
@@ -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)
+120
View File
@@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
}[char]));
}
function show(id) { screens.forEach(screen => screen.classList.toggle('hidden', screen.id !== id)); }
function setError(id, text = '') {
const el = document.querySelector(id); el.textContent = text; el.classList.toggle('hidden', !text);
}
function close() { post('close'); }
function refresh() { post('refresh'); }
function renderTransactions(entries = []) {
const list = document.querySelector('#transactions');
list.innerHTML = entries.length ? '' : '<div class="empty-state compact-empty"><p>Noch keine Buchungen.</p></div>';
entries.slice(0, 12).forEach(entry => {
const row = document.createElement('div');
row.className = 'transaction';
const credit = entry.direction === 'credit';
row.innerHTML = `<div><strong>${escapeHtml(entry.reference)}</strong><small>${escapeHtml(entry.counterparty || '')}</small></div><b class="${credit ? 'credit' : 'debit'}">${credit ? '+' : ''}${format.format(Number(entry.amount) || 0)} $</b>`;
list.appendChild(row);
});
}
function renderContacts(entries = []) {
const list = document.querySelector('#contactList');
list.innerHTML = entries.length ? '' : '<div class="empty-state compact-empty"><p>Noch keine Kontakte.</p></div>';
entries.forEach(entry => {
const row = document.createElement('div'); row.className = 'contact';
const system = entry.contactType && entry.contactType !== 'player';
row.innerHTML = `<div><strong>${escapeHtml(entry.contactName)}</strong><small>${escapeHtml(entry.phoneNumber)}</small></div><div class="contact-actions"></div>`;
const actions = row.querySelector('.contact-actions');
if (entry.contactType === 'npc_companion') {
const invite = document.createElement('button'); invite.className = 'invite-contact'; invite.textContent = 'Einladen';
invite.onclick = async () => {
invite.disabled = true;
const result = await post('companionInvite', { contactId: Number(entry.id), phoneNumber: entry.phoneNumber });
setError('#contactError', result.ok ? (result.message || 'Einladung gesendet.') : (result.error || 'Einladung fehlgeschlagen.'));
setTimeout(() => setError('#contactError'), 3500); invite.disabled = false;
};
actions.appendChild(invite);
}
if (!system) {
const remove = document.createElement('button'); remove.className = 'delete-contact'; remove.textContent = '×'; remove.ariaLabel = 'Löschen';
remove.onclick = async () => {
const result = await post('contactDelete', { contactId: Number(entry.id) });
if (!result.ok) setError('#contactError', result.error || 'Löschen fehlgeschlagen.');
};
actions.appendChild(remove);
}
list.appendChild(row);
});
}
function render() {
const player = state.player || {}; const phoneState = state.phone || {}; const account = state.banking?.account;
document.querySelector('#firstName').textContent = player.firstName || 'Spieler';
document.querySelector('#ownNumber').textContent = phoneState.phoneNumber || player.phoneNumber || '';
document.querySelector('#profileName').textContent = [player.firstName, player.lastName].filter(Boolean).join(' ') || 'Spieler';
document.querySelector('#profileNumber').textContent = phoneState.phoneNumber || player.phoneNumber || '';
document.querySelector('#rpXp').textContent = `${format.format(Number(phoneState.rpXp || player.rpXp) || 0)} XP`;
document.querySelector('#characterId').textContent = player.characterId || '';
document.querySelector('#balance').textContent = `${format.format(Number(account?.balance) || 0)} $`;
document.querySelector('#accountNumber').textContent = account?.accountNumber || 'Kein Konto';
renderTransactions(state.banking?.transactions || []);
renderContacts(phoneState.contacts || []);
}
for (const button of document.querySelectorAll('[data-app]')) button.onclick = () => show(button.dataset.app);
for (const button of document.querySelectorAll('.back')) button.onclick = () => show('home');
for (const button of document.querySelectorAll('.refresh')) button.onclick = refresh;
document.querySelector('#homeButton').onclick = close;
document.addEventListener('keyup', event => { if (event.key === 'Escape') close(); });
setInterval(() => { document.querySelector('#time').textContent = new Date().toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'}); }, 1000);
const transferForm = document.querySelector('#transferForm');
transferForm.addEventListener('submit', async event => {
event.preventDefault(); setError('#bankError');
const button = event.currentTarget.querySelector('button'); button.disabled = true;
const result = await post('bankTransfer', Object.fromEntries(new FormData(event.currentTarget)));
if (!result.ok) setError('#bankError', result.error || 'Überweisung fehlgeschlagen.');
else { event.currentTarget.reset(); await post('refresh'); }
button.disabled = false;
});
document.querySelector('#contactForm').addEventListener('submit', async event => {
event.preventDefault(); setError('#contactError');
const result = await post('contactCreate', Object.fromEntries(new FormData(event.currentTarget)));
if (!result.ok) setError('#contactError', result.error || 'Kontakt konnte nicht gespeichert werden.');
else { event.currentTarget.reset(); await post('refresh'); }
});
window.addEventListener('message', event => {
const data = event.data || {};
if (data.action === 'open') { phone.classList.remove('hidden'); show('home'); }
if (data.action === 'close') phone.classList.add('hidden');
if (data.action === 'state') {
state = { player: data.player || {}, phone: data.phone || {}, banking: data.banking || {} };
render();
if (data.error) setError('#bankError', data.error);
}
});
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="style.css">
</head>
<body>
<section id="phone" class="phone hidden">
<div class="camera-dot"></div>
<header class="status"><span id="time">12:00</span><span>DEVX LTE ▰</span></header>
<main>
<section id="home" class="screen">
<div class="home-heading">
<small>DEVX-CITY</small>
<h1>Hallo, <span id="firstName">Spieler</span>.</h1>
<p id="ownNumber">555-0000</p>
</div>
<div class="apps">
<button data-app="bank"><span class="bank-icon">$</span><b>Bank</b></button>
<button data-app="contacts"><span class="contacts-icon"></span><b>Kontakte</b></button>
<button data-app="messages"><span class="messages-icon"></span><b>Nachrichten</b></button>
<button data-app="profile"><span class="profile-icon">DX</span><b>Profil</b></button>
</div>
</section>
<section id="bank" class="screen hidden">
<div class="app-header"><button class="back"></button><h2>DevX Bank</h2><button class="refresh"></button></div>
<article class="bank-card">
<small>VERFÜGBAR</small>
<strong id="balance">0 $</strong>
<span id="accountNumber"></span>
</article>
<div id="bankError" class="inline-error hidden"></div>
<form id="transferForm" class="phone-form">
<h3>Überweisen</h3>
<input name="destination" placeholder="Empfängerkonto" required>
<input name="amount" type="number" min="1" step="1" placeholder="Betrag" required>
<input name="reference" maxlength="140" placeholder="Verwendungszweck" value="Überweisung">
<button type="submit">Überweisen</button>
</form>
<h3 class="history-title">Letzte Buchungen</h3>
<div id="transactions" class="transactions"></div>
</section>
<section id="contacts" class="screen hidden">
<div class="app-header"><button class="back"></button><h2>Kontakte</h2><span></span></div>
<form id="contactForm" class="phone-form compact">
<input name="contactName" placeholder="Name" required>
<input name="phoneNumber" placeholder="555-0000" required>
<button type="submit">Kontakt speichern</button>
</form>
<div id="contactError" class="inline-error hidden"></div>
<div id="contactList" class="contact-list"></div>
</section>
<section id="profile" class="screen hidden">
<div class="app-header"><button class="back"></button><h2>Profil</h2><span></span></div>
<div class="profile-card"><span>DX</span><h2 id="profileName">Spieler</h2><p id="profileNumber">555-0000</p></div>
<div class="profile-row"><span>RP-Erfahrung</span><strong id="rpXp">0 XP</strong></div>
<div class="profile-row"><span>Charakter-ID</span><strong id="characterId"></strong></div>
</section>
<section id="messages" class="screen hidden">
<div class="app-header"><button class="back"></button><h2>Nachrichten</h2><span></span></div>
<div class="empty-state"><strong>Noch keine Nachrichten</strong><p>SMS und Anrufe folgen in der nächsten Telefon-Ausbaustufe.</p></div>
</section>
</main>
<button id="homeButton" aria-label="Handy schließen"></button>
</section>
<script src="app.js"></script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #f7f8fc; }
* { box-sizing: border-box; }
body { margin: 0; overflow: hidden; background: transparent; user-select: none; }
.hidden { display: none !important; }
button, input { font: inherit; }
.phone {
position: fixed; right: 36px; bottom: 25px; width: 348px; height: 690px;
border: 9px solid #111319; border-radius: 44px;
background: linear-gradient(160deg, #244b9b 0%, #182a51 30%, #10141d 70%);
box-shadow: 0 30px 85px rgba(0,0,0,.58); overflow: hidden;
}
.camera-dot { position: absolute; top: 14px; left: 50%; transform: translateX(-50%); width: 72px; height: 7px; border-radius: 10px; background: #050608; z-index: 4; }
.status { height: 52px; padding: 27px 19px 0; display: flex; justify-content: space-between; font-size: 11px; font-weight: 750; }
main { height: 586px; padding: 8px 17px 16px; overflow: hidden; }
.screen { height: 100%; overflow-y: auto; padding: 4px 2px 18px; scrollbar-width: none; }
.screen::-webkit-scrollbar { display: none; }
.home-heading { padding-top: 25px; }
.home-heading small { color: #9db7ff; font-size: 9px; letter-spacing: .19em; font-weight: 850; }
h1 { margin: 8px 0 4px; font-size: 29px; letter-spacing: -.035em; }
.home-heading p { margin: 0; color: rgba(255,255,255,.62); font-size: 12px; }
.apps { display: grid; grid-template-columns: repeat(2,1fr); gap: 13px; margin-top: 30px; }
.apps button { height: 120px; padding: 13px; display: grid; place-items: center; gap: 7px; color: white; background: rgba(255,255,255,.095); border: 1px solid rgba(255,255,255,.105); border-radius: 21px; cursor: pointer; }
.apps button:hover { background: rgba(255,255,255,.15); }
.apps button span { width: 51px; height: 51px; border-radius: 15px; display: grid; place-items: center; font-size: 22px; font-weight: 850; box-shadow: 0 8px 20px rgba(0,0,0,.22); }
.apps button b { font-size: 12px; }
.bank-icon { background: linear-gradient(145deg,#4fb881,#27835a); }
.contacts-icon { background: linear-gradient(145deg,#f0a64b,#d86d38); }
.messages-icon { background: linear-gradient(145deg,#57a7ff,#426ce8); }
.profile-icon { background: linear-gradient(145deg,#7c86ff,#554dcc); font-size: 13px !important; }
.app-header { min-height: 45px; display: grid; grid-template-columns: 38px 1fr 38px; align-items: center; }
.app-header h2 { margin: 0; text-align: center; font-size: 17px; }
.back, .refresh { border: 0; background: transparent; color: white; font-size: 27px; cursor: pointer; }
.refresh { font-size: 19px; }
.bank-card { margin-top: 10px; min-height: 142px; padding: 20px; display: flex; flex-direction: column; justify-content: flex-end; border-radius: 20px; background: linear-gradient(145deg,#547cf3,#2546a0); box-shadow: 0 16px 35px rgba(16,34,92,.35); }
.bank-card small { font-size: 9px; letter-spacing: .14em; opacity: .72; }
.bank-card strong { margin: 5px 0 12px; font-size: 35px; letter-spacing: -.04em; }
.bank-card span { font-size: 11px; opacity: .75; }
.phone-form { display: grid; gap: 8px; margin-top: 18px; }
.phone-form h3, .history-title { margin: 0 0 3px; font-size: 13px; }
.phone-form input { width: 100%; padding: 11px; border: 1px solid rgba(255,255,255,.11); border-radius: 10px; outline: 0; color: white; background: rgba(0,0,0,.22); }
.phone-form input:focus { border-color: #6f96ff; }
.phone-form button { border: 0; border-radius: 10px; padding: 11px; color: white; background: #557df3; font-weight: 800; cursor: pointer; }
.phone-form.compact { margin-top: 12px; }
.history-title { margin-top: 20px; }
.transactions, .contact-list { display: grid; gap: 7px; margin-top: 8px; }
.transaction, .contact { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; padding: 10px 11px; border-radius: 11px; background: rgba(255,255,255,.06); }
.transaction strong, .contact strong { display: block; font-size: 11px; }
.transaction small, .contact small { color: rgba(255,255,255,.55); font-size: 9px; }
.transaction .credit { color: #7ce6a9; }
.transaction .debit { color: #ff8d9b; }
.contact button { width: 27px; height: 27px; border: 0; border-radius: 8px; color: #ff9daa; background: rgba(210,70,88,.17); cursor: pointer; }
.inline-error { margin-top: 9px; padding: 9px; border-radius: 9px; color: #ffadb7; background: rgba(179,47,66,.22); font-size: 10px; }
.profile-card { margin-top: 22px; text-align: center; }
.profile-card > span { width: 68px; height: 68px; margin: auto; display: grid; place-items: center; border-radius: 20px; background: linear-gradient(145deg,#7294ff,#3d5ed0); font-weight: 900; }
.profile-card h2 { margin: 13px 0 4px; }
.profile-card p { margin: 0 0 25px; color: rgba(255,255,255,.6); }
.profile-row { display: flex; justify-content: space-between; padding: 13px; border-bottom: 1px solid rgba(255,255,255,.08); font-size: 12px; }
.empty-state { margin-top: 90px; text-align: center; color: rgba(255,255,255,.62); }
.empty-state strong { color: white; }
.empty-state p { font-size: 11px; line-height: 1.5; }
#homeButton { position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); width: 112px; height: 5px; border: 0; border-radius: 10px; background: rgba(255,255,255,.82); cursor: pointer; }
.contact-actions{display:flex;align-items:center;gap:6px}.contact-actions button{height:29px;border:0;border-radius:8px;color:white;cursor:pointer;font-size:10px;font-weight:800}.delete-contact{width:29px;background:rgba(210,70,88,.2);color:#ff9daa!important}.invite-contact{padding:0 9px;background:#557df3}.contact-actions button:disabled{opacity:.45}.compact-empty{margin-top:18px}
.invite-contact{width:auto!important;min-width:64px}.delete-contact{width:29px!important}
@@ -0,0 +1,50 @@
local playerBlips = {}
local function removePlayerBlip(serverId)
local blip = playerBlips[serverId]
if blip and DoesBlipExist(blip) then RemoveBlip(blip) end
playerBlips[serverId] = nil
end
local function ensureBlip(entry)
local serverId = tonumber(entry.serverId)
if not serverId or serverId == GetPlayerServerId(PlayerId()) then return end
local blip = playerBlips[serverId]
if not blip or not DoesBlipExist(blip) then
blip = AddBlipForCoord(entry.x + 0.0, entry.y + 0.0, entry.z + 0.0)
SetBlipSprite(blip, 1)
SetBlipColour(blip, 3)
SetBlipScale(blip, 0.82)
SetBlipDisplay(blip, 2)
SetBlipAsShortRange(blip, false)
ShowHeadingIndicatorOnBlip(blip, true)
playerBlips[serverId] = blip
end
SetBlipCoords(blip, entry.x + 0.0, entry.y + 0.0, entry.z + 0.0)
SetBlipRotation(blip, math.floor(tonumber(entry.heading) or 0.0))
BeginTextCommandSetBlipName('STRING')
AddTextComponentString(('%s [%s]'):format(entry.name or 'Spieler', serverId))
EndTextCommandSetBlipName(blip)
end
RegisterNetEvent('devx_players:client:sync', function(entries)
local seen = {}
for _, entry in ipairs(entries or {}) do
local serverId = tonumber(entry.serverId)
if serverId and serverId ~= GetPlayerServerId(PlayerId()) then
seen[serverId] = true
ensureBlip(entry)
end
end
for serverId in pairs(playerBlips) do
if not seen[serverId] then removePlayerBlip(serverId) end
end
end)
AddEventHandler('onResourceStop', function(resourceName)
if resourceName ~= GetCurrentResourceName() then return end
for serverId in pairs(playerBlips) do removePlayerBlip(serverId) end
end)
@@ -0,0 +1,13 @@
fx_version 'cerulean'
game 'gta5'
author 'Colin-Joel / DevX Studios'
description 'DevX RP player visibility and GTA Online-style map blips'
version '0.4.3'
lua54 'yes'
client_script 'client/main.lua'
server_script 'server/main.lua'
dependency 'devx_core'
@@ -0,0 +1,37 @@
local function buildSnapshots()
local byBucket = {}
local activePlayers = exports.devx_core:GetActivePlayers()
for source, player in pairs(activePlayers) do
source = tonumber(source)
local ped = GetPlayerPed(source)
if ped and ped > 0 and DoesEntityExist(ped) then
local coords = GetEntityCoords(ped)
local bucket = GetPlayerRoutingBucket(source)
byBucket[bucket] = byBucket[bucket] or {}
byBucket[bucket][#byBucket[bucket] + 1] = {
serverId = source,
name = player.fullName or GetPlayerName(source) or ('Spieler %s'):format(source),
x = coords.x,
y = coords.y,
z = coords.z,
heading = GetEntityHeading(ped)
}
end
end
return byBucket, activePlayers
end
CreateThread(function()
while true do
Wait(1500)
local snapshots, activePlayers = buildSnapshots()
for source in pairs(activePlayers) do
source = tonumber(source)
local bucket = GetPlayerRoutingBucket(source)
TriggerClientEvent('devx_players:client:sync', source, snapshots[bucket] or {})
end
end
end)
@@ -0,0 +1,50 @@
local enabled = false
local station = 'RADIO_02_POP'
local function notify(message) exports.devx_core:Notify(message) end
local function apply(vehicle)
if not vehicle or vehicle == 0 then return end
if enabled then
SetVehicleRadioEnabled(vehicle, true)
SetVehRadioStation(vehicle, station)
SetRadioToStationName(station)
else
SetVehRadioStation(vehicle, 'OFF')
SetVehicleRadioEnabled(vehicle, false)
end
end
local function toggle()
local ped = PlayerPedId()
local vehicle = GetVehiclePedIsIn(ped, false)
if vehicle == 0 then notify('Das Radio ist nur in einem Fahrzeug verfügbar.'); return end
enabled = not enabled
apply(vehicle)
notify(enabled and 'Radio: Non-Stop-Pop FM' or 'Radio ausgeschaltet.')
end
RegisterCommand('radio', toggle, false)
RegisterCommand('+devxradio', toggle, false)
RegisterCommand('-devxradio', function() end, false)
RegisterKeyMapping('+devxradio', 'Fahrzeugradio: Non-Stop-Pop FM / Aus', 'keyboard', 'R')
CreateThread(function()
local lastVehicle = 0
while true do
local ped = PlayerPedId()
local vehicle = GetVehiclePedIsIn(ped, false)
if vehicle ~= 0 then
-- Hide the standard station wheel and keep the allowed state enforced.
DisableControlAction(0, 81, true)
DisableControlAction(0, 82, true)
DisableControlAction(0, 85, true)
if vehicle ~= lastVehicle then apply(vehicle); lastVehicle = vehicle end
if enabled and GetPlayerRadioStationName() ~= station then apply(vehicle) end
Wait(0)
else
lastVehicle = 0
Wait(500)
end
end
end)
@@ -0,0 +1,9 @@
fx_version 'cerulean'
game 'gta5'
author 'Colin-Joel / DevX Studios'
description 'DevX RP restricted vehicle radio: Non-Stop-Pop FM or off'
version '0.4.3'
lua54 'yes'
client_script 'client/main.lua'
dependency 'devx_core'
@@ -0,0 +1,124 @@
local peds = {}
local companion = nil
local busy = false
local function notify(message) exports.devx_core:Notify(message) end
local function help(text)
BeginTextCommandDisplayHelp('STRING'); AddTextComponentSubstringPlayerName(text); EndTextCommandDisplayHelp(0, false, true, -1)
end
local function subtitle(text, duration)
BeginTextCommandPrint('STRING'); AddTextComponentSubstringPlayerName(text); EndTextCommandPrint(duration or 3000, true)
end
local function loadModel(name)
local hash = GetHashKey(name); RequestModel(hash)
local deadline = GetGameTimer() + 6000
while not HasModelLoaded(hash) and GetGameTimer() < deadline do Wait(0) end
return HasModelLoaded(hash) and hash or nil
end
local function loadAnim(dict)
RequestAnimDict(dict); local deadline = GetGameTimer() + 5000
while not HasAnimDictLoaded(dict) and GetGameTimer() < deadline do Wait(0) end
return HasAnimDictLoaded(dict)
end
local function spawnPed(modelName, pos, scenario)
local model = loadModel(modelName); if not model then return nil end
local ped = CreatePed(4, model, pos.x, pos.y, pos.z, pos.w or 0.0, false, false)
SetEntityInvincible(ped, true); SetBlockingOfNonTemporaryEvents(ped, true); FreezeEntityPosition(ped, true)
if scenario then TaskStartScenarioInPlace(ped, scenario, 0, true) end
SetModelAsNoLongerNeeded(model); return ped
end
local function updateBar(result, message)
SendNUIMessage({ action = 'relationship', name = DevXStripclub.Nikki.name,
value = result.satisfaction or 0, maximum = result.threshold or 100, message = message })
end
local function runTalk(action)
if busy then return end
busy = true
DevXTriggerCallback('stripclub:interact', { action = action }, function(result)
if not result.ok then notify(result.error or 'Interaktion fehlgeschlagen.'); busy = false; return end
local player = PlayerPedId(); local nikki = peds.nikki
if nikki and DoesEntityExist(nikki) then
ClearPedTasksImmediately(nikki); FreezeEntityPosition(player, true)
TaskTurnPedToFaceEntity(player, nikki, 900); TaskTurnPedToFaceEntity(nikki, player, 900); Wait(900)
if action == 'lounge' then
local lounge = DevXStripclub.Nikki.lounge
SetEntityCoordsNoOffset(player, lounge.x + 1.25, lounge.y, lounge.z, false, false, false)
SetEntityHeading(player, lounge.w)
SetEntityCoordsNoOffset(nikki, lounge.x, lounge.y, lounge.z, false, false, false)
SetEntityHeading(nikki, lounge.w + 180.0)
if loadAnim('mini@strip_club@idles@stripper') then
TaskPlayAnim(nikki, 'mini@strip_club@idles@stripper', 'stripper_idle_02', 2.0, -2.0, 9000, 1, 0.0, false, false, false)
end
TaskStartScenarioInPlace(player, 'WORLD_HUMAN_SEAT_WALL', 0, true)
subtitle('~p~Nikki:~s~ Schön, dass du wieder da bist.', 4000); Wait(9000)
else
if loadAnim('gestures@f@standing@casual') then
TaskPlayAnim(nikki, 'gestures@f@standing@casual', 'gesture_hello', 2.0, -2.0, 3200, 49, 0.0, false, false, false)
end
subtitle('~p~Nikki:~s~ Hey, wie läuft dein neues Leben in DevX-City?', 3500); Wait(3600)
end
ClearPedTasksImmediately(player); FreezeEntityPosition(player, false)
FreezeEntityPosition(nikki, true); TaskStartScenarioInPlace(nikki, 'WORLD_HUMAN_STAND_MOBILE', 0, true)
end
updateBar(result, result.newlyUnlocked and 'Kontakt freigeschaltet Nikki wurde im Handy gespeichert.' or 'Zufriedenheit gestiegen.')
busy = false
end)
end
local function spawnClub()
for _, ped in pairs(peds) do if DoesEntityExist(ped) then DeleteEntity(ped) end end
peds = {}
peds.nikki = spawnPed(DevXStripclub.Nikki.model, DevXStripclub.Nikki.position, 'WORLD_HUMAN_STAND_MOBILE')
for index, definition in ipairs(DevXStripclub.Dancers) do
peds['ambient' .. index] = spawnPed(definition.model, definition.position, definition.scenario)
end
end
RegisterNetEvent('devx_stripclub:client:spawnCompanion', function(data)
if companion and DoesEntityExist(companion) then DeleteEntity(companion) end
local pos = data.spawn
companion = spawnPed(data.model, vector4(pos.x, pos.y, pos.z, pos.heading or pos.w or 0.0), 'WORLD_HUMAN_STAND_MOBILE')
if companion then notify(('%s ist in deinem Apartment angekommen.'):format(data.name or 'Dein Gast')) end
end)
AddEventHandler('devx_housing:client:leftApartment', function()
if companion and DoesEntityExist(companion) then DeleteEntity(companion) end
companion = nil
end)
CreateThread(function()
while not exports.devx_core:IsPlayerLoaded() do Wait(500) end
spawnClub()
local blip = AddBlipForCoord(DevXStripclub.Entry.x, DevXStripclub.Entry.y, DevXStripclub.Entry.z)
SetBlipSprite(blip, 121); SetBlipScale(blip, .7); SetBlipColour(blip, 27); SetBlipAsShortRange(blip, true)
BeginTextCommandSetBlipName('STRING'); AddTextComponentString('Vanilla Unicorn'); EndTextCommandSetBlipName(blip)
end)
CreateThread(function()
while true do
local sleep = 700
if exports.devx_core:IsPlayerLoaded() and peds.nikki and DoesEntityExist(peds.nikki) then
local coords = GetEntityCoords(PlayerPedId()); local npcCoords = GetEntityCoords(peds.nikki)
local distance = #(coords - npcCoords)
if distance < 8.0 then
sleep = 0
DrawMarker(2, npcCoords.x, npcCoords.y, npcCoords.z + 1.0, 0,0,0,0,180.0,0, .18,.18,.18, 220,90,180,150,false,false,2,false,nil,nil,false)
if distance < 1.8 then
help(('~INPUT_CONTEXT~ mit Nikki reden | ~INPUT_DETONATE~ VIP-Lounge (%s $)'):format(DevXStripclub.LoungePrice))
if IsControlJustReleased(0, 38) then runTalk('talk')
elseif IsControlJustReleased(0, 47) then runTalk('lounge') end
end
end
end
Wait(sleep)
end
end)
AddEventHandler('onResourceStop', function(resource)
if resource ~= GetCurrentResourceName() then return end
for _, ped in pairs(peds) do if DoesEntityExist(ped) then DeleteEntity(ped) end end
if companion and DoesEntityExist(companion) then DeleteEntity(companion) end
end)
@@ -0,0 +1,13 @@
fx_version 'cerulean'
game 'gta5'
author 'Colin-Joel / DevX Studios'
description 'DevX RP Vanilla Unicorn social relationship and apartment companion system'
version '0.4.3'
lua54 'yes'
shared_script 'shared/config.lua'
ui_page 'web/index.html'
files { 'web/index.html', 'web/style.css', 'web/app.js' }
client_scripts { '@devx_core/client/callbacks.lua', 'client/main.lua' }
server_scripts { '@oxmysql/lib/MySQL.lua', '@devx_core/server/callbacks.lua', 'server/main.lua' }
dependencies { 'devx_core', 'devx_banking', 'devx_phone', 'devx_housing' }
@@ -0,0 +1,139 @@
local actionCooldowns = {}
MySQL.ready(function()
MySQL.query.await([[
CREATE TABLE IF NOT EXISTS devx_club_relations (
character_id BIGINT UNSIGNED NOT NULL,
relation_key VARCHAR(32) NOT NULL,
satisfaction TINYINT UNSIGNED NOT NULL DEFAULT 0,
contact_unlocked TINYINT(1) NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (character_id, relation_key),
CONSTRAINT fk_devx_club_character FOREIGN KEY (character_id)
REFERENCES devx_characters (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
]])
end)
local function relation(characterId)
local row = MySQL.single.await([[
SELECT satisfaction, contact_unlocked AS contactUnlocked
FROM devx_club_relations WHERE character_id = ? AND relation_key = ? LIMIT 1
]], { characterId, DevXStripclub.Nikki.key })
return {
satisfaction = row and tonumber(row.satisfaction) or 0,
contactUnlocked = row and tonumber(row.contactUnlocked) == 1 or false
}
end
local function saveRelation(characterId, satisfaction, unlocked)
MySQL.query.await([[
INSERT INTO devx_club_relations (character_id, relation_key, satisfaction, contact_unlocked)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE satisfaction = VALUES(satisfaction), contact_unlocked = VALUES(contact_unlocked)
]], { characterId, DevXStripclub.Nikki.key, satisfaction, unlocked and 1 or 0 })
end
DevXRegisterCallback('stripclub:state', function(source)
local player = exports.devx_core:GetPlayer(source)
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
local current = relation(player.characterId)
current.ok = true
current.name = DevXStripclub.Nikki.name
current.threshold = DevXStripclub.Nikki.contactThreshold
return current
end)
DevXRegisterCallback('stripclub:interact', function(source, payload)
local player = exports.devx_core:GetPlayer(source)
local action = payload and payload.action
if not player or (action ~= 'talk' and action ~= 'lounge') then return { ok = false, error = 'Ungültige Anfrage.' } end
local now = os.time()
local cooldown = actionCooldowns[source] or 0
if cooldown > now then return { ok = false, error = ('Warte noch %s Sekunden.'):format(cooldown - now) } end
local gain = DevXStripclub.TalkGain
if action == 'lounge' then
local paid, result = exports.devx_banking:ChargeCharacter(
player.characterId, DevXStripclub.LoungePrice, 'VIP-Lounge', 'Vanilla Unicorn'
)
if not paid then return { ok = false, error = result } end
exports.devx_core:SetBankCache(source, result)
gain = DevXStripclub.LoungeGain
actionCooldowns[source] = now + 45
else
actionCooldowns[source] = now + 15
end
local current = relation(player.characterId)
current.satisfaction = math.min(DevXStripclub.Nikki.contactThreshold, current.satisfaction + gain)
local newlyUnlocked = not current.contactUnlocked and current.satisfaction >= DevXStripclub.Nikki.contactThreshold
if newlyUnlocked then current.contactUnlocked = true end
saveRelation(player.characterId, current.satisfaction, current.contactUnlocked)
if current.contactUnlocked then
exports.devx_phone:AddSystemContact(
player.characterId, DevXStripclub.Nikki.name, DevXStripclub.Nikki.phone,
'npc_companion', { relationKey = DevXStripclub.Nikki.key }
)
TriggerClientEvent('devx_phone:client:refresh', source)
end
return {
ok = true,
satisfaction = current.satisfaction,
threshold = DevXStripclub.Nikki.contactThreshold,
contactUnlocked = current.contactUnlocked,
newlyUnlocked = newlyUnlocked,
action = action
}
end)
local function inviteCompanion(source)
local player = exports.devx_core:GetPlayer(source)
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
local current = relation(player.characterId)
if not current.contactUnlocked then return { ok = false, error = 'Dieser Kontakt ist noch nicht freigeschaltet.' } end
local location = exports.devx_housing:GetPlayerHousingLocation(source)
if not location or location.type ~= 'unit' then
return { ok = false, error = 'Du musst dich in deinem Apartment befinden.' }
end
if tonumber(location.ownerCharacterId) ~= tonumber(player.characterId) then
return { ok = false, error = 'Du kannst Nikki nur in dein eigenes Apartment einladen.' }
end
local spawn = location.interior and location.interior.companionSpawn
if not spawn then return { ok = false, error = 'Für dieses Apartment ist kein Treffpunkt eingerichtet.' } end
TriggerClientEvent('devx_stripclub:client:spawnCompanion', source, {
name = DevXStripclub.Nikki.name,
model = DevXStripclub.Nikki.model,
spawn = spawn
})
return { ok = true, message = 'Nikki ist auf dem Weg zu deinem Apartment.' }
end
exports('InviteCompanion', inviteCompanion)
DevXRegisterCallback('stripclub:inviteCompanion', function(source)
return inviteCompanion(source)
end)
RegisterCommand('resetnikki', function(source, args)
if source ~= 0 and not IsPlayerAceAllowed(source, 'devx.admin') then return end
local target = tonumber(args[1]) or source
local player = exports.devx_core:GetPlayer(target)
if not player then return end
MySQL.query.await('DELETE FROM devx_club_relations WHERE character_id = ? AND relation_key = ?', {
player.characterId, DevXStripclub.Nikki.key
})
MySQL.query.await("DELETE FROM devx_phone_contacts WHERE character_id = ? AND contact_type = 'npc_companion'", {
player.characterId
})
TriggerClientEvent('devx_phone:client:refresh', target)
TriggerClientEvent('devx_housing:client:notify', target, 'Nikki-Beziehung wurde zurückgesetzt.')
end, false)
AddEventHandler('playerDropped', function() actionCooldowns[source] = nil end)
@@ -0,0 +1,20 @@
DevXStripclub = {
Entry = vector3(127.40, -1283.12, 29.27),
Nikki = {
key = 'nikki',
name = 'Nikki',
phone = '555-0199',
model = 'a_f_y_bevhills_02',
position = vector4(117.18, -1302.15, 28.77, 303.0),
lounge = vector4(120.70, -1300.05, 28.77, 210.0),
contactThreshold = 100
},
Dancers = {
{ model = 's_f_y_stripper_01', position = vector4(112.75, -1287.35, 28.46, 195.0), scenario = 'WORLD_HUMAN_MUSICIAN' },
{ model = 's_f_y_stripper_02', position = vector4(104.85, -1294.10, 28.26, 25.0), scenario = 'WORLD_HUMAN_PARTYING' },
{ model = 'a_f_y_bevhills_01', position = vector4(128.25, -1284.25, 29.28, 125.0), scenario = 'WORLD_HUMAN_STAND_MOBILE' }
},
TalkGain = 10,
LoungeGain = 25,
LoungePrice = 500
}
@@ -0,0 +1,2 @@
const box=document.querySelector('#relation'),fill=document.querySelector('#fill'),value=document.querySelector('#value'),nameEl=document.querySelector('#name'),message=document.querySelector('#message');let timer;
window.addEventListener('message',event=>{const d=event.data||{};if(d.action!=='relationship')return;const max=Number(d.maximum)||100,current=Math.max(0,Math.min(max,Number(d.value)||0));nameEl.textContent=d.name||'Kontakt';message.textContent=d.message||'Zufriedenheit';value.textContent=`${current} / ${max}`;fill.style.width=`${current/max*100}%`;box.classList.remove('hidden');clearTimeout(timer);timer=setTimeout(()=>box.classList.add('hidden'),6500)});
@@ -0,0 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><link rel="stylesheet" href="style.css"></head><body><section id="relation" class="hidden"><div><span id="name">Nikki</span><b id="message"></b><div class="track"><i id="fill"></i></div><small id="value">0 / 100</small></div></section><script src="app.js"></script></body></html>
@@ -0,0 +1 @@
:root{font-family:Inter,system-ui,sans-serif;color:white}*{box-sizing:border-box}body{margin:0;background:transparent;overflow:hidden}.hidden{display:none!important}#relation{position:fixed;left:50%;bottom:84px;transform:translateX(-50%);width:430px;padding:14px 18px;border:1px solid rgba(255,255,255,.14);border-radius:14px;background:rgba(8,10,16,.9);box-shadow:0 18px 50px rgba(0,0,0,.45)}#name{display:block;color:#f097ca;font-weight:900;font-size:12px;letter-spacing:.12em;text-transform:uppercase}#message{display:block;margin:4px 0 9px;font-size:13px}.track{height:8px;border-radius:20px;background:rgba(255,255,255,.1);overflow:hidden}.track i{display:block;height:100%;width:0;background:linear-gradient(90deg,#d958a8,#ff9ed5);transition:width .45s}small{display:block;margin-top:5px;text-align:right;color:rgba(255,255,255,.6)}
+51
View File
@@ -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}}
+39
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
-- DevX RP v0.4.0
-- Keine neue Tabelle erforderlich.
-- devx_housing synchronisiert die zusätzlichen Apartmentdatensätze beim Start.
+38
View File
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
-- DevX RP v0.4.3
-- Entfernt ausschließlich Apartment-Eigentümer, deren Charakter fehlt oder
-- bereits gelöscht wurde. Aktive Charaktere und deren Apartments bleiben erhalten.
DELETE po
FROM devx_property_owners po
LEFT JOIN devx_characters c ON c.id = po.character_id
WHERE c.id IS NULL OR c.deleted_at IS NOT NULL;
+159
View File
@@ -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;
+22
View File
@@ -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"
+15
View File
@@ -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"