From 03b405eea7deef87d48a7048157d69a20d781462 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 12 Jun 2026 15:32:12 -0700 Subject: [PATCH] Color nuke telegraph circles by launcher relation (self/ally/enemy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blast-radius warning circle was always red, so players couldn't tell who launched an incoming nuke. Now it's green for your own nukes, yellow for ally/teammate nukes, and red for everyone else's. Each telegraph carries a relation (0=self, 1=friendly, 2=enemy), classified from the per-tick relation matrix — the same friend/foe logic alt-view uses — and passed to the shader as a per-instance attribute. Replay/spectator mode (no local player) stays all red. Colors are tunable via the nukeTelegraph slice in render-settings.json. --- .../render/frame/derive/NukeTelegraphs.ts | 61 +++++- src/client/render/gl/RenderSettings.ts | 8 +- .../render/gl/passes/NukeTelegraphPass.ts | 27 ++- src/client/render/gl/render-settings.json | 8 +- .../nuke-telegraph/nuke-telegraph.frag.glsl | 10 +- .../nuke-telegraph/nuke-telegraph.vert.glsl | 4 + src/client/render/types/Renderer.ts | 2 + src/client/view/GameView.ts | 3 + .../frame/derive/nuke-telegraphs.test.ts | 175 ++++++++++++++++++ 9 files changed, 278 insertions(+), 20 deletions(-) create mode 100644 tests/client/render/frame/derive/nuke-telegraphs.test.ts diff --git a/src/client/render/frame/derive/NukeTelegraphs.ts b/src/client/render/frame/derive/NukeTelegraphs.ts index 5523efb16..df20cb27b 100644 --- a/src/client/render/frame/derive/NukeTelegraphs.ts +++ b/src/client/render/frame/derive/NukeTelegraphs.ts @@ -1,22 +1,56 @@ import type { NukeTelegraphData, UnitState } from "../../types"; import { NUKE_MAGNITUDES } from "../../types"; +// Must match RelationMatrix.ts +const RELATION_FRIENDLY = 1; + +export const TELEGRAPH_SELF = 0; +export const TELEGRAPH_FRIENDLY = 1; +export const TELEGRAPH_ENEMY = 2; + +/** + * Classify a nuke owner relative to the local player: + * 0 = own nuke, 1 = ally/teammate, 2 = everyone else. + * No local player (replay / spectator) → everything is 2 (enemy color). + */ +function classifyOwner( + ownerID: number, + localPlayerID: number, + relationMatrix: Uint8Array | undefined, + relationSize: number, +): number { + if (localPlayerID <= 0) return TELEGRAPH_ENEMY; + if (ownerID === localPlayerID) return TELEGRAPH_SELF; + if ( + relationMatrix && + ownerID > 0 && + ownerID < relationSize && + localPlayerID < relationSize && + relationMatrix[localPlayerID * relationSize + ownerID] === RELATION_FRIENDLY + ) { + return TELEGRAPH_FRIENDLY; + } + return TELEGRAPH_ENEMY; +} + /** * Extract nuke telegraph circles for active nukes with targets. * - * When `friendlyIDs` is provided, only nukes owned by those players are - * included (live game — you see your own + teammates' telegraphs). - * When omitted, all nukes are included (replay / spectator). + * Each telegraph carries a `relation` (self / friendly / enemy) so the + * renderer can color it by who launched the nuke. Pass the local player's + * smallID plus the relation matrix from RelationMatrix.ts; omit them in + * replay / spectator mode to color everything as enemy. */ export function extractNukeTelegraphs( units: ReadonlyMap, mapW: number, - friendlyIDs?: ReadonlySet, + localPlayerID = 0, + relationMatrix?: Uint8Array, + relationSize = 0, ): NukeTelegraphData[] { const telegraphs: NukeTelegraphData[] = []; for (const u of units.values()) { if (u.targetTile === null || !u.isActive) continue; - if (friendlyIDs && !friendlyIDs.has(u.ownerID)) continue; const mag = NUKE_MAGNITUDES[u.unitType]; if (!mag) continue; telegraphs.push({ @@ -24,6 +58,12 @@ export function extractNukeTelegraphs( y: (u.targetTile - (u.targetTile % mapW)) / mapW, innerRadius: mag.inner, outerRadius: mag.outer, + relation: classifyOwner( + u.ownerID, + localPlayerID, + relationMatrix, + relationSize, + ), }); } return telegraphs; @@ -37,13 +77,14 @@ export function extractNukeTelegraphsFromIds( nukeIds: readonly number[], units: ReadonlyMap, mapW: number, - friendlyIDs?: ReadonlySet, + localPlayerID = 0, + relationMatrix?: Uint8Array, + relationSize = 0, ): NukeTelegraphData[] { const telegraphs: NukeTelegraphData[] = []; for (const id of nukeIds) { const u = units.get(id); if (!u || u.targetTile === null || !u.isActive) continue; - if (friendlyIDs && !friendlyIDs.has(u.ownerID)) continue; const mag = NUKE_MAGNITUDES[u.unitType]; if (!mag) continue; telegraphs.push({ @@ -51,6 +92,12 @@ export function extractNukeTelegraphsFromIds( y: (u.targetTile - (u.targetTile % mapW)) / mapW, innerRadius: mag.inner, outerRadius: mag.outer, + relation: classifyOwner( + u.ownerID, + localPlayerID, + relationMatrix, + relationSize, + ), }); } return telegraphs; diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index b64491e77..180317948 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -299,9 +299,15 @@ export interface RenderSettings { pulseAmplitude: number; // alpha pulse ± pulseSpeed: number; // pulse frequency (radians/sec) fillAlphaOffset: number; // inner fill is baseAlpha minus this - colorR: number; // circle color + colorR: number; // circle color — enemy nukes colorG: number; colorB: number; + selfColorR: number; // circle color — own nukes + selfColorG: number; + selfColorB: number; + allyColorR: number; // circle color — ally/teammate nukes + allyColorG: number; + allyColorB: number; }; moveIndicator: { startRadius: number; // screen px — initial distance from center diff --git a/src/client/render/gl/passes/NukeTelegraphPass.ts b/src/client/render/gl/passes/NukeTelegraphPass.ts index 819ebf8b5..71803afd0 100644 --- a/src/client/render/gl/passes/NukeTelegraphPass.ts +++ b/src/client/render/gl/passes/NukeTelegraphPass.ts @@ -14,8 +14,8 @@ import { createProgram } from "../utils/GlUtils"; import fragSrc from "../shaders/nuke-telegraph/nuke-telegraph.frag.glsl?raw"; import vertSrc from "../shaders/nuke-telegraph/nuke-telegraph.vert.glsl?raw"; -// Per-instance: x, y, innerRadius, outerRadius -const FLOATS_PER_INSTANCE = 4; +// Per-instance: x, y, innerRadius, outerRadius, relation +const FLOATS_PER_INSTANCE = 5; export class NukeTelegraphPass { private gl: WebGL2RenderingContext; @@ -28,7 +28,9 @@ export class NukeTelegraphPass { private uTime: WebGLUniformLocation; private uTelegraphStyle: WebGLUniformLocation; private uTelegraphAlpha: WebGLUniformLocation; - private uTelegraphColor: WebGLUniformLocation; + private uColorSelf: WebGLUniformLocation; + private uColorAlly: WebGLUniformLocation; + private uColorEnemy: WebGLUniformLocation; private instanceCount = 0; private startTime = performance.now(); @@ -48,10 +50,9 @@ export class NukeTelegraphPass { this.program, "uTelegraphAlpha", )!; - this.uTelegraphColor = gl.getUniformLocation( - this.program, - "uTelegraphColor", - )!; + this.uColorSelf = gl.getUniformLocation(this.program, "uColorSelf")!; + this.uColorAlly = gl.getUniformLocation(this.program, "uColorAlly")!; + this.uColorEnemy = gl.getUniformLocation(this.program, "uColorEnemy")!; // VAO this.vao = gl.createVertexArray()!; @@ -69,6 +70,7 @@ export class NukeTelegraphPass { gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); // Attribute 1: per-instance vec4 (x, y, innerR, outerR) + // Attribute 2: per-instance float (relation: 0=self, 1=ally, 2=enemy) const glBuf = gl.createBuffer()!; this.instanceBuf = new DynamicInstanceBuffer( gl, @@ -76,10 +78,14 @@ export class NukeTelegraphPass { 16, FLOATS_PER_INSTANCE, ); + const stride = FLOATS_PER_INSTANCE * 4; gl.bindBuffer(gl.ARRAY_BUFFER, glBuf); gl.enableVertexAttribArray(1); - gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 0, 0); + gl.vertexAttribPointer(1, 4, gl.FLOAT, false, stride, 0); gl.vertexAttribDivisor(1, 1); + gl.enableVertexAttribArray(2); + gl.vertexAttribPointer(2, 1, gl.FLOAT, false, stride, 16); + gl.vertexAttribDivisor(2, 1); gl.bindVertexArray(null); } @@ -96,6 +102,7 @@ export class NukeTelegraphPass { buf[off + 1] = d.y; buf[off + 2] = d.innerRadius; buf[off + 3] = d.outerRadius; + buf[off + 4] = d.relation; } this.instanceCount = count; @@ -137,7 +144,9 @@ export class NukeTelegraphPass { s.pulseSpeed, s.fillAlphaOffset, ); - gl.uniform3f(this.uTelegraphColor, s.colorR, s.colorG, s.colorB); + gl.uniform3f(this.uColorSelf, s.selfColorR, s.selfColorG, s.selfColorB); + gl.uniform3f(this.uColorAlly, s.allyColorR, s.allyColorG, s.allyColorB); + gl.uniform3f(this.uColorEnemy, s.colorR, s.colorG, s.colorB); gl.bindVertexArray(this.vao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.instanceCount); diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json index c19c3a53c..a9f5ccc87 100644 --- a/src/client/render/gl/render-settings.json +++ b/src/client/render/gl/render-settings.json @@ -260,7 +260,13 @@ "fillAlphaOffset": 0.6, "colorR": 1, "colorG": 0, - "colorB": 0 + "colorB": 0, + "selfColorR": 0, + "selfColorG": 1, + "selfColorB": 0, + "allyColorR": 1, + "allyColorG": 1, + "allyColorB": 0 }, "moveIndicator": { "startRadius": 13, diff --git a/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.frag.glsl b/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.frag.glsl index dbc2081f7..be62b1af9 100644 --- a/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.frag.glsl +++ b/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.frag.glsl @@ -4,11 +4,14 @@ precision highp float; in vec2 vLocal; flat in float vInnerRadius; flat in float vOuterRadius; +flat in float vRelation; // 0 = self, 1 = ally, 2 = enemy uniform float uTime; // seconds uniform vec4 uTelegraphStyle; // (strokeWidth, dashLen, gapLen, rotationSpeed) uniform vec4 uTelegraphAlpha; // (baseAlpha, pulseAmplitude, pulseSpeed, fillAlphaOffset) -uniform vec3 uTelegraphColor; +uniform vec3 uColorSelf; +uniform vec3 uColorAlly; +uniform vec3 uColorEnemy; out vec4 fragColor; @@ -52,5 +55,8 @@ void main() { float alpha = max(max(fillAlpha, strokeAlpha), outerAlpha); if (alpha < 0.01) discard; - fragColor = vec4(uTelegraphColor, alpha); + vec3 color = vRelation < 0.5 ? uColorSelf + : vRelation < 1.5 ? uColorAlly + : uColorEnemy; + fragColor = vec4(color, alpha); } diff --git a/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.vert.glsl b/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.vert.glsl index a53be58d0..93bf3cc65 100644 --- a/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.vert.glsl +++ b/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.vert.glsl @@ -5,17 +5,21 @@ precision highp float; layout(location = 0) in vec2 aPos; // Per-instance: x, y, innerRadius, outerRadius layout(location = 1) in vec4 aInstance; +// Per-instance: 0 = self, 1 = ally, 2 = enemy +layout(location = 2) in float aRelation; uniform mat3 uCamera; out vec2 vLocal; // [-1, +1] local coords flat out float vInnerRadius; flat out float vOuterRadius; +flat out float vRelation; void main() { vLocal = aPos * 2.0 - 1.0; vInnerRadius = aInstance.z; vOuterRadius = aInstance.w; + vRelation = aRelation; // Expand quad to cover outer circle bbox + padding float r = aInstance.w + 2.0; diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts index 23d89236c..3da08ab0b 100644 --- a/src/client/render/types/Renderer.ts +++ b/src/client/render/types/Renderer.ts @@ -205,6 +205,8 @@ export interface NukeTelegraphData { y: number; innerRadius: number; outerRadius: number; + /** Launcher vs local player: 0 = self, 1 = ally/teammate, 2 = enemy. */ + relation: number; } /** Lean config for constructing the GPU renderer — no replay-specific fields. */ diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts index 283e571b3..0d2ac7b0f 100644 --- a/src/client/view/GameView.ts +++ b/src/client/view/GameView.ts @@ -485,6 +485,9 @@ export class GameView implements GameMap { f.nukeTelegraphs = extractNukeTelegraphs( this._unitStates, this._map.width(), + this._myPlayer?.smallID() ?? 0, + rel.matrix, + rel.size, ); f.attackRings = this._myPlayer ? extractAttackRings( diff --git a/tests/client/render/frame/derive/nuke-telegraphs.test.ts b/tests/client/render/frame/derive/nuke-telegraphs.test.ts new file mode 100644 index 000000000..2669a61f5 --- /dev/null +++ b/tests/client/render/frame/derive/nuke-telegraphs.test.ts @@ -0,0 +1,175 @@ +/** + * extractNukeTelegraphs colors each telegraph by who launched the nuke: + * + * - relation 0 (self): the local player owns the nuke + * - relation 1 (friendly): an ally or teammate owns it (via relation matrix) + * - relation 2 (enemy): everyone else, and everything in replay / spectator + * mode (no local player) + */ + +import { describe, expect, it } from "vitest"; +import { + extractNukeTelegraphs, + TELEGRAPH_ENEMY, + TELEGRAPH_FRIENDLY, + TELEGRAPH_SELF, +} from "../../../../../src/client/render/frame/derive/NukeTelegraphs"; +import { buildRelationMatrix } from "../../../../../src/client/render/frame/derive/RelationMatrix"; +import type { + PlayerState, + UnitState, +} from "../../../../../src/client/render/types"; +import { + UT_ATOM_BOMB, + UT_WARSHIP, +} from "../../../../../src/client/render/types"; + +const MAP_W = 100; + +function ps(overrides: Partial = {}): PlayerState { + return { + smallID: 1, + isAlive: true, + isDisconnected: false, + tilesOwned: 0, + gold: 0, + troops: 0, + isTraitor: false, + traitorRemainingTicks: 0, + betrayals: 0, + hasSpawned: true, + lastDeleteUnitTick: 0, + allies: [], + embargoes: [], + targets: [], + outgoingAttacks: [], + incomingAttacks: [], + outgoingAllianceRequests: [], + alliances: [], + outgoingEmojis: [], + ...overrides, + }; +} + +function nuke(overrides: Partial = {}): UnitState { + return { + id: 1, + unitType: UT_ATOM_BOMB, + ownerID: 1, + lastOwnerID: null, + pos: 0, + lastPos: 0, + isActive: true, + reachedTarget: false, + retreating: false, + targetable: true, + markedForDeletion: false, + health: null, + underConstruction: false, + targetUnitId: null, + targetTile: 305, + troops: 0, + missileTimerQueue: [], + level: 1, + hasTrainStation: false, + trainType: null, + loaded: null, + constructionStartTick: null, + ...overrides, + }; +} + +function units(...us: UnitState[]): Map { + return new Map(us.map((u) => [u.id, u])); +} + +describe("extractNukeTelegraphs", () => { + it("computes target x/y and blast radii", () => { + const [t] = extractNukeTelegraphs(units(nuke({ targetTile: 305 })), MAP_W); + expect(t).toMatchObject({ x: 5, y: 3, innerRadius: 12, outerRadius: 30 }); + }); + + it("skips inactive nukes, nukes without a target, and non-nuke units", () => { + const result = extractNukeTelegraphs( + units( + nuke({ id: 1, isActive: false }), + nuke({ id: 2, targetTile: null }), + nuke({ id: 3, unitType: UT_WARSHIP }), + ), + MAP_W, + ); + expect(result).toHaveLength(0); + }); + + it("marks the local player's own nukes as self", () => { + const rel = buildRelationMatrix(new Map([[1, ps({ smallID: 1 })]])); + const [t] = extractNukeTelegraphs( + units(nuke({ ownerID: 1 })), + MAP_W, + 1, + rel.matrix, + rel.size, + ); + expect(t.relation).toBe(TELEGRAPH_SELF); + }); + + it("marks an ally's nuke as friendly", () => { + const rel = buildRelationMatrix( + new Map([ + [1, ps({ smallID: 1, allies: [2] })], + [2, ps({ smallID: 2 })], + ]), + ); + const [t] = extractNukeTelegraphs( + units(nuke({ ownerID: 2 })), + MAP_W, + 1, + rel.matrix, + rel.size, + ); + expect(t.relation).toBe(TELEGRAPH_FRIENDLY); + }); + + it("marks a teammate's nuke as friendly", () => { + const rel = buildRelationMatrix( + new Map([ + [1, ps({ smallID: 1 })], + [2, ps({ smallID: 2 })], + ]), + new Map([ + [1, "red"], + [2, "red"], + ]), + ); + const [t] = extractNukeTelegraphs( + units(nuke({ ownerID: 2 })), + MAP_W, + 1, + rel.matrix, + rel.size, + ); + expect(t.relation).toBe(TELEGRAPH_FRIENDLY); + }); + + it("marks everyone else's nukes as enemy", () => { + const rel = buildRelationMatrix( + new Map([ + [1, ps({ smallID: 1 })], + [2, ps({ smallID: 2 })], + ]), + ); + const [t] = extractNukeTelegraphs( + units(nuke({ ownerID: 2 })), + MAP_W, + 1, + rel.matrix, + rel.size, + ); + expect(t.relation).toBe(TELEGRAPH_ENEMY); + }); + + it("marks everything as enemy without a local player (replay/spectator)", () => { + const [t] = extractNukeTelegraphs(units(nuke({ ownerID: 1 })), MAP_W); + expect(t.relation).toBe(TELEGRAPH_ENEMY); + }); +});