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); + }); });