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 new file mode 100644 index 00000000..acf3f2b4 --- /dev/null +++ b/example-fov-app.js @@ -0,0 +1,150 @@ +// FOV Demo App +// Demonstrates camera FOV manipulation through direct camera access + +// 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' + } +]); + +app.keepActive = true; + +// Variables to track state +let control, ui, fovText, currentFov; + +// Initialize the app +if (world.isClient) { + // Set initial FOV directly on camera + currentFov = props.initialFov || 70; + if (world.camera) { + world.camera.fov = currentFov; + world.camera.updateProjectionMatrix(); + } + + 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); + 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); + if (world.camera) { + world.camera.fov = currentFov; + world.camera.updateProjectionMatrix(); + } + console.log('FOV decreased to:', currentFov); + updateFovDisplay(); + }; + + control.keyR.onPress = () => { + currentFov = 70; + if (world.camera) { + world.camera.fov = currentFov; + world.camera.updateProjectionMatrix(); + } + 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 && world.camera) { + // Update display with current FOV from camera + const cameraFov = Math.round(world.camera.fov); + if (cameraFov !== currentFov) { + currentFov = cameraFov; + updateFovDisplay(); + } + } +}); + +// 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 new file mode 100644 index 00000000..7d1b13c8 --- /dev/null +++ b/src/client/components/ContextMenu.js @@ -0,0 +1,88 @@ +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?.settings?.fov || 70) + const menuRef = useRef() + + useEffect(() => { + if (visible && world?.settings) { + setFov(world.settings.fov) + } + }, [visible, world?.settings?.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) + // 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() + } + } + + const resetFov = () => { + handleFovChange(70) + } + + return ( +