FIX - stabilize payphone call lifecycle

This commit is contained in:
Dominik
2026-08-10 20:22:02 +02:00
parent 1e426fba80
commit 8a4207b2fd
4 changed files with 170 additions and 41 deletions
+1
View File
@@ -65,6 +65,7 @@ Config.Payphones = {
Dictionary = "anim@scripted@payphone_hits@male@",
PedClip = "FXFR_PAV_1_INTRO_MALE",
PropClip = "FXFR_PAV_1_INTRO_PHONE",
HangupDurationMs = 1200,
},
}
+1 -1
View File
@@ -55,5 +55,5 @@ function Bridge.Framework.ShowHelpNotification(message, key)
local control = key == "E" and "~INPUT_CONTEXT~" or ("[%s]"):format(tostring(key or "E"))
BeginTextCommandDisplayHelp("STRING")
AddTextComponentSubstringPlayerName(("%s %s"):format(control, tostring(message or "")))
EndTextCommandDisplayHelp(0, false, true, -1)
EndTextCommandDisplayHelp(0, false, false, -1)
end
+96 -10
View File
@@ -10,7 +10,10 @@ local call_channel = 0
local replacement_prop = nil
local hidden_prop = nil
local animation_scene = nil
local animation_ped = nil
local animation_floor_z = nil
local visuals_starting = false
local visuals_ending = false
local hangup_requested = false
local configured_models = {}
@@ -68,9 +71,10 @@ end
local function stop_call_visuals()
local animation = Config.Payphones.Animation
local ped = PlayerPedId()
local ped = animation_ped or PlayerPedId()
local visuals_active = animation_scene ~= nil or replacement_prop ~= nil
visuals_starting = false
visuals_ending = false
if visuals_active and DoesEntityExist(ped) then
StopAnimTask(ped, animation.Dictionary, animation.PedClip, 0.0)
@@ -83,6 +87,9 @@ local function stop_call_visuals()
animation_scene = nil
end
animation_ped = nil
animation_floor_z = nil
if replacement_prop and DoesEntityExist(replacement_prop) then
StopEntityAnim(replacement_prop, animation.PropClip, animation.Dictionary, 0.0)
SetEntityVisible(replacement_prop, false, false)
@@ -98,6 +105,69 @@ local function stop_call_visuals()
RemoveAnimDict(animation.Dictionary)
end
local function keep_animation_ped_grounded()
if not animation_ped or not animation_floor_z or not DoesEntityExist(animation_ped) then
return
end
local coords = GetEntityCoords(animation_ped)
if coords.z >= animation_floor_z - 0.15 then
return
end
SetEntityCoordsNoOffset(animation_ped, coords.x, coords.y, animation_floor_z, false, false, false)
SetEntityVelocity(animation_ped, 0.0, 0.0, 0.0)
end
local function play_hangup_visuals()
if visuals_ending then
return
end
if not animation_scene or not animation_ped or not DoesEntityExist(animation_ped) then
stop_call_visuals()
active_booth = nil
return
end
visuals_ending = true
local scene = animation_scene
local starting_phase = math.max(0.0, math.min(1.0, GetSynchronizedScenePhase(scene)))
local duration_ms = math.max(250, math.floor(tonumber(Config.Payphones.Animation.HangupDurationMs) or 1200))
local started_at = GetGameTimer()
SetSynchronizedSceneRate(scene, 0.0)
if replacement_prop and DoesEntityExist(replacement_prop) then
SetEntityAnimSpeed(
replacement_prop,
Config.Payphones.Animation.Dictionary,
Config.Payphones.Animation.PropClip,
0.0
)
end
CreateThread(function()
while animation_scene == scene do
local progress = math.min(1.0, (GetGameTimer() - started_at) / duration_ms)
local phase = starting_phase * (1.0 - progress)
SetSynchronizedScenePhase(scene, phase)
if replacement_prop and DoesEntityExist(replacement_prop) then
SetEntityAnimCurrentTime(
replacement_prop,
Config.Payphones.Animation.Dictionary,
Config.Payphones.Animation.PropClip,
phase
)
end
keep_animation_ped_grounded()
if progress >= 1.0 then
break
end
Wait(0)
end
if animation_scene == scene then
stop_call_visuals()
active_booth = nil
end
end)
end
local function start_call_visuals()
if visuals_starting or replacement_prop or not active_call_id
or not active_booth or not DoesEntityExist(active_booth.entity)
@@ -168,6 +238,15 @@ local function start_call_visuals()
return
end
SetSynchronizedSceneHoldLastFrame(animation_scene, true)
animation_ped = ped
local ped_coords = GetEntityCoords(animation_ped)
local found_ground, ground_z = GetGroundZFor_3dCoord(
ped_coords.x,
ped_coords.y,
ped_coords.z + 1.0,
false
)
animation_floor_z = found_ground and ground_z or ped_coords.z
TaskSynchronizedScene(
ped,
animation_scene,
@@ -334,15 +413,14 @@ RegisterNetEvent("sky_phone:payphone:state", function(data)
else
clear_active_call_state()
leave_call_voice()
stop_call_visuals()
active_booth = nil
play_hangup_visuals()
end
SendNUIMessage({ type = "payphone:state", data = data })
end)
CreateThread(function()
while true do
if not Config.Payphones.Enabled or payphone_open or active_call_id then
if not Config.Payphones.Enabled or payphone_open or active_call_id or visuals_ending then
nearest_payphone = nil
Wait(Config.Payphones.ScanIntervalMs)
else
@@ -391,19 +469,27 @@ end)
CreateThread(function()
while true do
if active_call_id and active_booth then
local call_id = active_call_id
local booth = active_booth
if call_id and booth then
keep_animation_ped_grounded()
Bridge.Framework.ShowHelpNotification(call_help_message(), "E")
if IsControlJustReleased(0, 38) and not hangup_requested then
hangup_requested = true
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = call_id })
end
local distance = #(GetEntityCoords(PlayerPedId()) - active_booth.coords)
if distance > Config.Payphones.MaximumCallDistance and not hangup_requested then
hangup_requested = true
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
if active_call_id == call_id and active_booth == booth then
local distance = #(GetEntityCoords(PlayerPedId()) - booth.coords)
if distance > Config.Payphones.MaximumCallDistance and not hangup_requested then
hangup_requested = true
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = call_id })
end
end
Wait(0)
elseif visuals_ending then
keep_animation_ped_grounded()
Wait(0)
else
Wait(250)
end
+72 -30
View File
@@ -100,8 +100,8 @@ local function send_state(call, source, state, channel)
channel = channel,
}
if call.payphone and outgoing then
payload.elapsedSeconds = call.payphone.billed_seconds or 0
payload.totalCost = (call.payphone.billed_seconds or 0) * call.payphone.price_per_second
payload.elapsedSeconds = call.payphone.elapsed_seconds or 0
payload.totalCost = call.payphone.total_cost or 0
TriggerClientEvent("sky_phone:payphone:state", source, payload)
return
end
@@ -116,6 +116,59 @@ local function notify_recents(device, source)
end
end
local function settle_payphone_call(call, duration)
local payphone = call.payphone
local elapsed_seconds = math.max(0, math.floor(tonumber(duration) or 0))
local price_per_second = math.max(0, math.floor(tonumber(payphone.price_per_second) or 0))
local billable_seconds = elapsed_seconds
local total_cost = billable_seconds * price_per_second
if total_cost > 0 then
local available_money = tonumber(Bridge.Framework.GetMoney(call.caller_source, Config.Payphones.PaymentAccount))
if not available_money then
Bridge.Debug(
"error",
"[sky_phone] Payphone settlement could not read the balance for source %s.",
tostring(call.caller_source)
)
billable_seconds = 0
total_cost = 0
elseif available_money < total_cost then
billable_seconds = math.min(elapsed_seconds, math.floor(math.max(0, available_money) / price_per_second))
total_cost = billable_seconds * price_per_second
end
end
local charged = total_cost == 0
if total_cost > 0 then
local success, result = pcall(
Bridge.Framework.RemoveMoney,
call.caller_source,
Config.Payphones.PaymentAccount,
total_cost
)
charged = success and result and true or false
if not success then
Bridge.Debug(
"error",
"[sky_phone] Payphone settlement failed for source %s: %s",
tostring(call.caller_source),
tostring(result)
)
elseif not result then
Bridge.Debug(
"warn",
"[sky_phone] Payphone settlement was rejected for source %s.",
tostring(call.caller_source)
)
end
end
payphone.elapsed_seconds = elapsed_seconds
payphone.total_cost = charged and total_cost or 0
return charged and billable_seconds == elapsed_seconds
end
local function finish_call(call, status)
if not call or call.ended then
return
@@ -123,6 +176,11 @@ local function finish_call(call, status)
call.ended = true
local ended_at = os.time()
local duration = call.answered_at and math.max(0, ended_at - call.answered_at) or 0
if call.payphone and call.answered_at and not settle_payphone_call(call, duration)
and status ~= "disconnected"
then
status = "insufficient_funds"
end
local callee_status = status
if status == "no_answer" or status == "cancelled" then
callee_status = "missed"
@@ -552,7 +610,8 @@ Bridge.Callbacks.Register("sky_phone:payphone:dial", function(source, data)
callee_device = target,
started_at = os.time(),
payphone = {
billed_seconds = 0,
elapsed_seconds = 0,
total_cost = 0,
coords = booth_coords,
price_per_second = price_per_second,
},
@@ -675,33 +734,16 @@ CreateThread(function()
calls_to_finish[call_id] = "disconnected"
elseif call.payphone and call.answered_at and not call.ended then
local elapsed_seconds = math.max(0, os.time() - call.answered_at)
local seconds_due = elapsed_seconds - call.payphone.billed_seconds
if seconds_due > 0 then
local amount_due = seconds_due * call.payphone.price_per_second
local charged = amount_due == 0
if amount_due > 0 then
local success, result = pcall(
Bridge.Framework.RemoveMoney,
call.caller_source,
Config.Payphones.PaymentAccount,
amount_due
)
charged = success and result and true or false
if not success then
Bridge.Debug(
"error",
"[sky_phone] Payphone billing failed for source %s: %s",
tostring(call.caller_source),
tostring(result)
)
end
end
if charged then
call.payphone.billed_seconds = elapsed_seconds
send_state(call, call.caller_source, "connected", call.channel)
else
calls_to_finish[call_id] = "insufficient_funds"
end
local total_cost = elapsed_seconds * call.payphone.price_per_second
local available_money = tonumber(
Bridge.Framework.GetMoney(call.caller_source, Config.Payphones.PaymentAccount)
)
if total_cost > 0 and (not available_money or available_money < total_cost) then
calls_to_finish[call_id] = "insufficient_funds"
elseif call.payphone.elapsed_seconds ~= elapsed_seconds then
call.payphone.elapsed_seconds = elapsed_seconds
call.payphone.total_cost = total_cost
send_state(call, call.caller_source, "connected", call.channel)
end
end
end