Color nuke telegraph circles by launcher relation (self/ally/enemy)

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.
This commit is contained in:
evanpelle
2026-06-12 15:32:12 -07:00
parent 32011d2ed2
commit 03b405eea7
9 changed files with 278 additions and 20 deletions
@@ -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<number, UnitState>,
mapW: number,
friendlyIDs?: ReadonlySet<number>,
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<number, UnitState>,
mapW: number,
friendlyIDs?: ReadonlySet<number>,
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;
+7 -1
View File
@@ -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
@@ -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);
+7 -1
View File
@@ -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,
@@ -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);
}
@@ -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;
+2
View File
@@ -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. */
+3
View File
@@ -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(
@@ -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> = {}): 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> = {}): 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<number, UnitState> {
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);
});
});