Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
87 changes: 76 additions & 11 deletions src/client/render/gl/passes/BarPass.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/**
* 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";
Expand Down Expand Up @@ -46,6 +48,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 +59,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 +76,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 +89,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 +136,25 @@ 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. Round to match the
// engine's UnitImpl.maxHealth() so a full veteran ship reads as full.
const maxHealth = Math.round(
this.warshipMaxHealth *
(1 + 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 +167,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 +224,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 +267,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
19 changes: 19 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.veterancy,
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.veterancy;
target.hasTrainStation = u.hasTrainStation;
target.trainType = trainTypeToNum(u.trainType);
target.loaded = u.loaded ?? null;
Expand Down Expand Up @@ -230,6 +232,23 @@ export class UnitView {
health(): number {
return this.state.health ?? 0;
}
maxHealth(): number {
Comment thread
bijx marked this conversation as resolved.
Outdated
const base = this.gameView.config().unitInfo(this.type()).maxHealth ?? 1;
if (this.type() === UnitType.Warship && this.state.veterancy > 0) {
const bonus = this.gameView.config().warshipVeterancyHealthBonus();
return Math.round(base * (1 + this.state.veterancy * bonus));
}
return base;
}
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
27 changes: 27 additions & 0 deletions src/core/configuration/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,33 @@ 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 a fraction of base max health. */
warshipVeterancyHealthBonus(): number {
return 0.2;
}

/** Shell-damage boost per veterancy level, as a fraction of the rolled damage. */
warshipVeterancyShellDamageBonus(): number {
return 0.2;
Comment thread
bijx marked this conversation as resolved.
Outdated
}

/** 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
23 changes: 22 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 @@ -69,7 +81,16 @@ export class ShellExecution implements Execution {
const roll = this.random.nextInt(1, 6);
const damageMultiplier = (roll - 1) * 25 + 200;

return Math.round((baseDamage / 250) * damageMultiplier);
let damageDealt = (baseDamage / 250) * damageMultiplier;

// Veteran warships hit harder — scale by the firing unit's veterancy.
const veterancy = this.ownerUnit.veterancy();
if (veterancy > 0) {
const bonus = this.mg.config().warshipVeterancyShellDamageBonus();
damageDealt *= 1 + veterancy * bonus;
Comment thread
bijx marked this conversation as resolved.
Outdated
}

return Math.round(damageDealt);
}

public getEffectOnTargetForTesting(): number {
Expand Down
7 changes: 4 additions & 3 deletions src/core/execution/WarshipExecution.ts
Comment thread
bijx marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,10 @@ export class WarshipExecution implements Execution {
}

private isFullyHealed(): boolean {
const maxHealth = this.mg.config().unitInfo(UnitType.Warship).maxHealth;
if (typeof maxHealth !== "number") {
if (!this.warship.hasHealth()) {
return true;
}
return this.warship.health() >= maxHealth;
return this.warship.health() >= this.warship.maxHealth();
}

private shouldStartRepairRetreat(
Expand Down Expand Up @@ -640,6 +639,7 @@ export class WarshipExecution implements Execution {

if (dist <= 5) {
this.warship.owner().captureUnit(target);
this.warship.recordTradeCapture();
this.warship.setTargetUnit(undefined);
this.warship.touch();
return;
Expand All @@ -659,6 +659,7 @@ export class WarshipExecution implements Execution {
switch (result.status) {
case PathStatus.COMPLETE:
this.warship.owner().captureUnit(target);
this.warship.recordTradeCapture();
this.warship.setTargetUnit(undefined);
this.warship.touch();
return;
Expand Down
Loading
Loading