Files
sky_phone-sky-systems/sky_phone/source/client/animations.lua
T
2026-08-21 21:49:05 +02:00

450 lines
13 KiB
Lua

local MODE_HIDDEN = "hidden"
local MODE_PHONE_READ = "phone_read"
local MODE_CALL = "call"
local MODE_CAMERA_REAR = "camera_rear"
local MODE_CAMERA_SELFIE = "camera_selfie"
local LOOPED_UPPER_BODY_FLAGS = 49
local TRANSITION_UPPER_BODY_FLAGS = 48
local animation_state = {
call_direction = nil,
call_state = nil,
camera_active = false,
camera_front = false,
camera_landscape = false,
current_animation = nil,
current_mode = MODE_HIDDEN,
ped = nil,
phone_open = false,
prop = nil,
revision = 0,
watcher_active = false,
}
local reevaluate
local function load_model(model_hash)
RequestModel(model_hash)
local deadline = GetGameTimer() + Config.Animations.LoadTimeoutMs
while not HasModelLoaded(model_hash) do
if GetGameTimer() >= deadline then
Bridge.Debug(
"error",
"[sky_phone] Timed out loading phone prop model '%s'.",
Config.Animations.PropModel
)
return false
end
Wait(0)
end
return true
end
local function load_animation_dictionary(dictionary)
RequestAnimDict(dictionary)
local deadline = GetGameTimer() + Config.Animations.LoadTimeoutMs
while not HasAnimDictLoaded(dictionary) do
if GetGameTimer() >= deadline then
Bridge.Debug(
"error",
"[sky_phone] Timed out loading animation dictionary '%s'.",
dictionary
)
return false
end
Wait(0)
end
return true
end
local function can_animate(ped)
if not DoesEntityExist(ped) or IsEntityDead(ped) then
return false
end
if IsPedRagdoll(ped) or IsPedFalling(ped) or IsPedClimbing(ped) then
return false
end
if IsPedSwimming(ped) or IsPedSwimmingUnderWater(ped) or IsPedInParachuteFreeFall(ped) then
return false
end
return true
end
local function get_phone_dictionary(ped)
if not IsPedInAnyVehicle(ped, false) then
return Config.Animations.Dictionaries.OnFoot, "on_foot"
end
local vehicle = GetVehiclePedIsIn(ped, false)
if GetPedInVehicleSeat(vehicle, -1) == ped then
return Config.Animations.Dictionaries.Driver, "driver:" .. tostring(vehicle)
end
return Config.Animations.Dictionaries.Passenger, "passenger:" .. tostring(vehicle)
end
local function derive_mode()
if not Config.Animations.Enabled then
return MODE_HIDDEN
end
if animation_state.call_state == "connected" then
return MODE_CALL
end
if animation_state.call_state == "ringing" and animation_state.call_direction == "outgoing" then
return MODE_CALL
end
if animation_state.camera_active and animation_state.camera_front then
return MODE_CAMERA_SELFIE
end
if animation_state.camera_active then
return MODE_CAMERA_REAR
end
if animation_state.call_state == "ringing" or animation_state.phone_open then
return MODE_PHONE_READ
end
return MODE_HIDDEN
end
local function stop_current_animation()
local current = animation_state.current_animation
if not current then
return
end
local ped = animation_state.ped
if ped and DoesEntityExist(ped) then
StopAnimTask(ped, current.dictionary, current.clip, 3.0)
end
animation_state.current_animation = nil
end
local function delete_phone_prop()
local prop = animation_state.prop
if not prop then
animation_state.ped = nil
return true
end
if not DoesEntityExist(prop) then
animation_state.prop = nil
animation_state.ped = nil
return true
end
DetachEntity(prop, true, true)
SetEntityAsMissionEntity(prop, true, true)
DeleteEntity(prop)
if DoesEntityExist(prop) then
Bridge.Debug("error", "[sky_phone] Failed to delete the attached phone prop.")
return false
end
animation_state.prop = nil
animation_state.ped = nil
return true
end
local function cleanup_phone()
stop_current_animation()
delete_phone_prop()
end
local function ensure_phone_prop(ped, revision)
if animation_state.prop and DoesEntityExist(animation_state.prop) and animation_state.ped == ped then
return true
end
if animation_state.prop then
delete_phone_prop()
end
local model_hash = joaat(Config.Animations.PropModel)
if not load_model(model_hash) then
return false
end
if animation_state.revision ~= revision then
SetModelAsNoLongerNeeded(model_hash)
return false
end
local coords = GetEntityCoords(ped)
local prop = CreateObject(model_hash, coords.x, coords.y, coords.z, true, true, false)
SetModelAsNoLongerNeeded(model_hash)
if not prop or prop == 0 or not DoesEntityExist(prop) then
Bridge.Debug("error", "[sky_phone] Failed to create the phone prop.")
return false
end
SetEntityCollision(prop, false, false)
animation_state.prop = prop
animation_state.ped = ped
return true
end
local function get_transform(mode)
if (mode == MODE_CAMERA_REAR or mode == MODE_CAMERA_SELFIE) and animation_state.camera_landscape then
return Config.Animations.Transforms.Landscape
end
return Config.Animations.Transforms.Portrait
end
local function attach_phone_prop(ped, mode)
local transform = get_transform(mode)
local position = transform.position
local rotation = transform.rotation
AttachEntityToEntity(
animation_state.prop,
ped,
GetPedBoneIndex(ped, Config.Animations.PropBone),
position.x,
position.y,
position.z,
rotation.x,
rotation.y,
rotation.z,
true,
false,
false,
false,
2,
true
)
end
local function play_animation(ped, dictionary, clip, looped, revision)
if not load_animation_dictionary(dictionary) or animation_state.revision ~= revision then
return nil
end
local duration = -1
local flags = LOOPED_UPPER_BODY_FLAGS
if not looped then
local duration_seconds = GetAnimDuration(dictionary, clip)
if duration_seconds <= 0.0 then
Bridge.Debug(
"error",
"[sky_phone] Animation '%s' was not found in dictionary '%s'.",
clip,
dictionary
)
return nil
end
duration = math.max(1, math.floor(duration_seconds * 1000.0))
flags = TRANSITION_UPPER_BODY_FLAGS
end
stop_current_animation()
TaskPlayAnim(ped, dictionary, clip, 4.0, -4.0, duration, flags, 1.0, false, false, false)
animation_state.current_animation = {
clip = clip,
dictionary = dictionary,
}
return duration
end
local function get_base_animation(ped, mode)
local dictionary = get_phone_dictionary(ped)
if mode == MODE_CALL then
return dictionary, Config.Animations.Clips.CallListen
end
if
(mode == MODE_CAMERA_REAR or mode == MODE_CAMERA_SELFIE)
and not IsPedInAnyVehicle(ped, false)
then
return Config.Animations.Dictionaries.Camera, Config.Animations.Clips.Camera
end
return dictionary, Config.Animations.Clips.TextRead
end
local function get_transition_clip(previous_mode, mode)
if previous_mode == MODE_HIDDEN then
return Config.Animations.Clips.TextIn
end
if previous_mode == MODE_CALL and mode ~= MODE_CALL then
return Config.Animations.Clips.CallToText
end
if previous_mode ~= MODE_CALL and mode == MODE_CALL then
return Config.Animations.Clips.TextToCall
end
return nil
end
local function apply_visible_mode(previous_mode, mode, revision)
local ped = PlayerPedId()
if not can_animate(ped) or not ensure_phone_prop(ped, revision) then
return
end
attach_phone_prop(ped, mode)
local transition_clip = get_transition_clip(previous_mode, mode)
if transition_clip then
local dictionary = get_phone_dictionary(ped)
local duration = play_animation(ped, dictionary, transition_clip, false, revision)
if not duration then
cleanup_phone()
return
end
Wait(duration)
if animation_state.revision ~= revision or not can_animate(ped) then
return
end
attach_phone_prop(ped, mode)
end
local dictionary, clip = get_base_animation(ped, mode)
if not play_animation(ped, dictionary, clip, true, revision) then
cleanup_phone()
end
end
local function apply_hidden_mode(previous_mode, revision)
local ped = animation_state.ped
if not ped or not DoesEntityExist(ped) or not animation_state.prop or not DoesEntityExist(animation_state.prop) then
cleanup_phone()
return
end
local clip = previous_mode == MODE_CALL and Config.Animations.Clips.CallOut or Config.Animations.Clips.TextOut
local dictionary = get_phone_dictionary(ped)
local duration = play_animation(ped, dictionary, clip, false, revision)
if not duration then
cleanup_phone()
return
end
Wait(duration)
if animation_state.revision == revision then
cleanup_phone()
end
end
local function apply_mode(previous_mode, mode, revision)
if mode == MODE_HIDDEN then
apply_hidden_mode(previous_mode, revision)
return
end
apply_visible_mode(previous_mode, mode, revision)
end
local function ensure_context_watcher()
if animation_state.watcher_active or derive_mode() == MODE_HIDDEN then
return
end
animation_state.watcher_active = true
CreateThread(function()
local observed_ped = nil
local observed_context = nil
local was_available = false
while derive_mode() ~= MODE_HIDDEN do
local ped = PlayerPedId()
local available = can_animate(ped)
local context = "unavailable"
if available then
local _, current_context = get_phone_dictionary(ped)
context = current_context
end
if not available and (was_available or animation_state.prop) then
animation_state.revision = animation_state.revision + 1
animation_state.current_mode = MODE_HIDDEN
cleanup_phone()
elseif available and (not was_available or observed_ped ~= ped or observed_context ~= context) then
reevaluate(true)
end
observed_ped = ped
observed_context = context
was_available = available
Wait(Config.Animations.ContextPollMs)
end
animation_state.watcher_active = false
end)
end
reevaluate = function(force)
local mode = derive_mode()
local ped = PlayerPedId()
if mode ~= MODE_HIDDEN and not can_animate(ped) then
animation_state.revision = animation_state.revision + 1
animation_state.current_mode = MODE_HIDDEN
cleanup_phone()
ensure_context_watcher()
return
end
if mode == animation_state.current_mode and not force then
ensure_context_watcher()
return
end
local previous_mode = animation_state.current_mode
animation_state.current_mode = mode
animation_state.revision = animation_state.revision + 1
local revision = animation_state.revision
ensure_context_watcher()
CreateThread(function()
apply_mode(previous_mode, mode, revision)
end)
end
local function reset_animation_state()
animation_state.phone_open = false
animation_state.call_state = nil
animation_state.call_direction = nil
animation_state.camera_active = false
animation_state.camera_front = false
animation_state.camera_landscape = false
animation_state.current_mode = MODE_HIDDEN
animation_state.revision = animation_state.revision + 1
cleanup_phone()
end
AddEventHandler("sky_phone:animation:phone", function(open)
if type(open) ~= "boolean" then
Bridge.Debug("warn", "[sky_phone] Ignored invalid phone animation state.")
return
end
animation_state.phone_open = open
if not open then
animation_state.camera_active = false
animation_state.camera_front = false
animation_state.camera_landscape = false
if derive_mode() == MODE_HIDDEN then
animation_state.current_mode = MODE_HIDDEN
animation_state.revision = animation_state.revision + 1
cleanup_phone()
return
end
end
reevaluate(false)
end)
AddEventHandler("sky_phone:animation:call", function(data)
if type(data) ~= "table" or type(data.state) ~= "string" or type(data.direction) ~= "string" then
Bridge.Debug("warn", "[sky_phone] Ignored invalid call animation state.")
return
end
animation_state.call_state = data.state
animation_state.call_direction = data.direction
reevaluate(false)
end)
AddEventHandler("sky_phone:animation:camera", function(data)
if type(data) ~= "table" or type(data.active) ~= "boolean" then
Bridge.Debug("warn", "[sky_phone] Ignored invalid camera animation state.")
return
end
animation_state.camera_active = data.active and animation_state.phone_open
animation_state.camera_front = data.front == true
animation_state.camera_landscape = data.landscape == true
reevaluate(true)
end)
AddEventHandler("sky_phone:animation:reset", reset_animation_state)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then
reset_animation_state()
end
end)