Merge 3c0fef83a1 into 759652207f
This commit is contained in:
commit
6d8ec3a69f
|
|
@ -129,6 +129,10 @@ AISSTREAM_API_KEY=
|
|||
# tiles instead of hitting upstream.
|
||||
# TOMTOM_DAILY_TILE_BUDGET=40000
|
||||
|
||||
# Optional: Steam Deck / gamepad controls default ON. Set to 0 to default off;
|
||||
# DISPLAY → Gamepad still overrides via localStorage.
|
||||
# GEV_DECK_CONTROLS=1
|
||||
|
||||
# Optional: CCTV layer tuning (advanced). Defaults are sensible — leave unset
|
||||
# unless you're customizing the camera source pack.
|
||||
# CCTV_SOURCES_FILE=config/cctv_sources.austin.json # path to a source-pack JSON
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Steam Deck / gamepad camera controls (`src/deckControls.js`): sticks for
|
||||
move/look, triggers for zoom, face buttons and D-pad for common UI actions,
|
||||
optional DISPLAY → Gamepad slider. Defaults on unless `GEV_DECK_CONTROLS=0`;
|
||||
the slider preference is stored in `localStorage`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mapped-site outages show their scheduled retry countdown and distinguish
|
||||
|
|
|
|||
|
|
@ -2318,7 +2318,7 @@ silently demoting every later lookup for the session.
|
|||
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `<button>` on press.
|
||||
- GEV MIC control is a glass capsule (var(--glass-bg), blur(24px) saturate(1.4), 999px radius; panel radius in error state).
|
||||
- The desktop right rail (`#right-context-rail`) owns `DISPLAY`, `CCTV`, its active parameter controls, and `GLOBAL CONTEXT` as one fixed responsive stack in that order. Its compact buttons use the same 176 px width as the left accordion and one consistent 50 px height, share the left stack's 52 px edge inset and measured top baseline across HUD variants, then constrain themselves against visible HUD/chrome rectangles and the remaining vertical corridor. `DISPLAY` is no longer draggable and legacy saved coordinates are ignored.
|
||||
- The right rail is labeled **DISPLAY** (formerly "MOVE") and groups, in order, HUD, DETECT, Bloom, Sharpen, 3D, Clean-UI (HUD + DETECT promoted to the top). Its expanded controls retain the same compact 176 px width as the right-side tabs instead of growing to the wider Context detail-card width. It starts expanded on first run and respects the user's later `v6` collapse choice. Collapses/expands with directional chevrons (`◀` collapsed, `▶` expanded).
|
||||
- The right rail is labeled **DISPLAY** (formerly "MOVE") and groups, in order, HUD, DETECT, Bloom, Sharpen, 3D, Clean-UI, Gamepad (HUD + DETECT promoted to the top). Gamepad controls (`src/deckControls.js`) default on unless `GEV_DECK_CONTROLS=0`; DISPLAY → Gamepad persists the choice in `localStorage` (`gev-deck-controls`). When a pad is connected and enabled, a center reticle appears, look sensitivity scales down near the ground, and R3 toggles an on-screen mapping help overlay. Its expanded controls retain the same compact 176 px width as the right-side tabs instead of growing to the wider Context detail-card width. It starts expanded on first run and respects the user's later `v6` collapse choice. Collapses/expands with directional chevrons (`◀` collapsed, `▶` expanded).
|
||||
- Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. DISPLAY may remain open beside one contextual panel; CCTV and Context are mutually exclusive. In Tactical HUD, expanding CCTV or Context hides the other contextual launcher while DISPLAY remains independently available. The most recently opened right-rail panel owns the constrained lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. Minimal and other HUD layouts retain the collapsed launchers; when their active panel exceeds the measured corridor, the rail reserves sibling heights and gaps and scrolls the active panel internally.
|
||||
- `STYLE PRESETS` and `LOCATIONS` start collapsed, expand on intentional hover/click, and auto-collapse after hover leave delay.
|
||||
- Collapsed mini-status indicators show active style and active location/landmark.
|
||||
|
|
|
|||
13
index.html
13
index.html
|
|
@ -454,6 +454,19 @@
|
|||
<span class="pp-icon">□</span>
|
||||
<span class="pp-label">Clean UI</span>
|
||||
</button>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="deck-controls-toggle" title="Steam Deck / gamepad camera controls" aria-pressed="false">
|
||||
<span class="pp-icon">◉</span>
|
||||
<span class="pp-label">Gamepad</span>
|
||||
</button>
|
||||
<div class="pp-slider-row visible" id="deck-controls-slider-row">
|
||||
<span class="pp-slider-mini-label">On</span>
|
||||
<label class="pp-switch" for="deck-controls-switch">
|
||||
<input type="checkbox" id="deck-controls-switch" role="switch" aria-label="Enable gamepad controls" />
|
||||
<span class="pp-switch-ui" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="bloom-toggle" title="Bloom / Glow">
|
||||
<span class="pp-icon">✨</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,485 @@
|
|||
/**
|
||||
* Steam Deck / gamepad controls for God's Eye View.
|
||||
*
|
||||
* Left stick — move (forward / back / strafe)
|
||||
* Right stick — look (rotate / tilt), sensitivity scales down when zoomed in
|
||||
* L2 / R2 — zoom out / in
|
||||
* L1 — toggle HUD
|
||||
* R1 — toggle data panel
|
||||
* A — select target at screen center
|
||||
* B — back / clear tracking (Escape)
|
||||
* X — toggle CCTV layer
|
||||
* Y — reset globe
|
||||
* D-pad up/down — cycle visual style
|
||||
* D-pad left — toggle orbit
|
||||
* D-pad right — toggle clean view
|
||||
* Select — cycle detection overlay
|
||||
* Start (hold) — push-to-talk voice
|
||||
* R3 — toggle controls help overlay
|
||||
* L3 (stick click) — toggle cockpit (when aircraft tracked)
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { interruptCameraMotion } from './cameraVerbs.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
governorRequestRender,
|
||||
} from './renderGovernor.js';
|
||||
|
||||
const DEADZONE = 0.18;
|
||||
const STYLES = ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'];
|
||||
|
||||
/** localStorage key for the DISPLAY → Gamepad slider. */
|
||||
export const DECK_CONTROLS_STORAGE_KEY = 'gev-deck-controls';
|
||||
|
||||
export function readDeckControlsEnabled(storage = globalThis.localStorage, envFlag = import.meta.env?.GEV_DECK_CONTROLS) {
|
||||
try {
|
||||
const stored = storage?.getItem?.(DECK_CONTROLS_STORAGE_KEY);
|
||||
if (stored === '1') return true;
|
||||
if (stored === '0') return false;
|
||||
} catch {
|
||||
// private mode / missing storage
|
||||
}
|
||||
return envFlag !== '0';
|
||||
}
|
||||
|
||||
export function writeDeckControlsEnabled(enabled, storage = globalThis.localStorage) {
|
||||
try {
|
||||
storage?.setItem?.(DECK_CONTROLS_STORAGE_KEY, enabled ? '1' : '0');
|
||||
} catch {
|
||||
// ignore quota / privacy errors
|
||||
}
|
||||
}
|
||||
|
||||
/** Camera altitude (m) below which look input is heavily damped. */
|
||||
export const DECK_LOOK_MIN_ALTITUDE_M = 400;
|
||||
/** Camera altitude (m) above which look input reaches full strength. */
|
||||
export const DECK_LOOK_FULL_ALTITUDE_M = 120_000;
|
||||
/** Look sensitivity at low altitude as a fraction of the global baseline. */
|
||||
export const DECK_LOOK_MIN_SCALE = 0.06;
|
||||
/** Baseline right-stick turn rate (radians/sec at full deflection). */
|
||||
export const DECK_LOOK_BASE_RATE = 1.35;
|
||||
/** Baseline mouse-look scale when pointer lock is active. */
|
||||
export const DECK_MOUSE_LOOK_BASE_SCALE = 0.0032;
|
||||
|
||||
const BTN = {
|
||||
A: 0,
|
||||
B: 1,
|
||||
X: 2,
|
||||
Y: 3,
|
||||
L1: 4,
|
||||
R1: 5,
|
||||
L2: 6,
|
||||
R2: 7,
|
||||
SELECT: 8,
|
||||
START: 9,
|
||||
L3: 10,
|
||||
R3: 11,
|
||||
DPAD_UP: 12,
|
||||
DPAD_DOWN: 13,
|
||||
DPAD_LEFT: 14,
|
||||
DPAD_RIGHT: 15,
|
||||
};
|
||||
|
||||
/**
|
||||
* Scale look sensitivity by camera altitude so street-level views stay controllable.
|
||||
* @param {number} heightM
|
||||
* @returns {number} Multiplier in [DECK_LOOK_MIN_SCALE, 1].
|
||||
*/
|
||||
export function deckLookSensitivityScale(heightM) {
|
||||
const height = Math.max(DECK_LOOK_MIN_ALTITUDE_M, Number(heightM) || DECK_LOOK_MIN_ALTITUDE_M);
|
||||
if (height >= DECK_LOOK_FULL_ALTITUDE_M) return 1;
|
||||
const minLog = Math.log(DECK_LOOK_MIN_ALTITUDE_M);
|
||||
const fullLog = Math.log(DECK_LOOK_FULL_ALTITUDE_M);
|
||||
const progress = (Math.log(height) - minLog) / (fullLog - minLog);
|
||||
const clamped = Math.max(0, Math.min(1, progress));
|
||||
return DECK_LOOK_MIN_SCALE + (1 - DECK_LOOK_MIN_SCALE) * clamped;
|
||||
}
|
||||
|
||||
function applyDeadzone(value) {
|
||||
const v = Number(value) || 0;
|
||||
if (Math.abs(v) < DEADZONE) return 0;
|
||||
const sign = Math.sign(v);
|
||||
return sign * ((Math.abs(v) - DEADZONE) / (1 - DEADZONE));
|
||||
}
|
||||
|
||||
function triggerValue(buttons, index) {
|
||||
const btn = buttons[index];
|
||||
if (!btn) return 0;
|
||||
if (typeof btn.value === 'number' && btn.value > 0) return btn.value;
|
||||
return btn.pressed ? 1 : 0;
|
||||
}
|
||||
|
||||
function buttonPressed(index, buttons, prevButtons) {
|
||||
return Boolean(buttons[index]?.pressed && !prevButtons[index]?.pressed);
|
||||
}
|
||||
|
||||
function buttonReleased(index, buttons, prevButtons) {
|
||||
return Boolean(!buttons[index]?.pressed && prevButtons[index]?.pressed);
|
||||
}
|
||||
|
||||
function dispatchKey(key) {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
}
|
||||
|
||||
function simulateCenterClick(viewer) {
|
||||
const canvas = viewer.scene.canvas;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const clientX = rect.left + rect.width / 2;
|
||||
const clientY = rect.top + rect.height / 2;
|
||||
const base = {
|
||||
clientX,
|
||||
clientY,
|
||||
pointerId: 9001,
|
||||
pointerType: 'mouse',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
view: window,
|
||||
};
|
||||
canvas.dispatchEvent(new PointerEvent('pointerdown', { ...base, buttons: 1 }));
|
||||
canvas.dispatchEvent(new PointerEvent('pointerup', { ...base, buttons: 0 }));
|
||||
}
|
||||
|
||||
function canControlCamera(viewer, styleManager) {
|
||||
const controller = viewer.scene.screenSpaceCameraController;
|
||||
if (!controller?.enableInputs) return false;
|
||||
if (document.body.classList.contains('cockpit-mode')) return false;
|
||||
if (styleManager?.cockpitView?.active) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isPointerLockedToCanvas(canvas) {
|
||||
return document.pointerLockElement === canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Steam Deck desktop profiles often expose L2/R2 as gamepad buttons but route
|
||||
* the right stick through the mouse cursor instead of axes 2/3. Pointer lock
|
||||
* turns those mouse deltas back into camera look input.
|
||||
*/
|
||||
function bindPointerLookFallback(canvas, getActive, onDelta) {
|
||||
const onMouseMove = (event) => {
|
||||
if (!getActive() || !isPointerLockedToCanvas(canvas)) return;
|
||||
const dx = Number(event.movementX) || 0;
|
||||
const dy = Number(event.movementY) || 0;
|
||||
if (dx || dy) onDelta(dx, dy);
|
||||
};
|
||||
canvas.addEventListener('mousemove', onMouseMove);
|
||||
return () => canvas.removeEventListener('mousemove', onMouseMove);
|
||||
}
|
||||
|
||||
function requestCanvasPointerLock(canvas) {
|
||||
if (isPointerLockedToCanvas(canvas)) return;
|
||||
canvas.requestPointerLock?.().catch(() => {
|
||||
// Steam/Chromium may reject until a fresh user gesture; we'll retry on input.
|
||||
});
|
||||
}
|
||||
|
||||
function createReticle() {
|
||||
const el = document.createElement('div');
|
||||
el.id = 'deck-reticle';
|
||||
el.hidden = true;
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
el.innerHTML = '<span class="deck-reticle-h"></span><span class="deck-reticle-v"></span>';
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function createHelpPanel() {
|
||||
const el = document.createElement('div');
|
||||
el.id = 'deck-controls-help';
|
||||
el.hidden = true;
|
||||
el.setAttribute('aria-label', 'Gamepad controls');
|
||||
el.innerHTML = `
|
||||
<div class="deck-controls-title">DECK CONTROLS</div>
|
||||
<div class="deck-controls-row"><span>L Stick</span><span>Move</span></div>
|
||||
<div class="deck-controls-row"><span>R Stick</span><span>Look</span></div>
|
||||
<div class="deck-controls-row"><span>L2 / R2</span><span>Zoom</span></div>
|
||||
<div class="deck-controls-row"><span>A</span><span>Select</span></div>
|
||||
<div class="deck-controls-row"><span>B</span><span>Back</span></div>
|
||||
<div class="deck-controls-row"><span>Y</span><span>Reset globe</span></div>
|
||||
<div class="deck-controls-row"><span>D-pad</span><span>Style / orbit / clean</span></div>
|
||||
<div class="deck-controls-row"><span>L3</span><span>Cockpit</span></div>
|
||||
<div class="deck-controls-row"><span>L1 / R1</span><span>HUD / panel</span></div>
|
||||
<div class="deck-controls-row"><span>Start (hold)</span><span>Push to talk</span></div>
|
||||
<div class="deck-controls-row"><span>R3</span><span>Hide this help</span></div>
|
||||
<div class="deck-controls-row deck-controls-note">DISPLAY → Gamepad slider turns this scheme on or off.</div>
|
||||
`;
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* @param {import('./ui.js').StyleManager} styleManager
|
||||
*/
|
||||
export function initDeckControls(viewer, styleManager) {
|
||||
const canvas = viewer.scene.canvas;
|
||||
const reticle = createReticle();
|
||||
const helpPanel = createHelpPanel();
|
||||
let connected = false;
|
||||
let enabled = readDeckControlsEnabled();
|
||||
let helpVisible = true;
|
||||
let prevButtons = [];
|
||||
let styleIndex = 0;
|
||||
let cameraActive = false;
|
||||
let lastFrameMs = performance.now();
|
||||
let rafId = 0;
|
||||
let lookMouseDX = 0;
|
||||
let lookMouseDY = 0;
|
||||
|
||||
const releasePointerLook = bindPointerLookFallback(
|
||||
canvas,
|
||||
() => enabled && connected && canControlCamera(viewer, styleManager),
|
||||
(dx, dy) => {
|
||||
lookMouseDX += dx;
|
||||
lookMouseDY += dy;
|
||||
},
|
||||
);
|
||||
|
||||
const syncHelpVisibility = () => {
|
||||
helpPanel.hidden = !enabled || !connected || !helpVisible;
|
||||
document.body.classList.toggle('deck-help-hidden', connected && enabled && !helpVisible);
|
||||
};
|
||||
|
||||
const setConnected = (active) => {
|
||||
connected = active && enabled;
|
||||
document.body.classList.toggle('deck-controls-active', connected);
|
||||
reticle.hidden = !connected;
|
||||
syncHelpVisibility();
|
||||
};
|
||||
|
||||
const syncToggleUi = () => {
|
||||
const toggleBtn = document.getElementById('deck-controls-toggle');
|
||||
const switchEl = document.getElementById('deck-controls-switch');
|
||||
toggleBtn?.classList.toggle('active', enabled);
|
||||
if (toggleBtn) toggleBtn.setAttribute('aria-pressed', String(enabled));
|
||||
if (switchEl) switchEl.checked = enabled;
|
||||
};
|
||||
|
||||
const setEnabled = (next) => {
|
||||
enabled = Boolean(next);
|
||||
writeDeckControlsEnabled(enabled);
|
||||
syncToggleUi();
|
||||
if (!enabled) {
|
||||
endPushToTalk();
|
||||
setConnected(false);
|
||||
prevButtons = [];
|
||||
if (cameraActive) {
|
||||
releaseContinuousRender('deck-controls');
|
||||
cameraActive = false;
|
||||
}
|
||||
if (document.pointerLockElement === canvas) document.exitPointerLock?.();
|
||||
} else {
|
||||
const pads = navigator.getGamepads?.() || [];
|
||||
if (pads.some((pad) => pad?.connected)) {
|
||||
syncStyleIndex();
|
||||
setConnected(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const syncStyleIndex = () => {
|
||||
const current = styleManager?.activeStyle || 'normal';
|
||||
const idx = STYLES.indexOf(current);
|
||||
if (idx >= 0) styleIndex = idx;
|
||||
};
|
||||
|
||||
const cycleStyle = (delta) => {
|
||||
styleIndex = (styleIndex + delta + STYLES.length) % STYLES.length;
|
||||
styleManager?.setStyle?.(STYLES[styleIndex]);
|
||||
};
|
||||
|
||||
const toggleHelpPanel = () => {
|
||||
helpVisible = !helpVisible;
|
||||
syncHelpVisibility();
|
||||
};
|
||||
|
||||
const beginPushToTalk = () => {
|
||||
window.__gevVoiceCommands?.beginPushToTalk?.();
|
||||
};
|
||||
|
||||
const endPushToTalk = () => {
|
||||
window.__gevVoiceCommands?.endPushToTalk?.();
|
||||
};
|
||||
|
||||
const toggleHud = () => {
|
||||
styleManager?.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
styleManager?.hud?.toggle?.();
|
||||
styleManager?._updateHudButtonState?.();
|
||||
styleManager?._syncShareState?.();
|
||||
};
|
||||
|
||||
const updateCamera = (gamepad, dt) => {
|
||||
if (!canControlCamera(viewer, styleManager)) {
|
||||
if (cameraActive) {
|
||||
releaseContinuousRender('deck-controls');
|
||||
cameraActive = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = viewer.camera;
|
||||
const height = Math.max(100, camera.positionCartographic?.height ?? 1000);
|
||||
const lookScale = deckLookSensitivityScale(height);
|
||||
const moveRate = Math.max(80, Math.min(height * 0.35, 400000)) * dt;
|
||||
const rotateRate = DECK_LOOK_BASE_RATE * lookScale * dt;
|
||||
const mouseLookScale = DECK_MOUSE_LOOK_BASE_SCALE * lookScale;
|
||||
const zoomRate = height * 0.9 * dt;
|
||||
|
||||
const lx = applyDeadzone(gamepad.axes[0]);
|
||||
const ly = applyDeadzone(-gamepad.axes[1]);
|
||||
const rx = applyDeadzone(gamepad.axes[2]);
|
||||
const ry = applyDeadzone(-gamepad.axes[3]);
|
||||
const l2 = triggerValue(gamepad.buttons, BTN.L2);
|
||||
const r2 = triggerValue(gamepad.buttons, BTN.R2);
|
||||
|
||||
const moved = lx || ly || rx || ry || l2 > 0.08 || r2 > 0.08;
|
||||
if (moved) {
|
||||
interruptCameraMotion('manual-input');
|
||||
requestCanvasPointerLock(canvas);
|
||||
}
|
||||
|
||||
if (ly) camera.moveForward(moveRate * ly);
|
||||
if (lx) camera.moveRight(moveRate * lx);
|
||||
if (rx) camera.rotateRight(rotateRate * rx);
|
||||
if (ry) camera.lookUp(rotateRate * ry);
|
||||
|
||||
const hadMouseLook = !rx && !ry && (lookMouseDX || lookMouseDY);
|
||||
if (hadMouseLook) {
|
||||
camera.rotateRight(lookMouseDX * mouseLookScale);
|
||||
camera.lookUp(-lookMouseDY * mouseLookScale);
|
||||
lookMouseDX = 0;
|
||||
lookMouseDY = 0;
|
||||
}
|
||||
|
||||
if (r2 > 0.08) camera.zoomIn(zoomRate * r2);
|
||||
if (l2 > 0.08) camera.zoomOut(zoomRate * l2);
|
||||
|
||||
const cameraChanged = moved || hadMouseLook;
|
||||
if (cameraChanged) {
|
||||
holdContinuousRender('deck-controls');
|
||||
governorRequestRender('deck-controls');
|
||||
cameraActive = true;
|
||||
} else if (cameraActive) {
|
||||
releaseContinuousRender('deck-controls');
|
||||
cameraActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtons = (gamepad) => {
|
||||
const { buttons } = gamepad;
|
||||
|
||||
if (buttonPressed(BTN.A, buttons, prevButtons)) {
|
||||
if (!isPointerLockedToCanvas(canvas)) requestCanvasPointerLock(canvas);
|
||||
else simulateCenterClick(viewer);
|
||||
}
|
||||
if (buttonPressed(BTN.B, buttons, prevButtons)) dispatchKey('Escape');
|
||||
if (buttonPressed(BTN.X, buttons, prevButtons)) dispatchKey('c');
|
||||
if (buttonPressed(BTN.Y, buttons, prevButtons)) styleManager?.resetToGlobeView?.();
|
||||
|
||||
if (buttonPressed(BTN.L1, buttons, prevButtons)) toggleHud();
|
||||
if (buttonPressed(BTN.R1, buttons, prevButtons)) dispatchKey('f');
|
||||
|
||||
if (buttonPressed(BTN.SELECT, buttons, prevButtons)) {
|
||||
styleManager?.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
styleManager._detectionUserOverridden = true;
|
||||
dispatchKey('d');
|
||||
}
|
||||
|
||||
if (buttonPressed(BTN.START, buttons, prevButtons)) beginPushToTalk();
|
||||
if (buttonReleased(BTN.START, buttons, prevButtons)) endPushToTalk();
|
||||
|
||||
if (buttonPressed(BTN.L3, buttons, prevButtons)) {
|
||||
const cockpit = styleManager?.cockpitView;
|
||||
if (cockpit?.active) cockpit.exit();
|
||||
else cockpit?.enter?.();
|
||||
}
|
||||
if (buttonPressed(BTN.R3, buttons, prevButtons)) toggleHelpPanel();
|
||||
|
||||
if (buttonPressed(BTN.DPAD_UP, buttons, prevButtons)) cycleStyle(1);
|
||||
if (buttonPressed(BTN.DPAD_DOWN, buttons, prevButtons)) cycleStyle(-1);
|
||||
if (buttonPressed(BTN.DPAD_LEFT, buttons, prevButtons)) dispatchKey('o');
|
||||
if (buttonPressed(BTN.DPAD_RIGHT, buttons, prevButtons)) dispatchKey('v');
|
||||
};
|
||||
|
||||
const poll = () => {
|
||||
const pads = navigator.getGamepads?.() || [];
|
||||
const gamepad = pads.find((pad) => pad?.connected) || null;
|
||||
const now = performance.now();
|
||||
const dt = Math.min(0.05, Math.max(0.001, (now - lastFrameMs) / 1000));
|
||||
lastFrameMs = now;
|
||||
|
||||
if (!enabled || !gamepad) {
|
||||
if (connected) {
|
||||
endPushToTalk();
|
||||
setConnected(false);
|
||||
}
|
||||
prevButtons = [];
|
||||
rafId = requestAnimationFrame(poll);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
syncStyleIndex();
|
||||
setConnected(true);
|
||||
}
|
||||
|
||||
updateCamera(gamepad, dt);
|
||||
handleButtons(gamepad);
|
||||
prevButtons = gamepad.buttons.map((btn) => ({ pressed: btn.pressed, value: btn.value }));
|
||||
rafId = requestAnimationFrame(poll);
|
||||
};
|
||||
|
||||
const onGamepadConnected = () => {
|
||||
if (!enabled) return;
|
||||
syncStyleIndex();
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
const onGamepadDisconnected = () => {
|
||||
if (!(navigator.getGamepads?.() || []).some((pad) => pad?.connected)) {
|
||||
endPushToTalk();
|
||||
setConnected(false);
|
||||
if (cameraActive) {
|
||||
releaseContinuousRender('deck-controls');
|
||||
cameraActive = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('gamepadconnected', onGamepadConnected);
|
||||
window.addEventListener('gamepaddisconnected', onGamepadDisconnected);
|
||||
|
||||
const toggleBtn = document.getElementById('deck-controls-toggle');
|
||||
const switchEl = document.getElementById('deck-controls-switch');
|
||||
const onToggleClick = () => setEnabled(!enabled);
|
||||
const onSwitchChange = () => setEnabled(Boolean(switchEl?.checked));
|
||||
toggleBtn?.addEventListener('click', onToggleClick);
|
||||
switchEl?.addEventListener('change', onSwitchChange);
|
||||
syncToggleUi();
|
||||
|
||||
rafId = requestAnimationFrame(poll);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('gamepadconnected', onGamepadConnected);
|
||||
window.removeEventListener('gamepaddisconnected', onGamepadDisconnected);
|
||||
toggleBtn?.removeEventListener('click', onToggleClick);
|
||||
switchEl?.removeEventListener('change', onSwitchChange);
|
||||
endPushToTalk();
|
||||
releasePointerLook();
|
||||
if (document.pointerLockElement === canvas) document.exitPointerLock?.();
|
||||
releaseContinuousRender('deck-controls');
|
||||
reticle.remove();
|
||||
helpPanel.remove();
|
||||
document.body.classList.remove('deck-controls-active');
|
||||
document.body.classList.remove('deck-help-hidden');
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DECK_CONTROLS_STORAGE_KEY,
|
||||
DECK_LOOK_FULL_ALTITUDE_M,
|
||||
DECK_LOOK_MIN_ALTITUDE_M,
|
||||
DECK_LOOK_MIN_SCALE,
|
||||
deckLookSensitivityScale,
|
||||
readDeckControlsEnabled,
|
||||
writeDeckControlsEnabled,
|
||||
} from './deckControls.js';
|
||||
|
||||
test('deck look sensitivity is lowest near the ground and full at regional altitude', () => {
|
||||
assert.equal(deckLookSensitivityScale(DECK_LOOK_MIN_ALTITUDE_M), DECK_LOOK_MIN_SCALE);
|
||||
assert.equal(deckLookSensitivityScale(DECK_LOOK_FULL_ALTITUDE_M), 1);
|
||||
assert.ok(deckLookSensitivityScale(2_000) < deckLookSensitivityScale(40_000));
|
||||
assert.ok(deckLookSensitivityScale(40_000) < deckLookSensitivityScale(DECK_LOOK_FULL_ALTITUDE_M));
|
||||
});
|
||||
|
||||
test('deck look sensitivity clamps below min and above full altitude', () => {
|
||||
assert.equal(deckLookSensitivityScale(0), DECK_LOOK_MIN_SCALE);
|
||||
assert.equal(deckLookSensitivityScale(-100), DECK_LOOK_MIN_SCALE);
|
||||
assert.equal(deckLookSensitivityScale(DECK_LOOK_FULL_ALTITUDE_M + 50_000), 1);
|
||||
});
|
||||
|
||||
test('deck controls slider preference prefers stored value over env default', () => {
|
||||
const storage = new Map();
|
||||
const fake = {
|
||||
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
|
||||
setItem: (key, value) => { storage.set(key, String(value)); },
|
||||
};
|
||||
assert.equal(readDeckControlsEnabled(fake, '0'), false);
|
||||
assert.equal(readDeckControlsEnabled(fake, '1'), true);
|
||||
fake.setItem(DECK_CONTROLS_STORAGE_KEY, '1');
|
||||
assert.equal(readDeckControlsEnabled(fake, '0'), true);
|
||||
fake.setItem(DECK_CONTROLS_STORAGE_KEY, '0');
|
||||
assert.equal(readDeckControlsEnabled(fake, '1'), false);
|
||||
});
|
||||
|
||||
test('writeDeckControlsEnabled persists 1/0 and survives read-back', () => {
|
||||
const storage = new Map();
|
||||
const fake = {
|
||||
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
|
||||
setItem: (key, value) => { storage.set(key, String(value)); },
|
||||
};
|
||||
writeDeckControlsEnabled(true, fake);
|
||||
assert.equal(fake.getItem(DECK_CONTROLS_STORAGE_KEY), '1');
|
||||
assert.equal(readDeckControlsEnabled(fake, '0'), true);
|
||||
writeDeckControlsEnabled(false, fake);
|
||||
assert.equal(fake.getItem(DECK_CONTROLS_STORAGE_KEY), '0');
|
||||
assert.equal(readDeckControlsEnabled(fake, '1'), false);
|
||||
});
|
||||
|
|
@ -33,6 +33,7 @@ import {
|
|||
import { installScopeMask } from './scopeMask.js';
|
||||
import { initFirstRunExperience } from './firstRunExperience.js';
|
||||
import { initKeySetup } from './keySetup.js';
|
||||
import { initDeckControls } from './deckControls.js';
|
||||
import { loadPhotorealisticTileset } from './mapStartup.js';
|
||||
|
||||
initLogoGaze();
|
||||
|
|
@ -327,6 +328,7 @@ async function init() {
|
|||
requestRender: governorRequestRender,
|
||||
};
|
||||
window.__godsEyeView.voiceCommands = initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector, annotations });
|
||||
initDeckControls(viewer, styleManager);
|
||||
|
||||
} catch (error) {
|
||||
console.error("God's Eye View initialization failed:", error);
|
||||
|
|
|
|||
137
style.css
137
style.css
|
|
@ -1056,6 +1056,57 @@ body.cockpit-mode #cockpit-cloud-effects.active {
|
|||
display: flex;
|
||||
}
|
||||
|
||||
.pp-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pp-switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.pp-switch-ui {
|
||||
width: 32px;
|
||||
height: 16px;
|
||||
border-radius: 99px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
position: relative;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.pp-switch-ui::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #d8ece6;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.pp-switch input:checked + .pp-switch-ui {
|
||||
background: rgba(31, 143, 109, 0.85);
|
||||
border-color: rgba(125, 255, 200, 0.45);
|
||||
}
|
||||
|
||||
.pp-switch input:checked + .pp-switch-ui::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.pp-switch input:focus-visible + .pp-switch-ui {
|
||||
outline: 1px solid rgba(125, 255, 200, 0.7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.pp-toggle-group .pp-toggle-btn {
|
||||
transition: all var(--transition-fast), border-radius var(--transition-fast);
|
||||
}
|
||||
|
|
@ -9520,3 +9571,89 @@ body.scene-playback-mode #key-setup {
|
|||
border-color: rgba(255, 170, 150, 0.65);
|
||||
outline: none;
|
||||
}
|
||||
/* Steam Deck / gamepad controls */
|
||||
#deck-reticle {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 1200;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
#deck-reticle .deck-reticle-h,
|
||||
#deck-reticle .deck-reticle-v {
|
||||
position: absolute;
|
||||
background: rgba(120, 255, 200, 0.85);
|
||||
box-shadow: 0 0 8px rgba(80, 220, 180, 0.45);
|
||||
}
|
||||
|
||||
#deck-reticle .deck-reticle-h {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 1px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
#deck-reticle .deck-reticle-v {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
#deck-controls-help {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 1200;
|
||||
min-width: 180px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(120, 255, 200, 0.35);
|
||||
background: rgba(8, 14, 18, 0.82);
|
||||
color: rgba(220, 245, 240, 0.92);
|
||||
font: 11px/1.35 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.04em;
|
||||
backdrop-filter: blur(8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#deck-controls-help .deck-controls-title {
|
||||
margin-bottom: 6px;
|
||||
color: rgba(120, 255, 200, 0.95);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#deck-controls-help .deck-controls-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#deck-controls-help .deck-controls-note {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
body.deck-controls-active #cesiumContainer {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#deck-controls-help {
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
font-size: 10px;
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7788,6 +7788,8 @@ export default defineConfig(({ mode }) => {
|
|||
define: {
|
||||
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(env.GOOGLE_MAPS_API_KEY),
|
||||
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(env.CESIUM_ION_TOKEN),
|
||||
// Gamepad / Steam Deck controls default on; set GEV_DECK_CONTROLS=0 to default off.
|
||||
'import.meta.env.GEV_DECK_CONTROLS': JSON.stringify(env.GEV_DECK_CONTROLS === '0' ? '0' : '1'),
|
||||
},
|
||||
build: {
|
||||
// The Cesium engine bundle is inherently large; raise the warning ceiling
|
||||
|
|
|
|||
Loading…
Reference in New Issue