From 4da63ac142fe80c42159f55ac3929e229e1aba0f Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:50:35 +0200 Subject: [PATCH] ADD - implement phone interaction animations --- frontend/src/views/apps/CameraApp.vue | 5 + sky_phone/config/config.lua | 34 ++ sky_phone/fxmanifest.lua | 1 + sky_phone/source/client/animations.lua | 446 +++++++++++++++++++++++++ sky_phone/source/client/camera.lua | 37 ++ sky_phone/source/client/main.lua | 6 + 6 files changed, 529 insertions(+) create mode 100644 sky_phone/source/client/animations.lua diff --git a/frontend/src/views/apps/CameraApp.vue b/frontend/src/views/apps/CameraApp.vue index efd2b98..43f7487 100644 --- a/frontend/src/views/apps/CameraApp.vue +++ b/frontend/src/views/apps/CameraApp.vue @@ -235,6 +235,9 @@ function toggleOrientation(): void { }, '*', ) + void nuiCall('camera:setOrientation', { + landscape: phone.cameraLandscape, + }) } function setZoom(zoom: (typeof zoomLevels)[number]): void { @@ -353,6 +356,7 @@ onMounted(() => { { data: { landscape: false }, type: 'camera:orientation' }, '*', ) + void nuiCall('camera:setOrientation', { landscape: false }) window.postMessage( { data: { zoom: selectedZoom.value }, type: 'camera:zoom' }, '*', @@ -386,6 +390,7 @@ onBeforeUnmount(() => { '*', ) window.postMessage({ type: 'camera:recordCancel' }, '*') + void nuiCall('camera:setOrientation', { landscape: false }) void nuiCall('camera:setFlash', { enabled: false }) void nuiCall('camera:setActive', { active: false }) }) diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index ee94130..afc4287 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -34,6 +34,40 @@ Config.Calls = { RecentPageSize = 100, } +Config.Animations = { + Enabled = true, + PropModel = "prop_npc_phone_02", + PropBone = 28422, + LoadTimeoutMs = 5000, + ContextPollMs = 250, + Dictionaries = { + OnFoot = "cellphone@", + Driver = "anim@cellphone@in_car@ds", + Passenger = "anim@cellphone@in_car@ps", + Selfie = "anim@mp_player_intuppertake_selfie", + }, + Clips = { + TextIn = "cellphone_text_in", + TextRead = "cellphone_text_read_base", + TextOut = "cellphone_text_out", + TextToCall = "cellphone_text_to_call", + CallListen = "cellphone_call_listen_base", + CallToText = "cellphone_call_to_text", + CallOut = "cellphone_call_out", + Selfie = "idle_a", + }, + Transforms = { + Portrait = { + position = { x = 0.0, y = 0.0, z = 0.0 }, + rotation = { x = 0.0, y = 0.0, z = 0.0 }, + }, + Landscape = { + position = { x = 0.0, y = 0.0, z = 0.0 }, + rotation = { x = 0.0, y = 0.0, z = 90.0 }, + }, + }, +} + Config.Messages = { BodyMaxLength = 2000, ConversationScanLimit = 1000, diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index a8a8c12..26d664c 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -24,6 +24,7 @@ client_scripts { 'config/locales/*.lua', 'source/bridge/client/framework.lua', 'source/bridge/client/callbacks.lua', + 'source/client/animations.lua', 'source/client/camera.lua', 'source/client/garage.lua', 'source/client/main.lua', diff --git a/sky_phone/source/client/animations.lua b/sky_phone/source/client/animations.lua new file mode 100644 index 0000000..b0318ae --- /dev/null +++ b/sky_phone/source/client/animations.lua @@ -0,0 +1,446 @@ +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_SELFIE and not IsPedInAnyVehicle(ped, false) then + return Config.Animations.Dictionaries.Selfie, Config.Animations.Clips.Selfie + 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) diff --git a/sky_phone/source/client/camera.lua b/sky_phone/source/client/camera.lua index c1ed3d6..1cfa566 100644 --- a/sky_phone/source/client/camera.lua +++ b/sky_phone/source/client/camera.lua @@ -14,6 +14,7 @@ local camera_state = { focus_watcher = false, front_camera = false, front_camera_handle = nil, + landscape = false, ultrawide_camera_handle = nil, nui_focused = true, previous_ped_view = nil, @@ -181,6 +182,7 @@ local function set_camera_active(active) camera_state.active = active if active then camera_state.front_camera = false + camera_state.landscape = false camera_state.zoom = 1.0 clear_front_camera() clear_ultrawide_camera() @@ -190,6 +192,11 @@ local function set_camera_active(active) DisplayRadar(false) set_camera_focus(true) apply_camera_view() + TriggerEvent("sky_phone:animation:camera", { + active = true, + front = camera_state.front_camera, + landscape = camera_state.landscape, + }) if camera_state.enforcing then return end @@ -240,6 +247,7 @@ local function set_camera_active(active) end set_flash_enabled(false) camera_state.front_camera = false + camera_state.landscape = false clear_front_camera() clear_ultrawide_camera() restore_camera_view() @@ -248,6 +256,11 @@ local function set_camera_active(active) SetNuiFocusKeepInput(false) SetNuiFocus(true, true) end + TriggerEvent("sky_phone:animation:camera", { + active = false, + front = false, + landscape = false, + }) end local function set_front_camera(active) @@ -268,6 +281,25 @@ local function set_front_camera(active) end end apply_camera_view() + TriggerEvent("sky_phone:animation:camera", { + active = true, + front = camera_state.front_camera, + landscape = camera_state.landscape, + }) +end + +local function set_camera_landscape(active) + if camera_state.landscape == active then + return + end + camera_state.landscape = active + if camera_state.active then + TriggerEvent("sky_phone:animation:camera", { + active = true, + front = camera_state.front_camera, + landscape = camera_state.landscape, + }) + end end local function set_camera_zoom(zoom) @@ -314,6 +346,11 @@ RegisterNUICallback("camera:setFacing", function(data, cb) cb({ success = true }) end) +RegisterNUICallback("camera:setOrientation", function(data, cb) + set_camera_landscape(data and data.landscape == true) + cb({ success = true }) +end) + RegisterNUICallback("camera:setZoom", function(data, cb) cb({ success = set_camera_zoom(tonumber(data and data.zoom)) }) end) diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 773c852..09ebfa3 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -124,6 +124,7 @@ end local function close_phone() open_requested = false + TriggerEvent("sky_phone:animation:phone", false) if not is_open then return end @@ -192,6 +193,7 @@ RegisterNUICallback("ui:opened", function(_, cb) is_open = true notification_focus = false SetNuiFocus(true, true) + TriggerEvent("sky_phone:animation:phone", true) cb({ success = true }) end) @@ -351,6 +353,7 @@ end) RegisterNetEvent("sky_phone:device:invalidated", function() open_requested = false device_payload = nil + TriggerEvent("sky_phone:animation:reset") close_phone() end) @@ -465,6 +468,7 @@ end) RegisterNetEvent("sky_phone:call:incoming", function(data) notification_focus = true SetNuiFocus(true, true) + TriggerEvent("sky_phone:animation:call", data) SendNUIMessage({ type = "call:incoming", data = data }) end) @@ -476,6 +480,7 @@ RegisterNetEvent("sky_phone:call:state", function(data) elseif data.state ~= "ringing" then leave_call_voice() end + TriggerEvent("sky_phone:animation:call", data) SendNUIMessage({ type = "call:state", data = data }) end) @@ -494,6 +499,7 @@ AddEventHandler("onResourceStop", function(resource_name) SetNuiFocus(false, false) end + TriggerEvent("sky_phone:animation:reset") leave_call_voice() if Config.Phone.DevelopmentCommand then