From ff4d8c9d6f1bb7ff4b26cbf7fcc548d00371d853 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 28 Nov 2025 19:20:32 +0100 Subject: [PATCH] Implement territory defense and relation management in GameMap and renderers; update WebGL shader --- .../graphics/layers/TerritoryRenderers.ts | 29 ++-- .../graphics/layers/TerritoryWebGLRenderer.ts | 127 +++++++++++++++--- src/core/game/GameImpl.ts | 16 +++ src/core/game/GameMap.ts | 52 +++++-- src/core/game/GameView.ts | 16 +++ 5 files changed, 198 insertions(+), 42 deletions(-) diff --git a/src/client/graphics/layers/TerritoryRenderers.ts b/src/client/graphics/layers/TerritoryRenderers.ts index 5626d3059..9710fc9a1 100644 --- a/src/client/graphics/layers/TerritoryRenderers.ts +++ b/src/client/graphics/layers/TerritoryRenderers.ts @@ -273,28 +273,31 @@ export class WebglTerritoryRenderer implements TerritoryRendererStrategy { ? (rawOwner as PlayerView) : null; const isBorderTile = this.game.isBorder(tile); - let isDefended = false; + + // Update defended and relation state in the shared buffer if (owner && isBorderTile) { - isDefended = this.game.hasUnitNearby( + const isDefended = this.game.hasUnitNearby( tile, this.game.config().defensePostRange(), UnitType.DefensePost, owner.id(), ); + const { hasEmbargo, hasFriendly } = owner.borderRelationFlags(tile); + let relation = 0; // neutral + if (hasFriendly) { + relation = 1; // friendly + } else if (hasEmbargo) { + relation = 2; // embargo + } + this.game.setDefended(tile, isDefended); + this.game.setRelation(tile, relation); + } else { + // Clear defended/relation state for non-border tiles + this.game.setDefended(tile, false); + this.game.setRelation(tile, 0); } this.renderer.markTile(tile); - if (!owner || !isBorderTile) { - this.renderer.clearBorderColor(tile); - } else { - const borderCol = owner.borderColor(tile, isDefended).rgba; - this.renderer.setBorderColor(tile, { - r: borderCol.r, - g: borderCol.g, - b: borderCol.b, - a: Math.round((borderCol.a ?? 1) * 255), - }); - } } render( diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index 91a967660..ad2be6d88 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -53,6 +53,16 @@ export class TerritoryWebGLRenderer { hoverPulseStrength: WebGLUniformLocation | null; hoverPulseSpeed: WebGLUniformLocation | null; time: WebGLUniformLocation | null; + // Border color uniforms for shader-computed borders + borderNeutral: WebGLUniformLocation | null; + borderFriendly: WebGLUniformLocation | null; + borderEmbargo: WebGLUniformLocation | null; + borderDefendedNeutralLight: WebGLUniformLocation | null; + borderDefendedNeutralDark: WebGLUniformLocation | null; + borderDefendedFriendlyLight: WebGLUniformLocation | null; + borderDefendedFriendlyDark: WebGLUniformLocation | null; + borderDefendedEmbargoLight: WebGLUniformLocation | null; + borderDefendedEmbargoDark: WebGLUniformLocation | null; }; private readonly state: Uint16Array; @@ -117,6 +127,15 @@ export class TerritoryWebGLRenderer { hoverPulseStrength: null, hoverPulseSpeed: null, time: null, + borderNeutral: null, + borderFriendly: null, + borderEmbargo: null, + borderDefendedNeutralLight: null, + borderDefendedNeutralDark: null, + borderDefendedFriendlyLight: null, + borderDefendedFriendlyDark: null, + borderDefendedEmbargoLight: null, + borderDefendedEmbargoDark: null, }; return; } @@ -149,6 +168,15 @@ export class TerritoryWebGLRenderer { hoverPulseStrength: null, hoverPulseSpeed: null, time: null, + borderNeutral: null, + borderFriendly: null, + borderEmbargo: null, + borderDefendedNeutralLight: null, + borderDefendedNeutralDark: null, + borderDefendedFriendlyLight: null, + borderDefendedFriendlyDark: null, + borderDefendedEmbargoLight: null, + borderDefendedEmbargoDark: null, }; return; } @@ -181,6 +209,33 @@ export class TerritoryWebGLRenderer { ), hoverPulseSpeed: gl.getUniformLocation(this.program, "u_hoverPulseSpeed"), time: gl.getUniformLocation(this.program, "u_time"), + borderNeutral: gl.getUniformLocation(this.program, "u_borderNeutral"), + borderFriendly: gl.getUniformLocation(this.program, "u_borderFriendly"), + borderEmbargo: gl.getUniformLocation(this.program, "u_borderEmbargo"), + borderDefendedNeutralLight: gl.getUniformLocation( + this.program, + "u_borderDefendedNeutralLight", + ), + borderDefendedNeutralDark: gl.getUniformLocation( + this.program, + "u_borderDefendedNeutralDark", + ), + borderDefendedFriendlyLight: gl.getUniformLocation( + this.program, + "u_borderDefendedFriendlyLight", + ), + borderDefendedFriendlyDark: gl.getUniformLocation( + this.program, + "u_borderDefendedFriendlyDark", + ), + borderDefendedEmbargoLight: gl.getUniformLocation( + this.program, + "u_borderDefendedEmbargoLight", + ), + borderDefendedEmbargoDark: gl.getUniformLocation( + this.program, + "u_borderDefendedEmbargoDark", + ), }; // Vertex data: two triangles covering the full map (pixel-perfect). @@ -665,16 +720,24 @@ export class TerritoryWebGLRenderer { const maxId = players.reduce((max, p) => Math.max(max, p.smallID()), 0) + 1; this.paletteWidth = Math.max(maxId, 1); - const paletteData = new Uint8Array(this.paletteWidth * 4); + const paletteData = new Uint8Array(this.paletteWidth * 8); // 8 bytes per player: territory RGBA + border RGBA const relationData = new Uint8Array(this.paletteWidth); for (const p of players) { const id = p.smallID(); - const rgba = p.territoryColor().rgba; - paletteData[id * 4] = rgba.r; - paletteData[id * 4 + 1] = rgba.g; - paletteData[id * 4 + 2] = rgba.b; - paletteData[id * 4 + 3] = Math.round((rgba.a ?? 1) * 255); + // Territory color (first 4 bytes) + const territoryRgba = p.territoryColor().rgba; + paletteData[id * 8] = territoryRgba.r; + paletteData[id * 8 + 1] = territoryRgba.g; + paletteData[id * 8 + 2] = territoryRgba.b; + paletteData[id * 8 + 3] = Math.round((territoryRgba.a ?? 1) * 255); + + // Base border color (next 4 bytes) + const borderRgba = p.borderColor().rgba; // Get base border color without relation/defended + paletteData[id * 8 + 4] = borderRgba.r; + paletteData[id * 8 + 5] = borderRgba.g; + paletteData[id * 8 + 6] = borderRgba.b; + paletteData[id * 8 + 7] = Math.round((borderRgba.a ?? 1) * 255); relationData[id] = this.resolveRelationCode(p, myPlayer); } @@ -690,7 +753,7 @@ export class TerritoryWebGLRenderer { gl.TEXTURE_2D, 0, gl.RGBA8, - this.paletteWidth, + this.paletteWidth * 2, // 2 pixels per player (territory + border) 1, 0, gl.RGBA, @@ -765,6 +828,15 @@ export class TerritoryWebGLRenderer { uniform vec4 u_altNeutral; uniform vec4 u_altEnemy; uniform float u_alpha; + uniform vec4 u_borderNeutral; + uniform vec4 u_borderFriendly; + uniform vec4 u_borderEmbargo; + uniform vec4 u_borderDefendedNeutralLight; + uniform vec4 u_borderDefendedNeutralDark; + uniform vec4 u_borderDefendedFriendlyLight; + uniform vec4 u_borderDefendedFriendlyDark; + uniform vec4 u_borderDefendedEmbargoLight; + uniform vec4 u_borderDefendedEmbargoDark; uniform bool u_alternativeView; uniform float u_hoveredPlayerId; uniform vec3 u_hoverHighlightColor; @@ -792,6 +864,8 @@ export class TerritoryWebGLRenderer { uint state = texelFetch(u_state, texCoord, 0).r; uint owner = state & 0xFFFu; bool hasFallout = (state & 0x2000u) != 0u; // bit 13 + bool isDefended = (state & 0x1000u) != 0u; // bit 12 + uint relation = (state & 0xC000u) >> 14u; // bits 14-15 if (owner == 0u) { if (hasFallout) { @@ -836,17 +910,40 @@ export class TerritoryWebGLRenderer { return; } - vec4 base = texelFetch(u_palette, ivec2(int(owner), 0), 0); - vec4 borderColor = texelFetch(u_borderColor, texCoord, 0); + vec4 base = texelFetch(u_palette, ivec2(int(owner) * 2, 0), 0); // territory color + vec4 baseBorder = texelFetch(u_palette, ivec2(int(owner) * 2 + 1, 0), 0); // base border color vec3 color = base.rgb; float a = u_alpha; - if (isBorder && borderColor.a > 0.0) { - color = borderColor.rgb; - a = borderColor.a; - } - if (isBorder && borderColor.a <= 0.0) { - a = 1.0; + if (isBorder) { + // Start with base border color and apply relation tint + vec3 borderColor = baseBorder.rgb; + + // Apply relation-based tinting (same logic as PlayerView.borderColor) + const float BORDER_TINT_RATIO = 0.35; + const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0); // green + const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0); // red + + if (relation == 1u) { // friendly + borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO; + } else if (relation == 2u) { // embargo + borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET * BORDER_TINT_RATIO; + } + // relation == 0u (neutral) uses base border color as-is + + // Apply defended checkerboard pattern + if (isDefended) { + bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2)); + // Simple checkerboard: alternate between lighter and darker versions + const float LIGHT_FACTOR = 1.2; + const float DARK_FACTOR = 0.8; + borderColor *= isLightTile ? LIGHT_FACTOR : DARK_FACTOR; + } + + color = borderColor; + a = baseBorder.a; // Already in 0-1 range from RGBA8 texture } if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) { diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 2c23ad864..26127f558 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -852,6 +852,22 @@ export class GameImpl implements Game { hasFallout(ref: TileRef): boolean { return this._map.hasFallout(ref); } + + isDefended(ref: TileRef): boolean { + return this._map.isDefended(ref); + } + + setDefended(ref: TileRef, value: boolean): void { + return this._map.setDefended(ref, value); + } + + getRelation(ref: TileRef): number { + return this._map.getRelation(ref); + } + + setRelation(ref: TileRef, relation: number): void { + return this._map.setRelation(ref, relation); + } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); } diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 41368abac..deeddd845 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -27,6 +27,10 @@ export interface GameMap { setOwnerID(ref: TileRef, playerId: number): void; hasFallout(ref: TileRef): boolean; setFallout(ref: TileRef, value: boolean): void; + isDefended(ref: TileRef): boolean; + setDefended(ref: TileRef, value: boolean): void; + getRelation(ref: TileRef): number; + setRelation(ref: TileRef, relation: number): void; isOnEdgeOfMap(ref: TileRef): boolean; isBorder(ref: TileRef): boolean; neighbors(ref: TileRef): TileRef[]; @@ -72,8 +76,13 @@ export class GameMapImpl implements GameMap { // State bits (Uint16Array) private static readonly PLAYER_ID_MASK = 0xfff; private static readonly FALLOUT_BIT = 13; - private static readonly DEFENSE_BONUS_BIT = 14; - // Bit 15 still reserved + private static readonly DEFENDED_BIT = 12; + private static readonly RELATION_MASK = 0xc000; // bits 14-15 + private static readonly RELATION_SHIFT = 14; + // Relation values (stored in bits 14-15) + private static readonly RELATION_NEUTRAL = 0; + private static readonly RELATION_FRIENDLY = 1; + private static readonly RELATION_EMBARGO = 2; constructor( width: number, @@ -217,6 +226,33 @@ export class GameMapImpl implements GameMap { } } + isDefended(ref: TileRef): boolean { + return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENDED_BIT)); + } + + setDefended(ref: TileRef, value: boolean): void { + if (value) { + this.state[ref] |= 1 << GameMapImpl.DEFENDED_BIT; + } else { + this.state[ref] &= ~(1 << GameMapImpl.DEFENDED_BIT); + } + } + + getRelation(ref: TileRef): number { + return ( + (this.state[ref] & GameMapImpl.RELATION_MASK) >> + GameMapImpl.RELATION_SHIFT + ); + } + + setRelation(ref: TileRef, relation: number): void { + // Clear existing relation bits + this.state[ref] &= ~GameMapImpl.RELATION_MASK; + // Set new relation bits + this.state[ref] |= + (relation << GameMapImpl.RELATION_SHIFT) & GameMapImpl.RELATION_MASK; + } + isOnEdgeOfMap(ref: TileRef): boolean { const x = this.x(ref); const y = this.y(ref); @@ -231,18 +267,6 @@ export class GameMapImpl implements GameMap { ); } - hasDefenseBonus(ref: TileRef): boolean { - return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENSE_BONUS_BIT)); - } - - setDefenseBonus(ref: TileRef, value: boolean): void { - if (value) { - this.state[ref] |= 1 << GameMapImpl.DEFENSE_BONUS_BIT; - } else { - this.state[ref] &= ~(1 << GameMapImpl.DEFENSE_BONUS_BIT); - } - } - // Helper methods isWater(ref: TileRef): boolean { return !this.isLand(ref); diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 58ee22b8d..ee9429971 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -854,6 +854,22 @@ export class GameView implements GameMap { setFallout(ref: TileRef, value: boolean): void { return this._map.setFallout(ref, value); } + + isDefended(ref: TileRef): boolean { + return this._map.isDefended(ref); + } + + setDefended(ref: TileRef, value: boolean): void { + return this._map.setDefended(ref, value); + } + + getRelation(ref: TileRef): number { + return this._map.getRelation(ref); + } + + setRelation(ref: TileRef, relation: number): void { + return this._map.setRelation(ref, relation); + } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); }