Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/client/render/gl/RenderSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,15 @@ export interface RenderSettings {
colorGreenR: number;
colorGreenG: number;
colorGreenB: number;
// Warship veterancy rank pips (gold lines at the sprite's bottom-right)
veterancyPipW: number;
veterancyPipH: number;
veterancyPipGap: number;
veterancyPipOffsetX: number;
veterancyPipOffsetY: number;
veterancyR: number;
veterancyG: number;
veterancyB: number;
};
unit: {
unitSize: number;
Expand Down
89 changes: 78 additions & 11 deletions src/client/render/gl/passes/BarPass.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
/**
* BarPass — instanced health/progress bars above units and below structures.
* BarPass — instanced health/progress bars and warship veterancy pips.
*
* Two draw calls per frame:
* Three draw calls per frame (all share one program + instance buffer):
* 1. Health bars (11x3 tiles, above warships)
* 2. Progress bars (14x3 tiles, below structures — construction + missile readiness)
* 3. Veterancy pips (solid gold rank bars stacked at a warship's bottom-right)
*
* Data flow:
* UnitState.health / .missileTimerQueue / .constructionStartTick → CPU progress
* → instance VBO (x, y, progress) → GPU colored rectangle
* UnitState.veterancy → one instance per level (x, y, slot) → solid gold rect
*/

import type { Config } from "../../../../core/configuration/Config";
import { UnitType } from "../../../../core/game/Game";
import { maxHealthWithVeterancy } from "../../../../core/game/Veterancy";
import type { RendererConfig, UnitState } from "../../types";
import { UT_MISSILE_SILO, UT_SAM_LAUNCHER } from "../../types";
import type { RenderSettings } from "../RenderSettings";
Expand Down Expand Up @@ -46,6 +49,9 @@ export class BarPass {
private uColorOrange: WebGLUniformLocation;
private uColorYellow: WebGLUniformLocation;
private uColorGreen: WebGLUniformLocation;
private uSolid: WebGLUniformLocation;
private uSolidColor: WebGLUniformLocation;
private uPipStride: WebGLUniformLocation;

private vao: WebGLVertexArrayObject;
private instanceBuf: WebGLBuffer;
Expand All @@ -54,9 +60,12 @@ export class BarPass {
private healthCount = 0;
private progressData: Float32Array;
private progressCount = 0;
private veterancyData: Float32Array;
private veterancyCount = 0;

private mapW: number;
private warshipMaxHealth: number;
private veterancyHealthBonus: number;

constructor(
gl: WebGL2RenderingContext,
Expand All @@ -68,6 +77,7 @@ export class BarPass {
this.settings = settings;
this.mapW = header.mapWidth;
this.warshipMaxHealth = config.unitInfo(UnitType.Warship).maxHealth ?? 0;
this.veterancyHealthBonus = config.warshipVeterancyHealthBonus();

// --- Shader program ---
this.program = createProgram(gl, barVertSrc, barFragSrc);
Expand All @@ -80,10 +90,14 @@ export class BarPass {
this.uColorOrange = gl.getUniformLocation(this.program, "uColorOrange")!;
this.uColorYellow = gl.getUniformLocation(this.program, "uColorYellow")!;
this.uColorGreen = gl.getUniformLocation(this.program, "uColorGreen")!;
this.uSolid = gl.getUniformLocation(this.program, "uSolid")!;
this.uSolidColor = gl.getUniformLocation(this.program, "uSolidColor")!;
this.uPipStride = gl.getUniformLocation(this.program, "uPipStride")!;

// --- Instance data buffers (CPU-side) ---
this.healthData = new Float32Array(this.maxBars * FLOATS_PER_INSTANCE);
this.progressData = new Float32Array(this.maxBars * FLOATS_PER_INSTANCE);
this.veterancyData = new Float32Array(this.maxBars * FLOATS_PER_INSTANCE);

// --- VAO: unit quad + instanced data ---
this.vao = gl.createVertexArray()!;
Expand Down Expand Up @@ -123,16 +137,26 @@ export class BarPass {
): void {
this.healthCount = 0;
this.progressCount = 0;
this.veterancyCount = 0;

// --- Health bars (warships) ---
// --- Health bars + veterancy pips (warships) ---
// Only warships carry health among mobile units, so this loop is effectively
// warship-only.
for (const unit of mobileUnits.values()) {
if (
unit.health === null ||
unit.health <= 0 ||
unit.health >= this.warshipMaxHealth
)
continue;
this.pushHealth(unit, unit.health / this.warshipMaxHealth);
if (unit.health === null || unit.health <= 0) continue;
// Veteran warships have a higher effective max health, so a full veteran
// ship reads as full. Shared with the engine's UnitImpl.maxHealth().
const maxHealth = maxHealthWithVeterancy(
this.warshipMaxHealth,
unit.veterancy,
this.veterancyHealthBonus,
);
if (unit.health < maxHealth) {
this.pushHealth(unit, unit.health / maxHealth);
}
if (unit.veterancy > 0) {
this.pushVeterancy(unit);
}
}

// --- Progress bars (structures) ---
Expand All @@ -145,13 +169,19 @@ export class BarPass {

/** Render bars. Call once per frame after FX, before names. */
draw(cameraMat: Float32Array): void {
if (this.healthCount === 0 && this.progressCount === 0) return;
if (
this.healthCount === 0 &&
this.progressCount === 0 &&
this.veterancyCount === 0
)
return;

const gl = this.gl;
const b = this.settings.bar;

gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMat);
gl.uniform1f(this.uSolid, 0); // health/progress bars use the colored path
gl.uniform1f(this.uBorderWidth, b.borderWidth);
gl.uniform3f(this.uThresholds, b.threshold1, b.threshold2, b.threshold3);
gl.uniform3f(this.uColorRed, b.colorRedR, b.colorRedG, b.colorRedB);
Expand Down Expand Up @@ -196,6 +226,29 @@ export class BarPass {
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.progressCount);
}

// Veterancy pips (solid gold rank bars, bottom-right of warship sprites)
if (this.veterancyCount > 0) {
gl.uniform1f(this.uSolid, 1);
gl.uniform3f(this.uSolidColor, b.veterancyR, b.veterancyG, b.veterancyB);
gl.uniform1f(this.uPipStride, b.veterancyPipH + b.veterancyPipGap);
gl.uniform2f(this.uBarSize, b.veterancyPipW, b.veterancyPipH);
gl.uniform2f(
this.uBarOffset,
b.veterancyPipOffsetX,
b.veterancyPipOffsetY,
);
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.veterancyData.subarray(
0,
this.veterancyCount * FLOATS_PER_INSTANCE,
),
);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.veterancyCount);
}

gl.bindVertexArray(null);
}

Expand All @@ -216,6 +269,20 @@ export class BarPass {
this.healthCount++;
}

/** Emit one gold pip instance per veterancy level, stacked by slot index. */
private pushVeterancy(unit: UnitState): void {
const x = unit.pos % this.mapW;
const y = (unit.pos - x) / this.mapW;
for (let slot = 0; slot < unit.veterancy; slot++) {
if (this.veterancyCount >= this.maxBars) return;
const off = this.veterancyCount * FLOATS_PER_INSTANCE;
this.veterancyData[off] = x;
this.veterancyData[off + 1] = y;
this.veterancyData[off + 2] = slot; // vertical stack slot, read by the shader
this.veterancyCount++;
}
}

private pushProgress(unit: UnitState, progress: number): void {
if (this.progressCount >= this.maxBars) return;
const off = this.progressCount * FLOATS_PER_INSTANCE;
Expand Down
10 changes: 9 additions & 1 deletion src/client/render/gl/render-settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,15 @@
"colorYellowB": 0.059,
"colorGreenR": 0.173,
"colorGreenG": 0.937,
"colorGreenB": 0.071
"colorGreenB": 0.071,
"veterancyPipW": 4,
"veterancyPipH": 1,
"veterancyPipGap": 1,
"veterancyPipOffsetX": 1.5,
"veterancyPipOffsetY": 3.5,
"veterancyR": 1.0,
"veterancyG": 0.84,
"veterancyB": 0.0
},
"unit": {
"unitSize": 13,
Expand Down
8 changes: 8 additions & 0 deletions src/client/render/gl/shaders/bar/bar.frag.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@ uniform vec3 uColorRed;
uniform vec3 uColorOrange;
uniform vec3 uColorYellow;
uniform vec3 uColorGreen;
uniform float uSolid; // 1.0 = veterancy pip: fill solid with uSolidColor
uniform vec3 uSolidColor;

in vec2 vLocalPos;
flat in float vProgress;

out vec4 fragColor;

void main() {
// Veterancy pips are simple solid-filled rectangles (no border/threshold).
if (uSolid > 0.5) {
fragColor = vec4(uSolidColor, 1.0);
return;
}

float x = vLocalPos.x;
float y = vLocalPos.y;
float w = uBarSize.x;
Expand Down
7 changes: 6 additions & 1 deletion src/client/render/gl/shaders/bar/bar.vert.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ layout(location = 1) in vec3 aInstData; // x, y, progress
uniform mat3 uCamera;
uniform vec2 uBarSize; // (width, height) in world tiles
uniform vec2 uBarOffset; // offset from unit center in tiles
uniform float uSolid; // 1.0 = veterancy pip mode (aInstData.z is a stack slot)
uniform float uPipStride; // vertical spacing between stacked pips, in tiles

out vec2 vLocalPos; // [0, barWidth] x [0, barHeight]
flat out float vProgress;
Expand All @@ -17,7 +19,10 @@ void main() {
vProgress = aInstData.z;

vec2 center = vec2(worldX + 0.5, worldY + 0.5);
vec2 barOrigin = center + uBarOffset;
vec2 offset = uBarOffset;
// In pip mode each instance is one stacked rank bar; raise it by its slot.
offset.y -= uSolid * aInstData.z * uPipStride;
vec2 barOrigin = center + offset;
vec2 worldPos = barOrigin + aPos * uBarSize;

vec3 clip = uCamera * vec3(worldPos, 1.0);
Expand Down
1 change: 1 addition & 0 deletions src/client/render/types/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export interface UnitState {
troops: number;
missileTimerQueue: number[];
level: number;
veterancy: number;
hasTrainStation: boolean;
trainType: number | null; // 0=Engine, 1=TailEngine, 2=Carriage
loaded: boolean | null;
Expand Down
11 changes: 11 additions & 0 deletions src/client/view/UnitView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ function unitStateFromUpdate(u: UnitUpdate): UnitState {
troops: u.troops,
missileTimerQueue: u.missileTimerQueue,
level: u.level,
veterancy: u.warshipState?.veterancy ?? 0,
hasTrainStation: u.hasTrainStation,
trainType: trainTypeToNum(u.trainType),
loaded: u.loaded ?? null,
Expand Down Expand Up @@ -93,6 +94,7 @@ function applyUpdateInPlace(target: UnitState, u: UnitUpdate): void {
target.troops = u.troops;
target.missileTimerQueue = u.missileTimerQueue;
target.level = u.level;
target.veterancy = u.warshipState?.veterancy ?? 0;
target.hasTrainStation = u.hasTrainStation;
target.trainType = trainTypeToNum(u.trainType);
target.loaded = u.loaded ?? null;
Expand Down Expand Up @@ -230,6 +232,15 @@ export class UnitView {
health(): number {
return this.state.health ?? 0;
}
veterancy(): number {
return this.state.veterancy;
}
recordKill(_targetType: UnitType): void {
throw new Error("recordKill is not supported on UnitView");
}
recordTradeCapture(): void {
throw new Error("recordTradeCapture is not supported on UnitView");
}
isUnderConstruction(): boolean {
return this.state.underConstruction;
}
Expand Down
35 changes: 33 additions & 2 deletions src/core/configuration/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -919,8 +919,10 @@ export class Config {
return 5;
}

warshipRetreatHealthThreshold(): number {
return 750;
/** Health at or below which a warship retreats to repair, as a percent of its
* (veterancy-adjusted) max health, so the threshold scales with max health. */
warshipRetreatHealthPercent(): number {
return 75;
}

warshipPassiveHealing(): number {
Expand All @@ -935,6 +937,35 @@ export class Config {
return 0.75;
}

// --- Warship veterancy ---

/** Maximum veterancy level a warship can reach. */
warshipMaxVeterancy(): number {
return 3;
}

/** Max-health boost per veterancy level, as an integer percent of base max
* health. Integer-only to keep src/core deterministic (no float constants). */
warshipVeterancyHealthBonus(): number {
return 20;
}

/** Shell-damage boost per veterancy level, as an integer percent of the
* rolled damage. Integer-only to keep src/core deterministic. */
warshipVeterancyShellDamageBonus(): number {
return 20;
}

/** Transport ships a warship must destroy to gain one veterancy level. */
warshipVeterancyTransportKills(): number {
return 10;
}

/** Trade ships a warship must capture to gain one veterancy level. */
warshipVeterancyTradeCaptures(): number {
return 25;
}

defensePostShellAttackRate(): number {
return 100;
}
Expand Down
24 changes: 23 additions & 1 deletion src/core/execution/ShellExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,19 @@ export class ShellExecution implements Execution {
);
if (result.status === PathStatus.COMPLETE) {
this.active = false;
const targetType = this.target.type();
const targetWasActive = this.target.isActive();
this.target.modifyHealth(-this.effectOnTarget(), this._owner);
// Award veterancy to the firing warship when this shell lands the
// killing blow on an enemy warship or transport ship.
if (
targetWasActive &&
!this.target.isActive() &&
this.ownerUnit.isActive() &&
this.ownerUnit.type() === UnitType.Warship
) {
this.ownerUnit.recordKill(targetType);
}
this.shell.setReachedTarget();
this.shell.delete(false);
return;
Expand All @@ -67,7 +79,17 @@ export class ShellExecution implements Execution {
const baseDamage = damage ?? 250;

const roll = this.random.nextInt(1, 6);
const damageMultiplier = (roll - 1) * 25 + 200;
let damageMultiplier = (roll - 1) * 25 + 200;

// Veteran warships hit harder — scale the (integer) multiplier by the firing
// unit's veterancy. Integer percent math keeps src/core float-free.
const veterancy = this.ownerUnit.veterancy();
if (veterancy > 0) {
const bonusPercent = this.mg.config().warshipVeterancyShellDamageBonus();
damageMultiplier = Math.floor(
(damageMultiplier * (100 + veterancy * bonusPercent)) / 100,
);
}

return Math.round((baseDamage / 250) * damageMultiplier);
}
Expand Down
Loading
Loading