FIX - restore camera input and configurator warning (#30)

* FIX - honor configured camera look control

Camera focus was owned by hard-coded Space DOM handlers, bypassing Config.Phone.HoldToLook. Move focus input to the FiveM client and add a bounded scripted-camera orbit so selfie mode can rotate while the configured control is held.

* FIX - preserve camera movement passthrough

* FIX - restore Phone Configurator startup warning

* FIX - keep camera modifier input readable
This commit is contained in:
Leon.Schmidt
2026-08-23 15:50:25 +02:00
committed by GitHub
parent 8b4a32bfab
commit ea959f59f2
12 changed files with 253 additions and 86 deletions
+8 -2
View File
@@ -46,10 +46,16 @@ describe('fixed server permissions', () => {
expect(configurator).toContain(`["${path}"] = true`) expect(configurator).toContain(`["${path}"] = true`)
} }
expect(configurator).toContain( expect(configurator).toContain(
'Phone Configurator enabled: file-based settings from config.lua', 'SKY PHONE CONFIGURATION FILES ARE DISABLED',
) )
expect(configurator).toContain( expect(configurator).toContain(
'(except Config.CommandPermissions) and media.lua are disabled', '^1 Runtime settings from config.lua and media.lua are DISABLED.^0',
)
expect(configurator).toContain(
'^1 Configure all phone and media settings IN GAME through /phonepanel.^0',
)
expect(configurator).toContain(
'^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0',
) )
}) })
+2 -2
View File
@@ -4523,8 +4523,8 @@ const defaultLocales: LocaleTree = {
video: 'Video', video: 'Video',
microphoneOn: 'Microphone on', microphoneOn: 'Microphone on',
microphoneOff: 'Microphone muted', microphoneOff: 'Microphone muted',
focusHelp: 'Space for movement', focusHelp: 'Hold the look key to look around',
returnHelp: 'Space to return', returnHelp: 'Release the look key for controls',
uploading: '{count} uploading', uploading: '{count} uploading',
saving: 'Saving video...', saving: 'Saving video...',
openGallery: 'Open Photos', openGallery: 'Open Photos',
@@ -77,7 +77,8 @@ describe('Camera app controls', () => {
it('locks look controls without changing the global gameplay camera', () => { it('locks look controls without changing the global gameplay camera', () => {
expect(cameraView).toContain("nuiCall('camera:setLocked'") expect(cameraView).toContain("nuiCall('camera:setLocked'")
expect(cameraView).toContain('cameraLocked.value') expect(cameraView).toContain('cameraLocked.value')
expect(cameraView).toContain('Apps.camera.spaceKey') expect(cameraView).toContain('Apps.camera.lookKey')
expect(cameraView).not.toContain('Apps.camera.spaceKey')
expect(cameraClient).toContain('RegisterNUICallback("camera:setLocked"') expect(cameraClient).toContain('RegisterNUICallback("camera:setLocked"')
expect(cameraClient).toContain('INPUT_LOOK_LR') expect(cameraClient).toContain('INPUT_LOOK_LR')
expect(cameraClient).toContain('INPUT_LOOK_UD') expect(cameraClient).toContain('INPUT_LOOK_UD')
@@ -95,10 +96,26 @@ describe('Camera app controls', () => {
expect(cameraClient).not.toContain('ensure_ultrawide_camera') expect(cameraClient).not.toContain('ensure_ultrawide_camera')
}) })
it('keeps the selfie camera stable while Space still allows movement', () => { it('uses the configured HoldToLook control and Space in camera modes', () => {
expect(cameraView).toMatch( expect(cameraView).not.toContain("event.code !== 'Space'")
/event\.code !== 'Space'[\s\S]*cameraLocked\.value/, expect(cameraView).not.toContain("window.addEventListener('keydown'")
expect(focusClient).toContain(
'function SkyPhoneFocus.IsHoldToLookPressed()',
) )
expect(focusClient).toContain(
'IsDisabledControlPressed(0, hold_to_look_control)',
)
expect(cameraClient).toContain('SkyPhoneFocus.IsHoldToLookPressed()')
expect(cameraClient).toContain(
'IsDisabledControlPressed(0, camera_passthrough_control)',
)
expect(cameraClient).toMatch(
/if data\.active then\s+watch_camera_controls\(\)/,
)
expect(cameraClient).not.toContain('IsDisabledControlJustReleased(0, 22)')
})
it('orbits the stable selfie camera while HoldToLook allows movement', () => {
expect(cameraClient).toContain( expect(cameraClient).toContain(
'if camera_state.locked or camera_state.front_camera then', 'if camera_state.locked or camera_state.front_camera then',
) )
@@ -106,14 +123,22 @@ describe('Camera app controls', () => {
expect(cameraClient).toContain('local front_camera_view_mode = 0') expect(cameraClient).toContain('local front_camera_view_mode = 0')
expect(cameraClient).toContain('local front_camera_fov = 32.0') expect(cameraClient).toContain('local front_camera_fov = 32.0')
expect(cameraClient).toContain('local front_camera_distance = 1.05') expect(cameraClient).toContain('local front_camera_distance = 1.05')
expect(cameraClient).toContain('local front_camera_horizontal_limit = 75.0')
expect(cameraClient).toContain('local front_camera_vertical_limit = 35.0')
expect(cameraClient).toContain('local head_position = GetPedBoneCoords') expect(cameraClient).toContain('local head_position = GetPedBoneCoords')
expect(cameraClient).toContain( expect(cameraClient).toContain('GetDisabledControlNormal(0, 1)')
'local dot = (to_camera.x * forward_vector.x)', expect(cameraClient).toContain('GetDisabledControlNormal(0, 2)')
) expect(cameraClient).toContain('update_front_camera_orbit()')
expect(cameraClient).toContain('camera_state.front_camera_yaw')
expect(cameraClient).toContain('camera_state.front_camera_pitch')
expect(cameraClient).toContain('front_camera_target_height') expect(cameraClient).toContain('front_camera_target_height')
expect(focusClient).toContain( expect(focusClient).toContain(
'return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }', 'return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }',
) )
expect(focusClient).toContain(
'return { block_game = true, block_look = true, cursor = true, focused = true, game_input = false, keep_input = true }',
)
expect(focusClient).toContain('gameInput = focus.game_input')
expect(cameraClient).toContain('SetCamCoord(') expect(cameraClient).toContain('SetCamCoord(')
expect(cameraClient).toContain('PointCamAtCoord(') expect(cameraClient).toContain('PointCamAtCoord(')
expect(cameraClient).not.toContain('SetCamRot(') expect(cameraClient).not.toContain('SetCamRot(')
@@ -121,10 +146,6 @@ describe('Camera app controls', () => {
expect(cameraClient).not.toContain('front_camera_position') expect(cameraClient).not.toContain('front_camera_position')
expect(cameraClient).not.toContain('AttachCamToEntity(') expect(cameraClient).not.toContain('AttachCamToEntity(')
expect(cameraClient).not.toContain('PointCamAtEntity(') expect(cameraClient).not.toContain('PointCamAtEntity(')
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', () => { it('uses a looping camera-hold pose instead of the old selfie dance', () => {
+1 -34
View File
@@ -56,7 +56,6 @@ const microphoneEnabled = ref(true)
const frontCamera = ref(false) const frontCamera = ref(false)
const shutterActive = ref(false) const shutterActive = ref(false)
const cameraLocked = ref(false) const cameraLocked = ref(false)
const movementEnabled = ref(false)
const recording = ref(false) const recording = ref(false)
const savingVideo = ref(false) const savingVideo = ref(false)
const recordingStartedAt = ref(0) const recordingStartedAt = ref(0)
@@ -238,10 +237,6 @@ async function toggleFacing(): Promise<void> {
async function toggleCameraLock(): Promise<void> { async function toggleCameraLock(): Promise<void> {
cameraLocked.value = !cameraLocked.value 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 }) await nuiCall('camera:setLocked', { locked: cameraLocked.value })
} }
@@ -340,26 +335,6 @@ function updateRecordingTimer(): void {
elapsed.value = formatRecordingDuration(Date.now() - recordingStartedAt.value) elapsed.value = formatRecordingDuration(Date.now() - recordingStartedAt.value)
} }
function onKeydown(event: KeyboardEvent): void {
if (
event.code !== 'Space' ||
event.repeat ||
cameraLocked.value ||
movementEnabled.value
)
return
event.preventDefault()
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 { function onMessage(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return if (!isTrustedRootMessageSource(event.source, window)) return
const message = event.data as { const message = event.data as {
@@ -442,8 +417,6 @@ onMounted(() => {
{ data: { zoom: selectedZoom.value }, type: 'camera:zoom' }, { data: { zoom: selectedZoom.value }, type: 'camera:zoom' },
'*', '*',
) )
window.addEventListener('keydown', onKeydown)
window.addEventListener('keyup', onKeyup)
window.addEventListener('message', onMessage) window.addEventListener('message', onMessage)
void nuiCall('camera:setActive', { active: true }) void nuiCall('camera:setActive', { active: true })
void nuiCall<MediaConfig>('media:config').then((response) => { void nuiCall<MediaConfig>('media:config').then((response) => {
@@ -459,13 +432,7 @@ onBeforeUnmount(() => {
if (shutterTimer !== undefined) window.clearTimeout(shutterTimer) if (shutterTimer !== undefined) window.clearTimeout(shutterTimer)
if (noticeTimer !== undefined) window.clearTimeout(noticeTimer) if (noticeTimer !== undefined) window.clearTimeout(noticeTimer)
if (recordingTimer !== undefined) window.clearInterval(recordingTimer) if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('keyup', onKeyup)
window.removeEventListener('message', onMessage) window.removeEventListener('message', onMessage)
if (movementEnabled.value) {
movementEnabled.value = false
void nuiCall('camera:setFocus', { focused: true })
}
if (renderFrameId !== undefined) window.cancelAnimationFrame(renderFrameId) if (renderFrameId !== undefined) window.cancelAnimationFrame(renderFrameId)
resizeObserver?.disconnect() resizeObserver?.disconnect()
gameView?.dispose() gameView?.dispose()
@@ -587,7 +554,7 @@ onBeforeUnmount(() => {
> >
<LockKeyhole v-if="cameraLocked" :size="12" /> <LockKeyhole v-if="cameraLocked" :size="12" />
<LockOpen v-else :size="12" /> <LockOpen v-else :size="12" />
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd> <kbd>{{ phone.t('Apps.camera.lookKey') }}</kbd>
</SkyButton> </SkyButton>
<sky-fab <sky-fab
component="button" component="button"
+2 -2
View File
@@ -1449,8 +1449,8 @@ Locales["de"] = {
camera = { camera = {
name = "Kamera", flash = "Blitz", flip = "Kamera wechseln", landscape = "Zum Querformat wechseln", name = "Kamera", flash = "Blitz", flip = "Kamera wechseln", landscape = "Zum Querformat wechseln",
portrait = "Zum Hochformat wechseln", photo = "Foto", video = "Video", microphoneOn = "Mikrofon eingeschaltet", microphoneOff = "Mikrofon stumm", portrait = "Zum Hochformat wechseln", photo = "Foto", video = "Video", microphoneOn = "Mikrofon eingeschaltet", microphoneOff = "Mikrofon stumm",
focusHelp = "Halte die Leertaste gedrückt, um dich umzusehen", returnHelp = "Leertaste für Steuerung", lockCamera = "Kamerabewegung sperren", focusHelp = "Halte die Umschautaste gedrückt, um dich umzusehen", returnHelp = "Umschautaste loslassen für Steuerung", lockCamera = "Kamerabewegung sperren",
unlockCamera = "Kamerabewegung entsperren", spaceKey = "Leertaste", uploading = "Upload: {count}", unlockCamera = "Kamerabewegung entsperren", lookKey = "Umschauen", uploading = "Upload: {count}",
saving = "Video speichern...", openGallery = "Fotos öffnen", takePhoto = "Foto machen", saving = "Video speichern...", openGallery = "Fotos öffnen", takePhoto = "Foto machen",
startRecording = "Aufnahme starten", stopRecording = "Aufnahme beenden", saved = "Auf Fotos gespeichert.", startRecording = "Aufnahme starten", stopRecording = "Aufnahme beenden", saved = "Auf Fotos gespeichert.",
zoom = "Kamerazoom auf {zoom} einstellen", zoom = "Kamerazoom auf {zoom} einstellen",
+2 -2
View File
@@ -1449,8 +1449,8 @@ Locales["en"] = {
camera = { camera = {
name = "Camera", flash = "Flash", flip = "Flip camera", landscape = "Switch to landscape", name = "Camera", flash = "Flash", flip = "Flip camera", landscape = "Switch to landscape",
portrait = "Switch to portrait", photo = "Photo", video = "Video", microphoneOn = "Microphone on", microphoneOff = "Microphone muted", portrait = "Switch to portrait", photo = "Photo", video = "Video", microphoneOn = "Microphone on", microphoneOff = "Microphone muted",
focusHelp = "Hold Space to look around", returnHelp = "Release Space for controls", lockCamera = "Lock camera movement", focusHelp = "Hold the look key to look around", returnHelp = "Release the look key for controls", lockCamera = "Lock camera movement",
unlockCamera = "Unlock camera movement", spaceKey = "Space", uploading = "{count} uploading", unlockCamera = "Unlock camera movement", lookKey = "Look", uploading = "{count} uploading",
saving = "Saving video...", openGallery = "Open Photos", takePhoto = "Take photo", saving = "Saving video...", openGallery = "Open Photos", takePhoto = "Take photo",
startRecording = "Start recording", stopRecording = "Stop recording", saved = "Saved to Photos.", startRecording = "Start recording", stopRecording = "Stop recording", saved = "Saved to Photos.",
zoom = "Set camera zoom to {zoom}", zoom = "Set camera zoom to {zoom}",
+2 -2
View File
@@ -1449,8 +1449,8 @@ Locales["es"] = {
camera = { camera = {
name = "Cámara", flash = "Linterna", flip = "Cambiar de cámara", landscape = "Cambiar a paisaje", name = "Cámara", flash = "Linterna", flip = "Cambiar de cámara", landscape = "Cambiar a paisaje",
portrait = "Cambiar a vertical", photo = "Foto", video = "Vídeo", microphoneOn = "Micrófono encendido", microphoneOff = "Micrófono silenciado", portrait = "Cambiar a vertical", photo = "Foto", video = "Vídeo", microphoneOn = "Micrófono encendido", microphoneOff = "Micrófono silenciado",
focusHelp = "Presionar Espacio para mirar alrededor", returnHelp = "Soltar espacio para controles", lockCamera = "Bloquear movimiento de la cámara", focusHelp = "Mantén pulsada la tecla de vista para mirar alrededor", returnHelp = "Suelta la tecla de vista para usar los controles", lockCamera = "Bloquear movimiento de la cámara",
unlockCamera = "Desbloquear movimiento de la cámara", spaceKey = "Espacio", uploading = "Subiendo {count}", unlockCamera = "Desbloquear movimiento de la cámara", lookKey = "Mirar", uploading = "Subiendo {count}",
saving = "Guardando video...", openGallery = "Abrir fotos", takePhoto = "Tomar foto", saving = "Guardando video...", openGallery = "Abrir fotos", takePhoto = "Tomar foto",
startRecording = "Iniciar grabación", stopRecording = "Detener grabación", saved = "Guardado en fotos.", startRecording = "Iniciar grabación", stopRecording = "Detener grabación", saved = "Guardado en fotos.",
zoom = "Establecer zoom de la cámara en {zoom}", zoom = "Establecer zoom de la cámara en {zoom}",
+57 -15
View File
@@ -9,9 +9,13 @@ local front_camera_fov = 32.0
local front_camera_distance = 1.05 local front_camera_distance = 1.05
local front_camera_height = 0.05 local front_camera_height = 0.05
local front_camera_target_height = 0.03 local front_camera_target_height = 0.03
local front_camera_horizontal_limit = 75.0
local front_camera_vertical_limit = 35.0
local front_camera_rotate_speed = 5.0
local camera_passthrough_control = 22 -- INPUT_JUMP (Space by default)
local blocked_camera_controls = { local blocked_camera_controls = {
0, -- INPUT_NEXT_CAMERA 0, -- INPUT_NEXT_CAMERA
22, -- INPUT_JUMP camera_passthrough_control,
24, -- INPUT_ATTACK 24, -- INPUT_ATTACK
25, -- INPUT_AIM 25, -- INPUT_AIM
37, -- INPUT_SELECT_WEAPON 37, -- INPUT_SELECT_WEAPON
@@ -45,6 +49,8 @@ local camera_state = {
focus_watcher = false, focus_watcher = false,
front_camera = false, front_camera = false,
front_camera_handle = nil, front_camera_handle = nil,
front_camera_pitch = 0.0,
front_camera_yaw = 0.0,
game_input = false, game_input = false,
landscape = false, landscape = false,
locked = false, locked = false,
@@ -89,20 +95,39 @@ end
local function get_front_camera_transform(ped) local function get_front_camera_transform(ped)
local head_position = GetPedBoneCoords(ped, 31086, 0.0, 0.0, 0.0) local head_position = GetPedBoneCoords(ped, 31086, 0.0, 0.0, 0.0)
local forward = GetEntityForwardVector(ped) local forward = GetEntityForwardVector(ped)
local forward_vector = vector3(forward.x, forward.y, forward.z) local yaw = math.rad(camera_state.front_camera_yaw)
local camera_offset = forward_vector * front_camera_distance local pitch = math.rad(camera_state.front_camera_pitch)
local camera_position = head_position + camera_offset + vector3(0.0, 0.0, front_camera_height) local orbit_direction = vector3(
local to_camera = camera_position - head_position (forward.x * math.cos(yaw)) - (forward.y * math.sin(yaw)),
local dot = (to_camera.x * forward_vector.x) (forward.x * math.sin(yaw)) + (forward.y * math.cos(yaw)),
+ (to_camera.y * forward_vector.y) 0.0
+ (to_camera.z * forward_vector.z) )
if dot < 0.0 then local camera_position = head_position
camera_position = head_position - camera_offset + vector3(0.0, 0.0, front_camera_height) + (orbit_direction * (math.cos(pitch) * front_camera_distance))
end + vector3(0.0, 0.0, front_camera_height + (math.sin(pitch) * front_camera_distance))
local target_position = head_position + vector3(0.0, 0.0, front_camera_target_height) local target_position = head_position + vector3(0.0, 0.0, front_camera_target_height)
return camera_position, target_position return camera_position, target_position
end end
local function update_front_camera_orbit()
local horizontal_input = GetDisabledControlNormal(0, 1)
local vertical_input = GetDisabledControlNormal(0, 2)
camera_state.front_camera_yaw = math.max(
-front_camera_horizontal_limit,
math.min(
front_camera_horizontal_limit,
camera_state.front_camera_yaw - (horizontal_input * front_camera_rotate_speed)
)
)
camera_state.front_camera_pitch = math.max(
-front_camera_vertical_limit,
math.min(
front_camera_vertical_limit,
camera_state.front_camera_pitch - (vertical_input * front_camera_rotate_speed)
)
)
end
local function apply_front_camera(ped) local function apply_front_camera(ped)
if not camera_state.front_camera_handle or not DoesCamExist(camera_state.front_camera_handle) then if not camera_state.front_camera_handle or not DoesCamExist(camera_state.front_camera_handle) then
camera_state.front_camera_handle = CreateCam("DEFAULT_SCRIPTED_CAMERA", true) camera_state.front_camera_handle = CreateCam("DEFAULT_SCRIPTED_CAMERA", true)
@@ -198,7 +223,18 @@ local function watch_camera_controls()
CreateThread(function() CreateThread(function()
while camera_state.active do while camera_state.active do
apply_camera_controls() apply_camera_controls()
if not camera_state.walkable then
local passthrough_pressed = SkyPhoneFocus.IsHoldToLookPressed()
or IsDisabledControlPressed(0, camera_passthrough_control)
local should_focus = camera_state.locked or not passthrough_pressed
if camera_state.nui_focused ~= should_focus then
set_camera_focus(should_focus)
end
end
if camera_state.game_input then if camera_state.game_input then
if camera_state.front_camera and not camera_state.locked then
update_front_camera_orbit()
end
if if
IsDisabledControlJustPressed(0, 241) IsDisabledControlJustPressed(0, 241)
or IsDisabledControlJustPressed(0, 261) or IsDisabledControlJustPressed(0, 261)
@@ -214,9 +250,6 @@ local function watch_camera_controls()
math.max(minimum_zoom, camera_state.zoom - mouse_wheel_zoom_step) math.max(minimum_zoom, camera_state.zoom - mouse_wheel_zoom_step)
) )
end end
if IsDisabledControlJustReleased(0, 22) then
set_camera_focus(true)
end
end end
Wait(0) Wait(0)
end end
@@ -243,7 +276,7 @@ AddEventHandler("sky_phone:client:cameraFocusApplied", function(data)
camera_state.applied_nui_focus = data.focused camera_state.applied_nui_focus = data.focused
SendNUIMessage({ type = "camera:focus", data = { focused = data.focused } }) SendNUIMessage({ type = "camera:focus", data = { focused = data.focused } })
end end
if data.active and data.gameInput then if data.active then
watch_camera_controls() watch_camera_controls()
end end
end) end)
@@ -256,6 +289,8 @@ local function set_camera_active(active)
TriggerEvent("sky_phone:client:cameraActiveChanged", active) TriggerEvent("sky_phone:client:cameraActiveChanged", active)
if active then if active then
camera_state.front_camera = false camera_state.front_camera = false
camera_state.front_camera_pitch = 0.0
camera_state.front_camera_yaw = 0.0
camera_state.landscape = false camera_state.landscape = false
camera_state.locked = false camera_state.locked = false
camera_state.zoom = 1.0 camera_state.zoom = 1.0
@@ -290,6 +325,8 @@ local function set_camera_active(active)
end end
set_flash_enabled(false) set_flash_enabled(false)
camera_state.front_camera = false camera_state.front_camera = false
camera_state.front_camera_pitch = 0.0
camera_state.front_camera_yaw = 0.0
camera_state.game_input = false camera_state.game_input = false
camera_state.landscape = false camera_state.landscape = false
camera_state.locked = false camera_state.locked = false
@@ -314,6 +351,8 @@ local function set_front_camera(active)
return return
end end
if active then if active then
camera_state.front_camera_pitch = 0.0
camera_state.front_camera_yaw = 0.0
apply_front_camera(PlayerPedId()) apply_front_camera(PlayerPedId())
else else
clear_front_camera() clear_front_camera()
@@ -405,6 +444,9 @@ RegisterNUICallback("camera:setLocked", function(data, cb)
return return
end end
camera_state.locked = data.locked == true camera_state.locked = data.locked == true
if camera_state.locked and not camera_state.walkable then
set_camera_focus(true)
end
cb({ success = true }) cb({ success = true })
end) end)
+17 -4
View File
@@ -58,6 +58,16 @@ AddEventHandler("sky_phone:configurator:updated", function()
SkyPhoneFocus.Reapply() SkyPhoneFocus.Reapply()
end) end)
function SkyPhoneFocus.IsHoldToLookPressed()
if not hold_to_look_enabled then
return false
end
if IsControlPressed(0, hold_to_look_control) then
return true
end
return IsDisabledControlPressed(0, hold_to_look_control)
end
function SkyPhoneFocus.ApplyFocusedControls() function SkyPhoneFocus.ApplyFocusedControls()
for _, group in ipairs(focused_control_groups) do for _, group in ipairs(focused_control_groups) do
DisableAllControlActions(group) DisableAllControlActions(group)
@@ -96,6 +106,10 @@ function SkyPhoneFocus.Resolve(state)
if state.camera_active and not state.camera_nui_focused then if state.camera_active and not state.camera_nui_focused then
return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true } return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }
end end
if state.camera_active then
-- Forward controls so disabled inputs remain readable while the NUI cursor owns focus.
return { block_game = true, block_look = true, cursor = true, focused = true, game_input = false, keep_input = true }
end
local game_input = state.is_open local game_input = state.is_open
and allows_game_input(state) and allows_game_input(state)
and not state.camera_active and not state.camera_active
@@ -137,7 +151,7 @@ function SkyPhoneFocus.Reapply()
active = state.camera_active, active = state.camera_active,
cursor = focus.cursor, cursor = focus.cursor,
focused = focus.focused, focused = focus.focused,
gameInput = focus.keep_input, gameInput = focus.game_input,
}) })
end end
@@ -234,10 +248,9 @@ end
CreateThread(function() CreateThread(function()
while true do while true do
if game_input or block_game then if game_input or block_game then
local look_passthrough = hold_to_look_enabled local look_passthrough = game_input
and game_input
and not state.cursor_disabled and not state.cursor_disabled
and IsControlPressed(0, hold_to_look_control) and SkyPhoneFocus.IsHoldToLookPressed()
if look_passthrough ~= state.look_passthrough then if look_passthrough ~= state.look_passthrough then
state.look_passthrough = look_passthrough state.look_passthrough = look_passthrough
SkyPhoneFocus.Reapply() SkyPhoneFocus.Reapply()
+10 -4
View File
@@ -26,10 +26,16 @@ local FIXED_CONFIG_PATHS = {
} }
if configurator_enabled then if configurator_enabled then
print( local border = "======================================================================"
"[sky_phone] Phone Configurator enabled: file-based settings from config.lua " .. print(([[
"(except Config.CommandPermissions) and media.lua are disabled; SQL configuration is active." ^1%s^0
) ^1 SKY PHONE CONFIGURATION FILES ARE DISABLED ^0
^1%s^0
^1 The Phone Configurator is ENABLED.^0
^1 Runtime settings from config.lua and media.lua are DISABLED.^0
^1 Configure all phone and media settings IN GAME through /phonepanel.^0
^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0
^1%s^0]]):format(border, border, border))
end end
local CLIENT_CONFIG_KEYS = { local CLIENT_CONFIG_KEYS = {
+63 -5
View File
@@ -4,6 +4,20 @@ local camera_target = nil
local camera_created = false local camera_created = false
local camera_destroyed = false local camera_destroyed = false
local scripted_camera_rendering = false local scripted_camera_rendering = false
local event_handlers = {}
local threads = {}
local hold_to_look_pressed = false
local disabled_controls = {}
local disabled_pressed_controls = {}
local disabled_control_normals = {}
local triggered_events = {}
local thread_stop = {}
SkyPhoneFocus = {
IsHoldToLookPressed = function()
return hold_to_look_pressed
end,
}
local vector_meta = {} local vector_meta = {}
vector_meta.__index = vector_meta vector_meta.__index = vector_meta
@@ -32,12 +46,20 @@ function RegisterNUICallback(name, callback)
end end
function RegisterNetEvent() end function RegisterNetEvent() end
function AddEventHandler() end function AddEventHandler(name, callback)
function TriggerEvent() end event_handlers[name] = callback
end
function TriggerEvent(name, data)
triggered_events[#triggered_events + 1] = { name = name, data = data }
end
function TriggerServerEvent() end function TriggerServerEvent() end
function SendNUIMessage() end function SendNUIMessage() end
function CreateThread() end function CreateThread(callback)
function Wait() end threads[#threads + 1] = callback
end
function Wait()
error(thread_stop, 0)
end
function PlayerPedId() return 7 end function PlayerPedId() return 7 end
function PlayerId() return 8 end function PlayerId() return 8 end
function GetFollowPedCamViewMode() return 1 end function GetFollowPedCamViewMode() return 1 end
@@ -47,10 +69,19 @@ function DisplayRadar() end
function SetFollowPedCamViewMode() end function SetFollowPedCamViewMode() end
function SetFollowVehicleCamViewMode() end function SetFollowVehicleCamViewMode() end
function IsPedInAnyVehicle() return false end function IsPedInAnyVehicle() return false end
function DisableControlAction() end function DisableControlAction(group, control, disabled)
assert(group == 0 and disabled, "camera controls must be disabled in the primary input group")
disabled_controls[control] = true
end
function DisablePlayerFiring() end function DisablePlayerFiring() end
function HideHudAndRadarThisFrame() end function HideHudAndRadarThisFrame() end
function GetCurrentResourceName() return "sky_phone" end function GetCurrentResourceName() return "sky_phone" end
function IsDisabledControlJustPressed() return false end
function IsDisabledControlPressed(group, control)
assert(group == 0, "camera passthrough must read the primary input group")
return disabled_pressed_controls[control] == true
end
function GetDisabledControlNormal(_, control) return disabled_control_normals[control] or 0.0 end
function GetEntityCoords() function GetEntityCoords()
return vector3(10.0, 20.0, 1.0) return vector3(10.0, 20.0, 1.0)
@@ -120,6 +151,33 @@ assert(close_enough(camera_target.x, 10.0))
assert(close_enough(camera_target.y, 20.0)) assert(close_enough(camera_target.y, 20.0))
assert(close_enough(camera_target.z, 2.73)) assert(close_enough(camera_target.z, 2.73))
event_handlers["sky_phone:client:cameraFocusApplied"]({
active = true,
cursor = false,
focused = true,
gameInput = true,
})
disabled_pressed_controls[22] = true
disabled_control_normals[1] = 0.2
local watcher_ok, watcher_error = pcall(threads[2])
assert(not watcher_ok and watcher_error == thread_stop, "camera watcher must run one controlled frame")
local camera_focus_event = triggered_events[#triggered_events]
assert(
camera_focus_event.name == "sky_phone:client:setCameraFocus"
and camera_focus_event.data.active
and not camera_focus_event.data.nuiFocused,
"holding Space must release the camera cursor for simultaneous look and movement"
)
for _, control in ipairs({ 30, 31, 32, 33, 34, 35 }) do
assert(not disabled_controls[control], ("camera passthrough must preserve movement control %d"):format(control))
end
local camera_ok, camera_error = pcall(threads[1])
assert(not camera_ok and camera_error == thread_stop, "selfie camera must render one controlled frame")
assert(not close_enough(camera_coord.x, 10.0), "horizontal look input must orbit the selfie camera around the player")
assert(camera_coord.y < 21.05, "selfie orbit must retain its configured distance from the player")
disabled_pressed_controls[22] = false
disabled_control_normals[1] = 0.0
assert(response_from("camera:setFacing", { front = false }).success) assert(response_from("camera:setFacing", { front = false }).success)
assert(camera_destroyed and not scripted_camera_rendering, "rear mode must release the selfie camera") assert(camera_destroyed and not scripted_camera_rendering, "rear mode must release the selfie camera")
+57 -3
View File
@@ -5,6 +5,9 @@ local event_handlers = {}
local nui_callbacks = {} local nui_callbacks = {}
local nui_focus = nil local nui_focus = nil
local nui_keep_input = nil local nui_keep_input = nil
local pressed_controls = {}
local disabled_pressed_controls = {}
local triggered_events = {}
Config = { Config = {
Phone = { Phone = {
@@ -34,7 +37,19 @@ function SetNuiFocusKeepInput(keep_input)
nui_keep_input = keep_input nui_keep_input = keep_input
end end
function TriggerEvent() end function TriggerEvent(name, data)
triggered_events[#triggered_events + 1] = { name = name, data = data }
end
function IsControlPressed(group, control)
assert(group == 0, "HoldToLook must read the primary input group")
return pressed_controls[control] == true
end
function IsDisabledControlPressed(group, control)
assert(group == 0, "HoldToLook must read disabled controls from the primary input group")
return disabled_pressed_controls[control] == true
end
function DisableControlAction(group, control, disabled) function DisableControlAction(group, control, disabled)
assert(group == 0 and disabled, "phone controls must be disabled in the primary input group") assert(group == 0 and disabled, "phone controls must be disabled in the primary input group")
@@ -56,6 +71,25 @@ end
dofile("sky_phone/source/client/focus.lua") dofile("sky_phone/source/client/focus.lua")
assert(not SkyPhoneFocus.IsHoldToLookPressed(), "HoldToLook must be idle until its configured control is held")
pressed_controls[19] = true
assert(SkyPhoneFocus.IsHoldToLookPressed(), "HoldToLook must read its configured active control")
pressed_controls[19] = false
Config.Phone.HoldToLook.Control = 38
event_handlers["sky_phone:configurator:updated"]()
disabled_pressed_controls[38] = true
assert(
SkyPhoneFocus.IsHoldToLookPressed(),
"HoldToLook must read its configured control while NUI focus has disabled GTA input"
)
disabled_pressed_controls[38] = false
Config.Phone.HoldToLook.Enabled = false
event_handlers["sky_phone:configurator:updated"]()
assert(not SkyPhoneFocus.IsHoldToLookPressed(), "disabled HoldToLook must reject every control state")
Config.Phone.HoldToLook.Enabled = true
Config.Phone.HoldToLook.Control = 19
event_handlers["sky_phone:configurator:updated"]()
local function resolve(overrides) local function resolve(overrides)
local state = { local state = {
activity_suspended = false, activity_suspended = false,
@@ -233,6 +267,9 @@ disabled_controls = {}
firing_disabled = false firing_disabled = false
SkyPhoneFocus.ApplyGameInputControls(false) SkyPhoneFocus.ApplyGameInputControls(false)
assert(not disabled_controls[1] and not disabled_controls[2], "camera passthrough must preserve camera look") assert(not disabled_controls[1] and not disabled_controls[2], "camera passthrough must preserve camera look")
for _, control in ipairs({ 30, 31, 32, 33, 34, 35 }) do
assert(not disabled_controls[control], ("camera passthrough must preserve movement control %d"):format(control))
end
assert(firing_disabled, "player attacks must remain disabled during camera passthrough") assert(firing_disabled, "player attacks must remain disabled during camera passthrough")
local movable_notification = resolve({ allow_movement = true, notification_focus = true }) local movable_notification = resolve({ allow_movement = true, notification_focus = true })
@@ -264,11 +301,28 @@ local focused_camera = resolve({
assert( assert(
focused_camera.cursor focused_camera.cursor
and focused_camera.focused and focused_camera.focused
and not focused_camera.keep_input and focused_camera.keep_input
and not focused_camera.game_input, and not focused_camera.game_input,
"focused camera must override movement configuration until Space enables passthrough" "focused camera must forward readable input while blocking game actions until passthrough is held"
) )
event_handlers["sky_phone:client:setCameraFocus"]({ active = true, nuiFocused = true })
local focused_camera_event = triggered_events[#triggered_events]
assert(
nui_focus.focused and nui_focus.cursor and nui_keep_input
and focused_camera_event.name == "sky_phone:client:cameraFocusApplied"
and not focused_camera_event.data.gameInput,
"focused camera must keep controls readable without reporting movement passthrough"
)
event_handlers["sky_phone:client:setCameraFocus"]({ active = true, nuiFocused = false })
local passthrough_camera_event = triggered_events[#triggered_events]
assert(
nui_focus.focused and not nui_focus.cursor and nui_keep_input
and passthrough_camera_event.data.gameInput,
"camera passthrough must apply keyboard focus without a cursor and keep GTA input enabled"
)
event_handlers["sky_phone:client:setCameraFocus"]({ active = false, nuiFocused = true })
local camera_interrupted_by_call = resolve({ local camera_interrupted_by_call = resolve({
call_focus = true, call_focus = true,
camera_active = true, camera_active = true,