diff --git a/CHANGELOG.md b/CHANGELOG.md index 21544fa..860e8cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +### Added + +- Tactical Naval Sonar visual style (`Key 8`) with dynamic rotating acoustic sweep, + phosphor persistence decay trail, concentric range rings, and acoustic gain controls. + ### Fixed - Mapped-site outages show their scheduled retry countdown and distinguish diff --git a/README.md b/README.md index 86f69c5..ddb5694 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea 7. **Talk to it** *(needs an OpenAI key)*: *"Take me to LAX and select the nearest airborne aircraft."* 8. **Come home.** Hit **Reset Globe** — or just say *"zoom out to a globe view."* -**Keyboard:** `1`–`7` visual styles · `H` HUD · `D` detection · `C` cockpit · `Esc` out. +**Keyboard:** `1`–`8` visual styles · `H` HUD · `D` detection · `C` cockpit · `Esc` out. --- diff --git a/index.html b/index.html index e8b0b8d..751c06d 100644 --- a/index.html +++ b/index.html @@ -527,6 +527,11 @@ Snow 7 +
diff --git a/src/celestialRing.test.mjs b/src/celestialRing.test.mjs index 7260c99..a3737a4 100644 --- a/src/celestialRing.test.mjs +++ b/src/celestialRing.test.mjs @@ -87,7 +87,7 @@ test('circular angle distance remains small across the wrap point', () => { test('celestial ring is available only in Normal style', () => { assert.equal(isCelestialRingStyleSupported('normal'), true); - for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow']) { + for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow', 'sonar']) { assert.equal(isCelestialRingStyleSupported(style), false); } }); diff --git a/src/sharelink.js b/src/sharelink.js index b6eb6b3..cc8ce60 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -26,6 +26,7 @@ const STYLE_TO_URL = { anime: 'anime', noir: 'noir', snow: 'snow', + sonar: 'sonar', }; const SHARE_UI_STATE_PARAM = 'ui'; @@ -84,6 +85,12 @@ const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({ { key: 'density', token: 'd', min: 0, max: 1 }, { key: 'wind', token: 'w', min: 0, max: 1 }, ]), + sonar: Object.freeze([ + { key: 'sweepSpeed', token: 's', min: 0.1, max: 3 }, + { key: 'ringDensity', token: 'r', min: 1, max: 10 }, + { key: 'persistence', token: 'p', min: 0.1, max: 1 }, + { key: 'gain', token: 'g', min: 0.5, max: 2.5 }, + ]), }); export class ShareLinkManager { diff --git a/src/styles/sonar.js b/src/styles/sonar.js new file mode 100644 index 0000000..84e325a --- /dev/null +++ b/src/styles/sonar.js @@ -0,0 +1,88 @@ +/** + * Sonar Style — Tactical Naval / Hydrographic Acoustic Display + * Rotating radial sweep beam + phosphor persistence trail + + * concentric range rings + high-contrast acoustic echo return + */ +export const sonarShader = { + name: 'sonar', + uniforms: { + sweepSpeed: { default: 0.8, min: 0.1, max: 3.0, label: 'Sweep speed' }, + ringDensity: { default: 4.0, min: 1.0, max: 10.0, label: 'Range rings' }, + persistence: { default: 0.7, min: 0.1, max: 1.0, label: 'Persistence' }, + gain: { default: 1.4, min: 0.5, max: 2.5, label: 'Acoustic gain' }, + }, + fragmentShader: /* glsl */ ` + uniform sampler2D colorTexture; + uniform vec2 colorTextureDimensions; + uniform float intensity; + uniform float time; + uniform float sweepSpeed; + uniform float ringDensity; + uniform float persistence; + uniform float gain; + in vec2 v_textureCoordinates; + + #define PI 3.14159265359 + #define TWO_PI 6.28318530718 + + void main() { + vec2 uv = v_textureCoordinates; + vec4 color = texture(colorTexture, uv); + + // Centered coordinates with aspect ratio correction + vec2 centered = uv * 2.0 - 1.0; + float aspect = colorTextureDimensions.x / max(1.0, colorTextureDimensions.y); + centered.x *= aspect; + + float dist = length(centered); + float angle = atan(centered.y, centered.x); + if (angle < 0.0) angle += TWO_PI; + + // Rotating sweep angle (clockwise) + float sweepAngle = mod(time * sweepSpeed, TWO_PI); + float sweepDiff = mod(sweepAngle - angle, TWO_PI); + + // Sharp leading sweep line with smooth falloff + float sweepLine = smoothstep(0.045, 0.0, sweepDiff); + + // Exponential phosphor persistence trail behind the sweep + float decayRate = 3.5 / max(0.08, persistence); + float trail = exp(-sweepDiff * decayRate); + + // Concentric circular range rings + float ringPattern = abs(sin(dist * ringDensity * PI)); + float rings = smoothstep(0.96, 0.995, ringPattern) * 0.22; + rings *= smoothstep(1.5, 0.2, dist); + + // Subtle crosshair axes + float crosshairs = (smoothstep(0.003 * aspect, 0.0, abs(centered.x)) + + smoothstep(0.003, 0.0, abs(centered.y))) * 0.12; + crosshairs *= smoothstep(1.2, 0.1, dist); + + // Acoustic echo response: extract scene luminance and boost contrast + float luma = dot(color.rgb, vec3(0.299, 0.587, 0.114)); + float pingEcho = pow(clamp(luma * gain, 0.0, 1.0), 1.6); + + // Palette: deep oceanic navy baseline -> bright phosphor cyan + vec3 oceanDark = vec3(0.012, 0.042, 0.11); + vec3 phosphorCyan = vec3(0.0, 0.96, 0.78); + vec3 pingBright = vec3(0.65, 1.0, 0.92); + + // Illuminated echo: contacts light up as the sweep passes, decaying with the trail + float echoIllumination = pingEcho * (trail * 0.88 + 0.12); + vec3 echoColor = mix(oceanDark, phosphorCyan, echoIllumination); + echoColor += pingBright * pingEcho * sweepLine * 0.75; + + // Add sweep beam, rings, and crosshairs + vec3 result = echoColor; + result += phosphorCyan * sweepLine * 0.45; + result += phosphorCyan * (rings + crosshairs) * (trail * 0.45 + 0.55); + + // Circular viewport vignette + float vignette = smoothstep(1.45, 0.4, dist); + result *= vignette; + + out_FragColor = vec4(mix(color.rgb, result, intensity), color.a); + } + `, +}; diff --git a/src/styles/sonar.test.mjs b/src/styles/sonar.test.mjs new file mode 100644 index 0000000..0f0a34a --- /dev/null +++ b/src/styles/sonar.test.mjs @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { sonarShader } from './sonar.js'; + +test('sonarShader: metadata and name contract', () => { + assert.equal(typeof sonarShader, 'object'); + assert.equal(sonarShader.name, 'sonar'); + assert.equal(typeof sonarShader.fragmentShader, 'string'); + assert.ok(sonarShader.fragmentShader.length > 100); +}); + +test('sonarShader: uniforms schema and bounds validity', () => { + const { uniforms } = sonarShader; + assert.ok(uniforms, 'uniforms object must be defined'); + + const expectedUniforms = ['sweepSpeed', 'ringDensity', 'persistence', 'gain']; + for (const name of expectedUniforms) { + const u = uniforms[name]; + assert.ok(u, `uniform ${name} must exist`); + assert.equal(typeof u.default, 'number', `${name}.default must be number`); + assert.equal(typeof u.min, 'number', `${name}.min must be number`); + assert.equal(typeof u.max, 'number', `${name}.max must be number`); + assert.equal(typeof u.label, 'string', `${name}.label must be string`); + assert.ok(u.min <= u.default, `${name}.min must be <= default`); + assert.ok(u.default <= u.max, `${name}.default must be <= max`); + } +}); + +test('sonarShader: GLSL fragment shader declares required uniforms and ins/outs', () => { + const glsl = sonarShader.fragmentShader; + + assert.match(glsl, /uniform\s+sampler2D\s+colorTexture;/); + assert.match(glsl, /uniform\s+vec2\s+colorTextureDimensions;/); + assert.match(glsl, /uniform\s+float\s+intensity;/); + assert.match(glsl, /uniform\s+float\s+time;/); + assert.match(glsl, /uniform\s+float\s+sweepSpeed;/); + assert.match(glsl, /uniform\s+float\s+ringDensity;/); + assert.match(glsl, /uniform\s+float\s+persistence;/); + assert.match(glsl, /uniform\s+float\s+gain;/); + assert.match(glsl, /in\s+vec2\s+v_textureCoordinates;/); + assert.match(glsl, /out_FragColor\s*=/); +}); + +test('sonarShader: contains rotational sweep and distance calculations', () => { + const glsl = sonarShader.fragmentShader; + assert.ok(glsl.includes('atan('), 'must compute angular coordinate'); + assert.ok(glsl.includes('length('), 'must compute radial distance'); + assert.ok(glsl.includes('smoothstep('), 'must use smoothstep for soft edges'); + assert.ok(glsl.includes('exp('), 'must use exponential falloff for phosphor decay'); +}); + +test('sonarShader: integration with sharelink parameters and URL tokens', async () => { + const sharelinkModule = await import('../sharelink.js'); + const fileContent = await import('node:fs').then((fs) => + fs.promises.readFile(new URL('../sharelink.js', import.meta.url), 'utf8') + ); + assert.match(fileContent, /sonar:\s*'sonar'/); + assert.match(fileContent, /sonar:\s*Object\.freeze\(\[/); + assert.match(fileContent, /key:\s*'sweepSpeed'/); + assert.match(fileContent, /key:\s*'ringDensity'/); + assert.match(fileContent, /key:\s*'persistence'/); + assert.match(fileContent, /key:\s*'gain'/); +}); + +test('sonarShader: integration with voice actions grammar and aliases', async () => { + const fileContent = await import('node:fs').then((fs) => + fs.promises.readFile(new URL('../voice/gevActions.js', import.meta.url), 'utf8') + ); + assert.match(fileContent, /'sonar'/); + assert.match(fileContent, /raw === 'sonar' \|\| raw === 'tactical sonar'/); +}); + diff --git a/src/ui.js b/src/ui.js index 668e752..128c6bc 100644 --- a/src/ui.js +++ b/src/ui.js @@ -5,6 +5,7 @@ import { noirShader } from './styles/noir.js'; import { snowShader } from './styles/snow.js'; import { nightVisionShader } from './styles/surveillance.js'; import { thermalShader } from './styles/thermal.js'; +import { sonarShader } from './styles/sonar.js'; import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION, @@ -198,7 +199,7 @@ import { /** Duration (ms) for shader intensity crossfade between style presets. */ const TRANSITION_DURATION_MS = 500; /** Map of style name to its GLSL shader module for post-process stages. */ -const STYLES = { retro: retroShader, surveillance: nightVisionShader, thermal: thermalShader, anime: animeShader, noir: noirShader, snow: snowShader }; +const STYLES = { retro: retroShader, surveillance: nightVisionShader, thermal: thermalShader, anime: animeShader, noir: noirShader, snow: snowShader, sonar: sonarShader }; /** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; const SHARE_PANEL_STATE_SPECS = Object.freeze([ @@ -363,6 +364,7 @@ const STYLE_STATUS_LABELS = { anime: 'ANIME', noir: 'NOIR', snow: 'SNOW', + sonar: 'SONAR', }; /** * The tactical detection look: Dense at 75%. @@ -3341,7 +3343,7 @@ export class StyleManager { const keyMap = { '1': 'normal', '2': 'retro', '3': 'surveillance', '4': 'thermal', '5': 'anime', '6': 'noir', - '7': 'snow', + '7': 'snow', '8': 'sonar', }; if (keyMap[e.key]) this.setStyle(keyMap[e.key]); if (e.key === 'Escape') { @@ -8986,7 +8988,7 @@ export class StyleManager { * 2. Crossfades the new shader stage intensity to 1. * 3. Applies style preset defaults (bloom/sharpen/HUD) if applyPreset is true. * 4. Updates button highlights, style indicator, slider panel, HUD, and detection overlay. - * @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir'|'snow'). + * @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir'|'snow'|'sonar'). * @param {object} [options] * @param {boolean} [options.applyPreset=true] - Whether to apply STYLE_PRESET_DEFAULTS for the new style. * @returns {void} diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index a56c840..daf77c6 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -21,7 +21,7 @@ import { resolveRegionRingForQuery } from '../annotations/annotationResolver.js' import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { TR3B_CLASS } from '../data/tr3bRegistry.js'; -const ALLOWED_STYLES = new Set(['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow']); +const ALLOWED_STYLES = new Set(['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow', 'sonar']); const PANEL_ALIASES = new Map([ ['data', 'data-panel'], ['data layers', 'data-panel'], @@ -2201,6 +2201,7 @@ function normalizeStyle(value) { if (raw === 'filter off' || raw === 'off' || raw === 'default') return 'normal'; if (raw === 'night vision' || raw === 'nvg') return 'surveillance'; if (raw === 'flir') return 'thermal'; + if (raw === 'sonar' || raw === 'tactical sonar') return 'sonar'; if (ALLOWED_STYLES.has(raw)) return raw; return null; } diff --git a/vite.config.js b/vite.config.js index 34dac52..8a9859d 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5893,7 +5893,7 @@ const GEV_REALTIME_TOOLS = [ properties: { style: { type: 'string', - enum: ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'], + enum: ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow', 'sonar'], }, }, required: ['style'],