first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
const resource = typeof GetParentResourceName === 'function' ? GetParentResourceName() : 'devx_characters';
|
||||
|
||||
const app = document.querySelector('#app');
|
||||
const charactersEl = document.querySelector('#characters');
|
||||
const createOverlay = document.querySelector('#createOverlay');
|
||||
const deleteOverlay = document.querySelector('#deleteOverlay');
|
||||
const createForm = document.querySelector('#createForm');
|
||||
const newButton = document.querySelector('#newCharacter');
|
||||
const playButton = document.querySelector('#playCharacter');
|
||||
const deleteButton = document.querySelector('#deleteCharacter');
|
||||
const sexSelect = document.querySelector('#sexSelect');
|
||||
const messageEl = document.querySelector('#message');
|
||||
const slotCounter = document.querySelector('#slotCounter');
|
||||
const selectedName = document.querySelector('#selectedName');
|
||||
const selectedDob = document.querySelector('#selectedDob');
|
||||
const selectedSlot = document.querySelector('#selectedSlot');
|
||||
const selectedPhone = document.querySelector('#selectedPhone');
|
||||
const selectedStatus = document.querySelector('#selectedStatus');
|
||||
const dialogue = document.querySelector('#dialogue');
|
||||
const dialogueSpeaker = document.querySelector('#dialogueSpeaker');
|
||||
const dialogueText = document.querySelector('#dialogueText');
|
||||
const createButton = createForm.querySelector('button[type="submit"]');
|
||||
const deleteConfirmInput = document.querySelector('#deleteConfirmInput');
|
||||
const confirmDeleteButton = document.querySelector('#confirmDelete');
|
||||
|
||||
let maxCharacters = 3;
|
||||
let characters = [];
|
||||
let selectedId = null;
|
||||
let deleteTarget = null;
|
||||
|
||||
async function post(event, data = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||
try {
|
||||
const response = await fetch(`https://${resource}/${event}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
body: JSON.stringify(data),
|
||||
signal: controller.signal
|
||||
});
|
||||
const body = await response.text();
|
||||
if (!response.ok) throw new Error(`NUI ${event}: HTTP ${response.status}`);
|
||||
if (!body) return { ok: true };
|
||||
return JSON.parse(body);
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw new Error('Der Server hat nach 30 Sekunden nicht geantwortet.');
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, char => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[char]));
|
||||
}
|
||||
|
||||
function message(text = '', type = 'error') {
|
||||
messageEl.textContent = text;
|
||||
messageEl.dataset.type = type;
|
||||
messageEl.classList.toggle('hidden', !text);
|
||||
}
|
||||
|
||||
function selectedCharacter() {
|
||||
return characters.find(character => Number(character.id) === Number(selectedId)) || null;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (typeof value === 'number' || /^\d{11,}$/.test(String(value || ''))) {
|
||||
const date = new Date(Number(value));
|
||||
if (!Number.isNaN(date.getTime())) return date.toLocaleDateString('de-DE');
|
||||
}
|
||||
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
return match ? `${match[3]}.${match[2]}.${match[1]}` : String(value || '–');
|
||||
}
|
||||
|
||||
function previewCharacter(character) {
|
||||
const payload = character
|
||||
? { model: character.model, sex: character.sex }
|
||||
: { model: sexSelect.value === 'f' ? 'mp_f_freemode_01' : 'mp_m_freemode_01', sex: sexSelect.value };
|
||||
post('preview', payload).catch(error => message(`Vorschaufehler: ${error.message}`));
|
||||
}
|
||||
|
||||
function updateDetails() {
|
||||
const character = selectedCharacter();
|
||||
const hasCharacter = Boolean(character);
|
||||
selectedName.textContent = hasCharacter ? `${character.firstName} ${character.lastName}` : 'Kein Charakter';
|
||||
selectedDob.textContent = hasCharacter ? formatDate(character.dateOfBirth) : '–';
|
||||
selectedSlot.textContent = hasCharacter ? `Slot ${character.slot}` : '–';
|
||||
selectedPhone.textContent = hasCharacter ? (character.phoneNumber || 'Wird erstellt') : '–';
|
||||
selectedStatus.textContent = hasCharacter ? (character.introCompleted ? 'Einwohner' : 'Neue Ankunft') : '–';
|
||||
playButton.disabled = !hasCharacter;
|
||||
deleteButton.disabled = !hasCharacter;
|
||||
}
|
||||
|
||||
function selectCharacter(characterId, shouldPreview = true) {
|
||||
selectedId = characterId == null ? null : Number(characterId);
|
||||
document.querySelectorAll('.character-slot').forEach(card => {
|
||||
card.classList.toggle('active', Number(card.dataset.characterId) === Number(selectedId));
|
||||
});
|
||||
updateDetails();
|
||||
if (shouldPreview) previewCharacter(selectedCharacter());
|
||||
}
|
||||
|
||||
function openCreator() {
|
||||
if (characters.length >= maxCharacters) return;
|
||||
message('');
|
||||
createForm.reset();
|
||||
createOverlay.classList.remove('hidden');
|
||||
previewCharacter(null);
|
||||
setTimeout(() => createForm.querySelector('input')?.focus(), 50);
|
||||
}
|
||||
|
||||
function closeCreator() {
|
||||
createOverlay.classList.add('hidden');
|
||||
createForm.reset();
|
||||
previewCharacter(selectedCharacter());
|
||||
}
|
||||
|
||||
function openDelete() {
|
||||
deleteTarget = selectedCharacter();
|
||||
if (!deleteTarget) return;
|
||||
document.querySelector('#deleteDescription').textContent = `${deleteTarget.firstName} ${deleteTarget.lastName} wird mit Bankkonto, Telefonnummer und Fortschritt dauerhaft gelöscht.`;
|
||||
deleteConfirmInput.value = '';
|
||||
confirmDeleteButton.disabled = true;
|
||||
deleteOverlay.classList.remove('hidden');
|
||||
setTimeout(() => deleteConfirmInput.focus(), 50);
|
||||
}
|
||||
|
||||
function closeDelete() {
|
||||
deleteOverlay.classList.add('hidden');
|
||||
deleteTarget = null;
|
||||
deleteConfirmInput.value = '';
|
||||
}
|
||||
|
||||
function render() {
|
||||
charactersEl.innerHTML = '';
|
||||
slotCounter.textContent = `${characters.length} / ${maxCharacters}`;
|
||||
|
||||
for (let slot = 1; slot <= maxCharacters; slot += 1) {
|
||||
const character = characters.find(entry => Number(entry.slot) === slot);
|
||||
const card = document.createElement('button');
|
||||
card.type = 'button';
|
||||
card.className = `character-slot ${character ? '' : 'vacant'}`;
|
||||
|
||||
if (character) {
|
||||
card.dataset.characterId = character.id;
|
||||
card.innerHTML = `
|
||||
<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; }
|
||||
}
|
||||
Reference in New Issue
Block a user