From 7daf95492be62083942cba83c653cbd2020bf236 Mon Sep 17 00:00:00 2001 From: jb0gie Date: Wed, 16 Jul 2025 23:09:26 +0100 Subject: [PATCH 1/3] feat: implement context menu functionality in CoreUI and update camera handling in ClientControls --- example-fov-app.js | 49 ++++++++++++++++ src/client/components/ContextMenu.js | 85 ++++++++++++++++++++++++++++ src/client/components/CoreUI.js | 26 +++++++++ src/core/systems/ClientControls.js | 15 ++++- 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 example-fov-app.js create mode 100644 src/client/components/ContextMenu.js diff --git a/example-fov-app.js b/example-fov-app.js new file mode 100644 index 00000000..1af159d3 --- /dev/null +++ b/example-fov-app.js @@ -0,0 +1,49 @@ +// Example app demonstrating FOV manipulation +// This app shows how to control camera FOV through the app.control() API + +export default { + name: 'FOV Demo', + version: '1.0.0', + + // App lifecycle + start() { + // Get control of the camera + this.control = app.control() + + // Set initial FOV to 90 degrees (wide angle) + this.control.camera.fov = 90 + this.control.camera.write = true + + // Log current FOV + console.log('Current FOV:', this.control.camera.fov) + + // Set up keyboard controls for FOV adjustment + this.control.keyF.onPress = () => { + // Increase FOV (wider angle) + this.control.camera.fov = Math.min(120, this.control.camera.fov + 10) + console.log('FOV increased to:', this.control.camera.fov) + } + + this.control.keyG.onPress = () => { + // Decrease FOV (narrower angle) + this.control.camera.fov = Math.max(30, this.control.camera.fov - 10) + console.log('FOV decreased to:', this.control.camera.fov) + } + + this.control.keyR.onPress = () => { + // Reset to default FOV + this.control.camera.fov = 70 + console.log('FOV reset to:', this.control.camera.fov) + } + + // Display instructions + app.chat('FOV Demo loaded! Press F to increase FOV, G to decrease, R to reset') + }, + + // Clean up when app is destroyed + destroy() { + if (this.control) { + this.control.release() + } + } +} \ No newline at end of file diff --git a/src/client/components/ContextMenu.js b/src/client/components/ContextMenu.js new file mode 100644 index 00000000..61ac95d0 --- /dev/null +++ b/src/client/components/ContextMenu.js @@ -0,0 +1,85 @@ +import { useEffect, useRef, useState } from 'react' +import { css } from '@firebolt-dev/css' +import { Menu, MenuItemBtn, MenuItemNumber } from './Menu' + +export function ContextMenu({ world, visible, position, onClose }) { + const [fov, setFov] = useState(world?.camera?.fov || 70) + const menuRef = useRef() + + useEffect(() => { + if (visible && world?.camera) { + setFov(world.camera.fov) + } + }, [visible, world?.camera?.fov]) + + useEffect(() => { + if (!visible) return + + const handleClickOutside = (e) => { + if (menuRef.current && !menuRef.current.contains(e.target)) { + onClose() + } + } + + const handleEscape = (e) => { + if (e.key === 'Escape') { + onClose() + } + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleEscape) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleEscape) + } + }, [visible, onClose]) + + if (!visible || !world) return null + + const handleFovChange = (newFov) => { + setFov(newFov) + if (world.camera) { + world.camera.fov = newFov + world.camera.updateProjectionMatrix() + // Trigger graphics system to recalculate worldToScreenFactor + world.graphics?.preTick() + } + } + + const resetFov = () => { + handleFovChange(70) + } + + return ( +
+ + + + +
+ ) +} \ No newline at end of file diff --git a/src/client/components/CoreUI.js b/src/client/components/CoreUI.js index c0e374be..7ff34291 100644 --- a/src/client/components/CoreUI.js +++ b/src/client/components/CoreUI.js @@ -18,6 +18,7 @@ import { ControlPriorities } from '../../core/extras/ControlPriorities' // import { MenuApp } from './MenuApp' import { ChevronDoubleUpIcon, HandIcon } from './Icons' import { Sidebar } from './Sidebar' +import { ContextMenu } from './ContextMenu' export function CoreUI({ world }) { const ref = useRef() @@ -31,6 +32,7 @@ export function CoreUI({ world }) { const [disconnected, setDisconnected] = useState(false) const [apps, setApps] = useState(false) const [kicked, setKicked] = useState(null) + const [contextMenu, setContextMenu] = useState({ visible: false, position: { x: 0, y: 0 } }) useEffect(() => { world.on('ready', setReady) world.on('player', setPlayer) @@ -70,6 +72,24 @@ export function CoreUI({ world }) { // elem.addEventListener('touchmove', onEvent) // elem.addEventListener('touchend', onEvent) }, []) + + useEffect(() => { + const handleContextMenu = (e) => { + // Only show context menu when not in pointer lock mode + if (!world.controls.pointer.locked) { + e.preventDefault() + setContextMenu({ + visible: true, + position: { x: e.clientX, y: e.clientY } + }) + } + } + + document.addEventListener('contextmenu', handleContextMenu) + return () => { + document.removeEventListener('contextmenu', handleContextMenu) + } + }, [world]) useEffect(() => { document.documentElement.style.fontSize = `${16 * world.prefs.ui}px` function onChange(changes) { @@ -109,6 +129,12 @@ export function CoreUI({ world }) { {ready && isTouch && } {ready && isTouch && } {confirm && } + setContextMenu({ visible: false, position: { x: 0, y: 0 } })} + />
) diff --git a/src/core/systems/ClientControls.js b/src/core/systems/ClientControls.js index 424e96ab..ad2cc1a8 100644 --- a/src/core/systems/ClientControls.js +++ b/src/core/systems/ClientControls.js @@ -209,11 +209,18 @@ export class ClientControls extends System { this.world.rig.position.copy(camera.position) this.world.rig.quaternion.copy(camera.quaternion) this.world.camera.position.z = camera.zoom + if (camera.fov !== undefined && camera.fov !== this.world.camera.fov) { + this.world.camera.fov = camera.fov + this.world.camera.updateProjectionMatrix() + // Trigger graphics system to recalculate worldToScreenFactor + this.world.graphics?.preTick() + } written = true } else if (camera) { camera.position.copy(this.world.rig.position) camera.quaternion.copy(this.world.rig.quaternion) camera.zoom = this.world.camera.position.z + camera.fov = this.world.camera.fov } } // clear touch deltas @@ -586,7 +593,11 @@ export class ClientControls extends System { } onContextMenu = e => { - e.preventDefault() + // Only prevent default if in pointer lock mode + // Otherwise let the CoreUI handle the context menu + if (this.pointer.locked) { + e.preventDefault() + } } onTouchStart = e => { @@ -755,12 +766,14 @@ function createCamera(controls, control) { const rotation = new THREE.Euler(0, 0, 0, 'YXZ').copy(world.rig.rotation) bindRotations(quaternion, rotation) const zoom = world.camera.position.z + const fov = world.camera.fov return { $camera: true, position, quaternion, rotation, zoom, + fov, write: false, } } From 81851b23b3b9bc133bd1639735177c513d62ba7b Mon Sep 17 00:00:00 2001 From: jb0gie Date: Thu, 17 Jul 2025 01:17:18 +0100 Subject: [PATCH 2/3] feat: enhance FOV manipulation with UI controls and settings synchronization --- example-fov-app.js | 175 +++++++++++++++++++------- src/client/components/ContextMenu.js | 15 ++- src/client/components/MenuMain.js | 27 ++++ src/client/components/SettingsPane.js | 71 ++++++++++- src/client/components/Sidebar.js | 36 ++++-- src/core/entities/PlayerLocal.js | 15 ++- src/core/systems/ClientControls.js | 13 ++ src/core/systems/ClientGraphics.js | 7 ++ src/core/systems/Settings.js | 89 +++++++++++++ 9 files changed, 379 insertions(+), 69 deletions(-) diff --git a/example-fov-app.js b/example-fov-app.js index 1af159d3..216099f4 100644 --- a/example-fov-app.js +++ b/example-fov-app.js @@ -1,49 +1,138 @@ -// Example app demonstrating FOV manipulation -// This app shows how to control camera FOV through the app.control() API - -export default { - name: 'FOV Demo', - version: '1.0.0', - - // App lifecycle - start() { - // Get control of the camera - this.control = app.control() - - // Set initial FOV to 90 degrees (wide angle) - this.control.camera.fov = 90 - this.control.camera.write = true - - // Log current FOV - console.log('Current FOV:', this.control.camera.fov) - - // Set up keyboard controls for FOV adjustment - this.control.keyF.onPress = () => { - // Increase FOV (wider angle) - this.control.camera.fov = Math.min(120, this.control.camera.fov + 10) - console.log('FOV increased to:', this.control.camera.fov) - } +// FOV Demo App +// Demonstrates camera FOV manipulation through the app.control() API - this.control.keyG.onPress = () => { - // Decrease FOV (narrower angle) - this.control.camera.fov = Math.max(30, this.control.camera.fov - 10) - console.log('FOV decreased to:', this.control.camera.fov) - } +// Configuration for the app +app.configure([ + { + key: 'initialFov', + type: 'number', + label: 'Initial FOV', + hint: 'Starting field of view in degrees (30-120)', + min: 30, + max: 120, + initial: 70 + }, + { + key: 'showUI', + type: 'switch', + label: 'Show UI', + hint: 'Whether to show the FOV control UI', + options: [ + { label: 'Show', value: 'show', hint: 'Display FOV controls' }, + { label: 'Hide', value: 'hide', hint: 'Hide FOV controls' } + ], + initial: 'show' + } +]); - this.control.keyR.onPress = () => { - // Reset to default FOV - this.control.camera.fov = 70 - console.log('FOV reset to:', this.control.camera.fov) - } +app.keepActive = true; - // Display instructions - app.chat('FOV Demo loaded! Press F to increase FOV, G to decrease, R to reset') - }, +// Variables to track state +let control, ui, fovText, currentFov; - // Clean up when app is destroyed - destroy() { - if (this.control) { - this.control.release() +// Initialize the app +if (world.isClient) { + // Set initial FOV through settings (doesn't take control from player) + currentFov = props.initialFov || 70; + world.settings.set('fov', currentFov, true); + + console.log('FOV Demo loaded! Current FOV:', currentFov); + + // Set up keyboard controls for FOV adjustment + control = app.control(); + control.keyF.onPress = () => { + currentFov = Math.min(120, currentFov + 10); + world.settings.set('fov', currentFov, true); + console.log('FOV increased to:', currentFov); + updateFovDisplay(); + }; + + control.keyG.onPress = () => { + currentFov = Math.max(30, currentFov - 10); + world.settings.set('fov', currentFov, true); + console.log('FOV decreased to:', currentFov); + updateFovDisplay(); + }; + + control.keyR.onPress = () => { + currentFov = 70; + world.settings.set('fov', currentFov, true); + console.log('FOV reset to:', currentFov); + updateFovDisplay(); + }; + + // Create UI if enabled + if (props.showUI === 'show') { + createUI(); + } + + // Display instructions + app.chat('FOV Demo loaded! Press F to increase FOV, G to decrease, R to reset'); +} + +// Create UI for FOV display and controls +function createUI() { + // Create UI container + ui = app.create('ui', { + width: 200, + height: 120, + backgroundColor: 'rgba(0,15,30,0.9)', + borderRadius: 8, + padding: 10, + justifyContent: 'center', + gap: 8, + alignItems: 'center' + }); + ui.billboard = 'y'; // Face camera on Y-axis + ui.position.set(0, 1, 0); // Position above app + + // Create FOV display text + fovText = app.create('uitext', { + value: `FOV: ${currentFov}°`, + fontSize: 18, + color: '#ffffff', + textAlign: 'center' + }); + + // Create instructions text + const instructionsText = app.create('uitext', { + value: 'F: +10°\nG: -10°\nR: Reset', + fontSize: 14, + color: '#cccccc', + textAlign: 'center' + }); + + // Add text to UI container + ui.add(fovText); + ui.add(instructionsText); + + // Add UI to app + app.add(ui); +} + +// Update FOV display +function updateFovDisplay() { + if (fovText) { + fovText.value = `FOV: ${currentFov}°`; + } +} + +// Update loop +app.on('update', () => { + // Keep the app active and update FOV display + if (fovText) { + // Update display with current FOV from settings + const settingsFov = Math.round(world.settings.fov); + if (settingsFov !== currentFov) { + currentFov = settingsFov; + updateFovDisplay(); } } -} \ No newline at end of file +}); + +// Clean up when app is destroyed +app.on('destroy', () => { + if (control) { + control.release(); + } +}); \ No newline at end of file diff --git a/src/client/components/ContextMenu.js b/src/client/components/ContextMenu.js index 61ac95d0..7d1b13c8 100644 --- a/src/client/components/ContextMenu.js +++ b/src/client/components/ContextMenu.js @@ -3,14 +3,14 @@ import { css } from '@firebolt-dev/css' import { Menu, MenuItemBtn, MenuItemNumber } from './Menu' export function ContextMenu({ world, visible, position, onClose }) { - const [fov, setFov] = useState(world?.camera?.fov || 70) + const [fov, setFov] = useState(world?.settings?.fov || 70) const menuRef = useRef() useEffect(() => { - if (visible && world?.camera) { - setFov(world.camera.fov) + if (visible && world?.settings) { + setFov(world.settings.fov) } - }, [visible, world?.camera?.fov]) + }, [visible, world?.settings?.fov]) useEffect(() => { if (!visible) return @@ -40,11 +40,12 @@ export function ContextMenu({ world, visible, position, onClose }) { const handleFovChange = (newFov) => { setFov(newFov) + // Update settings which will update the camera + world.settings.set('fov', newFov, true) + // Also directly update camera for immediate feedback if (world.camera) { world.camera.fov = newFov world.camera.updateProjectionMatrix() - // Trigger graphics system to recalculate worldToScreenFactor - world.graphics?.preTick() } } @@ -62,6 +63,8 @@ export function ContextMenu({ world, visible, position, onClose }) { left: ${position.x}px; z-index: 1000; pointer-events: auto; + border-radius: 1.375rem; + overflow: hidden; `} > diff --git a/src/client/components/MenuMain.js b/src/client/components/MenuMain.js index 9053cbdb..3d27fbda 100644 --- a/src/client/components/MenuMain.js +++ b/src/client/components/MenuMain.js @@ -124,6 +124,11 @@ function MenuMainGraphics({ world, pop, push }) { const [shadows, setShadows] = useState(world.prefs.shadows) const [postprocessing, setPostprocessing] = useState(world.prefs.postprocessing) const [bloom, setBloom] = useState(world.prefs.bloom) + const [fov, setFov] = useState(() => { + // Try to get FOV from settings first, then camera, then default to 70 + const fovValue = world.settings?.fov || world.camera?.fov || 70 + return fovValue + }) const dprOptions = useMemo(() => { const width = world.graphics.width const height = world.graphics.height @@ -149,9 +154,14 @@ function MenuMainGraphics({ world, pop, push }) { if (changes.postprocessing) setPostprocessing(changes.postprocessing.value) if (changes.bloom) setBloom(changes.bloom.value) } + const onSettingsChange = changes => { + if (changes.fov) setFov(changes.fov.value) + } world.prefs.on('change', onChange) + world.settings.on('change', onSettingsChange) return () => { world.prefs.off('change', onChange) + world.settings.off('change', onSettingsChange) } }, []) return ( @@ -187,6 +197,23 @@ function MenuMainGraphics({ world, pop, push }) { value={bloom} onChange={bloom => world.prefs.setBloom(bloom)} /> + { + // Update settings which will update the camera + world.settings.set('fov', fov, true) + // Also directly update camera for immediate feedback + if (world.camera) { + world.camera.fov = fov + world.camera.updateProjectionMatrix() + } + }} + /> ) } diff --git a/src/client/components/SettingsPane.js b/src/client/components/SettingsPane.js index 0ea21d39..734dea51 100644 --- a/src/client/components/SettingsPane.js +++ b/src/client/components/SettingsPane.js @@ -128,9 +128,31 @@ function GeneralSettings({ world, player }) { const [shadows, setShadows] = useState(world.prefs.shadows) const [postprocessing, setPostprocessing] = useState(world.prefs.postprocessing) const [bloom, setBloom] = useState(world.prefs.bloom) + const [ao, setAO] = useState(() => { + // Try to get AO from settings first, then default to true + const aoValue = world.settings?.ao !== undefined ? world.settings.ao : true + return aoValue + }) + const [fov, setFov] = useState(() => { + // Try to get FOV from settings first, then camera, then default to 70 + const fovValue = world.settings?.fov || world.camera?.fov || 70 + return fovValue + }) const [music, setMusic] = useState(world.prefs.music) const [sfx, setSFX] = useState(world.prefs.sfx) const [voice, setVoice] = useState(world.prefs.voice) + + // Update FOV when settings become available + useEffect(() => { + if (world.settings?.fov !== undefined) { + setFov(world.settings.fov) + } else { + // If no FOV setting, try to get it from camera + if (world.camera) { + setFov(world.camera.fov) + } + } + }, [world.settings?.fov, world.camera?.fov]) const dprOptions = useMemo(() => { const width = world.graphics.width const height = world.graphics.height @@ -149,6 +171,10 @@ function GeneralSettings({ world, player }) { return options }, []) useEffect(() => { + // Ensure FOV is properly synchronized + if (world.settings) { + world.settings.ensureFOVSync() + } const onChange = changes => { // TODO: rename .dpr if (changes.dpr) setDPR(changes.dpr.value) @@ -159,9 +185,15 @@ function GeneralSettings({ world, player }) { if (changes.sfx) setSFX(changes.sfx.value) if (changes.voice) setVoice(changes.voice.value) } + const onSettingsChange = changes => { + if (changes.fov) setFov(changes.fov.value) + if (changes.ao) setAO(changes.ao.value) + } world.prefs.on('change', onChange) + world.settings.on('change', onSettingsChange) return () => { world.prefs.off('change', onChange) + world.settings.off('change', onSettingsChange) } }, []) return ( @@ -250,13 +282,42 @@ function GeneralSettings({ world, player }) { {postprocessing && ( -
-
Bloom
-
- world.prefs.setBloom(bloom)} /> + <> +
+
Bloom
+
+ world.prefs.setBloom(bloom)} /> +
-
+
+
Ambient Occlusion
+
+ world.settings.set('ao', ao, true)} /> +
+
+ )} +
+
Field of View
+
+ { + // Update settings which will update the camera + world.settings.set('fov', fov, true) + // Also directly update camera for immediate feedback + if (world.camera) { + world.camera.fov = fov + world.camera.updateProjectionMatrix() + } + }} + min={30} + max={120} + step={1} + instant + /> +
+
Audio diff --git a/src/client/components/Sidebar.js b/src/client/components/Sidebar.js index a6998706..17d5cfa2 100644 --- a/src/client/components/Sidebar.js +++ b/src/client/components/Sidebar.js @@ -425,6 +425,11 @@ function Prefs({ world, hidden }) { const [postprocessing, setPostprocessing] = useState(world.prefs.postprocessing) const [bloom, setBloom] = useState(world.prefs.bloom) const [ao, setAO] = useState(world.prefs.ao) + const [fov, setFov] = useState(() => { + // Try to get FOV from settings first, then camera, then default to 70 + const fovValue = world.settings?.fov || world.camera?.fov || 70 + return fovValue + }) const [music, setMusic] = useState(world.prefs.music) const [sfx, setSFX] = useState(world.prefs.sfx) const [voice, setVoice] = useState(world.prefs.voice) @@ -468,9 +473,14 @@ function Prefs({ world, hidden }) { if (changes.actions) setActions(changes.actions.value) if (changes.stats) setStats(changes.stats.value) } + const onSettingsChange = changes => { + if (changes.fov) setFov(changes.fov.value) + } world.prefs.on('change', onPrefsChange) + world.settings.on('change', onSettingsChange) return () => { world.prefs.off('change', onPrefsChange) + world.settings.off('change', onSettingsChange) } }, []) return ( @@ -561,16 +571,22 @@ function Prefs({ world, hidden }) { value={bloom} onChange={bloom => world.prefs.setBloom(bloom)} /> - {world.settings.ao && ( - world.prefs.setAO(ao)} - /> - )} + { + // Update settings which will update the camera + world.settings.set('fov', fov, true) + // Also directly update camera for immediate feedback + if (world.camera) { + world.camera.fov = fov + world.camera.updateProjectionMatrix() + } + }} + /> { + if (changes.fov) { + // Update camera FOV from settings + this.world.camera.fov = changes.fov.value + this.world.camera.updateProjectionMatrix() + // Trigger graphics system to recalculate worldToScreenFactor + this.world.graphics?.preTick() + } + } + isInputFocused() { return document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA' } diff --git a/src/core/systems/ClientGraphics.js b/src/core/systems/ClientGraphics.js index 59923851..c9342915 100644 --- a/src/core/systems/ClientGraphics.js +++ b/src/core/systems/ClientGraphics.js @@ -235,6 +235,13 @@ export class ClientGraphics extends System { this.aoPass.enabled = changes.ao.value && this.world.prefs.ao console.log(this.aoPass.enabled) } + if (changes.fov) { + // Update camera FOV and recalculate world to screen factor + this.world.camera.fov = changes.fov.value + this.world.camera.updateProjectionMatrix() + // Recalculate world to screen factor on next preTick + this.preTick() + } } updatePostProcessingEffects() { diff --git a/src/core/systems/Settings.js b/src/core/systems/Settings.js index 949dbb9d..fbc387d8 100644 --- a/src/core/systems/Settings.js +++ b/src/core/systems/Settings.js @@ -12,6 +12,7 @@ export class Settings extends System { this.public = null this.playerLimit = null this.ao = null + this.fov = null this.changes = null } @@ -24,6 +25,15 @@ export class Settings extends System { this.public = data.public this.playerLimit = data.playerLimit this.ao = isBoolean(data.ao) ? data.ao : true // default true + this.fov = data.fov || 70 // default 70 degrees + + // Update camera FOV when settings are loaded + if (this.world.camera) { + console.log('Settings: Setting camera FOV to:', this.fov) + this.world.camera.fov = this.fov + this.world.camera.updateProjectionMatrix() + } + this.emit('change', { title: { value: this.title }, desc: { value: this.desc }, @@ -32,7 +42,13 @@ export class Settings extends System { public: { value: this.public }, playerLimit: { value: this.playerLimit }, ao: { value: this.ao }, + fov: { value: this.fov }, }) + + // Force apply settings to camera after a short delay to ensure everything is initialized + setTimeout(() => { + this.forceApplyToCamera() + }, 100) } serialize() { @@ -44,6 +60,7 @@ export class Settings extends System { public: this.public, playerLimit: this.playerLimit, ao: this.ao, + fov: this.fov, } } @@ -64,8 +81,80 @@ export class Settings extends System { set(key, value, broadcast) { this.modify(key, value) + + // Immediately apply FOV changes to camera + if (key === 'fov' && this.world.camera) { + console.log('Settings: Applying FOV change to camera:', value) + this.world.camera.fov = value + this.world.camera.updateProjectionMatrix() + // Also update the graphics system if available + if (this.world.graphics) { + this.world.graphics.preTick() + } + } + if (broadcast) { this.world.network.send('settingsModified', { key, value }) } } + + syncCameraFOV() { + if (this.world.camera) { + // If settings FOV is not set, use current camera FOV + if (!this.fov) { + console.log('Settings: Syncing settings FOV from camera:', this.world.camera.fov) + this.fov = this.world.camera.fov + this.emit('change', { fov: { value: this.fov } }) + } else if (this.world.camera.fov !== this.fov) { + // If settings FOV is set but different from camera, update camera + console.log('Settings: Updating camera FOV from settings:', this.fov) + this.world.camera.fov = this.fov + this.world.camera.updateProjectionMatrix() + } + } + } + + start() { + // Initialize camera FOV from settings if available + if (this.fov && this.world.camera) { + console.log('Settings: Initializing camera FOV to:', this.fov) + this.world.camera.fov = this.fov + this.world.camera.updateProjectionMatrix() + } else if (this.world.camera && !this.fov) { + // If no FOV setting but camera exists, sync current camera FOV to settings + console.log('Settings: Syncing settings FOV from camera:', this.world.camera.fov) + this.fov = this.world.camera.fov + this.emit('change', { fov: { value: this.fov } }) + } + } + + // Method to force apply settings to camera + forceApplyToCamera() { + if (this.world.camera) { + console.log('Settings: Force applying FOV to camera:', this.fov) + this.world.camera.fov = this.fov + this.world.camera.updateProjectionMatrix() + // Also update the graphics system if available + if (this.world.graphics) { + this.world.graphics.preTick() + } + } + } + + // Method to ensure settings are properly synchronized with camera + ensureFOVSync() { + if (this.world.camera) { + if (!this.fov) { + // If no FOV setting, use current camera FOV + console.log('Settings: Syncing settings FOV from camera:', this.world.camera.fov) + this.fov = this.world.camera.fov + this.emit('change', { fov: { value: this.fov } }) + } else if (this.world.camera.fov !== this.fov) { + // If settings FOV is set but different from camera, update camera + console.log('Settings: Updating camera FOV from settings:', this.fov) + this.world.camera.fov = this.fov + this.world.camera.updateProjectionMatrix() + } + } + } } From d8ef823e8b3e2b4acc3206235e29547a6c4941f9 Mon Sep 17 00:00:00 2001 From: jb0gie Date: Sat, 19 Jul 2025 13:17:02 +0100 Subject: [PATCH 3/3] refactor: streamline FOV manipulation by accessing camera directly and improve FOV display updates --- PR_FOV_CORE_FEATURE.md | 86 +++++++++++++++++++++++++++++++++ example-fov-app.js | 34 ++++++++----- src/client/components/CoreUI.js | 4 +- 3 files changed, 112 insertions(+), 12 deletions(-) create mode 100644 PR_FOV_CORE_FEATURE.md diff --git a/PR_FOV_CORE_FEATURE.md b/PR_FOV_CORE_FEATURE.md new file mode 100644 index 00000000..9f13059d --- /dev/null +++ b/PR_FOV_CORE_FEATURE.md @@ -0,0 +1,86 @@ +# Add Field of View (FOV) as Core Feature + +## Description + +This PR implements Field of View (FOV) as a core feature in Hyperfy, allowing users to adjust their camera field of view through the settings interface. The FOV setting is now available in both the SettingsPane and MenuMain components, with proper server-side synchronization and camera integration. + +**Key Changes:** +- Added FOV setting to Sidebar Prefs component with number input (30-120degrees) +- Added FOV setting to MenuMainGraphics component with range slider +- Implemented server-side FOV synchronization via `world.settings.set(fov, value, true)` +- Added FOV synchronization in PlayerLocal entity for proper camera control +- Fixed duplicate Ambient Occlusion setting by using server-side settings +- Temporarily hidden ContextMenu for PR focus on core FOV feature + +## Type of Change + +- [x] New feature (non-breaking change which adds functionality) +- [x] Bug fix (non-breaking change which fixes an issue) +- [x] Code refactoring (no functional changes) + +## Testing + +- [x] FOV setting appears in SettingsPane graphics section +- [x] FOV setting appears in MenuMain graphics section +- [x] FOV changes are properly synchronized to server +- [x] Camera FOV updates immediately when setting is changed +- [x] FOV persists across sessions via server-side settings +- [x] FOV range is properly constrained (30-120ees) +- [x] Default FOV value of 70 degrees is applied correctly + +## Implementation Details + +### Server-Side Settings Integration +- FOV is stored in `world.settings.fov` (server-side) +- Changes are broadcast to all clients with `world.settings.set('fov', fov, true)` +- Default value of 70es +- Proper serialization/deserialization + +### Camera Integration +- Direct camera FOV updates: `world.camera.fov = value` +- Projection matrix updates: `world.camera.updateProjectionMatrix()` +- Synchronization between settings and camera +- Fallback to camera FOV if no setting exists + +### UI Integration +- FOV slider in SettingsPane (30120grees) +- FOV range slider in MenuMainGraphics +- Number input in Sidebar Prefs for precise control +- Proper state management and change listeners + +### Player Camera Synchronization +- Added FOV sync in PlayerLocal entity's `lateUpdate` method +- Ensures player camera control respects FOV settings +- Prevents camera control from overriding FOV changes + +## Files Changed + +### Core Implementation +- `src/core/entities/PlayerLocal.js` - Added FOV synchronization +- `src/core/systems/ClientControls.js` - FOV settings change handling +- `src/core/systems/Settings.js` - Server-side FOV property + +### UI Components +- `src/client/components/SettingsPane.js` - Added FOV setting with range slider +- `src/client/components/MenuMain.js` - Added FOV setting with range slider +- `src/client/components/Sidebar.js` - Added FOV setting with number input +- `src/client/components/ContextMenu.js` - Temporarily hidden for PR focus + +## Breaking Changes + +None - this is a purely additive feature that doesn't break existing functionality. + +## Additional Notes + +- ContextMenu has been temporarily hidden to focus on the core FOV feature implementation +- Ambient Occlusion setting was also fixed to use server-side settings instead of client preferences +- FOV implementation follows the same pattern as other server-side settings like AO + +## Screenshots + +FOV setting now appears in: +- SettingsPane graphics section with range slider +- MenuMain graphics section with range slider +- Sidebar Prefs with number input for precise control + +The FOV setting properly integrates with the existing settings system and provides immediate visual feedback when adjusted. \ No newline at end of file diff --git a/example-fov-app.js b/example-fov-app.js index 216099f4..acf3f2b4 100644 --- a/example-fov-app.js +++ b/example-fov-app.js @@ -1,5 +1,5 @@ // FOV Demo App -// Demonstrates camera FOV manipulation through the app.control() API +// Demonstrates camera FOV manipulation through direct camera access // Configuration for the app app.configure([ @@ -32,9 +32,12 @@ let control, ui, fovText, currentFov; // Initialize the app if (world.isClient) { - // Set initial FOV through settings (doesn't take control from player) + // Set initial FOV directly on camera currentFov = props.initialFov || 70; - world.settings.set('fov', currentFov, true); + if (world.camera) { + world.camera.fov = currentFov; + world.camera.updateProjectionMatrix(); + } console.log('FOV Demo loaded! Current FOV:', currentFov); @@ -42,21 +45,30 @@ if (world.isClient) { control = app.control(); control.keyF.onPress = () => { currentFov = Math.min(120, currentFov + 10); - world.settings.set('fov', currentFov, true); + if (world.camera) { + world.camera.fov = currentFov; + world.camera.updateProjectionMatrix(); + } console.log('FOV increased to:', currentFov); updateFovDisplay(); }; control.keyG.onPress = () => { currentFov = Math.max(30, currentFov - 10); - world.settings.set('fov', currentFov, true); + if (world.camera) { + world.camera.fov = currentFov; + world.camera.updateProjectionMatrix(); + } console.log('FOV decreased to:', currentFov); updateFovDisplay(); }; control.keyR.onPress = () => { currentFov = 70; - world.settings.set('fov', currentFov, true); + if (world.camera) { + world.camera.fov = currentFov; + world.camera.updateProjectionMatrix(); + } console.log('FOV reset to:', currentFov); updateFovDisplay(); }; @@ -120,11 +132,11 @@ function updateFovDisplay() { // Update loop app.on('update', () => { // Keep the app active and update FOV display - if (fovText) { - // Update display with current FOV from settings - const settingsFov = Math.round(world.settings.fov); - if (settingsFov !== currentFov) { - currentFov = settingsFov; + if (fovText && world.camera) { + // Update display with current FOV from camera + const cameraFov = Math.round(world.camera.fov); + if (cameraFov !== currentFov) { + currentFov = cameraFov; updateFovDisplay(); } } diff --git a/src/client/components/CoreUI.js b/src/client/components/CoreUI.js index 7ff34291..4cb79e88 100644 --- a/src/client/components/CoreUI.js +++ b/src/client/components/CoreUI.js @@ -129,12 +129,14 @@ export function CoreUI({ world }) { {ready && isTouch && } {ready && isTouch && } {confirm && } + {/* Temporarily hidden for PR - ContextMenu setContextMenu({ visible: false, position: { x: 0, y: 0 } })} + onClose={() => setContextMenu({ visible: false, position:[object Object] x: 0, y:0})} /> + */}
)