From b6679a5a7d822a8ccd24a957ceeb5a18a4a6b95d Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Sun, 16 Aug 2026 00:49:24 +0200 Subject: [PATCH] FIX - stabilize camera movement and selfie view --- .../src/views/apps/CameraApp.contract.test.ts | 37 ++++- frontend/src/views/apps/CameraApp.vue | 47 +++++- sky_phone/source/client/camera.lua | 145 ++++++++++++------ sky_phone/source/client/focus.lua | 22 +-- sky_phone/source/client/main.lua | 16 +- tests/client_focus.lua | 74 +++++++-- 6 files changed, 259 insertions(+), 82 deletions(-) diff --git a/frontend/src/views/apps/CameraApp.contract.test.ts b/frontend/src/views/apps/CameraApp.contract.test.ts index 47a9906..a4640cf 100644 --- a/frontend/src/views/apps/CameraApp.contract.test.ts +++ b/frontend/src/views/apps/CameraApp.contract.test.ts @@ -38,7 +38,16 @@ describe('Camera app controls', () => { it('keeps continuous wheel zoom without an extra slider bar', () => { expect(cameraView).not.toContain('camera-zoom-slider') expect(cameraView).not.toContain('type="range"') - expect(cameraView).toContain('event.deltaY * 0.0025') + expect(cameraView).toContain('@wheel.prevent.stop="zoomWithWheel"') + expect(cameraView).toContain('Math.min(120, event.deltaY * deltaMultiplier)') + expect(cameraView).toContain('wheelDelta * 0.00075') + expect(cameraView).toContain("message.type === 'camera:zoom'") + expect(cameraClient).toContain('mouse_wheel_zoom_step = 0.08') + expect(cameraClient).toContain('INPUT_CURSOR_SCROLL_UP') + expect(cameraClient).toContain('INPUT_CURSOR_SCROLL_DOWN') + expect(cameraClient).toContain('IsDisabledControlJustPressed(0, 241)') + expect(cameraClient).toContain('IsDisabledControlJustPressed(0, 242)') + expect(cameraClient).toContain('type = "camera:zoom"') expect(mediaCapture).toContain('nextZoom < 0.5 || nextZoom > 3') expect(mediaCapture).not.toContain('[0.5, 1, 2, 3].includes(nextZoom)') }) @@ -65,10 +74,36 @@ describe('Camera app controls', () => { expect(cameraClient).toContain( 'SetFollowVehicleCamViewMode(first_person_view_mode)', ) + expect(cameraClient).toMatch( + /while camera_state\.active do[\s\S]*else\s+apply_rear_camera_view\(\)/, + ) + expect(cameraClient).not.toContain('next_view_apply') expect(cameraClient).not.toContain('ultrawide_camera_handle') expect(cameraClient).not.toContain('ensure_ultrawide_camera') }) + it('keeps the selfie camera stable while Space still allows movement', () => { + expect(cameraView).toMatch( + /event\.code !== 'Space'[\s\S]*cameraLocked\.value/, + ) + expect(cameraClient).toContain( + 'if camera_state.locked or camera_state.front_camera then', + ) + expect(cameraClient).toContain('capture_front_camera_transform') + expect(cameraClient).toContain('forward_vector * front_camera_distance') + expect(cameraClient).toContain('SetCamCoord(') + expect(cameraClient).toContain('SetCamRot(') + expect(cameraClient).not.toContain('SetEntityHeading(') + expect(cameraClient).not.toContain('front_camera_position') + expect(cameraClient).not.toContain('AttachCamToEntity(') + expect(cameraClient).not.toContain('PointCamAtEntity(') + expect(cameraClient).not.toContain('PointCamAtCoord(') + expect(cameraView).toContain("window.addEventListener('keyup', onKeyup)") + expect(cameraView).toContain( + "nuiCall('camera:setFocus', { focused: true })", + ) + }) + it('uses a looping camera-hold pose instead of the old selfie dance', () => { expect(cameraConfig).toContain('Camera = "cellphone@self"') expect(cameraConfig).toContain('Camera = "selfie"') diff --git a/frontend/src/views/apps/CameraApp.vue b/frontend/src/views/apps/CameraApp.vue index dd183d6..86a0dfe 100644 --- a/frontend/src/views/apps/CameraApp.vue +++ b/frontend/src/views/apps/CameraApp.vue @@ -50,8 +50,8 @@ const flashEnabled = ref(false) const microphoneEnabled = ref(true) const frontCamera = ref(false) const shutterActive = ref(false) -const focused = ref(true) const cameraLocked = ref(false) +const movementEnabled = ref(false) const recording = ref(false) const savingVideo = ref(false) const recordingStartedAt = ref(0) @@ -231,6 +231,10 @@ async function toggleFacing(): Promise { async function toggleCameraLock(): Promise { cameraLocked.value = !cameraLocked.value + if (cameraLocked.value && movementEnabled.value) { + movementEnabled.value = false + await nuiCall('camera:setFocus', { focused: true }) + } await nuiCall('camera:setLocked', { locked: cameraLocked.value }) } @@ -265,7 +269,17 @@ function setZoom(zoom: number): void { } function zoomWithWheel(event: WheelEvent): void { - setZoom(selectedZoom.value - event.deltaY * 0.0025) + const deltaMultiplier = + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? window.innerHeight + : 1 + const wheelDelta = Math.max( + -120, + Math.min(120, event.deltaY * deltaMultiplier), + ) + setZoom(selectedZoom.value - wheelDelta * 0.00075) } function resizeGameView(entry?: ResizeObserverEntry): void { @@ -323,23 +337,34 @@ function onKeydown(event: KeyboardEvent): void { if ( event.code !== 'Space' || event.repeat || - !focused.value || - cameraLocked.value + cameraLocked.value || + movementEnabled.value ) return event.preventDefault() - focused.value = false + movementEnabled.value = true void nuiCall('camera:setFocus', { focused: false }) } +function onKeyup(event: KeyboardEvent): void { + if (event.code !== 'Space' || !movementEnabled.value) return + event.preventDefault() + movementEnabled.value = false + void nuiCall('camera:setFocus', { focused: true }) +} + function onMessage(event: MessageEvent): void { if (!isTrustedRootMessageSource(event.source, window)) return const message = event.data as { data?: Record type?: string } - if (message.type === 'camera:focus') { - focused.value = message.data?.focused === true + if (message.type === 'camera:zoom') { + const zoom = Number(message.data?.zoom) + if (Number.isFinite(zoom) && zoom >= minimumZoom && zoom <= maximumZoom) { + selectedZoom.value = zoom + resizeGameView() + } } else if (message.type === 'camera:recordState') { const active = message.data?.active === true savingVideo.value = message.data?.saving === true @@ -406,6 +431,7 @@ onMounted(() => { '*', ) window.addEventListener('keydown', onKeydown) + window.addEventListener('keyup', onKeyup) window.addEventListener('message', onMessage) void nuiCall('camera:setActive', { active: true }) void nuiCall<{ videoBitrateKbps?: number }>('media:config').then( @@ -424,7 +450,12 @@ onBeforeUnmount(() => { if (noticeTimer !== undefined) window.clearTimeout(noticeTimer) if (recordingTimer !== undefined) window.clearInterval(recordingTimer) window.removeEventListener('keydown', onKeydown) + window.removeEventListener('keyup', onKeyup) window.removeEventListener('message', onMessage) + if (movementEnabled.value) { + movementEnabled.value = false + void nuiCall('camera:setFocus', { focused: true }) + } if (renderFrameId !== undefined) window.cancelAnimationFrame(renderFrameId) resizeObserver?.disconnect() gameView?.dispose() @@ -446,7 +477,7 @@ onBeforeUnmount(() => { :class="{ 'camera-page--landscape': phone.cameraLandscape }" :aria-label="phone.t('Apps.camera.name')" > -
+
maximum_zoom then + return false + end + zoom = math.floor((zoom * 100.0) + 0.5) / 100.0 + if camera_state.zoom == zoom then + return true + end + camera_state.zoom = zoom + SendNUIMessage({ type = "camera:zoom", data = { zoom = zoom } }) + return true +end + local function watch_camera_controls() if camera_state.focus_watcher then return @@ -163,8 +211,25 @@ local function watch_camera_controls() CreateThread(function() while camera_state.active do apply_camera_controls() - if not camera_state.applied_nui_focus and IsDisabledControlJustReleased(0, 22) then - set_camera_focus(true) + if camera_state.game_input then + if + IsDisabledControlJustPressed(0, 241) + or IsDisabledControlJustPressed(0, 261) + then + set_camera_zoom( + math.min(maximum_zoom, camera_state.zoom + mouse_wheel_zoom_step) + ) + elseif + IsDisabledControlJustPressed(0, 242) + or IsDisabledControlJustPressed(0, 262) + then + set_camera_zoom( + math.max(minimum_zoom, camera_state.zoom - mouse_wheel_zoom_step) + ) + end + if IsDisabledControlJustReleased(0, 22) then + set_camera_focus(true) + end end Wait(0) end @@ -180,11 +245,13 @@ end AddEventHandler("sky_phone:client:cameraFocusApplied", function(data) if type(data) ~= "table" or type(data.active) ~= "boolean" + or type(data.cursor) ~= "boolean" or type(data.focused) ~= "boolean" or type(data.gameInput) ~= "boolean" then return end + camera_state.game_input = data.gameInput if camera_state.applied_nui_focus ~= data.focused then camera_state.applied_nui_focus = data.focused SendNUIMessage({ type = "camera:focus", data = { focused = data.focused } }) @@ -221,26 +288,12 @@ local function set_camera_active(active) end camera_state.enforcing = true CreateThread(function() - local next_view_apply = 0 while camera_state.active do HideHudAndRadarThisFrame() if camera_state.front_camera then - local ped = PlayerPedId() - ensure_front_camera() - local camera_position, target = front_camera_position(ped) - SetCamCoord( - camera_state.front_camera_handle, - camera_position.x, - camera_position.y, - camera_position.z - ) - PointCamAtCoord(camera_state.front_camera_handle, target.x, target.y, target.z) + apply_front_camera(PlayerPedId()) else - local now = GetGameTimer() - if now >= next_view_apply then - apply_rear_camera_view() - next_view_apply = now + 250 - end + apply_rear_camera_view() end Wait(0) end @@ -250,6 +303,7 @@ local function set_camera_active(active) end set_flash_enabled(false) camera_state.front_camera = false + camera_state.game_input = false camera_state.landscape = false camera_state.locked = false clear_front_camera() @@ -272,7 +326,7 @@ local function set_front_camera(active) return end if active then - ensure_front_camera() + apply_front_camera(PlayerPedId()) else clear_front_camera() apply_rear_camera_view() @@ -298,15 +352,6 @@ local function set_camera_landscape(active) end end -local function set_camera_zoom(zoom) - if not zoom or zoom < minimum_zoom or zoom > maximum_zoom then - return false - end - zoom = math.floor((zoom * 100.0) + 0.5) / 100.0 - camera_state.zoom = zoom - return true -end - RegisterNUICallback("camera:setActive", function(data, cb) if type(data) ~= "table" then cb({ success = false, error = "invalid_request" }) diff --git a/sky_phone/source/client/focus.lua b/sky_phone/source/client/focus.lua index e5c3103..a565ea3 100644 --- a/sky_phone/source/client/focus.lua +++ b/sky_phone/source/client/focus.lua @@ -2,20 +2,24 @@ SkyPhoneFocus = {} function SkyPhoneFocus.Resolve(state) if state.activity_suspended then - return { focused = false, keep_input = false } + return { cursor = false, focused = false, keep_input = false, movement_only = false } end if state.call_focus then - return { focused = true, keep_input = false } + return { cursor = true, focused = true, keep_input = false, movement_only = false } end if state.camera_active and not state.camera_nui_focused then - return { focused = false, keep_input = true } + return { cursor = false, focused = true, keep_input = true, movement_only = false } end + local movement_only = state.is_open and state.allow_movement and not state.camera_active + local focused = state.is_open + or state.notification_focus + or state.payphone_focus + or state.sim_picker_open + or (state.camera_active and state.camera_nui_focused) return { - focused = state.is_open - or state.notification_focus - or state.payphone_focus - or state.sim_picker_open - or (state.camera_active and state.camera_nui_focused), - keep_input = state.is_open and state.allow_movement and not state.camera_active, + cursor = focused, + focused = focused, + keep_input = movement_only, + movement_only = movement_only, } end diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 9faa30f..9b6aa64 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -12,6 +12,7 @@ local active_call_payload = nil local call_channel = 0 local nui_generation = 0 local activity_suspended = false +local movement_only_input = false Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true }) @@ -301,15 +302,28 @@ local function update_nui_focus() payphone_focus = payphone_focus, sim_picker_open = sim_picker_open, }) - SetNuiFocus(focus.focused, focus.focused) + SetNuiFocus(focus.focused, focus.cursor) SetNuiFocusKeepInput(focus.keep_input) + movement_only_input = focus.movement_only TriggerEvent("sky_phone:client:cameraFocusApplied", { active = camera_active, + cursor = focus.cursor, focused = focus.focused, gameInput = focus.keep_input, }) end +CreateThread(function() + while true do + if movement_only_input then + SkyPhoneFocus.ApplyMovementOnlyControls() + Wait(0) + else + Wait(250) + end + end +end) + AddEventHandler("sky_phone:client:setSuspended", function(suspended) activity_suspended = suspended == true update_nui_focus() diff --git a/tests/client_focus.lua b/tests/client_focus.lua index 5cad84b..11b4269 100644 --- a/tests/client_focus.lua +++ b/tests/client_focus.lua @@ -1,3 +1,25 @@ +local disabled_control_group = nil +local enabled_controls = {} +local firing_disabled = false + +function DisableAllControlActions(group) + disabled_control_group = group +end + +function EnableControlAction(group, control, enabled) + assert(group == 0 and enabled, "movement controls must be enabled in the primary input group") + enabled_controls[control] = true +end + +function PlayerId() + return 7 +end + +function DisablePlayerFiring(player, disabled) + assert(player == 7, "movement filtering must target the local player") + firing_disabled = disabled +end + dofile("sky_phone/source/client/focus.lua") local function resolve(overrides) @@ -19,26 +41,45 @@ local function resolve(overrides) end local idle = resolve() -assert(not idle.focused and not idle.keep_input, "idle NUI must release focus and game input override") +assert( + not idle.cursor and not idle.focused and not idle.keep_input, + "idle NUI must release focus and game input override" +) local minimized_call = resolve() assert(not minimized_call.focused, "a replayed call without an attention claim must stay unfocused") local incoming_call = resolve({ call_focus = true }) -assert(incoming_call.focused and not incoming_call.keep_input, "incoming call attention must focus the NUI") +assert( + incoming_call.cursor and incoming_call.focused and not incoming_call.keep_input, + "incoming call attention must focus the NUI" +) local stationary_phone = resolve({ is_open = true }) assert( - stationary_phone.focused and not stationary_phone.keep_input, + stationary_phone.cursor and stationary_phone.focused and not stationary_phone.keep_input, "an open phone must block game input when movement is disabled" ) local movable_phone = resolve({ allow_movement = true, is_open = true }) assert( - movable_phone.focused and movable_phone.keep_input, - "an open phone must keep game input when movement is enabled" + movable_phone.cursor + and movable_phone.focused + and movable_phone.keep_input + and movable_phone.movement_only, + "an open phone must keep only movement input when movement is enabled" ) +SkyPhoneFocus.ApplyMovementOnlyControls() +assert(disabled_control_group == 0, "movement filtering must disable the primary input group") +for _, control in ipairs({ 21, 30, 31, 32, 33, 34, 35 }) do + assert(enabled_controls[control], ("movement control %d must stay enabled"):format(control)) +end +assert(not enabled_controls[1] and not enabled_controls[2], "look controls must stay disabled") +assert(not enabled_controls[24] and not enabled_controls[25], "combat controls must stay disabled") +assert(not enabled_controls[22], "jump must stay disabled") +assert(firing_disabled, "player firing must remain disabled while the phone is open") + local movable_notification = resolve({ allow_movement = true, notification_focus = true }) assert( movable_notification.focused and not movable_notification.keep_input, @@ -51,8 +92,11 @@ local camera_game_input = resolve({ is_open = true, }) assert( - not camera_game_input.focused and camera_game_input.keep_input, - "unfocused camera must own game input over the open phone" + not camera_game_input.cursor + and camera_game_input.focused + and camera_game_input.keep_input + and not camera_game_input.movement_only, + "camera movement must keep keyboard focus without retaining the NUI cursor" ) local focused_camera = resolve({ @@ -62,8 +106,8 @@ local focused_camera = resolve({ is_open = true, }) assert( - focused_camera.focused and not focused_camera.keep_input, - "focused camera must override movement configuration until Space releases NUI focus" + focused_camera.cursor and focused_camera.focused and not focused_camera.keep_input, + "focused camera must override movement configuration until Space enables passthrough" ) local camera_interrupted_by_call = resolve({ @@ -73,8 +117,10 @@ local camera_interrupted_by_call = resolve({ is_open = true, }) assert( - camera_interrupted_by_call.focused and not camera_interrupted_by_call.keep_input, - "incoming call attention must override unfocused camera input" + camera_interrupted_by_call.cursor + and camera_interrupted_by_call.focused + and not camera_interrupted_by_call.keep_input, + "incoming call attention must override camera passthrough input" ) local camera_after_connected_call = resolve({ @@ -84,8 +130,10 @@ local camera_after_connected_call = resolve({ is_open = true, }) assert( - not camera_after_connected_call.focused and camera_after_connected_call.keep_input, - "connected call without an attention claim must restore unfocused camera input" + not camera_after_connected_call.cursor + and camera_after_connected_call.focused + and camera_after_connected_call.keep_input, + "connected call without an attention claim must restore camera movement input" ) local payphone_closed_behind_phone = resolve({ is_open = true, payphone_focus = false })