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,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; }
}