From 2d747d0f8be3ebe9dd9370ea4f34d9dd1896b570 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 11 Jun 2026 14:41:46 -0700 Subject: [PATCH] Flash alliance icon when renewal prompt is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an alliance is within the renewal-prompt window, the alliance icon above the player's name now pulses, ramping from 2 Hz to 5 Hz as expiry approaches (same effect as the traitor flash). The flash window is driven by allianceExtensionPromptOffset() — the same Config value that triggers the "renew alliance" prompt in the actionable events display — so the two always stay in sync. The shader only knew the alliance fraction, not absolute time, so computePlayerStatus now also emits allianceRemainingTicks, packed into the free pd7.w slot of the player-data texture. --- .../render/frame/derive/PlayerStatus.ts | 3 +++ src/client/render/gl/Renderer.ts | 8 +++++- .../gl/passes/name-pass/StatusIconProgram.ts | 6 +++++ .../render/gl/passes/name-pass/Types.ts | 1 + .../render/gl/passes/name-pass/index.ts | 13 +++++++--- .../gl/shaders/name/status-icon.vert.glsl | 16 +++++++++++- src/client/render/types/Renderer.ts | 1 + .../render/frame/derive/player-status.test.ts | 26 +++++++++++++++++++ 8 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/client/render/frame/derive/PlayerStatus.ts b/src/client/render/frame/derive/PlayerStatus.ts index f351134d6..ed0b1b6b4 100644 --- a/src/client/render/frame/derive/PlayerStatus.ts +++ b/src/client/render/frame/derive/PlayerStatus.ts @@ -90,6 +90,7 @@ export function computePlayerStatus( let embargo = false; let allianceReq = false; let allianceFraction = 0; + let allianceRemainingTicks = 0; // Nukes: show during replay too, except the nukeTargetsMe flag for (const u of units.values()) { @@ -143,6 +144,7 @@ export function computePlayerStatus( 0, Math.min(1, remainingTicks / Math.max(1, opts.allianceDuration)), ); + allianceRemainingTicks = remainingTicks; } } } @@ -171,6 +173,7 @@ export function computePlayerStatus( nukeTargetsMe, traitorRemainingTicks, allianceFraction, + allianceRemainingTicks, }); } } diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 8cdfb0e83..3835f29df 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -466,7 +466,13 @@ export class GPURenderer { ); this.structureLevelPass = new StructureLevelPass(gl, header, this.settings); this.unitPass = new UnitPass(gl, header, this.paletteTex, this.settings); - this.namePass = new NamePass(gl, header, paletteData, this.settings); + this.namePass = new NamePass( + gl, + header, + paletteData, + this.settings, + config, + ); this.fxPass = new FxPass(gl, header, this.settings, config); this.barPass = new BarPass(gl, header, this.settings, config); this.worldTextPass = new WorldTextPass(gl, this.settings, config); diff --git a/src/client/render/gl/passes/name-pass/StatusIconProgram.ts b/src/client/render/gl/passes/name-pass/StatusIconProgram.ts index 09e357ac0..4fced34dd 100644 --- a/src/client/render/gl/passes/name-pass/StatusIconProgram.ts +++ b/src/client/render/gl/passes/name-pass/StatusIconProgram.ts @@ -46,6 +46,7 @@ export class StatusIconProgram { atlas: ParsedAtlas, playerDataTex: WebGLTexture, maxPlayers: number, + allianceFlashWindowTicks: number, ) { this.gl = gl; this.playerDataTex = playerDataTex; @@ -82,6 +83,11 @@ export class StatusIconProgram { gl.getUniformLocation(this.program, "uStatusPad")!, sm.pad ?? 0, ); + // Flash window matches the alliance renewal prompt (10 ticks/sec) + gl.uniform1f( + gl.getUniformLocation(this.program, "uAllianceFlashWindowSec")!, + allianceFlashWindowTicks / 10, + ); // Dynamic uniform locations this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; diff --git a/src/client/render/gl/passes/name-pass/Types.ts b/src/client/render/gl/passes/name-pass/Types.ts index a1b6e8ce4..128315992 100644 --- a/src/client/render/gl/passes/name-pass/Types.ts +++ b/src/client/render/gl/passes/name-pass/Types.ts @@ -77,6 +77,7 @@ export interface PlayerSlot { nukeTargetsMe: boolean; traitorRemainingTicks: number; allianceFraction: number; + allianceRemainingTicks: number; } // --------------------------------------------------------------------------- diff --git a/src/client/render/gl/passes/name-pass/index.ts b/src/client/render/gl/passes/name-pass/index.ts index 41ac742b3..6072c82de 100644 --- a/src/client/render/gl/passes/name-pass/index.ts +++ b/src/client/render/gl/passes/name-pass/index.ts @@ -16,6 +16,7 @@ * - types — shared interfaces + constants */ +import type { Config } from "../../../../../core/configuration/Config"; import type { NameEntry, PlayerState, @@ -117,6 +118,7 @@ export class NamePass { header: RendererConfig, paletteData: Float32Array, settings: RenderSettings, + config: Config, ) { this.gl = gl; this.settings = settings; @@ -194,6 +196,7 @@ export class NamePass { atlas, this.playerDataTex, this.maxPlayers, + config.allianceExtensionPromptOffset(), ); this.debugProgram = new DebugProgram( gl, @@ -325,6 +328,7 @@ export class NamePass { nukeTargetsMe: false, traitorRemainingTicks: 0, allianceFraction: 0, + allianceRemainingTicks: 0, }; this.slots.set(p.id, slot); this.resolveSlotFlag(slot); @@ -441,6 +445,7 @@ export class NamePass { const nukeTargetsMe = sd?.nukeTargetsMe ?? false; const traitorRemainingTicks = sd?.traitorRemainingTicks ?? 0; const allianceFraction = sd?.allianceFraction ?? 0; + const allianceRemainingTicks = sd?.allianceRemainingTicks ?? 0; if ( crown !== slot.crown || @@ -453,7 +458,8 @@ export class NamePass { nukeActive !== slot.nukeActive || nukeTargetsMe !== slot.nukeTargetsMe || traitorRemainingTicks !== slot.traitorRemainingTicks || - allianceFraction !== slot.allianceFraction + allianceFraction !== slot.allianceFraction || + allianceRemainingTicks !== slot.allianceRemainingTicks ) { slot.crown = crown; slot.traitor = traitor; @@ -466,6 +472,7 @@ export class NamePass { slot.nukeTargetsMe = nukeTargetsMe; slot.traitorRemainingTicks = traitorRemainingTicks; slot.allianceFraction = allianceFraction; + slot.allianceRemainingTicks = allianceRemainingTicks; dirty = true; } @@ -560,11 +567,11 @@ export class NamePass { d[off + 26] = slot.embargo ? 1.0 : 0.0; d[off + 27] = slot.nukeActive ? 1.0 : 0.0; - // Column 7: nukeTargetsMe, traitorRemainingTicks, allianceFraction, [free] + // Column 7: nukeTargetsMe, traitorRemainingTicks, allianceFraction, allianceRemainingTicks d[off + 28] = slot.nukeTargetsMe ? 1.0 : 0.0; d[off + 29] = slot.traitorRemainingTicks; d[off + 30] = slot.allianceFraction; - d[off + 31] = 0; + d[off + 31] = slot.allianceRemainingTicks; this.playerDataDirty = true; } diff --git a/src/client/render/gl/shaders/name/status-icon.vert.glsl b/src/client/render/gl/shaders/name/status-icon.vert.glsl index 66eaa7f81..6d5bccb7d 100644 --- a/src/client/render/gl/shaders/name/status-icon.vert.glsl +++ b/src/client/render/gl/shaders/name/status-icon.vert.glsl @@ -30,6 +30,7 @@ uniform float uStatusRowOffset; // row Y offset (multiples of uFontBase * nameW uniform float uFadeOwnerID; // smallID of player whose name plate the cursor is over (0 = none) uniform float uHoverFadeAlpha; // alpha multiplier applied to that player's name plate +uniform float uAllianceFlashWindowSec; // seconds before expiry the alliance icon flashes (= renewal prompt offset) out vec2 vUV; out vec2 vLocalUV; // 0..1 within the icon cell @@ -90,7 +91,7 @@ void main() { vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); // srcX, srcY, srcScale, startTime vec4 pd1 = texelFetch(uPlayerData, ivec2(1, playerIdx), 0); // tgtX, tgtY, tgtScale, alive vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); // flagIdx, emojiIdx, smallID, [free] - vec4 pd7 = texelFetch(uPlayerData, ivec2(7, playerIdx), 0); // nukeTargetsMe, traitorRemainingTicks, allianceFraction, [free] + vec4 pd7 = texelFetch(uPlayerData, ivec2(7, playerIdx), 0); // nukeTargetsMe, traitorRemainingTicks, allianceFraction, allianceRemainingTicks // Early out: dead player OR emoji is active if (pd1.w <= 0.0 || pd4.y >= 0.0) { @@ -226,5 +227,18 @@ void main() { } } + // Alliance expiry flash: slot 3 = alliance icon + // Window matches the renewal prompt offset so the icon flashes exactly + // while the prompt is up. Same pulse as the traitor flash (2 Hz → 5 Hz). + if (iconSlot == 3) { + float window = uAllianceFlashWindowSec; + float remainingSec = pd7.w / 10.0; // ticks → seconds + if (window > 0.0 && remainingSec <= window && remainingSec > 0.0) { + float elapsed = window - remainingSec; + float phase = uTime * 2.0 + elapsed * elapsed * (1.5 / window); + vFlashAlpha = 0.3 + 0.7 * (0.5 + 0.5 * cos(phase * 6.2832)); + } + } + vDiscard = 0; } diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts index 355013ced..23d89236c 100644 --- a/src/client/render/types/Renderer.ts +++ b/src/client/render/types/Renderer.ts @@ -145,6 +145,7 @@ export interface PlayerStatusData { nukeTargetsMe: boolean; traitorRemainingTicks: number; allianceFraction: number; + allianceRemainingTicks: number; } /** Ghost structure preview data for build-mode visualization. */ diff --git a/tests/client/render/frame/derive/player-status.test.ts b/tests/client/render/frame/derive/player-status.test.ts index 4d452d97e..f6b44b332 100644 --- a/tests/client/render/frame/derive/player-status.test.ts +++ b/tests/client/render/frame/derive/player-status.test.ts @@ -333,4 +333,30 @@ describe("computePlayerStatus — live mode (localPlayerSmallID set)", () => { expect(status.get(2)?.allianceReq).toBe(false); expect(status.get(2)?.allianceFraction).toBe(0); }); + + it("allianceFraction and allianceRemainingTicks come from the alliance expiry", () => { + const players = playersMap( + ps({ smallID: 1, allies: [2] }), + ps({ + smallID: 2, + alliances: [ + { + id: 1, + other: "me", + createdAt: 100, + expiresAt: 700, + hasExtensionRequest: false, + }, + ], + }), + ); + const status = computePlayerStatus(players, unitsMap(), { + localPlayerSmallID: 1, + localPlayerID: "me", + tick: 400, + allianceDuration: 600, + }); + expect(status.get(2)?.allianceFraction).toBe(0.5); + expect(status.get(2)?.allianceRemainingTicks).toBe(300); + }); });