FIX - stabilize camera movement and selfie view

This commit is contained in:
Leon.Schmidt
2026-08-16 00:49:24 +02:00
parent 2f70444071
commit b6679a5a7d
6 changed files with 259 additions and 82 deletions
@@ -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"')
+39 -8
View File
@@ -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<void> {
async function toggleCameraLock(): Promise<void> {
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<string, unknown>
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')"
>
<div class="camera-viewport" @wheel.prevent="zoomWithWheel">
<div class="camera-viewport" @wheel.prevent.stop="zoomWithWheel">
<canvas
v-if="!isDevelopment && !gameViewUnavailable"
ref="gameCanvas"
+95 -50
View File
@@ -1,10 +1,11 @@
local minimum_zoom = 0.5
local maximum_zoom = 3.0
local mouse_wheel_zoom_step = 0.08
local first_person_view_mode = 4
local front_camera_fov = 40.0
local front_camera_distance = 1.05
local front_camera_side_offset = 0.08
local front_camera_height = 0.12
local front_camera_fov = 48.0
local front_camera_distance = 1.45
local front_camera_side_offset = 0.0
local front_camera_height = 0.06
local front_camera_target_height = -0.1
local blocked_camera_controls = {
0, -- INPUT_NEXT_CAMERA
@@ -22,7 +23,11 @@ local blocked_camera_controls = {
140, -- INPUT_MELEE_ATTACK_LIGHT
141, -- INPUT_MELEE_ATTACK_HEAVY
142, -- INPUT_MELEE_ATTACK_ALTERNATE
241, -- INPUT_CURSOR_SCROLL_UP
242, -- INPUT_CURSOR_SCROLL_DOWN
257, -- INPUT_ATTACK2
261, -- INPUT_PREV_WEAPON
262, -- INPUT_NEXT_WEAPON
263, -- INPUT_MELEE_ATTACK1
264, -- INPUT_MELEE_ATTACK2
}
@@ -38,6 +43,9 @@ local camera_state = {
focus_watcher = false,
front_camera = false,
front_camera_handle = nil,
front_camera_offset = nil,
front_camera_rotation = nil,
game_input = false,
landscape = false,
locked = false,
applied_nui_focus = true,
@@ -77,29 +85,54 @@ local function set_flash_enabled(enabled)
end)
end
local function front_camera_position(ped)
local head = GetPedBoneCoords(ped, 31086, 0.0, 0.0, 0.0)
local function capture_front_camera_transform(ped)
local ped_position = GetEntityCoords(ped)
local head_position = GetPedBoneCoords(ped, 31086, 0.0, 0.0, 0.0)
local head_height = head_position.z - ped_position.z
local forward = GetEntityForwardVector(ped)
local forward_vector = vector3(forward.x, forward.y, forward.z)
local forward_vector = vector3(forward.x, forward.y, 0.0)
local right_vector = vector3(forward_vector.y, -forward_vector.x, 0.0)
local camera_position = head
+ (forward_vector * front_camera_distance)
local camera_offset = (forward_vector * front_camera_distance)
+ (right_vector * front_camera_side_offset)
+ vector3(0.0, 0.0, front_camera_height)
local target = head
+ (right_vector * (front_camera_side_offset * 0.25))
+ vector3(0.0, 0.0, front_camera_target_height)
return camera_position, target
+ vector3(0.0, 0.0, head_height + front_camera_height)
local target_offset = (right_vector * (front_camera_side_offset * 0.25))
+ vector3(0.0, 0.0, head_height + front_camera_target_height)
local direction = target_offset - camera_offset
local horizontal_length = math.sqrt((direction.x * direction.x) + (direction.y * direction.y))
local rotation = vector3(
math.deg(math.atan(direction.z, horizontal_length)),
0.0,
math.deg(math.atan(-direction.x, direction.y))
)
return camera_offset, rotation
end
local function ensure_front_camera()
if camera_state.front_camera_handle and DoesCamExist(camera_state.front_camera_handle) then
return
local function apply_front_camera(ped)
if not camera_state.front_camera_handle or not DoesCamExist(camera_state.front_camera_handle) then
camera_state.front_camera_offset, camera_state.front_camera_rotation =
capture_front_camera_transform(ped)
camera_state.front_camera_handle = CreateCam("DEFAULT_SCRIPTED_CAMERA", true)
SetCamFov(camera_state.front_camera_handle, front_camera_fov)
SetCamActive(camera_state.front_camera_handle, true)
RenderScriptCams(true, false, 0, true, true)
end
camera_state.front_camera_handle = CreateCam("DEFAULT_SCRIPTED_CAMERA", true)
SetCamFov(camera_state.front_camera_handle, front_camera_fov)
SetCamActive(camera_state.front_camera_handle, true)
RenderScriptCams(true, false, 0, true, true)
local ped_position = GetEntityCoords(ped)
local camera_position = ped_position + camera_state.front_camera_offset
local rotation = camera_state.front_camera_rotation
SetCamCoord(
camera_state.front_camera_handle,
camera_position.x,
camera_position.y,
camera_position.z
)
SetCamRot(
camera_state.front_camera_handle,
rotation.x,
rotation.y,
rotation.z,
2
)
end
local function clear_front_camera()
@@ -108,6 +141,8 @@ local function clear_front_camera()
DestroyCam(camera_state.front_camera_handle, false)
end
camera_state.front_camera_handle = nil
camera_state.front_camera_offset = nil
camera_state.front_camera_rotation = nil
end
local function apply_rear_camera_view()
@@ -138,7 +173,7 @@ local function apply_camera_controls()
for _, control in ipairs(blocked_camera_controls) do
DisableControlAction(0, control, true)
end
if camera_state.locked then
if camera_state.locked or camera_state.front_camera then
for _, control in ipairs(camera_look_controls) do
DisableControlAction(0, control, true)
end
@@ -155,6 +190,19 @@ end
local set_camera_focus
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
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" })
+13 -9
View File
@@ -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
+15 -1
View File
@@ -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()
+61 -13
View File
@@ -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 })