first commit
This commit is contained in:
@@ -0,0 +1,989 @@
|
||||
local propertyState = {}
|
||||
local ownedPropertyKey = nil
|
||||
local ownedPropertyLabel = nil
|
||||
local locationMode = 'world'
|
||||
local currentBuilding = nil
|
||||
local currentFloor = nil
|
||||
local currentProperty = nil
|
||||
local currentInteriorStyle = nil
|
||||
local previewOpen = false
|
||||
local previewUnits = {}
|
||||
local previewIndex = 1
|
||||
local previewCamera = nil
|
||||
local previewShotIndex = 1
|
||||
local nextPreviewShotAt = 0
|
||||
local pendingDoorbell = nil
|
||||
local pendingInvite = nil
|
||||
local activeInteraction = false
|
||||
local hallwayObjects = {}
|
||||
local lastPropertyRefresh = 0
|
||||
|
||||
local function notify(message)
|
||||
exports.devx_core:Notify(message)
|
||||
end
|
||||
|
||||
local function help(text)
|
||||
BeginTextCommandDisplayHelp('STRING')
|
||||
AddTextComponentSubstringPlayerName(text)
|
||||
EndTextCommandDisplayHelp(0, false, true, -1)
|
||||
end
|
||||
|
||||
local function drawText3D(coords, lineOne, lineTwo)
|
||||
local onScreen, screenX, screenY = World3dToScreen2d(coords.x, coords.y, coords.z)
|
||||
if not onScreen then return end
|
||||
|
||||
SetTextScale(0.0, 0.30)
|
||||
SetTextFont(4)
|
||||
SetTextProportional(true)
|
||||
SetTextColour(255, 255, 255, 235)
|
||||
SetTextCentre(true)
|
||||
SetTextOutline()
|
||||
BeginTextCommandDisplayText('STRING')
|
||||
AddTextComponentSubstringPlayerName(lineOne or '')
|
||||
EndTextCommandDisplayText(screenX, screenY)
|
||||
|
||||
if lineTwo and lineTwo ~= '' then
|
||||
SetTextScale(0.0, 0.25)
|
||||
SetTextFont(4)
|
||||
SetTextProportional(true)
|
||||
SetTextColour(155, 190, 255, 230)
|
||||
SetTextCentre(true)
|
||||
SetTextOutline()
|
||||
BeginTextCommandDisplayText('STRING')
|
||||
AddTextComponentSubstringPlayerName(lineTwo)
|
||||
EndTextCommandDisplayText(screenX, screenY + 0.018)
|
||||
end
|
||||
end
|
||||
|
||||
local function findBuilding(key)
|
||||
for _, building in ipairs(DevXHousing.Buildings) do
|
||||
if building.key == key then return building end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function findUnit(key)
|
||||
for _, building in ipairs(DevXHousing.Buildings) do
|
||||
for _, unit in ipairs(building.units) do
|
||||
if unit.key == key then return unit, building end
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
local function requestAnim(dict)
|
||||
RequestAnimDict(dict)
|
||||
local deadline = GetGameTimer() + 5000
|
||||
while not HasAnimDictLoaded(dict) and GetGameTimer() < deadline do Wait(0) end
|
||||
return HasAnimDictLoaded(dict)
|
||||
end
|
||||
|
||||
local function requestModel(name)
|
||||
local hash = GetHashKey(name)
|
||||
if not IsModelInCdimage(hash) or not IsModelValid(hash) then return nil end
|
||||
RequestModel(hash)
|
||||
local deadline = GetGameTimer() + 5000
|
||||
while not HasModelLoaded(hash) and GetGameTimer() < deadline do Wait(0) end
|
||||
return HasModelLoaded(hash) and hash or nil
|
||||
end
|
||||
|
||||
local function clearHallwayObjects()
|
||||
for _, entity in ipairs(hallwayObjects) do
|
||||
if entity and DoesEntityExist(entity) then DeleteEntity(entity) end
|
||||
end
|
||||
hallwayObjects = {}
|
||||
end
|
||||
|
||||
local function createHallwayObject(modelName, coords, collision)
|
||||
local model = requestModel(modelName)
|
||||
if not model then
|
||||
print(('[devx_housing] Dekorationsmodell fehlt: %s'):format(modelName))
|
||||
return nil
|
||||
end
|
||||
|
||||
local object = CreateObjectNoOffset(model, coords.x, coords.y, coords.z, false, false, false)
|
||||
if object and object ~= 0 then
|
||||
SetEntityHeading(object, coords.heading or coords.w or 0.0)
|
||||
FreezeEntityPosition(object, true)
|
||||
SetEntityCollision(object, collision ~= false, collision ~= false)
|
||||
SetEntityAsMissionEntity(object, true, true)
|
||||
hallwayObjects[#hallwayObjects + 1] = object
|
||||
end
|
||||
SetModelAsNoLongerNeeded(model)
|
||||
return object
|
||||
end
|
||||
|
||||
local function spawnHallwayObjects(showDoors)
|
||||
clearHallwayObjects()
|
||||
local shell = DevXHousing.FloorShell
|
||||
for _, entry in ipairs(shell.setDressing or {}) do
|
||||
createHallwayObject(entry.model, entry.coords, true)
|
||||
end
|
||||
if showDoors then
|
||||
for _, door in ipairs(shell.doors or {}) do
|
||||
createHallwayObject(shell.doorModel, door, true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function restoreWorldStreaming(reason)
|
||||
clearHallwayObjects()
|
||||
TriggerEvent('devx_ipl:client:restoreWorld', reason or 'housing')
|
||||
end
|
||||
|
||||
local function prepareInterior(definition)
|
||||
local position = definition and (definition.loadAt or definition.spawn) or nil
|
||||
if not position then return true end
|
||||
|
||||
-- v0.4.3 never removes or swaps global apartment IPLs. It only streams the
|
||||
-- already existing interior around the target coordinate.
|
||||
NewLoadSceneStop()
|
||||
ClearFocus()
|
||||
SetFocusPosAndVel(position.x, position.y, position.z, 0.0, 0.0, 0.0)
|
||||
RequestCollisionAtCoord(position.x, position.y, position.z)
|
||||
RequestAdditionalCollisionAtCoord(position.x, position.y, position.z)
|
||||
NewLoadSceneStartSphere(position.x, position.y, position.z, 120.0, 0)
|
||||
|
||||
local interiorId = GetInteriorAtCoords(position.x, position.y, position.z)
|
||||
if interiorId == 0 then interiorId = GetInteriorAtCoords(position.x, position.y, position.z + 1.0) end
|
||||
if interiorId ~= 0 then
|
||||
PinInteriorInMemory(interiorId)
|
||||
RefreshInterior(interiorId)
|
||||
end
|
||||
|
||||
local deadline = GetGameTimer() + 15000
|
||||
local ready = false
|
||||
while GetGameTimer() < deadline do
|
||||
local sceneReady = IsNewLoadSceneLoaded()
|
||||
local interiorReady = interiorId == 0 or IsInteriorReady(interiorId)
|
||||
if sceneReady and interiorReady then
|
||||
ready = true
|
||||
break
|
||||
end
|
||||
RequestCollisionAtCoord(position.x, position.y, position.z)
|
||||
RequestAdditionalCollisionAtCoord(position.x, position.y, position.z)
|
||||
Wait(0)
|
||||
end
|
||||
|
||||
NewLoadSceneStop()
|
||||
if not ready then
|
||||
print(('[devx_housing] Streaming-Timeout bei %.4f %.4f %.4f (interior=%s)'):format(
|
||||
position.x, position.y, position.z, interiorId
|
||||
))
|
||||
end
|
||||
return ready
|
||||
end
|
||||
|
||||
local function teleport(position, interiorDefinition)
|
||||
DoScreenFadeOut(280)
|
||||
local fadeDeadline = GetGameTimer() + 1500
|
||||
while not IsScreenFadedOut() and GetGameTimer() < fadeDeadline do Wait(0) end
|
||||
|
||||
if interiorDefinition and not prepareInterior(interiorDefinition) then
|
||||
ClearFocus()
|
||||
DoScreenFadeIn(300)
|
||||
notify('Der Innenraum konnte nicht gestreamt werden. Du wurdest nicht teleportiert.')
|
||||
return false
|
||||
end
|
||||
|
||||
local ped = PlayerPedId()
|
||||
FreezeEntityPosition(ped, true)
|
||||
SetEntityCollision(ped, true, true)
|
||||
RequestCollisionAtCoord(position.x, position.y, position.z)
|
||||
RequestAdditionalCollisionAtCoord(position.x, position.y, position.z)
|
||||
SetEntityCoordsNoOffset(ped, position.x, position.y, position.z, false, false, false)
|
||||
SetEntityHeading(ped, position.heading or position.w or 0.0)
|
||||
ClearPedTasksImmediately(ped)
|
||||
|
||||
local collisionDeadline = GetGameTimer() + 10000
|
||||
while not HasCollisionLoadedAroundEntity(ped) and GetGameTimer() < collisionDeadline do
|
||||
RequestCollisionAtCoord(position.x, position.y, position.z)
|
||||
RequestAdditionalCollisionAtCoord(position.x, position.y, position.z)
|
||||
Wait(0)
|
||||
end
|
||||
|
||||
FreezeEntityPosition(ped, false)
|
||||
Wait(180)
|
||||
ClearFocus()
|
||||
DoScreenFadeIn(380)
|
||||
return true
|
||||
end
|
||||
|
||||
local function refreshProperties(callback)
|
||||
DevXTriggerCallback('housing:list', {}, function(result)
|
||||
if result.ok then
|
||||
propertyState = {}
|
||||
ownedPropertyKey = result.ownedPropertyKey
|
||||
ownedPropertyLabel = result.ownedPropertyLabel
|
||||
for _, property in ipairs(result.properties or {}) do
|
||||
propertyState[property.propertyKey] = {
|
||||
id = property.id,
|
||||
ownerCharacterId = property.ownerCharacterId,
|
||||
ownerName = property.ownerName,
|
||||
characterId = result.characterId,
|
||||
price = tonumber(property.price) or 0,
|
||||
label = property.label
|
||||
}
|
||||
end
|
||||
lastPropertyRefresh = GetGameTimer()
|
||||
end
|
||||
if callback then callback(result) end
|
||||
end)
|
||||
end
|
||||
|
||||
local function stateFor(unit)
|
||||
local player = exports.devx_core:GetPlayerData()
|
||||
local state = propertyState[unit.key] or {
|
||||
characterId = player and player.characterId,
|
||||
price = unit.price
|
||||
}
|
||||
local ownerName = type(state.ownerName) == 'string' and state.ownerName:gsub('^%s+', ''):gsub('%s+$', '') or ''
|
||||
state.ownerName = ownerName ~= '' and ownerName or nil
|
||||
state.ownedByMe = state.ownerCharacterId
|
||||
and tonumber(state.ownerCharacterId) == tonumber(state.characterId)
|
||||
state.sold = state.ownerCharacterId ~= nil and state.ownerName ~= nil
|
||||
return state
|
||||
end
|
||||
|
||||
local function sendUi(action, data)
|
||||
data = data or {}
|
||||
data.action = action
|
||||
SendNUIMessage(data)
|
||||
end
|
||||
|
||||
local function destroyPreviewCamera()
|
||||
if previewCamera and DoesCamExist(previewCamera) then
|
||||
RenderScriptCams(false, true, 450, true, true)
|
||||
DestroyCam(previewCamera, false)
|
||||
end
|
||||
previewCamera = nil
|
||||
end
|
||||
|
||||
local function destroyPreview()
|
||||
previewOpen = false
|
||||
destroyPreviewCamera()
|
||||
SetNuiFocus(false, false)
|
||||
local ped = PlayerPedId()
|
||||
SetEntityVisible(ped, true, false)
|
||||
SetEntityCollision(ped, true, true)
|
||||
FreezeEntityPosition(ped, false)
|
||||
ClearFocus()
|
||||
end
|
||||
|
||||
local function currentPreviewUnit()
|
||||
return previewUnits[previewIndex]
|
||||
end
|
||||
|
||||
local function updatePreviewUi()
|
||||
local unit = currentPreviewUnit()
|
||||
if not unit then return end
|
||||
local state = stateFor(unit)
|
||||
sendUi('previewData', {
|
||||
label = unit.label,
|
||||
building = unit.buildingLabel,
|
||||
floor = unit.floor,
|
||||
unitNumber = unit.unitNumber,
|
||||
style = DevXHousing.Interiors[unit.style].label,
|
||||
price = state.price or unit.price,
|
||||
sold = state.sold,
|
||||
ownedByMe = state.ownedByMe,
|
||||
ownerName = state.ownerName,
|
||||
index = previewIndex,
|
||||
total = #previewUnits
|
||||
})
|
||||
end
|
||||
|
||||
local function usePreviewShot(instant)
|
||||
local unit = currentPreviewUnit()
|
||||
if not unit then return end
|
||||
local interior = DevXHousing.Interiors[unit.style]
|
||||
local shots = interior.cameraShots or {}
|
||||
if #shots == 0 then return end
|
||||
if previewShotIndex > #shots then previewShotIndex = 1 end
|
||||
local shot = shots[previewShotIndex]
|
||||
|
||||
local newCamera = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
|
||||
SetCamCoord(newCamera, shot.cam.x, shot.cam.y, shot.cam.z)
|
||||
PointCamAtCoord(newCamera, shot.target.x, shot.target.y, shot.target.z)
|
||||
SetCamFov(newCamera, 48.0)
|
||||
SetCamActive(newCamera, true)
|
||||
|
||||
if previewCamera and DoesCamExist(previewCamera) and not instant then
|
||||
SetCamActiveWithInterp(newCamera, previewCamera, 1450, true, true)
|
||||
local oldCamera = previewCamera
|
||||
CreateThread(function()
|
||||
Wait(1550)
|
||||
if DoesCamExist(oldCamera) then DestroyCam(oldCamera, false) end
|
||||
end)
|
||||
else
|
||||
RenderScriptCams(true, true, 450, true, true)
|
||||
if previewCamera and DoesCamExist(previewCamera) then DestroyCam(previewCamera, false) end
|
||||
end
|
||||
|
||||
previewCamera = newCamera
|
||||
nextPreviewShotAt = GetGameTimer() + 6000
|
||||
end
|
||||
|
||||
local function showPreviewUnit()
|
||||
local unit = currentPreviewUnit()
|
||||
if not unit then return end
|
||||
local interior = DevXHousing.Interiors[unit.style]
|
||||
local ped = PlayerPedId()
|
||||
|
||||
DoScreenFadeOut(180)
|
||||
while not IsScreenFadedOut() do Wait(0) end
|
||||
if not prepareInterior(interior) then
|
||||
DoScreenFadeIn(250)
|
||||
notify('Diese Apartmentansicht konnte nicht geladen werden.')
|
||||
return
|
||||
end
|
||||
|
||||
SetEntityCoordsNoOffset(ped, interior.spawn.x, interior.spawn.y, interior.spawn.z, false, false, false)
|
||||
SetEntityHeading(ped, interior.spawn.heading or 0.0)
|
||||
SetEntityVisible(ped, false, false)
|
||||
SetEntityCollision(ped, false, false)
|
||||
FreezeEntityPosition(ped, true)
|
||||
|
||||
destroyPreviewCamera()
|
||||
previewShotIndex = 1
|
||||
usePreviewShot(true)
|
||||
updatePreviewUi()
|
||||
Wait(120)
|
||||
DoScreenFadeIn(260)
|
||||
end
|
||||
|
||||
local function closePreview(returnToWorld)
|
||||
local building = currentBuilding
|
||||
destroyPreview()
|
||||
sendUi('close')
|
||||
DevXTriggerCallback('housing:previewEnd', {}, function() end)
|
||||
locationMode = 'world'
|
||||
if returnToWorld ~= false and building then
|
||||
teleport(building.exterior)
|
||||
restoreWorldStreaming('housing-preview-end')
|
||||
end
|
||||
end
|
||||
|
||||
local function startPreview(building)
|
||||
if previewOpen then return end
|
||||
clearHallwayObjects()
|
||||
refreshProperties(function(result)
|
||||
if not result.ok then notify(result.error or 'Wohnungen konnten nicht geladen werden.'); return end
|
||||
if ownedPropertyKey then
|
||||
notify(('Du besitzt bereits %s. Pro Charakter ist nur ein Apartment möglich.'):format(ownedPropertyLabel or 'ein Apartment'))
|
||||
return
|
||||
end
|
||||
|
||||
previewUnits = {}
|
||||
for _, unit in ipairs(building.units) do
|
||||
if not stateFor(unit).sold then previewUnits[#previewUnits + 1] = unit end
|
||||
end
|
||||
table.sort(previewUnits, function(a, b) return a.price < b.price end)
|
||||
if #previewUnits == 0 then notify('In diesem Gebäude sind derzeit keine Apartments frei.'); return end
|
||||
|
||||
previewIndex = 1
|
||||
currentBuilding = building
|
||||
DevXTriggerCallback('housing:previewStart', { buildingKey = building.key }, function(startResult)
|
||||
if not startResult.ok then notify(startResult.error or 'Vorschau konnte nicht gestartet werden.'); return end
|
||||
previewOpen = true
|
||||
locationMode = 'preview'
|
||||
SetNuiFocus(true, true)
|
||||
sendUi('previewOpen')
|
||||
showPreviewUnit()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
local function applyUnitResult(result, unit, building)
|
||||
clearHallwayObjects()
|
||||
locationMode = 'unit'
|
||||
currentProperty = unit.key
|
||||
currentBuilding = building
|
||||
currentFloor = unit.floor
|
||||
currentInteriorStyle = unit.style
|
||||
if teleport(result.interior.spawn, result.interior) then
|
||||
notify(('Du bist in %s.'):format(result.label or unit.label))
|
||||
else
|
||||
locationMode = 'world'
|
||||
currentProperty = nil
|
||||
currentInteriorStyle = nil
|
||||
TriggerServerEvent('devx_core:server:returnToWorld')
|
||||
teleport(building.exterior)
|
||||
restoreWorldStreaming('housing-unit-failed')
|
||||
end
|
||||
end
|
||||
|
||||
local function enterUnit(unit)
|
||||
DevXTriggerCallback('housing:enterUnit', { propertyKey = unit.key }, function(result)
|
||||
if not result.ok then notify(result.error or 'Wohnung konnte nicht betreten werden.'); return end
|
||||
local _, building = findUnit(unit.key)
|
||||
applyUnitResult(result, unit, building)
|
||||
end)
|
||||
end
|
||||
|
||||
local function enterOwned(building)
|
||||
DevXTriggerCallback('housing:enterOwned', { buildingKey = building.key }, function(result)
|
||||
if not result.ok then notify(result.error or 'Dein Apartment konnte nicht betreten werden.'); return end
|
||||
local unit, unitBuilding = findUnit(result.propertyKey)
|
||||
if unit then applyUnitResult(result, unit, unitBuilding) end
|
||||
end)
|
||||
end
|
||||
|
||||
local function enterLobby(building)
|
||||
DevXTriggerCallback('housing:enterLobby', { buildingKey = building.key }, function(result)
|
||||
if not result.ok then notify(result.error); return end
|
||||
currentBuilding = building
|
||||
currentFloor = 0
|
||||
currentProperty = nil
|
||||
currentInteriorStyle = nil
|
||||
locationMode = 'lobby'
|
||||
if teleport(result.position, DevXHousing.FloorShell) then
|
||||
spawnHallwayObjects(false)
|
||||
notify(('Lobby von %s betreten. Am Aufzug kannst du eine Etage wählen.'):format(building.label))
|
||||
else
|
||||
TriggerServerEvent('devx_core:server:returnToWorld')
|
||||
locationMode = 'world'
|
||||
teleport(building.exterior)
|
||||
restoreWorldStreaming('housing-lobby-failed')
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function openDoorMenu(unit)
|
||||
local state = stateFor(unit)
|
||||
if not state.sold then return end
|
||||
SetNuiFocus(true, true)
|
||||
sendUi('doorOpen', {
|
||||
propertyKey = unit.key,
|
||||
label = ('Wohnung %s'):format(unit.doorNumber),
|
||||
ownerName = state.ownerName,
|
||||
sold = true,
|
||||
ownedByMe = state.ownedByMe
|
||||
})
|
||||
end
|
||||
|
||||
local function openInviteMenu()
|
||||
DevXTriggerCallback('housing:inviteCandidates', {}, function(result)
|
||||
if not result.ok then notify(result.error or 'Spielerliste konnte nicht geladen werden.'); return end
|
||||
SetNuiFocus(true, true)
|
||||
sendUi('inviteOpen', {
|
||||
title = 'Spieler einladen',
|
||||
players = result.players or {}
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
local function playDrink(kind)
|
||||
if activeInteraction then return end
|
||||
activeInteraction = true
|
||||
local ped = PlayerPedId()
|
||||
local modelName = kind == 'whiskey' and 'prop_drink_whisky' or 'prop_amb_beer_bottle'
|
||||
local model = requestModel(modelName)
|
||||
local prop = nil
|
||||
if model then
|
||||
prop = CreateObject(model, 0.0, 0.0, 0.0, false, false, false)
|
||||
AttachEntityToEntity(prop, ped, GetPedBoneIndex(ped, 28422), 0.0, -0.01, -0.03, 0.0, 0.0, 0.0, true, true, false, true, 1, true)
|
||||
end
|
||||
if requestAnim('amb@world_human_drinking@beer@male@idle_a') then
|
||||
TaskPlayAnim(ped, 'amb@world_human_drinking@beer@male@idle_a', 'idle_c', 3.0, -3.0, 6500, 49, 0.0, false, false, false)
|
||||
end
|
||||
Wait(6500)
|
||||
ClearPedSecondaryTask(ped)
|
||||
if prop and DoesEntityExist(prop) then DeleteEntity(prop) end
|
||||
if model then SetModelAsNoLongerNeeded(model) end
|
||||
notify(kind == 'whiskey' and 'Du hast einen Whiskey getrunken.' or 'Du hast ein Bier getrunken.')
|
||||
activeInteraction = false
|
||||
end
|
||||
|
||||
local function playSleep(point)
|
||||
if activeInteraction then return end
|
||||
activeInteraction = true
|
||||
local ped = PlayerPedId()
|
||||
SetEntityCoordsNoOffset(ped, point.coords.x, point.coords.y, point.coords.z + 0.15, false, false, false)
|
||||
SetEntityHeading(ped, point.heading or 0.0)
|
||||
if requestAnim('timetable@tracy@sleep@') then
|
||||
TaskPlayAnim(ped, 'timetable@tracy@sleep@', 'idle_c', 2.0, -2.0, 10000, 1, 0.0, false, false, false)
|
||||
end
|
||||
notify('Du schläfst kurz …')
|
||||
Wait(10000)
|
||||
ClearPedTasksImmediately(ped)
|
||||
SetEntityHealth(ped, math.min(GetEntityMaxHealth(ped), GetEntityHealth(ped) + 15))
|
||||
activeInteraction = false
|
||||
end
|
||||
|
||||
local function playBong()
|
||||
if activeInteraction then return end
|
||||
activeInteraction = true
|
||||
local ped = PlayerPedId()
|
||||
TaskStartScenarioInPlace(ped, 'WORLD_HUMAN_SMOKING_POT', 0, true)
|
||||
StartScreenEffect('DrugsMichaelAliensFight', 0, false)
|
||||
Wait(9000)
|
||||
StopScreenEffect('DrugsMichaelAliensFight')
|
||||
ClearPedTasksImmediately(ped)
|
||||
activeInteraction = false
|
||||
end
|
||||
|
||||
local function useApartmentInteraction(point)
|
||||
if point.type == 'beer' or point.type == 'whiskey' then
|
||||
CreateThread(function() playDrink(point.type) end)
|
||||
elseif point.type == 'sleep' then
|
||||
CreateThread(function() playSleep(point) end)
|
||||
elseif point.type == 'bong' then
|
||||
CreateThread(playBong)
|
||||
end
|
||||
end
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
if previewOpen and GetGameTimer() >= nextPreviewShotAt then
|
||||
local unit = currentPreviewUnit()
|
||||
if unit then
|
||||
local shots = DevXHousing.Interiors[unit.style].cameraShots or {}
|
||||
previewShotIndex = previewShotIndex + 1
|
||||
if previewShotIndex > #shots then previewShotIndex = 1 end
|
||||
usePreviewShot(false)
|
||||
end
|
||||
end
|
||||
Wait(250)
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
if previewOpen then
|
||||
DisableAllControlActions(0)
|
||||
EnableControlAction(0, 200, true)
|
||||
Wait(0)
|
||||
else
|
||||
Wait(500)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
RegisterNUICallback('previewPrevious', function(_, cb)
|
||||
if #previewUnits > 0 then
|
||||
previewIndex = previewIndex - 1
|
||||
if previewIndex < 1 then previewIndex = #previewUnits end
|
||||
showPreviewUnit()
|
||||
end
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('previewNext', function(_, cb)
|
||||
if #previewUnits > 0 then
|
||||
previewIndex = previewIndex + 1
|
||||
if previewIndex > #previewUnits then previewIndex = 1 end
|
||||
showPreviewUnit()
|
||||
end
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('previewBuy', function(_, cb)
|
||||
local unit = currentPreviewUnit()
|
||||
if not unit then cb({ ok = false, error = 'Keine Wohnung ausgewählt.' }); return end
|
||||
DevXTriggerCallback('housing:buy', { propertyKey = unit.key }, function(result)
|
||||
if result.ok then
|
||||
refreshProperties(function()
|
||||
notify('Wohnung erfolgreich gekauft. Am Gebäudeeingang kannst du sie jetzt direkt betreten.')
|
||||
closePreview(true)
|
||||
end)
|
||||
end
|
||||
cb(result)
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('previewClose', function(_, cb)
|
||||
closePreview(true)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('elevatorFloor', function(data, cb)
|
||||
local building = currentBuilding
|
||||
local floor = tonumber(data.floor)
|
||||
if not building or not floor then cb({ ok = false }); return end
|
||||
|
||||
DevXTriggerCallback('housing:goFloor', { buildingKey = building.key, floor = floor }, function(result)
|
||||
if result.ok then
|
||||
SetNuiFocus(false, false)
|
||||
sendUi('close')
|
||||
locationMode = 'floor'
|
||||
currentFloor = floor
|
||||
refreshProperties()
|
||||
if teleport(result.position, DevXHousing.FloorShell) then
|
||||
spawnHallwayObjects(true)
|
||||
else
|
||||
locationMode = 'world'
|
||||
TriggerServerEvent('devx_core:server:returnToWorld')
|
||||
teleport(building.exterior)
|
||||
restoreWorldStreaming('housing-floor-failed')
|
||||
end
|
||||
else
|
||||
notify(result.error)
|
||||
end
|
||||
cb(result)
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('uiClose', function(_, cb)
|
||||
SetNuiFocus(false, false)
|
||||
sendUi('close')
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('doorRing', function(data, cb)
|
||||
DevXTriggerCallback('housing:ring', { propertyKey = data.propertyKey }, function(result)
|
||||
notify(result.message or result.error or 'Klingelanfrage beendet.')
|
||||
cb(result)
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('doorKnock', function(data, cb)
|
||||
DevXTriggerCallback('housing:knock', { propertyKey = data.propertyKey }, function(result)
|
||||
notify(result.message or result.error or 'Klopfen beendet.')
|
||||
cb(result)
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('doorEnter', function(data, cb)
|
||||
local unit = findUnit(data.propertyKey)
|
||||
SetNuiFocus(false, false)
|
||||
sendUi('close')
|
||||
if unit then enterUnit(unit) end
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('invitePlayer', function(data, cb)
|
||||
DevXTriggerCallback('housing:invite', { target = tonumber(data.target) }, function(result)
|
||||
if result.ok then
|
||||
notify('Apartment-Einladung wurde gesendet. Der Spieler muss sie zuerst annehmen.')
|
||||
SetNuiFocus(false, false)
|
||||
sendUi('close')
|
||||
else
|
||||
notify(result.error or 'Einladung konnte nicht gesendet werden.')
|
||||
end
|
||||
cb(result)
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNetEvent('devx_housing:client:notify', notify)
|
||||
|
||||
RegisterNetEvent('devx_housing:client:invite', function(data)
|
||||
pendingInvite = {
|
||||
propertyKey = data.propertyKey,
|
||||
label = data.label or 'Apartment',
|
||||
invitedBy = data.invitedBy or 'Jemand',
|
||||
expiresAt = GetGameTimer() + ((tonumber(data.expiresIn) or 120) * 1000)
|
||||
}
|
||||
PlaySoundFrontend(-1, 'Menu_Accept', 'Phone_SoundSet_Default', true)
|
||||
notify(('%s lädt dich in %s ein. Drücke E zum Annehmen oder X zum Ablehnen.'):format(
|
||||
pendingInvite.invitedBy, pendingInvite.label
|
||||
))
|
||||
end)
|
||||
|
||||
RegisterNetEvent('devx_housing:client:enterGranted', function(result)
|
||||
local unit, building = findUnit(result.propertyKey)
|
||||
if not unit then return end
|
||||
pendingInvite = nil
|
||||
applyUnitResult(result, unit, building)
|
||||
notify(('Zutritt zu %s erhalten.'):format(result.label or unit.label))
|
||||
end)
|
||||
|
||||
RegisterNetEvent('devx_housing:client:doorbell', function(data)
|
||||
pendingDoorbell = data
|
||||
pendingDoorbell.expiresAt = GetGameTimer() + 30000
|
||||
PlaySoundFrontend(-1, 'Door_Open', 'DOCKS_HEIST_FINALE_2B_SOUNDS', true)
|
||||
notify(('%s klingelt an deiner Wohnung. Drücke E zum Öffnen oder X zum Ablehnen.'):format(data.visitorName))
|
||||
end)
|
||||
|
||||
RegisterNetEvent('devx_housing:client:knock', function(visitorName)
|
||||
PlaySoundFrontend(-1, 'Knuckle_Crack_Hard_Cel', 'MP_SNACKS_SOUNDSET', true)
|
||||
notify(('%s klopft an der Wohnungstür.'):format(visitorName or 'Jemand'))
|
||||
end)
|
||||
|
||||
RegisterNetEvent('devx_housing:client:visitorAccepted', function(visitorName)
|
||||
notify(('%s wurde in deine Wohnung gelassen.'):format(visitorName or 'Der Besucher'))
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
if pendingInvite then
|
||||
if GetGameTimer() > pendingInvite.expiresAt then
|
||||
pendingInvite = nil
|
||||
notify('Die Apartment-Einladung ist abgelaufen.')
|
||||
else
|
||||
help(('%s lädt dich ein: ~INPUT_CONTEXT~ annehmen | ~INPUT_VEH_DUCK~ ablehnen'):format(pendingInvite.invitedBy))
|
||||
if IsControlJustReleased(0, 38) then
|
||||
DevXTriggerCallback('housing:acceptInvite', {}, function(result)
|
||||
if result.ok then
|
||||
local unit, building = findUnit(result.propertyKey)
|
||||
if unit then applyUnitResult(result, unit, building) end
|
||||
else
|
||||
notify(result.error or 'Einladung ist nicht mehr gültig.')
|
||||
end
|
||||
end)
|
||||
pendingInvite = nil
|
||||
elseif IsControlJustReleased(0, 73) then
|
||||
DevXTriggerCallback('housing:declineInvite', {}, function() end)
|
||||
pendingInvite = nil
|
||||
notify('Apartment-Einladung abgelehnt.')
|
||||
end
|
||||
end
|
||||
Wait(0)
|
||||
else
|
||||
Wait(400)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
if pendingDoorbell then
|
||||
if pendingDoorbell.expiresAt and GetGameTimer() > pendingDoorbell.expiresAt then
|
||||
pendingDoorbell = nil
|
||||
else
|
||||
help(('%s klingelt: ~INPUT_CONTEXT~ öffnen | ~INPUT_VEH_DUCK~ ablehnen'):format(
|
||||
pendingDoorbell.visitorName or 'Besucher'
|
||||
))
|
||||
if IsControlJustReleased(0, 38) then
|
||||
DevXTriggerCallback('housing:acceptVisitor', {}, function(result)
|
||||
if not result.ok then notify(result.error) end
|
||||
end)
|
||||
pendingDoorbell = nil
|
||||
elseif IsControlJustReleased(0, 73) then
|
||||
DevXTriggerCallback('housing:declineVisitor', {}, function() end)
|
||||
pendingDoorbell = nil
|
||||
notify('Klingelanfrage abgelehnt.')
|
||||
end
|
||||
end
|
||||
Wait(0)
|
||||
else
|
||||
Wait(400)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler('devx_core:client:playerLoaded', function()
|
||||
locationMode = 'world'
|
||||
currentBuilding = nil
|
||||
currentFloor = nil
|
||||
currentProperty = nil
|
||||
currentInteriorStyle = nil
|
||||
clearHallwayObjects()
|
||||
refreshProperties()
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while not exports.devx_core:IsPlayerLoaded() do Wait(500) end
|
||||
refreshProperties()
|
||||
|
||||
for _, building in ipairs(DevXHousing.Buildings) do
|
||||
local blip = AddBlipForCoord(building.exterior.x, building.exterior.y, building.exterior.z)
|
||||
SetBlipSprite(blip, 40)
|
||||
SetBlipScale(blip, 0.72)
|
||||
SetBlipColour(blip, 3)
|
||||
SetBlipAsShortRange(blip, true)
|
||||
BeginTextCommandSetBlipName('STRING')
|
||||
AddTextComponentString(building.label)
|
||||
EndTextCommandSetBlipName(blip)
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
if locationMode == 'floor' and GetGameTimer() - lastPropertyRefresh >= (DevXHousing.PropertyRefreshMs or 8000) then
|
||||
refreshProperties()
|
||||
end
|
||||
Wait(1000)
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
local sleep = 900
|
||||
if exports.devx_core:IsPlayerLoaded() and not previewOpen then
|
||||
local ped = PlayerPedId()
|
||||
local coords = GetEntityCoords(ped)
|
||||
|
||||
if locationMode == 'world' then
|
||||
local _, ownedBuilding = nil, nil
|
||||
if ownedPropertyKey then _, ownedBuilding = findUnit(ownedPropertyKey) end
|
||||
|
||||
for _, building in ipairs(DevXHousing.Buildings) do
|
||||
local entry = building.exterior
|
||||
local distance = #(coords - vector3(entry.x, entry.y, entry.z))
|
||||
if distance < DevXHousing.MarkerDistance then
|
||||
sleep = 0
|
||||
DrawMarker(1, entry.x, entry.y, entry.z - 1.0, 0.0,0.0,0.0,0.0,0.0,0.0,
|
||||
1.0,1.0,0.25,65,110,245,125,false,false,2,false,nil,nil,false)
|
||||
if distance < DevXHousing.InteractionDistance then
|
||||
if ownedPropertyKey and ownedBuilding and ownedBuilding.key == building.key then
|
||||
help(('~INPUT_CONTEXT~ Apartment betreten | ~INPUT_DETONATE~ Lobby von %s'):format(building.label))
|
||||
if IsControlJustReleased(0, 38) then enterOwned(building)
|
||||
elseif IsControlJustReleased(0, 47) then enterLobby(building) end
|
||||
elseif ownedPropertyKey then
|
||||
help(('Du besitzt bereits %s | ~INPUT_DETONATE~ Lobby betreten'):format(ownedPropertyLabel or 'ein Apartment'))
|
||||
if IsControlJustReleased(0, 47) then enterLobby(building) end
|
||||
else
|
||||
help(('~INPUT_CONTEXT~ Apartments ansehen | ~INPUT_DETONATE~ Lobby von %s betreten'):format(building.label))
|
||||
if IsControlJustReleased(0, 38) then startPreview(building)
|
||||
elseif IsControlJustReleased(0, 47) then enterLobby(building) end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif locationMode == 'lobby' or locationMode == 'floor' then
|
||||
local shell = DevXHousing.FloorShell
|
||||
local elevatorDistance = #(coords - vector3(shell.elevator.x, shell.elevator.y, shell.elevator.z))
|
||||
local exitDistance = #(coords - vector3(shell.exit.x, shell.exit.y, shell.exit.z))
|
||||
|
||||
if elevatorDistance < 10.0 then
|
||||
sleep = 0
|
||||
DrawMarker(1, shell.elevator.x, shell.elevator.y, shell.elevator.z - 1.0,
|
||||
0.0,0.0,0.0,0.0,0.0,0.0,0.75,0.75,0.22,80,145,255,130,false,false,2,false,nil,nil,false)
|
||||
if elevatorDistance < 1.5 then
|
||||
help('Drücke ~INPUT_CONTEXT~, um den Aufzug zu benutzen.')
|
||||
if IsControlJustReleased(0, 38) then
|
||||
SetNuiFocus(true, true)
|
||||
sendUi('elevatorOpen', {
|
||||
building = currentBuilding.label,
|
||||
floors = currentBuilding.floors,
|
||||
currentFloor = currentFloor or 0
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if exitDistance < 8.0 then
|
||||
sleep = 0
|
||||
DrawMarker(1, shell.exit.x, shell.exit.y, shell.exit.z - 1.0,
|
||||
0.0,0.0,0.0,0.0,0.0,0.0,0.75,0.75,0.22,255,255,255,100,false,false,2,false,nil,nil,false)
|
||||
if exitDistance < 1.5 then
|
||||
help('Drücke ~INPUT_CONTEXT~, um das Gebäude zu verlassen.')
|
||||
if IsControlJustReleased(0, 38) then
|
||||
DevXTriggerCallback('housing:leaveBuilding', {}, function(result)
|
||||
if result.ok then
|
||||
locationMode = 'world'
|
||||
currentFloor = nil
|
||||
clearHallwayObjects()
|
||||
teleport(result.position)
|
||||
restoreWorldStreaming('housing-leave-building')
|
||||
else notify(result.error) end
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if locationMode == 'floor' and currentBuilding and currentFloor then
|
||||
for _, unit in ipairs(currentBuilding.units) do
|
||||
if unit.floor == currentFloor then
|
||||
local state = stateFor(unit)
|
||||
-- Free units have no resident sign and no door action.
|
||||
if state.sold then
|
||||
local door = unit.door
|
||||
local distance = #(coords - vector3(door.x, door.y, door.z))
|
||||
if distance < 12.0 then
|
||||
sleep = 0
|
||||
drawText3D(door.sign or { x = door.x + 0.75, y = door.y, z = door.z + 1.2 },
|
||||
('Wohnung %s'):format(unit.doorNumber),
|
||||
state.ownedByMe and ('~b~%s (Deine Wohnung)'):format(state.ownerName) or state.ownerName)
|
||||
if distance < 1.55 then
|
||||
if state.ownedByMe then
|
||||
help(('~INPUT_CONTEXT~ Wohnung %s betreten'):format(unit.doorNumber))
|
||||
if IsControlJustReleased(0, 38) then enterUnit(unit) end
|
||||
else
|
||||
help(('~INPUT_CONTEXT~ bei %s klingeln oder klopfen'):format(state.ownerName))
|
||||
if IsControlJustReleased(0, 38) then openDoorMenu(unit) end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif locationMode == 'unit' and currentProperty then
|
||||
local unit = findUnit(currentProperty)
|
||||
if unit then
|
||||
local interior = DevXHousing.Interiors[unit.style]
|
||||
local exit = interior.exit
|
||||
local distance = #(coords - vector3(exit.x, exit.y, exit.z))
|
||||
if distance < 10.0 then
|
||||
sleep = 0
|
||||
DrawMarker(1, exit.x, exit.y, exit.z - 1.0, 0.0,0.0,0.0,0.0,0.0,0.0,
|
||||
0.85,0.85,0.22,255,255,255,105,false,false,2,false,nil,nil,false)
|
||||
if distance < 1.55 then
|
||||
local state = stateFor(unit)
|
||||
if state.ownedByMe then
|
||||
help('~INPUT_CONTEXT~ Apartment verlassen | ~INPUT_DETONATE~ Spieler einladen')
|
||||
else
|
||||
help('~INPUT_CONTEXT~ Apartment verlassen')
|
||||
end
|
||||
|
||||
if IsControlJustReleased(0, 38) then
|
||||
DevXTriggerCallback('housing:exitUnit', {}, function(result)
|
||||
if result.ok then
|
||||
TriggerEvent('devx_housing:client:leftApartment')
|
||||
locationMode = 'floor'
|
||||
currentProperty = nil
|
||||
currentInteriorStyle = nil
|
||||
currentFloor = result.floor
|
||||
refreshProperties()
|
||||
if teleport(result.position, DevXHousing.FloorShell) then
|
||||
spawnHallwayObjects(true)
|
||||
end
|
||||
else notify(result.error) end
|
||||
end)
|
||||
elseif state.ownedByMe and IsControlJustReleased(0, 47) then
|
||||
openInviteMenu()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for _, point in ipairs(interior.interactions or {}) do
|
||||
local pointDistance = #(coords - vector3(point.coords.x, point.coords.y, point.coords.z))
|
||||
if pointDistance < 7.0 then
|
||||
sleep = 0
|
||||
DrawMarker(2, point.coords.x, point.coords.y, point.coords.z + 0.15,
|
||||
0.0,0.0,0.0,0.0,180.0,0.0,0.22,0.22,0.22,95,160,255,155,false,false,2,false,nil,nil,false)
|
||||
if pointDistance < 1.35 then
|
||||
help(('~INPUT_CONTEXT~ %s'):format(point.label))
|
||||
if IsControlJustReleased(0, 38) then useApartmentInteraction(point) end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Wait(sleep)
|
||||
end
|
||||
end)
|
||||
|
||||
RegisterCommand('inviteapartment', function(_, args)
|
||||
local target = tonumber(args[1])
|
||||
if not target then
|
||||
openInviteMenu()
|
||||
return
|
||||
end
|
||||
DevXTriggerCallback('housing:invite', { target = target }, function(result)
|
||||
notify(result.ok and 'Apartment-Einladung gesendet.' or (result.error or 'Einladung fehlgeschlagen.'))
|
||||
end)
|
||||
end, false)
|
||||
|
||||
exports('GetHousingState', function()
|
||||
return {
|
||||
mode = locationMode,
|
||||
propertyKey = currentProperty,
|
||||
buildingKey = currentBuilding and currentBuilding.key or nil,
|
||||
floor = currentFloor,
|
||||
ownedPropertyKey = ownedPropertyKey,
|
||||
interiorStyle = currentInteriorStyle,
|
||||
interior = currentInteriorStyle and DevXHousing.Interiors[currentInteriorStyle] or nil
|
||||
}
|
||||
end)
|
||||
|
||||
AddEventHandler('onResourceStop', function(resourceName)
|
||||
if resourceName ~= GetCurrentResourceName() then return end
|
||||
destroyPreview()
|
||||
clearHallwayObjects()
|
||||
TriggerServerEvent('devx_core:server:returnToWorld')
|
||||
TriggerEvent('devx_ipl:client:restoreWorld', 'housing-stop')
|
||||
end)
|
||||
@@ -0,0 +1,34 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
author 'Colin-Joel / DevX Studios'
|
||||
description 'DevX RP multi-floor apartments, previews, elevators and visitors'
|
||||
version '0.4.3'
|
||||
|
||||
lua54 'yes'
|
||||
|
||||
shared_script 'shared/config.lua'
|
||||
ui_page 'web/index.html'
|
||||
|
||||
files {
|
||||
'web/index.html',
|
||||
'web/style.css',
|
||||
'web/app.js'
|
||||
}
|
||||
|
||||
client_scripts {
|
||||
'@devx_core/client/callbacks.lua',
|
||||
'client/main.lua'
|
||||
}
|
||||
|
||||
server_scripts {
|
||||
'@oxmysql/lib/MySQL.lua',
|
||||
'@devx_core/server/callbacks.lua',
|
||||
'server/main.lua'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
'devx_core',
|
||||
'devx_banking',
|
||||
'devx_ipl'
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
local propertyLocks = {}
|
||||
local characterPurchaseLocks = {}
|
||||
local playerLocation = {}
|
||||
local temporaryInvites = {}
|
||||
local directInvites = {}
|
||||
local pendingDoorbells = {}
|
||||
local configuredPropertyKeys = {}
|
||||
|
||||
for _, building in ipairs(DevXHousing.Buildings) do
|
||||
for _, unit in ipairs(building.units) do
|
||||
configuredPropertyKeys[unit.key] = true
|
||||
end
|
||||
end
|
||||
|
||||
local function findBuilding(key)
|
||||
for _, building in ipairs(DevXHousing.Buildings) do
|
||||
if building.key == key then return building end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function findUnit(key)
|
||||
for _, building in ipairs(DevXHousing.Buildings) do
|
||||
for _, unit in ipairs(building.units) do
|
||||
if unit.key == key then return unit, building end
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
local function unitRow(propertyKey)
|
||||
return MySQL.single.await([[
|
||||
SELECT p.id, p.property_key AS propertyKey, p.price,
|
||||
po.character_id AS ownerCharacterId,
|
||||
CONCAT(c.first_name, ' ', c.last_name) AS ownerName
|
||||
FROM devx_properties p
|
||||
LEFT JOIN devx_property_owners po ON po.property_id = p.id
|
||||
LEFT JOIN devx_characters c ON c.id = po.character_id AND c.deleted_at IS NULL
|
||||
WHERE p.property_key = ?
|
||||
LIMIT 1
|
||||
]], { propertyKey })
|
||||
end
|
||||
|
||||
local function ownedUnitRow(characterId)
|
||||
return MySQL.single.await([[
|
||||
SELECT p.id, p.property_key AS propertyKey, p.label, p.price,
|
||||
po.character_id AS ownerCharacterId,
|
||||
CONCAT(c.first_name, ' ', c.last_name) AS ownerName
|
||||
FROM devx_property_owners po
|
||||
INNER JOIN devx_properties p ON p.id = po.property_id
|
||||
LEFT JOIN devx_characters c ON c.id = po.character_id AND c.deleted_at IS NULL
|
||||
WHERE po.character_id = ? AND p.property_type = 'apartment'
|
||||
ORDER BY po.purchased_at ASC
|
||||
LIMIT 1
|
||||
]], { characterId })
|
||||
end
|
||||
|
||||
local function floorBucket(building, floor)
|
||||
return DevXHousing.FloorBucketBase + (building.index * 100) + tonumber(floor or 0)
|
||||
end
|
||||
|
||||
local function unitBucket(propertyId)
|
||||
return DevXHousing.UnitBucketBase + tonumber(propertyId)
|
||||
end
|
||||
|
||||
local function setLocation(source, location, bucket)
|
||||
playerLocation[source] = location
|
||||
SetPlayerRoutingBucket(source, bucket or 0)
|
||||
if bucket and bucket > 0 then
|
||||
SetRoutingBucketPopulationEnabled(bucket, false)
|
||||
SetRoutingBucketEntityLockdownMode(bucket, 'relaxed')
|
||||
end
|
||||
end
|
||||
|
||||
local function resetPlayer(source)
|
||||
playerLocation[source] = nil
|
||||
temporaryInvites[source] = nil
|
||||
directInvites[source] = nil
|
||||
pendingDoorbells[source] = nil
|
||||
SetPlayerRoutingBucket(source, 0)
|
||||
end
|
||||
|
||||
local function syncProperties()
|
||||
for _, building in ipairs(DevXHousing.Buildings) do
|
||||
for _, unit in ipairs(building.units) do
|
||||
local interior = DevXHousing.Interiors[unit.style]
|
||||
MySQL.query.await([[
|
||||
INSERT INTO devx_properties
|
||||
(property_key, label, price, property_type, entry_data, interior_data)
|
||||
VALUES (?, ?, ?, 'apartment', ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
label = VALUES(label),
|
||||
price = VALUES(price),
|
||||
entry_data = VALUES(entry_data),
|
||||
interior_data = VALUES(interior_data)
|
||||
]], {
|
||||
unit.key,
|
||||
unit.label,
|
||||
unit.price,
|
||||
json.encode({
|
||||
buildingKey = building.key,
|
||||
floor = unit.floor,
|
||||
unitNumber = unit.unitNumber,
|
||||
exterior = building.exterior
|
||||
}),
|
||||
json.encode(interior)
|
||||
})
|
||||
end
|
||||
end
|
||||
-- Soft-deleted or otherwise missing characters must not keep a door sign.
|
||||
-- This also repairs ownership rows left behind by older test versions.
|
||||
MySQL.query.await([[
|
||||
DELETE po FROM devx_property_owners po
|
||||
LEFT JOIN devx_characters c ON c.id = po.character_id
|
||||
WHERE c.id IS NULL OR c.deleted_at IS NOT NULL
|
||||
]])
|
||||
|
||||
print('[devx_housing] Apartmentdaten v0.4.3 synchronisiert; verwaiste Eigentümer bereinigt.')
|
||||
end
|
||||
|
||||
MySQL.ready(syncProperties)
|
||||
|
||||
DevXRegisterCallback('housing:list', function(source)
|
||||
local player = exports.devx_core:GetPlayer(source)
|
||||
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||
|
||||
local rows = MySQL.query.await([[
|
||||
SELECT p.id, p.property_key AS propertyKey, p.label, p.price,
|
||||
po.character_id AS ownerCharacterId,
|
||||
CONCAT(c.first_name, ' ', c.last_name) AS ownerName
|
||||
FROM devx_properties p
|
||||
LEFT JOIN devx_property_owners po ON po.property_id = p.id
|
||||
LEFT JOIN devx_characters c ON c.id = po.character_id AND c.deleted_at IS NULL
|
||||
WHERE p.property_type = 'apartment'
|
||||
ORDER BY p.id
|
||||
]]) or {}
|
||||
|
||||
local filtered = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if configuredPropertyKeys[row.propertyKey] then
|
||||
-- An ownership row without a valid active character is never exposed
|
||||
-- to the hallway UI as an occupied apartment.
|
||||
if row.ownerCharacterId and (not row.ownerName or row.ownerName == '') then
|
||||
MySQL.query.await('DELETE FROM devx_property_owners WHERE property_id = ?', { row.id })
|
||||
row.ownerCharacterId = nil
|
||||
row.ownerName = nil
|
||||
end
|
||||
filtered[#filtered + 1] = row
|
||||
end
|
||||
end
|
||||
|
||||
local owned = ownedUnitRow(player.characterId)
|
||||
if owned and not configuredPropertyKeys[owned.propertyKey] then owned = nil end
|
||||
return {
|
||||
ok = true,
|
||||
properties = filtered,
|
||||
characterId = player.characterId,
|
||||
ownedPropertyKey = owned and owned.propertyKey or nil,
|
||||
ownedPropertyLabel = owned and owned.label or nil
|
||||
}
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:buy', function(source, payload)
|
||||
local player = exports.devx_core:GetPlayer(source)
|
||||
local unit = findUnit(payload.propertyKey)
|
||||
if not player or not unit then return { ok = false, error = 'Ungültige Wohnung.' } end
|
||||
|
||||
local existing = ownedUnitRow(player.characterId)
|
||||
if existing then
|
||||
return {
|
||||
ok = false,
|
||||
error = ('Du besitzt bereits %s. Pro Charakter ist derzeit nur ein Apartment erlaubt.'):format(existing.label)
|
||||
}
|
||||
end
|
||||
|
||||
if characterPurchaseLocks[player.characterId] then
|
||||
return { ok = false, error = 'Ein anderer Wohnungskauf wird bereits verarbeitet.' }
|
||||
end
|
||||
if propertyLocks[unit.key] then return { ok = false, error = 'Die Wohnung wird gerade bearbeitet.' } end
|
||||
characterPurchaseLocks[player.characterId] = true
|
||||
propertyLocks[unit.key] = true
|
||||
|
||||
local ok, response = pcall(function()
|
||||
local row = unitRow(unit.key)
|
||||
if not row then return { ok = false, error = 'Wohnung fehlt in der Datenbank.' } end
|
||||
if row.ownerCharacterId then return { ok = false, error = 'Die Wohnung ist bereits verkauft.' } end
|
||||
|
||||
local charged, chargeResult = exports.devx_banking:ChargeCharacter(
|
||||
player.characterId,
|
||||
tonumber(row.price),
|
||||
('Kauf: %s'):format(unit.label),
|
||||
'Immobilienverwaltung DevX-City'
|
||||
)
|
||||
if not charged then return { ok = false, error = chargeResult } end
|
||||
|
||||
local ownerId = MySQL.insert.await([[
|
||||
INSERT INTO devx_property_owners (property_id, character_id, purchase_price)
|
||||
VALUES (?, ?, ?)
|
||||
]], { row.id, player.characterId, row.price })
|
||||
|
||||
if not ownerId then
|
||||
exports.devx_banking:CreditCharacter(
|
||||
player.characterId,
|
||||
tonumber(row.price),
|
||||
('Rückbuchung: %s'):format(unit.label),
|
||||
'Immobilienverwaltung DevX-City'
|
||||
)
|
||||
return { ok = false, error = 'Kauf wurde abgebrochen und zurückgebucht.' }
|
||||
end
|
||||
|
||||
local overview = MySQL.single.await('SELECT balance FROM devx_bank_accounts WHERE id = ?', { player.accountId })
|
||||
if overview then exports.devx_core:SetBankCache(source, tonumber(overview.balance)) end
|
||||
return { ok = true, propertyKey = unit.key }
|
||||
end)
|
||||
|
||||
propertyLocks[unit.key] = nil
|
||||
characterPurchaseLocks[player.characterId] = nil
|
||||
if not ok then
|
||||
print(('[devx_housing] Kauf fehlgeschlagen: %s'):format(response))
|
||||
return { ok = false, error = 'Interner Immobilienfehler.' }
|
||||
end
|
||||
return response
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:previewStart', function(source, payload)
|
||||
local player = exports.devx_core:GetPlayer(source)
|
||||
local building = findBuilding(payload.buildingKey)
|
||||
if not player or not building then return { ok = false, error = 'Ungültiges Gebäude.' } end
|
||||
local owned = ownedUnitRow(player.characterId)
|
||||
if owned then
|
||||
return { ok = false, error = ('Du besitzt bereits %s.'):format(owned.label or 'ein Apartment') }
|
||||
end
|
||||
|
||||
local bucket = DevXHousing.PreviewBucketBase + tonumber(source)
|
||||
setLocation(source, { type = 'preview', buildingKey = building.key }, bucket)
|
||||
return { ok = true }
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:previewEnd', function(source)
|
||||
local location = playerLocation[source]
|
||||
if location and location.type == 'preview' then resetPlayer(source) end
|
||||
return { ok = true }
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:enterLobby', function(source, payload)
|
||||
local player = exports.devx_core:GetPlayer(source)
|
||||
local building = findBuilding(payload.buildingKey)
|
||||
if not player or not building then return { ok = false, error = 'Ungültiges Gebäude.' } end
|
||||
|
||||
setLocation(source, { type = 'lobby', buildingKey = building.key, floor = 0 }, floorBucket(building, 0))
|
||||
return { ok = true, position = DevXHousing.FloorShell.spawn, building = building.key }
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:goFloor', function(source, payload)
|
||||
local player = exports.devx_core:GetPlayer(source)
|
||||
local building = findBuilding(payload.buildingKey)
|
||||
local floor = tonumber(payload.floor)
|
||||
if not player or not building or not floor or floor < 1 or floor > building.floors then
|
||||
return { ok = false, error = 'Ungültige Etage.' }
|
||||
end
|
||||
|
||||
setLocation(source, { type = 'floor', buildingKey = building.key, floor = floor }, floorBucket(building, floor))
|
||||
return { ok = true, position = DevXHousing.FloorShell.spawn, floor = floor }
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:leaveBuilding', function(source)
|
||||
local location = playerLocation[source]
|
||||
if not location then return { ok = false, error = 'Du bist in keinem Gebäude.' } end
|
||||
local building = findBuilding(location.buildingKey)
|
||||
if not building then return { ok = false, error = 'Gebäude nicht gefunden.' } end
|
||||
|
||||
resetPlayer(source)
|
||||
return { ok = true, position = building.exterior }
|
||||
end)
|
||||
|
||||
local function hasUnitAccess(source, row)
|
||||
local player = exports.devx_core:GetPlayer(source)
|
||||
if not player or not row then return false end
|
||||
if tonumber(row.ownerCharacterId) == tonumber(player.characterId) then return true end
|
||||
|
||||
local invite = temporaryInvites[source]
|
||||
return invite
|
||||
and invite.propertyKey == row.propertyKey
|
||||
and invite.expiresAt >= os.time()
|
||||
end
|
||||
|
||||
local function enterUnitFor(source, propertyKey)
|
||||
local unit, building = findUnit(propertyKey)
|
||||
local row = unit and unitRow(propertyKey) or nil
|
||||
if not unit or not building or not row or not row.ownerCharacterId then
|
||||
return { ok = false, error = 'Diese Wohnung ist nicht bewohnt.' }
|
||||
end
|
||||
if not hasUnitAccess(source, row) then
|
||||
return { ok = false, error = 'Du hast keine Einladung für diese Wohnung.' }
|
||||
end
|
||||
|
||||
local interior = DevXHousing.Interiors[unit.style]
|
||||
setLocation(source, {
|
||||
type = 'unit',
|
||||
propertyKey = unit.key,
|
||||
propertyId = tonumber(row.id),
|
||||
buildingKey = building.key,
|
||||
floor = unit.floor,
|
||||
ownerCharacterId = tonumber(row.ownerCharacterId)
|
||||
}, unitBucket(row.id))
|
||||
|
||||
return {
|
||||
ok = true,
|
||||
interior = interior,
|
||||
propertyKey = unit.key,
|
||||
label = unit.label,
|
||||
ownerName = row.ownerName,
|
||||
floor = unit.floor,
|
||||
style = unit.style,
|
||||
interactions = interior.interactions or {}
|
||||
}
|
||||
end
|
||||
|
||||
DevXRegisterCallback('housing:enterUnit', function(source, payload)
|
||||
return enterUnitFor(source, payload.propertyKey)
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:enterOwned', function(source, payload)
|
||||
local player = exports.devx_core:GetPlayer(source)
|
||||
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||
|
||||
local owned = ownedUnitRow(player.characterId)
|
||||
if not owned then return { ok = false, error = 'Du besitzt noch kein Apartment.' } end
|
||||
|
||||
local unit, building = findUnit(owned.propertyKey)
|
||||
if not unit or not building then return { ok = false, error = 'Dein Apartment ist nicht mehr konfiguriert.' } end
|
||||
if payload and payload.buildingKey and payload.buildingKey ~= building.key then
|
||||
return { ok = false, error = ('Dein Apartment befindet sich in %s.'):format(building.label) }
|
||||
end
|
||||
|
||||
return enterUnitFor(source, owned.propertyKey)
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:exitUnit', function(source)
|
||||
local location = playerLocation[source]
|
||||
if not location or location.type ~= 'unit' then return { ok = false, error = 'Du bist in keiner Wohnung.' } end
|
||||
|
||||
local building = findBuilding(location.buildingKey)
|
||||
setLocation(source, {
|
||||
type = 'floor',
|
||||
buildingKey = location.buildingKey,
|
||||
floor = location.floor
|
||||
}, floorBucket(building, location.floor))
|
||||
|
||||
return { ok = true, position = DevXHousing.FloorShell.spawn, floor = location.floor }
|
||||
end)
|
||||
|
||||
local function activeSourceForCharacter(characterId)
|
||||
for source, player in pairs(exports.devx_core:GetActivePlayers()) do
|
||||
if tonumber(player.characterId) == tonumber(characterId) then return tonumber(source) end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
DevXRegisterCallback('housing:ring', function(source, payload)
|
||||
local visitor = exports.devx_core:GetPlayer(source)
|
||||
local row = unitRow(payload.propertyKey)
|
||||
local unit = findUnit(payload.propertyKey)
|
||||
if not visitor or not row or not row.ownerCharacterId or not unit then
|
||||
return { ok = false, error = 'Hier wohnt niemand.' }
|
||||
end
|
||||
|
||||
local ownerSource = activeSourceForCharacter(row.ownerCharacterId)
|
||||
if not ownerSource then return { ok = false, error = 'Der Bewohner ist derzeit nicht online.' } end
|
||||
|
||||
pendingDoorbells[ownerSource] = {
|
||||
visitorSource = source,
|
||||
visitorName = visitor.fullName,
|
||||
propertyKey = payload.propertyKey,
|
||||
expiresAt = os.time() + 30
|
||||
}
|
||||
TriggerClientEvent('devx_housing:client:doorbell', ownerSource, {
|
||||
visitorSource = source,
|
||||
visitorName = visitor.fullName,
|
||||
propertyKey = payload.propertyKey,
|
||||
label = unit.label
|
||||
})
|
||||
return { ok = true, message = 'Du hast geklingelt.' }
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:knock', function(source, payload)
|
||||
local visitor = exports.devx_core:GetPlayer(source)
|
||||
local row = unitRow(payload.propertyKey)
|
||||
if not visitor or not row or not row.ownerCharacterId then return { ok = false, error = 'Hier wohnt niemand.' } end
|
||||
|
||||
for target, location in pairs(playerLocation) do
|
||||
if location.type == 'unit' and location.propertyKey == payload.propertyKey then
|
||||
TriggerClientEvent('devx_housing:client:knock', target, visitor.fullName)
|
||||
end
|
||||
end
|
||||
return { ok = true, message = 'Du hast angeklopft.' }
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:acceptVisitor', function(source)
|
||||
local owner = exports.devx_core:GetPlayer(source)
|
||||
local location = playerLocation[source]
|
||||
local bell = pendingDoorbells[source]
|
||||
if not owner or not location or location.type ~= 'unit' or not bell or bell.expiresAt < os.time() then
|
||||
return { ok = false, error = 'Keine aktuelle Klingelanfrage.' }
|
||||
end
|
||||
if bell.propertyKey ~= location.propertyKey then return { ok = false, error = 'Die Anfrage gehört zu einer anderen Wohnung.' } end
|
||||
|
||||
temporaryInvites[bell.visitorSource] = {
|
||||
propertyKey = bell.propertyKey,
|
||||
expiresAt = os.time() + DevXHousing.InvitationSeconds
|
||||
}
|
||||
pendingDoorbells[source] = nil
|
||||
|
||||
local result = enterUnitFor(bell.visitorSource, bell.propertyKey)
|
||||
if result.ok then
|
||||
TriggerClientEvent('devx_housing:client:enterGranted', bell.visitorSource, result)
|
||||
TriggerClientEvent('devx_housing:client:visitorAccepted', source, bell.visitorName)
|
||||
end
|
||||
return result
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:inviteCandidates', function(source)
|
||||
local owner = exports.devx_core:GetPlayer(source)
|
||||
local location = playerLocation[source]
|
||||
if not owner or not location or location.type ~= 'unit' then
|
||||
return { ok = false, error = 'Du musst dich in deiner Wohnung befinden.' }
|
||||
end
|
||||
if tonumber(location.ownerCharacterId) ~= tonumber(owner.characterId) then
|
||||
return { ok = false, error = 'Nur der Eigentümer kann Spieler einladen.' }
|
||||
end
|
||||
|
||||
local players = {}
|
||||
for playerSource, player in pairs(exports.devx_core:GetActivePlayers()) do
|
||||
local numericSource = tonumber(playerSource)
|
||||
if numericSource and numericSource ~= tonumber(source) then
|
||||
players[#players + 1] = {
|
||||
source = numericSource,
|
||||
name = player.fullName or GetPlayerName(numericSource) or ('Spieler %s'):format(numericSource)
|
||||
}
|
||||
end
|
||||
end
|
||||
table.sort(players, function(a, b) return a.source < b.source end)
|
||||
return { ok = true, players = players }
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:invite', function(source, payload)
|
||||
local owner = exports.devx_core:GetPlayer(source)
|
||||
local target = tonumber(payload.target)
|
||||
local location = playerLocation[source]
|
||||
if not owner or not location or location.type ~= 'unit' then
|
||||
return { ok = false, error = 'Du musst dich in deiner Wohnung befinden.' }
|
||||
end
|
||||
if tonumber(location.ownerCharacterId) ~= tonumber(owner.characterId) then
|
||||
return { ok = false, error = 'Nur der Eigentümer kann Gäste einladen.' }
|
||||
end
|
||||
if not target or not exports.devx_core:GetPlayer(target) then return { ok = false, error = 'Spieler nicht gefunden.' } end
|
||||
if target == tonumber(source) then return { ok = false, error = 'Du kannst dich nicht selbst einladen.' } end
|
||||
|
||||
local unit = findUnit(location.propertyKey)
|
||||
directInvites[target] = {
|
||||
propertyKey = location.propertyKey,
|
||||
invitedBy = owner.fullName,
|
||||
expiresAt = os.time() + DevXHousing.InvitationSeconds
|
||||
}
|
||||
TriggerClientEvent('devx_housing:client:invite', target, {
|
||||
propertyKey = location.propertyKey,
|
||||
invitedBy = owner.fullName,
|
||||
label = unit and unit.label or 'Apartment',
|
||||
expiresIn = DevXHousing.InvitationSeconds
|
||||
})
|
||||
return { ok = true }
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:acceptInvite', function(source)
|
||||
local invite = directInvites[source]
|
||||
if not invite or invite.expiresAt < os.time() then
|
||||
directInvites[source] = nil
|
||||
return { ok = false, error = 'Du hast keine gültige Apartment-Einladung.' }
|
||||
end
|
||||
|
||||
temporaryInvites[source] = {
|
||||
propertyKey = invite.propertyKey,
|
||||
expiresAt = invite.expiresAt
|
||||
}
|
||||
directInvites[source] = nil
|
||||
return enterUnitFor(source, invite.propertyKey)
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:declineInvite', function(source)
|
||||
directInvites[source] = nil
|
||||
return { ok = true }
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('housing:declineVisitor', function(source)
|
||||
pendingDoorbells[source] = nil
|
||||
return { ok = true }
|
||||
end)
|
||||
|
||||
-- /inviteapartment is handled client-side so it can open the player picker.
|
||||
|
||||
RegisterCommand('acceptapartment', function(source)
|
||||
if source == 0 then return end
|
||||
local invite = directInvites[source]
|
||||
if not invite or invite.expiresAt < os.time() then
|
||||
directInvites[source] = nil
|
||||
TriggerClientEvent('devx_housing:client:notify', source, 'Keine gültige Apartment-Einladung.')
|
||||
return
|
||||
end
|
||||
|
||||
temporaryInvites[source] = { propertyKey = invite.propertyKey, expiresAt = invite.expiresAt }
|
||||
directInvites[source] = nil
|
||||
local result = enterUnitFor(source, invite.propertyKey)
|
||||
if result.ok then TriggerClientEvent('devx_housing:client:enterGranted', source, result)
|
||||
else TriggerClientEvent('devx_housing:client:notify', source, result.error) end
|
||||
end, false)
|
||||
|
||||
RegisterCommand('acceptvisitor', function(source)
|
||||
if source == 0 then return end
|
||||
local bell = pendingDoorbells[source]
|
||||
local location = playerLocation[source]
|
||||
if not bell or bell.expiresAt < os.time() or not location or location.type ~= 'unit' then
|
||||
TriggerClientEvent('devx_housing:client:notify', source, 'Keine aktuelle Klingelanfrage.')
|
||||
return
|
||||
end
|
||||
|
||||
temporaryInvites[bell.visitorSource] = { propertyKey = bell.propertyKey, expiresAt = os.time() + DevXHousing.InvitationSeconds }
|
||||
pendingDoorbells[source] = nil
|
||||
local result = enterUnitFor(bell.visitorSource, bell.propertyKey)
|
||||
if result.ok then
|
||||
TriggerClientEvent('devx_housing:client:enterGranted', bell.visitorSource, result)
|
||||
TriggerClientEvent('devx_housing:client:visitorAccepted', source, bell.visitorName)
|
||||
end
|
||||
end, false)
|
||||
|
||||
exports('GetPlayerHousingLocation', function(source)
|
||||
local location = playerLocation[tonumber(source)]
|
||||
if not location then return nil end
|
||||
local copy = {}
|
||||
for key, value in pairs(location) do copy[key] = value end
|
||||
if location.propertyKey then
|
||||
local unit = findUnit(location.propertyKey)
|
||||
if unit then
|
||||
copy.style = unit.style
|
||||
copy.interior = DevXHousing.Interiors[unit.style]
|
||||
end
|
||||
end
|
||||
return copy
|
||||
end)
|
||||
|
||||
exports('GetOwnedApartment', function(characterId)
|
||||
return ownedUnitRow(tonumber(characterId))
|
||||
end)
|
||||
|
||||
AddEventHandler('playerDropped', function()
|
||||
resetPlayer(source)
|
||||
end)
|
||||
|
||||
AddEventHandler('onResourceStart', function(resourceName)
|
||||
if resourceName ~= GetCurrentResourceName() then return end
|
||||
for _, playerId in ipairs(GetPlayers()) do SetPlayerRoutingBucket(tonumber(playerId), 0) end
|
||||
end)
|
||||
|
||||
AddEventHandler('onResourceStop', function(resourceName)
|
||||
if resourceName ~= GetCurrentResourceName() then return end
|
||||
for _, playerId in ipairs(GetPlayers()) do SetPlayerRoutingBucket(tonumber(playerId), 0) end
|
||||
end)
|
||||
@@ -0,0 +1,164 @@
|
||||
DevXHousing = {
|
||||
MarkerDistance = 25.0,
|
||||
InteractionDistance = 1.8,
|
||||
PreviewBucketBase = 50000,
|
||||
UnitBucketBase = 40000,
|
||||
FloorBucketBase = 30000,
|
||||
InvitationSeconds = 120,
|
||||
MaxPropertiesPerCharacter = 1,
|
||||
PropertyRefreshMs = 8000,
|
||||
|
||||
-- v0.4.3 only uses GTA Online interiors that are available without
|
||||
-- RequestIpl/RemoveIpl. This avoids overlapping apartment IPLs and world
|
||||
-- holes after returning to Los Santos.
|
||||
Interiors = {
|
||||
low = {
|
||||
label = 'Klassisch',
|
||||
premium = 0,
|
||||
ipls = {},
|
||||
spawn = { x = 266.11, y = -1007.55, z = -101.01, heading = 357.0 },
|
||||
exit = { x = 266.11, y = -1007.55, z = -101.01 },
|
||||
loadAt = { x = 261.4586, y = -998.8196, z = -99.00863 },
|
||||
cameraShots = {
|
||||
{ cam = { x = 265.20, y = -1003.85, z = -99.38 }, target = { x = 263.00, y = -1000.55, z = -99.70 } },
|
||||
{ cam = { x = 262.90, y = -1001.15, z = -99.42 }, target = { x = 266.10, y = -999.90, z = -99.72 } },
|
||||
{ cam = { x = 268.05, y = -999.65, z = -99.40 }, target = { x = 265.60, y = -1003.10, z = -99.72 } }
|
||||
},
|
||||
companionSpawn = { x = 265.10, y = -1001.20, z = -99.00, heading = 165.0 },
|
||||
interactions = {
|
||||
{ key = 'beer', label = 'Bier trinken', type = 'beer', coords = { x = 263.45, y = -1002.25, z = -99.00 } },
|
||||
{ key = 'sleep', label = 'Im Bett schlafen', type = 'sleep', coords = { x = 262.90, y = -1004.05, z = -99.00 }, heading = 178.0 },
|
||||
{ key = 'bong', label = 'Bong benutzen', type = 'bong', coords = { x = 266.90, y = -999.15, z = -99.00 } }
|
||||
}
|
||||
},
|
||||
mid = {
|
||||
label = 'Modern',
|
||||
premium = 24000,
|
||||
ipls = {},
|
||||
spawn = { x = 346.52, y = -1012.90, z = -99.20, heading = 2.0 },
|
||||
exit = { x = 346.52, y = -1012.90, z = -99.20 },
|
||||
loadAt = { x = 347.2686, y = -999.2955, z = -99.19622 },
|
||||
cameraShots = {
|
||||
{ cam = { x = 344.80, y = -1008.00, z = -97.58 }, target = { x = 341.25, y = -1003.70, z = -97.95 } },
|
||||
{ cam = { x = 340.25, y = -1003.20, z = -97.62 }, target = { x = 345.15, y = -1001.15, z = -97.95 } },
|
||||
{ cam = { x = 348.55, y = -1003.20, z = -97.60 }, target = { x = 343.75, y = -1006.50, z = -97.95 } }
|
||||
},
|
||||
companionSpawn = { x = 342.80, y = -1004.90, z = -99.20, heading = 95.0 },
|
||||
interactions = {
|
||||
{ key = 'whiskey', label = 'Whiskey trinken', type = 'whiskey', coords = { x = 343.65, y = -1002.65, z = -99.20 } },
|
||||
{ key = 'beer', label = 'Bier trinken', type = 'beer', coords = { x = 341.20, y = -1003.10, z = -99.20 } },
|
||||
{ key = 'sleep', label = 'Im Bett schlafen', type = 'sleep', coords = { x = 350.15, y = -1007.55, z = -99.20 }, heading = 270.0 },
|
||||
{ key = 'bong', label = 'Bong benutzen', type = 'bong', coords = { x = 337.95, y = -997.45, z = -99.20 } }
|
||||
}
|
||||
},
|
||||
high = {
|
||||
label = 'Luxus',
|
||||
premium = 65000,
|
||||
ipls = {},
|
||||
spawn = { x = -17.86, y = -589.22, z = 79.43, heading = 337.0 },
|
||||
exit = { x = -17.86, y = -589.22, z = 79.43 },
|
||||
loadAt = { x = -18.07856, y = -583.6725, z = 79.46569 },
|
||||
cameraShots = {
|
||||
{ cam = { x = -20.65, y = -586.10, z = 81.10 }, target = { x = -16.10, y = -582.90, z = 80.05 } },
|
||||
{ cam = { x = -13.10, y = -581.15, z = 81.05 }, target = { x = -18.70, y = -578.80, z = 80.00 } },
|
||||
{ cam = { x = -22.10, y = -577.90, z = 81.05 }, target = { x = -17.00, y = -582.50, z = 80.00 } }
|
||||
},
|
||||
companionSpawn = { x = -15.20, y = -583.20, z = 79.47, heading = 155.0 },
|
||||
interactions = {
|
||||
{ key = 'whiskey', label = 'Whiskey trinken', type = 'whiskey', coords = { x = -17.20, y = -579.75, z = 79.47 } },
|
||||
{ key = 'beer', label = 'Bier trinken', type = 'beer', coords = { x = -13.90, y = -582.30, z = 79.47 } },
|
||||
{ key = 'sleep', label = 'Im Bett schlafen', type = 'sleep', coords = { x = -20.45, y = -570.55, z = 79.47 }, heading = 180.0 },
|
||||
{ key = 'bong', label = 'Bong benutzen', type = 'bong', coords = { x = -10.85, y = -583.20, z = 79.47 } }
|
||||
}
|
||||
},
|
||||
penthouse = {
|
||||
label = 'Penthouse',
|
||||
premium = 118000,
|
||||
ipls = {},
|
||||
spawn = { x = -781.95, y = 339.78, z = 211.20, heading = 88.0 },
|
||||
exit = { x = -781.95, y = 339.78, z = 211.20 },
|
||||
loadAt = { x = -773.407, y = 341.766, z = 211.397 },
|
||||
cameraShots = {
|
||||
{ cam = { x = -779.30, y = 342.10, z = 213.10 }, target = { x = -773.40, y = 341.75, z = 211.90 } },
|
||||
{ cam = { x = -771.90, y = 338.45, z = 213.05 }, target = { x = -768.20, y = 344.20, z = 211.95 } },
|
||||
{ cam = { x = -768.80, y = 348.30, z = 213.00 }, target = { x = -774.10, y = 343.25, z = 211.95 } }
|
||||
},
|
||||
companionSpawn = { x = -773.10, y = 343.25, z = 211.40, heading = 260.0 },
|
||||
interactions = {
|
||||
{ key = 'whiskey', label = 'Whiskey trinken', type = 'whiskey', coords = { x = -768.15, y = 341.80, z = 211.40 } },
|
||||
{ key = 'beer', label = 'Bier trinken', type = 'beer', coords = { x = -771.60, y = 338.80, z = 211.40 } },
|
||||
{ key = 'sleep', label = 'Im Bett schlafen', type = 'sleep', coords = { x = -765.15, y = 329.30, z = 211.40 }, heading = 90.0 },
|
||||
{ key = 'bong', label = 'Bong benutzen', type = 'bong', coords = { x = -778.10, y = 338.35, z = 211.40 } }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
-- The floor is a private, always-loaded 10-car garage scene. Local doors,
|
||||
-- signs and decoration turn it into a stable virtual corridor. No
|
||||
-- apartment IPL is requested or removed here.
|
||||
FloorShell = {
|
||||
ipls = {},
|
||||
loadAt = { x = 229.9559, y = -981.7928, z = -99.66071 },
|
||||
spawn = { x = 239.70, y = -1004.65, z = -99.00, heading = 88.5 },
|
||||
elevator = { x = 239.55, y = -1004.55, z = -99.00 },
|
||||
exit = { x = 239.55, y = -1001.30, z = -99.00 },
|
||||
doorModel = 'v_ilev_fib_door1',
|
||||
doors = {
|
||||
{ x = 237.55, y = -1007.05, z = -99.00, heading = 0.0, sign = { x = 238.32, y = -1006.82, z = -97.78 } },
|
||||
{ x = 234.35, y = -1007.05, z = -99.00, heading = 0.0, sign = { x = 235.12, y = -1006.82, z = -97.78 } },
|
||||
{ x = 231.15, y = -1007.05, z = -99.00, heading = 0.0, sign = { x = 231.92, y = -1006.82, z = -97.78 } },
|
||||
{ x = 227.95, y = -1007.05, z = -99.00, heading = 0.0, sign = { x = 228.72, y = -1006.82, z = -97.78 } }
|
||||
},
|
||||
setDressing = {
|
||||
{ model = 'prop_plant_int_03a', coords = { x = 240.65, y = -1005.80, z = -99.65, heading = 0.0 } },
|
||||
{ model = 'prop_plant_int_03b', coords = { x = 225.95, y = -1005.80, z = -99.65, heading = 0.0 } },
|
||||
{ model = 'prop_off_chair_04', coords = { x = 239.55, y = -1003.10, z = -99.65, heading = 180.0 } },
|
||||
{ model = 'prop_table_03', coords = { x = 237.85, y = -1003.10, z = -99.65, heading = 0.0 } }
|
||||
}
|
||||
},
|
||||
|
||||
Buildings = {
|
||||
{ key = 'eclipse', label = 'Eclipse Towers', exterior = { x = -773.92, y = 312.09, z = 85.70, heading = 178.0 }, floors = 5, unitsPerFloor = 4, basePrice = 110000, floorPrice = 8500 },
|
||||
{ key = 'integrity', label = '4 Integrity Way', exterior = { x = -47.32, y = -585.99, z = 37.95, heading = 69.0 }, floors = 5, unitsPerFloor = 4, basePrice = 76000, floorPrice = 7000 },
|
||||
{ key = 'del_perro', label = 'Del Perro Heights', exterior = { x = -1447.49, y = -537.49, z = 34.74, heading = 212.0 }, floors = 5, unitsPerFloor = 4, basePrice = 59000, floorPrice = 6000 }
|
||||
}
|
||||
}
|
||||
|
||||
local legacyKeys = {
|
||||
eclipse = { ['3:1'] = 'eclipse_31' },
|
||||
integrity = { ['2:1'] = 'integrity_28' },
|
||||
del_perro = { ['1:1'] = 'del_perro_7' }
|
||||
}
|
||||
|
||||
local function styleFor(floor, unitNumber)
|
||||
if floor == 1 then return unitNumber <= 2 and 'low' or 'mid' end
|
||||
if floor == 2 then return 'mid' end
|
||||
if floor == 3 then return unitNumber == 4 and 'high' or 'mid' end
|
||||
if floor == 4 then return 'high' end
|
||||
return unitNumber == 4 and 'penthouse' or 'high'
|
||||
end
|
||||
|
||||
for buildingIndex, building in ipairs(DevXHousing.Buildings) do
|
||||
building.index = buildingIndex
|
||||
building.units = {}
|
||||
for floor = 1, building.floors do
|
||||
for unitNumber = 1, building.unitsPerFloor do
|
||||
local legacy = legacyKeys[building.key] and legacyKeys[building.key][('%s:%s'):format(floor, unitNumber)]
|
||||
local key = legacy or ('%s_f%02d_u%02d'):format(building.key, floor, unitNumber)
|
||||
local style = styleFor(floor, unitNumber)
|
||||
local premium = DevXHousing.Interiors[style].premium or 0
|
||||
building.units[#building.units + 1] = {
|
||||
key = key,
|
||||
buildingKey = building.key,
|
||||
buildingLabel = building.label,
|
||||
floor = floor,
|
||||
unitNumber = unitNumber,
|
||||
doorNumber = ('%s%02d'):format(floor, unitNumber),
|
||||
label = ('%s – Etage %s, Wohnung %s'):format(building.label, floor, unitNumber),
|
||||
price = building.basePrice + (floor - 1) * building.floorPrice + (unitNumber - 1) * 1750 + premium,
|
||||
style = style,
|
||||
door = DevXHousing.FloorShell.doors[unitNumber]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,110 @@
|
||||
const resource = typeof GetParentResourceName === 'function' ? GetParentResourceName() : 'devx_housing';
|
||||
const preview = document.querySelector('#preview');
|
||||
const panel = document.querySelector('#panel');
|
||||
const building = document.querySelector('#building');
|
||||
const label = document.querySelector('#label');
|
||||
const meta = document.querySelector('#meta');
|
||||
const price = document.querySelector('#price');
|
||||
const buy = document.querySelector('#buy');
|
||||
const buyText = document.querySelector('#buyText');
|
||||
const panelTitle = document.querySelector('#panelTitle');
|
||||
const panelText = document.querySelector('#panelText');
|
||||
const panelBody = document.querySelector('#panelBody');
|
||||
let currentDoor = null;
|
||||
|
||||
async function post(name, data = {}) {
|
||||
const response = await fetch(`https://${resource}/${name}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function money(value){return `${new Intl.NumberFormat('de-DE').format(Number(value)||0)} $`}
|
||||
function closePanel(){panel.classList.add('hidden');post('uiClose')}
|
||||
|
||||
document.querySelector('#previous').onclick = () => post('previewPrevious');
|
||||
document.querySelector('#next').onclick = () => post('previewNext');
|
||||
document.querySelector('#previewClose').onclick = () => post('previewClose');
|
||||
document.querySelector('#panelClose').onclick = closePanel;
|
||||
buy.onclick = async () => {
|
||||
buy.disabled = true;
|
||||
const result = await post('previewBuy');
|
||||
if (!result.ok) {
|
||||
buyText.textContent = result.error || 'KAUF FEHLGESCHLAGEN';
|
||||
setTimeout(() => buyText.textContent = 'KAUFEN', 2200);
|
||||
buy.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keyup', event => {
|
||||
if (event.key === 'Escape') {
|
||||
if (!preview.classList.contains('hidden')) post('previewClose');
|
||||
else if (!panel.classList.contains('hidden')) closePanel();
|
||||
}
|
||||
});
|
||||
|
||||
function elevator(data){
|
||||
panel.classList.remove('hidden');
|
||||
panelTitle.textContent = data.building;
|
||||
panelText.textContent = `Aufzug · aktuelle Etage ${data.currentFloor || 'Lobby'}`;
|
||||
panelBody.innerHTML = '';
|
||||
for(let floor=1;floor<=Number(data.floors||1);floor++){
|
||||
const button=document.createElement('button');button.className='panel-action';
|
||||
button.innerHTML=`<b>Etage ${floor}</b><span>Wohnungen ${floor}01–${floor}04</span>`;
|
||||
button.onclick=()=>post('elevatorFloor',{floor});
|
||||
panelBody.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function invite(data){
|
||||
panel.classList.remove('hidden');
|
||||
panelTitle.textContent=data.title||'Spieler einladen';
|
||||
panelText.textContent='Der ausgewählte Spieler erhält eine Einladung und wird erst nach der Annahme teleportiert.';
|
||||
panelBody.innerHTML='';
|
||||
const players=Array.isArray(data.players)?data.players:[];
|
||||
if(players.length===0){
|
||||
const empty=document.createElement('div');empty.className='empty-state';empty.textContent='Aktuell ist kein anderer Spieler online.';panelBody.appendChild(empty);return;
|
||||
}
|
||||
for(const player of players){
|
||||
const button=document.createElement('button');button.className='panel-action';
|
||||
button.innerHTML=`<b>${player.name||'Spieler'}</b><span>Server-ID ${player.source}</span>`;
|
||||
button.onclick=async()=>{button.disabled=true;const result=await post('invitePlayer',{target:player.source});if(!result.ok)button.disabled=false;};
|
||||
panelBody.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
function door(data){
|
||||
currentDoor=data;
|
||||
panel.classList.remove('hidden');
|
||||
panelTitle.textContent=data.label;
|
||||
panelText.textContent=data.sold?`Bewohner: ${data.ownerName||'Unbekannt'}`:'Diese Wohnung ist noch frei.';
|
||||
panelBody.innerHTML='';
|
||||
const actions=document.createElement('div');actions.className='door-actions';
|
||||
if(data.sold){
|
||||
const ring=document.createElement('button');ring.className='panel-action';ring.innerHTML='<b>Klingeln</b>';ring.onclick=()=>post('doorRing',{propertyKey:data.propertyKey});
|
||||
const knock=document.createElement('button');knock.className='panel-action';knock.innerHTML='<b>Klopfen</b>';knock.onclick=()=>post('doorKnock',{propertyKey:data.propertyKey});
|
||||
actions.append(ring,knock);
|
||||
const enter=document.createElement('button');enter.className='panel-action enter';enter.innerHTML='<b>Mit Einladung eintreten</b>';enter.onclick=()=>post('doorEnter',{propertyKey:data.propertyKey});actions.appendChild(enter);
|
||||
}
|
||||
panelBody.appendChild(actions);
|
||||
}
|
||||
|
||||
window.addEventListener('message', event => {
|
||||
const data=event.data||{};
|
||||
if(data.action==='previewOpen'){preview.classList.remove('hidden');panel.classList.add('hidden')}
|
||||
if(data.action==='previewData'){
|
||||
building.textContent=data.building||'DEVX IMMOBILIEN';
|
||||
label.textContent=data.label||'Wohnung';
|
||||
meta.textContent=`Etage ${data.floor} · Wohnung ${data.unitNumber} · ${data.style} · ${data.index}/${data.total}`;
|
||||
price.textContent=money(data.price);
|
||||
buy.disabled=Boolean(data.sold);
|
||||
buyText.textContent=data.ownedByMe?'DEINE WOHNUNG':data.sold?`VERKAUFT · ${data.ownerName||'Bewohnt'}`:'KAUFEN';
|
||||
}
|
||||
if(data.action==='elevatorOpen')elevator(data);
|
||||
if(data.action==='doorOpen')door(data);
|
||||
if(data.action==='inviteOpen')invite(data);
|
||||
if(data.action==='close'){preview.classList.add('hidden');panel.classList.add('hidden')}
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="preview" class="hidden">
|
||||
<section class="preview-bar">
|
||||
<button id="previous" class="arrow" aria-label="Vorherige Wohnung">‹</button>
|
||||
<div class="preview-copy">
|
||||
<small id="building">DEVX IMMOBILIEN</small>
|
||||
<strong id="label">Wohnung</strong>
|
||||
<span id="meta">Etage · Stil</span>
|
||||
</div>
|
||||
<button id="buy" class="buy">
|
||||
<span id="buyText">KAUFEN</span>
|
||||
<b id="price">0 $</b>
|
||||
</button>
|
||||
<button id="next" class="arrow" aria-label="Nächste Wohnung">›</button>
|
||||
<button id="previewClose" class="close">×</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main id="panel" class="hidden overlay">
|
||||
<section class="panel-card">
|
||||
<button id="panelClose" class="close panel-close">×</button>
|
||||
<small id="panelEyebrow">DEVX-CITY</small>
|
||||
<h1 id="panelTitle">Aufzug</h1>
|
||||
<p id="panelText"></p>
|
||||
<div id="panelBody" class="panel-body"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#fff}*{box-sizing:border-box}body{margin:0;background:transparent;overflow:hidden}.hidden{display:none!important}button{font:inherit}.preview-bar{position:fixed;left:50%;bottom:42px;transform:translateX(-50%);display:grid;grid-template-columns:64px minmax(260px,1fr) 190px 64px;align-items:stretch;width:min(860px,calc(100vw - 80px));min-height:98px;border:1px solid rgba(255,255,255,.14);border-radius:18px;background:linear-gradient(180deg,rgba(12,17,28,.96),rgba(7,10,18,.97));box-shadow:0 25px 80px rgba(0,0,0,.55);backdrop-filter:blur(14px);overflow:visible}.preview-copy{display:flex;flex-direction:column;justify-content:center;padding:14px 22px}.preview-copy small,#panelEyebrow{font-size:10px;font-weight:900;letter-spacing:.2em;color:#79a7ff}.preview-copy strong{font-size:20px;margin-top:4px}.preview-copy span{font-size:12px;color:rgba(255,255,255,.58);margin-top:4px}.arrow,.buy{border:0;cursor:pointer;color:#fff}.arrow{font-size:42px;background:rgba(255,255,255,.035);transition:.15s}.arrow:hover{background:rgba(90,136,255,.22)}.buy{display:flex;flex-direction:column;align-items:center;justify-content:center;background:linear-gradient(135deg,#4977f3,#6e95ff);font-weight:900}.buy span{font-size:13px;letter-spacing:.1em}.buy b{font-size:18px;margin-top:4px}.buy:disabled{cursor:not-allowed;background:#2b303c;color:rgba(255,255,255,.5)}.close{border:1px solid rgba(255,255,255,.16);background:#111722;color:white;border-radius:999px;width:34px;height:34px;cursor:pointer;font-size:22px;line-height:1}.preview-bar>.close{position:absolute;right:-14px;top:-14px}.overlay{position:fixed;inset:0;display:grid;place-items:center;background:rgba(2,5,11,.62);backdrop-filter:blur(8px)}.panel-card{position:relative;width:min(520px,calc(100vw - 60px));max-height:76vh;padding:30px;border:1px solid rgba(255,255,255,.14);border-radius:22px;background:linear-gradient(180deg,#101725,#080d16);box-shadow:0 35px 100px rgba(0,0,0,.65)}.panel-close{position:absolute;right:18px;top:18px}.panel-card h1{margin:6px 0 8px;font-size:28px}.panel-card p{margin:0 0 20px;color:rgba(255,255,255,.62)}.panel-body{display:grid;gap:10px;max-height:52vh;overflow:auto}.panel-action{display:flex;align-items:center;justify-content:space-between;width:100%;padding:14px 16px;border:1px solid rgba(255,255,255,.11);border-radius:12px;background:rgba(255,255,255,.04);color:white;cursor:pointer;text-align:left}.panel-action:hover{border-color:#6e95ff;background:rgba(80,120,235,.15)}.panel-action b{font-size:15px}.panel-action span{font-size:12px;color:rgba(255,255,255,.55)}.door-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.door-actions .panel-action{justify-content:center;text-align:center}.door-actions .enter{grid-column:1/-1;background:linear-gradient(135deg,#4977f3,#6e95ff);border:0}
|
||||
.empty-state{padding:18px;border:1px dashed rgba(255,255,255,.16);border-radius:12px;color:rgba(255,255,255,.62);text-align:center}.panel-action:disabled{opacity:.55;cursor:wait}
|
||||
Reference in New Issue
Block a user