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
@@ -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)}