- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark, Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI) - aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource, an aether-core gekoppelt (Rechte über aether_users.admin_group), mit WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU) - Prototypen admin/ vervollständigt - SQL-Schema aether_admin.sql - Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
345 lines
12 KiB
Lua
345 lines
12 KiB
Lua
-- =====================================================================
|
|
-- Aether Jobs — Clientlogik
|
|
-- Die Arbeit findet in der Welt statt: Marker, Route, Animation.
|
|
-- Der Server prueft Ort und Dauer, bevor er bezahlt.
|
|
-- =====================================================================
|
|
|
|
local istOffen = false
|
|
local istGeladen = false
|
|
|
|
-- Aktuelle Schicht (vom Server gespiegelt)
|
|
local schicht = nil -- { jobId, schrittIdx, auftrag, schritte, farbe, name }
|
|
local arbeitetGerade = false -- laeuft gerade eine Handlung?
|
|
local zielBlip = nil -- Markierung des aktuellen Arbeitsorts
|
|
|
|
local TASTE_BENUTZEN = 38 -- E
|
|
|
|
local function SendeAnNui(nachricht)
|
|
SendNuiMessage(json.encode(nachricht))
|
|
end
|
|
|
|
local function ZeigeSpielmeldung(text)
|
|
if not text or text == '' then return end
|
|
BeginTextCommandThefeedPost('STRING')
|
|
AddTextComponentSubstringPlayerName(text)
|
|
EndTextCommandThefeedPostTicker(false, true)
|
|
end
|
|
|
|
local function Schliessen()
|
|
if not istOffen then return end
|
|
istOffen = false
|
|
SetNuiFocus(false, false)
|
|
SendeAnNui({ aktion = 'schliessen' })
|
|
TriggerServerEvent('aether:jobs:schliessen')
|
|
pcall(function() exports['aether-hud']:SetzeSichtbar(true) end)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Wegpunkt zum aktuellen Arbeitsort
|
|
-- ---------------------------------------------------------------------
|
|
|
|
--- Liefert den Ort des aktuellen Arbeitsschritts
|
|
--- @return table|nil { position, name }
|
|
local function AktuellerOrt()
|
|
if not schicht or not schicht.auftrag or not schicht.auftrag.orte then return nil end
|
|
return schicht.auftrag.orte[schicht.schrittIdx + 1]
|
|
end
|
|
|
|
--- Setzt die Kartenmarkierung auf den aktuellen Arbeitsort
|
|
local function AktualisiereZielBlip()
|
|
if zielBlip then
|
|
RemoveBlip(zielBlip)
|
|
zielBlip = nil
|
|
end
|
|
|
|
local ort = AktuellerOrt()
|
|
if not ort then return end
|
|
|
|
zielBlip = AddBlipForCoord(ort.position.x, ort.position.y, ort.position.z)
|
|
SetBlipSprite(zielBlip, 1)
|
|
SetBlipColour(zielBlip, 5)
|
|
SetBlipScale(zielBlip, 0.9)
|
|
SetBlipRoute(zielBlip, true) -- Route auf der Karte anzeigen
|
|
SetBlipRouteColour(zielBlip, 5)
|
|
BeginTextCommandSetBlipName('STRING')
|
|
AddTextComponentString('Arbeitsort: ' .. (ort.name or ''))
|
|
EndTextCommandSetBlipName(zielBlip)
|
|
end
|
|
|
|
--- Entfernt die Zielmarkierung
|
|
local function EntferneZielBlip()
|
|
if zielBlip then
|
|
RemoveBlip(zielBlip)
|
|
zielBlip = nil
|
|
end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Arbeitshandlung ausfuehren
|
|
-- ---------------------------------------------------------------------
|
|
|
|
--- Spielt die Handlung ab und meldet sie danach dem Server
|
|
--- @param schrittDef table Schrittdefinition
|
|
local function FuehreHandlungAus(schrittDef)
|
|
if arbeitetGerade then return end
|
|
arbeitetGerade = true
|
|
|
|
local ped = PlayerPedId()
|
|
local dauer = schrittDef.dauer or 4000
|
|
|
|
-- Passendes Szenario abspielen, damit die Handlung sichtbar ist
|
|
if schrittDef.szenario then
|
|
TaskStartScenarioInPlace(ped, schrittDef.szenario, 0, true)
|
|
end
|
|
FreezeEntityPosition(ped, true)
|
|
|
|
-- Fortschrittsbalken anzeigen
|
|
local start = GetGameTimer()
|
|
CreateThread(function()
|
|
while arbeitetGerade do
|
|
local vergangen = GetGameTimer() - start
|
|
local anteil = math.min(1.0, vergangen / dauer)
|
|
|
|
-- Balken am unteren Bildschirmrand
|
|
DrawRect(0.5, 0.86, 0.24, 0.028, 0, 0, 0, 170)
|
|
DrawRect(0.38 + 0.12 * anteil, 0.86, 0.24 * anteil, 0.028, 90, 200, 120, 220)
|
|
|
|
SetTextFont(4)
|
|
SetTextScale(0.34, 0.34)
|
|
SetTextColour(255, 255, 255, 230)
|
|
SetTextCentre(true)
|
|
SetTextEntry('STRING')
|
|
AddTextComponentString(schrittDef.text or 'Arbeite …')
|
|
SetTextOutline()
|
|
DrawText(0.5, 0.895)
|
|
|
|
Wait(0)
|
|
end
|
|
end)
|
|
|
|
Wait(dauer)
|
|
|
|
ClearPedTasks(ped)
|
|
FreezeEntityPosition(ped, false)
|
|
arbeitetGerade = false
|
|
|
|
-- Erst jetzt meldet der Client den Schritt; der Server prueft Ort und Zeit
|
|
TriggerServerEvent('aether:jobs:schritt')
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Ereignisse vom Server
|
|
-- ---------------------------------------------------------------------
|
|
AddEventHandler('aether:client:bereit', function()
|
|
istGeladen = true
|
|
end)
|
|
|
|
RegisterNetEvent('aether:jobs:zeige', function(zustand)
|
|
istOffen = true
|
|
SetNuiFocus(true, true)
|
|
pcall(function() exports['aether-hud']:SetzeSichtbar(false) end)
|
|
zustand.aktion = 'oeffnen'
|
|
SendeAnNui(zustand)
|
|
end)
|
|
|
|
RegisterNetEvent('aether:jobs:zustand', function(zustand)
|
|
-- Schicht lokal spiegeln, damit Marker und Anzeige stimmen
|
|
if zustand.aktiv then
|
|
local job = nil
|
|
for _, j in ipairs(zustand.jobs or {}) do
|
|
if j.id == zustand.aktiv.jobId then job = j break end
|
|
end
|
|
schicht = {
|
|
jobId = zustand.aktiv.jobId,
|
|
schrittIdx = zustand.aktiv.schrittIdx,
|
|
auftrag = zustand.aktiv.aktuell,
|
|
schritte = job and job.schritte or {},
|
|
name = job and job.name or '',
|
|
}
|
|
AktualisiereZielBlip()
|
|
else
|
|
schicht = nil
|
|
EntferneZielBlip()
|
|
end
|
|
|
|
if istOffen then
|
|
zustand.aktion = 'zustand'
|
|
SendeAnNui(zustand)
|
|
elseif zustand.hinweis then
|
|
ZeigeSpielmeldung(zustand.hinweis)
|
|
end
|
|
end)
|
|
|
|
RegisterNetEvent('aether:jobs:fehler', function(text)
|
|
if istOffen then
|
|
SendeAnNui({ aktion = 'fehler', text = text })
|
|
else
|
|
ZeigeSpielmeldung(text)
|
|
end
|
|
end)
|
|
|
|
--- Dienstfahrzeug steht bereit
|
|
RegisterNetEvent('aether:jobs:fahrzeugBereit', function(netId, spawn)
|
|
Schliessen()
|
|
|
|
local versuche = 0
|
|
while not NetworkDoesEntityExistWithNetworkId(netId) and versuche < 100 do
|
|
Wait(50)
|
|
versuche = versuche + 1
|
|
end
|
|
if not NetworkDoesEntityExistWithNetworkId(netId) then return end
|
|
|
|
local fahrzeug = NetToVeh(netId)
|
|
if fahrzeug and DoesEntityExist(fahrzeug) then
|
|
SetVehicleNumberPlateText(fahrzeug, 'DIENST')
|
|
ZeigeSpielmeldung('~g~Dienstfahrzeug~s~ steht bereit — folge der Markierung.')
|
|
|
|
-- Wegpunkt zum Fahrzeug setzen, damit man es findet
|
|
SetNewWaypoint(spawn.x, spawn.y)
|
|
end
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Jobcenter in der Naehe erkennen
|
|
-- ---------------------------------------------------------------------
|
|
CreateThread(function()
|
|
local blip = AddBlipForCoord(Jobs.JOBCENTER.x, Jobs.JOBCENTER.y, Jobs.JOBCENTER.z)
|
|
SetBlipSprite(blip, 407)
|
|
SetBlipColour(blip, 5)
|
|
SetBlipScale(blip, 0.8)
|
|
SetBlipAsShortRange(blip, true)
|
|
BeginTextCommandSetBlipName('STRING')
|
|
AddTextComponentString('Jobcenter')
|
|
EndTextCommandSetBlipName(blip)
|
|
|
|
while true do
|
|
local pause = 800
|
|
|
|
if istGeladen and not istOffen and not schicht then
|
|
local position = GetEntityCoords(PlayerPedId())
|
|
|
|
if #(position - Jobs.JOBCENTER) <= Jobs.INTERAKTION_WEITE then
|
|
pause = 0
|
|
SetTextFont(4)
|
|
SetTextScale(0.34, 0.34)
|
|
SetTextColour(255, 255, 255, 215)
|
|
SetTextCentre(true)
|
|
SetTextOutline()
|
|
SetTextEntry('STRING')
|
|
AddTextComponentString('[E] Jobcenter')
|
|
SetDrawOrigin(Jobs.JOBCENTER.x, Jobs.JOBCENTER.y, Jobs.JOBCENTER.z + 0.9, 0)
|
|
DrawText(0.0, 0.0)
|
|
ClearDrawOrigin()
|
|
|
|
if IsControlJustReleased(0, TASTE_BENUTZEN) then
|
|
TriggerServerEvent('aether:jobs:oeffnen')
|
|
end
|
|
end
|
|
end
|
|
|
|
Wait(pause)
|
|
end
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Arbeitsort: Marker, Aufgabenanzeige und Aktion
|
|
-- ---------------------------------------------------------------------
|
|
CreateThread(function()
|
|
while true do
|
|
local pause = 500
|
|
|
|
if schicht and not istOffen and not arbeitetGerade then
|
|
local ort = AktuellerOrt()
|
|
local schrittDef = schicht.schritte[schicht.schrittIdx + 1]
|
|
|
|
if ort and schrittDef then
|
|
pause = 0
|
|
local position = GetEntityCoords(PlayerPedId())
|
|
local ziel = vector3(ort.position.x, ort.position.y, ort.position.z)
|
|
local abstand = #(position - ziel)
|
|
|
|
-- Aufgabenanzeige oben links
|
|
SetTextFont(4)
|
|
SetTextScale(0.36, 0.36)
|
|
SetTextColour(255, 255, 255, 220)
|
|
SetTextEntry('STRING')
|
|
AddTextComponentString(('~y~%s~s~ %s'):format(schicht.name, schrittDef.text))
|
|
SetTextOutline()
|
|
DrawText(0.012, 0.62)
|
|
|
|
SetTextFont(4)
|
|
SetTextScale(0.31, 0.31)
|
|
SetTextColour(200, 200, 210, 190)
|
|
SetTextEntry('STRING')
|
|
AddTextComponentString(('Ziel: %s · %d m'):format(ort.name or '', math.floor(abstand)))
|
|
SetTextOutline()
|
|
DrawText(0.012, 0.655)
|
|
|
|
-- Marker am Arbeitsort, sobald er in Sichtweite ist
|
|
if abstand < 60.0 then
|
|
DrawMarker(1, ziel.x, ziel.y, ziel.z - 0.95, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
|
|
1.6, 1.6, 0.7, 90, 200, 120, 120, false, false, 2, false, nil, nil, false)
|
|
end
|
|
|
|
-- In Reichweite: Aktion anbieten
|
|
if abstand <= Jobs.ORT_RADIUS then
|
|
SetTextFont(4)
|
|
SetTextScale(0.34, 0.34)
|
|
SetTextColour(255, 255, 255, 220)
|
|
SetTextCentre(true)
|
|
SetTextOutline()
|
|
SetTextEntry('STRING')
|
|
AddTextComponentString('[E] ' .. (schrittDef.btn or 'Arbeiten'))
|
|
SetDrawOrigin(ziel.x, ziel.y, ziel.z + 0.9, 0)
|
|
DrawText(0.0, 0.0)
|
|
ClearDrawOrigin()
|
|
|
|
if IsControlJustReleased(0, TASTE_BENUTZEN) then
|
|
FuehreHandlungAus(schrittDef)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
Wait(pause)
|
|
end
|
|
end)
|
|
|
|
--- Taste J: Arbeitsansicht oeffnen
|
|
RegisterCommand('job', function()
|
|
if istGeladen and not istOffen then
|
|
TriggerServerEvent('aether:jobs:oeffnen')
|
|
end
|
|
end, false)
|
|
RegisterKeyMapping('job', 'Jobcenter / Arbeitsansicht öffnen', 'keyboard', 'J')
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- NUI-Callbacks
|
|
-- ---------------------------------------------------------------------
|
|
RegisterNUICallback('schliessen', function(_, rueckgabe)
|
|
Schliessen()
|
|
rueckgabe({ ok = true })
|
|
end)
|
|
|
|
RegisterNUICallback('antreten', function(daten, rueckgabe)
|
|
TriggerServerEvent('aether:jobs:antreten', tostring(daten.jobId or ''))
|
|
Schliessen() -- gearbeitet wird in der Welt, nicht im Menue
|
|
rueckgabe({ ok = true })
|
|
end)
|
|
|
|
RegisterNUICallback('feierabend', function(_, rueckgabe)
|
|
TriggerServerEvent('aether:jobs:feierabend')
|
|
rueckgabe({ ok = true })
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Aufraeumen
|
|
-- ---------------------------------------------------------------------
|
|
AddEventHandler('onResourceStop', function(ressource)
|
|
if ressource ~= GetCurrentResourceName() then return end
|
|
SetNuiFocus(false, false)
|
|
EntferneZielBlip()
|
|
ClearPedTasks(PlayerPedId())
|
|
FreezeEntityPosition(PlayerPedId(), false)
|
|
end)
|