diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index b9a01518e..c95d1bcbf 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -1048,7 +1048,7 @@ export class GPURenderer { this.trackFps(now); this.uploadTextures(); this.computeTextures(); - this.renderFrame(); + this.renderFrame(now / 1000); if (this.onFrame) this.onFrame(performance.now() - now); if (this.afterRender) this.afterRender(this.canvas); } @@ -1084,7 +1084,7 @@ export class GPURenderer { if (this.settings.passEnabled.mapOverlay) this.borderPass.draw(); } - private renderFrame(): void { + private renderFrame(timeSec: number): void { const cam = this.camera.getMatrix(); const zoom = this.camera.zoom; const cw = this.canvas.width; @@ -1094,14 +1094,14 @@ export class GPURenderer { if (nightActive) { this.resizeSceneTargetIfNeeded(cw, ch); const sceneTex = toTarget(this.gl, this.sceneTarget, () => - this.drawBaseLayer(cam), + this.drawBaseLayer(cam, timeSec), ); const lightTex = this.lightmapPass.draw(cam, cw, ch, this.frameTick); toScreen(this.gl, cw, ch, () => this.nightCompositePass.draw(sceneTex, lightTex), ); } else { - toScreen(this.gl, cw, ch, () => this.drawBaseLayer(cam)); + toScreen(this.gl, cw, ch, () => this.drawBaseLayer(cam, timeSec)); } this.renderOverlays(cam, zoom); @@ -1130,7 +1130,7 @@ export class GPURenderer { ); } - private drawBaseLayer(cam: Float32Array): void { + private drawBaseLayer(cam: Float32Array, timeSec: number): void { const gl = this.gl; const pe = this.settings.passEnabled; gl.clearColor(0.04, 0.04, 0.06, 1.0); @@ -1139,7 +1139,7 @@ export class GPURenderer { if (pe.terrain) this.terrainPass.draw(cam); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); - if (pe.mapOverlay) this.territoryPass.draw(cam); + if (pe.mapOverlay) this.territoryPass.draw(cam, timeSec); } private renderOverlays(cam: Float32Array, zoom: number): void { diff --git a/src/client/render/gl/passes/TerritoryPass.ts b/src/client/render/gl/passes/TerritoryPass.ts index 3b27a17d0..f283944e3 100644 --- a/src/client/render/gl/passes/TerritoryPass.ts +++ b/src/client/render/gl/passes/TerritoryPass.ts @@ -37,9 +37,19 @@ export class TerritoryPass { private uStaleNukeColor: WebGLUniformLocation; private uHighlightOwner: WebGLUniformLocation; private uHighlightBrighten: WebGLUniformLocation; + private uTime: WebGLUniformLocation; + private uHoverBBox: WebGLUniformLocation; + private uHoverFlash: WebGLUniformLocation; private uShowPatterns: WebGLUniformLocation; private highlightOwner = 0; + /** Cached AABB of the hovered player's territory; [minX, minY, maxX, maxY]. */ + private hoverBBox = new Float32Array(4); + /** Wall-clock seconds when hover last started; -Infinity if not hovering. */ + private hoverEnterTimeSec = -Infinity; + /** Duration (sec) of the hover-enter flash. */ + private static readonly FLASH_DURATION = 0.4; + private vao: WebGLVertexArrayObject; private tileTex: WebGLTexture; private paletteTex: WebGLTexture; @@ -128,6 +138,9 @@ export class TerritoryPass { this.program, "uHighlightBrighten", )!; + this.uTime = gl.getUniformLocation(this.program, "uTime")!; + this.uHoverBBox = gl.getUniformLocation(this.program, "uHoverBBox")!; + this.uHoverFlash = gl.getUniformLocation(this.program, "uHoverFlash")!; this.uShowPatterns = gl.getUniformLocation(this.program, "uShowPatterns")!; gl.useProgram(this.program); @@ -367,16 +380,32 @@ export class TerritoryPass { /** Set the hovered player's smallID for territory-fill brightening (0 = off). */ setHighlightOwner(ownerID: number): void { + if (ownerID === this.highlightOwner) return; this.highlightOwner = ownerID; + if (ownerID === 0) { + this.hoverEnterTimeSec = -Infinity; + return; + } + const bbox = this.getBBoxForOwner(ownerID); + if (bbox !== null) { + this.hoverBBox[0] = bbox.minX; + this.hoverBBox[1] = bbox.minY; + this.hoverBBox[2] = bbox.maxX; + this.hoverBBox[3] = bbox.maxY; + } + this.hoverEnterTimeSec = performance.now() / 1000; } /** Draw territory fill + stale-nuke ground. Blending must be enabled by caller. */ - draw(cameraMatrix: Float32Array): void { + draw(cameraMatrix: Float32Array, timeSec: number): void { this.flushTileTexture(); const gl = this.gl; const mo = this.settings.mapOverlay; + // Bound time before scaling so int(timeSec * speed) in the shader stays safe. + const t = timeSec % 1000; + gl.useProgram(this.program); gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); gl.uniform2f(this.uMapSize, this.mapW, this.mapH); @@ -392,6 +421,11 @@ export class TerritoryPass { ); gl.uniform1ui(this.uHighlightOwner, this.highlightOwner); gl.uniform1f(this.uHighlightBrighten, mo.highlightFillBrighten); + gl.uniform1f(this.uTime, t); + gl.uniform4fv(this.uHoverBBox, this.hoverBBox); + const flashElapsed = timeSec - this.hoverEnterTimeSec; + const flash = Math.max(0, 1 - flashElapsed / TerritoryPass.FLASH_DURATION); + gl.uniform1f(this.uHoverFlash, flash); gl.uniform1i( this.uShowPatterns, this.settings.passEnabled.territoryPatterns && this.showPatterns ? 1 : 0, diff --git a/src/client/render/gl/shaders/map-overlay/territory.frag.glsl b/src/client/render/gl/shaders/map-overlay/territory.frag.glsl index e78b3b9ed..544053b85 100644 --- a/src/client/render/gl/shaders/map-overlay/territory.frag.glsl +++ b/src/client/render/gl/shaders/map-overlay/territory.frag.glsl @@ -15,7 +15,32 @@ uniform float uStaleNukeVariation; uniform float uStaleNukeAlpha; uniform vec3 uStaleNukeColor; uniform uint uHighlightOwner; // 0 = no highlight; otherwise smallID of hovered owner -uniform float uHighlightBrighten; // mix amount toward white for highlighted tiles +uniform float uHighlightBrighten; // base mix amount toward white for highlighted tiles +uniform float uTime; // seconds (bounded), drives hover pan + glow pulse +uniform vec4 uHoverBBox; // hovered owner's AABB: [minX, minY, maxX, maxY] +uniform float uHoverFlash; // 0..1, decays after hover-enter (one-shot brightening) + +// Hover-only effects applied to the territory of uHighlightOwner. +const float PAN_SPEED_X = 6.0; // pattern pan, world tiles / sec (horizontal only) +const float GLOW_PULSE_HZ = 0.5; // ~2s pulse cycle +const float GLOW_PULSE_AMP = 0.25; // extra brighten at pulse peak, on top of uHighlightBrighten +const float SPARKLE_THRESHOLD = 0.97; // hash > this → tile is a sparkle candidate (~3% of tiles) +const float SPARKLE_HZ = 0.7; // twinkle cycle speed per tile +const float SPARKLE_SHARPNESS = 8.0; // higher = narrower flash window +const float SPARKLE_INTENSITY = 1.2; // additive whiteness at flash peak +const float SWEEP_DURATION = 3.5; // seconds for sweep to cross the territory +const float SWEEP_WIDTH = 6.0; // half-width of the sweep band, in world tiles +const float SWEEP_INTENSITY = 0.35; // additive whiteness at sweep peak +const float FLASH_INTENSITY = 0.6; // additive whiteness at hover-enter peak +const float RAINBOW_HZ = 0.25; // hue cycles / sec (4s full loop) +const float RAINBOW_SAT = 0.8; // saturation of rainbow override +const float RAINBOW_VAL = 0.85; // value/brightness of rainbow override + +vec3 hsv2rgb(vec3 c) { + vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); + vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); + return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); +} in vec2 vWorldPos; out vec4 fragColor; @@ -47,6 +72,7 @@ void main() { // --- Territory fill (owned, not fallout) --- float u = (float(owner) + 0.5) / float(PALETTE_SIZE); vec4 color = texture(uPalette, vec2(u, 0.25)); + bool onSecondary = false; if (uShowPatterns == 1) { vec4 meta = texelFetch(uPatternMeta, ivec2(int(owner), 0), 0); @@ -54,26 +80,64 @@ void main() { int pWidth = int(meta.g); int pHeight = int(meta.b); int pScale = int(meta.a); - - int px = tc.x >> pScale; + + // Pan the pattern for the hovered owner so it slides right-to-left across territory. + int isHover = (uHighlightOwner != 0u && owner == uHighlightOwner) ? 1 : 0; + int offX = isHover * int(uTime * PAN_SPEED_X); + + int px = (tc.x + offX) >> pScale; int py = tc.y >> pScale; int mx = ((px % pWidth) + pWidth) % pWidth; int my = ((py % pHeight) + pHeight) % pHeight; int bitIndex = my * pWidth + mx; int byteIndex = bitIndex >> 3; - + uint patternByte = texelFetch(uPatternData, ivec2(byteIndex, int(owner)), 0).r; bool isPrimary = (patternByte & (1u << uint(bitIndex & 7))) == 0u; - + if (!isPrimary) { color = texture(uPalette, vec2(u, 0.75)); + onSecondary = true; } } } - // Hover highlight: brighten every tile owned by the hovered player. - if (uHighlightOwner != 0u && owner == uHighlightOwner) { - color.rgb = mix(color.rgb, vec3(1.0), uHighlightBrighten); + // Rainbow override on hovered territory — cycle hue over time. Primary and + // secondary cycle 180° out of phase so the pattern stays readable. + bool isHovered = uHighlightOwner != 0u && owner == uHighlightOwner; + if (isHovered) { + float hue = fract(uTime * RAINBOW_HZ + (onSecondary ? 0.5 : 0.0)); + color.rgb = hsv2rgb(vec3(hue, RAINBOW_SAT, RAINBOW_VAL)); + } + + // Glow pulse — only on the primary color, so the rainbow pattern stays + // structured with primary regions reading slightly hotter than secondary. + if (isHovered && !onSecondary) { + float pulse = 0.5 + 0.5 * sin(uTime * GLOW_PULSE_HZ * 6.2831853); + float glow = uHighlightBrighten + pulse * GLOW_PULSE_AMP; + color.rgb = mix(color.rgb, vec3(1.0), glow); + } + + // Sparkles on hovered territory: a small subset of tiles twinkle on + // phase-shifted cycles. Additive white, clamped by output format. + if (isHovered) { + float hash = fract(sin(float(tc.x) * 12.9898 + float(tc.y) * 78.233) * 43758.5453); + if (hash > SPARKLE_THRESHOLD) { + float phase = fract(uTime * SPARKLE_HZ + hash * 31.0); + float spark = pow(max(0.0, 1.0 - abs(phase - 0.5) * SPARKLE_SHARPNESS), 4.0); + color.rgb += spark * SPARKLE_INTENSITY; + } + + // Scan-line sweep: a bright vertical band that traverses the territory's + // bounding box left→right, wraps, and repeats. + float bboxW = max(1.0, uHoverBBox.z - uHoverBBox.x); + float sweepX = uHoverBBox.x + mod(uTime / SWEEP_DURATION, 1.0) * bboxW; + float sweepDist = abs(float(tc.x) - sweepX); + float sweep = exp(-sweepDist * sweepDist / (SWEEP_WIDTH * SWEEP_WIDTH)); + color.rgb += sweep * SWEEP_INTENSITY; + + // Hover-enter flash: brief one-shot brightening when hover begins. + color.rgb += uHoverFlash * FLASH_INTENSITY; } fragColor = color;