diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index d8edb3f02..7d40fc35d 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -19,6 +19,10 @@ const COLOR_PROGRESSION = [ const HEALTHBAR_WIDTH = 11; // Width of the health bar const LOADINGBAR_WIDTH = 14; // Width of the loading bar const PROGRESSBAR_HEIGHT = 3; // Height of a bar +const VETERANCY_DOT_RADIUS = 1.4; +const VETERANCY_DOT_SPACING = 5; +const VETERANCY_DOT_Y_OFFSET = 10; +const VETERANCY_DOT_CLEAR_PADDING = 2; /** * Layer responsible for drawing UI elements that overlay the game @@ -35,6 +39,10 @@ export class UILayer implements Layer { { unit: UnitView; progressBar: ProgressBar } > = new Map(); private allHealthBars: Map = new Map(); + private allVeterancyDots: Map< + number, + { x: number; y: number; bars: number } + > = new Map(); // Keep track of currently selected unit private selectedUnit: UnitView | null = null; @@ -103,6 +111,10 @@ export class UILayer implements Layer { } onUnitEvent(unit: UnitView) { + if (!unit.isActive()) { + this.clearVeterancyDots(unit.id()); + return; + } const underConst = unit.isUnderConstruction(); if (underConst) { this.createLoadingBar(unit); @@ -111,6 +123,7 @@ export class UILayer implements Layer { switch (unit.type()) { case UnitType.Warship: { this.drawHealthBar(unit); + this.drawVeterancyDots(unit); break; } case UnitType.City: @@ -297,6 +310,58 @@ export class UILayer implements Layer { } } + private clearVeterancyDots(unitID: number): void { + const previous = this.allVeterancyDots.get(unitID); + if (previous === undefined || this.context === null) { + return; + } + const width = + previous.bars <= 0 + ? 0 + : (previous.bars - 1) * VETERANCY_DOT_SPACING + + VETERANCY_DOT_RADIUS * 2; + const startX = + previous.x - + width / 2 - + VETERANCY_DOT_RADIUS - + VETERANCY_DOT_CLEAR_PADDING; + const startY = + previous.y - VETERANCY_DOT_RADIUS - VETERANCY_DOT_CLEAR_PADDING; + const clearWidth = + width + (VETERANCY_DOT_RADIUS + VETERANCY_DOT_CLEAR_PADDING) * 2; + const clearHeight = + (VETERANCY_DOT_RADIUS + VETERANCY_DOT_CLEAR_PADDING) * 2; + this.context.clearRect(startX, startY, clearWidth, clearHeight); + this.allVeterancyDots.delete(unitID); + } + + private drawVeterancyDots(unit: UnitView): void { + if (this.context === null) { + return; + } + this.clearVeterancyDots(unit.id()); + const bars = Math.min(3, Math.max(0, unit.veterancyLevel())); + if (bars === 0) { + return; + } + + const centerX = this.game.x(unit.tile()); + const y = this.game.y(unit.tile()) - VETERANCY_DOT_Y_OFFSET; + const totalWidth = + (bars - 1) * VETERANCY_DOT_SPACING + VETERANCY_DOT_RADIUS * 2; + const startX = centerX - totalWidth / 2 + VETERANCY_DOT_RADIUS; + + this.context.fillStyle = "#d4af37"; + for (let i = 0; i < bars; i++) { + const x = startX + i * VETERANCY_DOT_SPACING; + this.context.beginPath(); + this.context.arc(x, y, VETERANCY_DOT_RADIUS, 0, Math.PI * 2); + this.context.fill(); + } + + this.allVeterancyDots.set(unit.id(), { x: centerX, y, bars }); + } + private updateProgressBars() { this.allProgressBars.forEach((progressBarInfo, unitId) => { const progress = this.getProgress(progressBarInfo.unit); diff --git a/src/core/execution/ShellExecution.ts b/src/core/execution/ShellExecution.ts index b28101e16..4c4f289f0 100644 --- a/src/core/execution/ShellExecution.ts +++ b/src/core/execution/ShellExecution.ts @@ -52,7 +52,11 @@ export class ShellExecution implements Execution { ); if (result.status === PathStatus.COMPLETE) { this.active = false; - this.target.modifyHealth(-this.effectOnTarget(), this._owner); + this.target.modifyHealth( + -this.effectOnTarget(), + this._owner, + this.ownerUnit, + ); this.shell.setReachedTarget(); this.shell.delete(false); return; @@ -64,7 +68,8 @@ export class ShellExecution implements Execution { private effectOnTarget(): number { const { damage } = this.mg.config().unitInfo(UnitType.Shell); - const baseDamage = damage ?? 250; + const veterancyBonus = this.ownerUnit.veterancyLevel() * 25; + const baseDamage = (damage ?? 250) + veterancyBonus; const roll = this.random.nextInt(1, 6); const damageMultiplier = (roll - 1) * 25 + 200; diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index 6a0845d64..5f2b486a0 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -151,7 +151,9 @@ export class WarshipExecution implements Execution { } private shootTarget() { - const shellAttackRate = this.mg.config().warshipShellAttackRate(); + const baseAttackRate = this.mg.config().warshipShellAttackRate(); + const veterancyReduction = this.warship.veterancyLevel() * 2; + const shellAttackRate = Math.max(1, baseAttackRate - veterancyReduction); if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) { if (this.warship.targetUnit()?.type() !== UnitType.TransportShip) { // Warships don't need to reload when attacking transport ships. diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 4fcf1aa84..a54bf2eb6 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -495,7 +495,11 @@ export interface Unit { isMarkedForDeletion(): boolean; markForDeletion(): void; isOverdueDeletion(): boolean; - delete(displayMessage?: boolean, destroyer?: Player): void; + delete( + displayMessage?: boolean, + destroyer?: Player, + destroyerUnit?: Unit, + ): void; tile(): TileRef; lastTile(): TileRef; move(tile: TileRef): void; @@ -534,7 +538,7 @@ export interface Unit { retreating(): boolean; orderBoatRetreat(): void; health(): number; - modifyHealth(delta: number, attacker?: Player): void; + modifyHealth(delta: number, attacker?: Player, attackerUnit?: Unit): void; // Troops setTroops(troops: number): void; @@ -564,6 +568,8 @@ export interface Unit { // Warships setPatrolTile(tile: TileRef): void; patrolTile(): TileRef | undefined; + veterancyLevel(): number; + gainVeterancy(): void; } export interface TerraNullius { diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index f5e125c3b..21cdda91d 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -141,6 +141,7 @@ export interface UnitUpdate { hasTrainStation: boolean; trainType?: TrainType; // Only for trains loaded?: boolean; // Only for trains + veterancyLevel?: number; // Only for warships } export interface AttackUpdate { diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 974874ad1..ec0211c6c 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -180,6 +180,9 @@ export class UnitView { isLoaded(): boolean | undefined { return this.data.loaded; } + veterancyLevel(): number { + return this.data.veterancyLevel ?? 0; + } } export class PlayerView { diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 67791b458..e1032d7e3 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -38,6 +38,7 @@ export class UnitImpl implements Unit { private _targetable: boolean = true; private _loaded: boolean | undefined; private _trainType: TrainType | undefined; + private _veterancyLevel = 0; // Nuke only private _trajectoryIndex: number = 0; private _trajectory: TrajectoryTile[]; @@ -142,6 +143,8 @@ export class UnitImpl implements Unit { hasTrainStation: this._hasTrainStation, trainType: this._trainType, loaded: this._loaded, + veterancyLevel: + this._type === UnitType.Warship ? this._veterancyLevel : undefined, }; } @@ -221,14 +224,14 @@ export class UnitImpl implements Unit { ); } - modifyHealth(delta: number, attacker?: Player): void { + modifyHealth(delta: number, attacker?: Player, attackerUnit?: Unit): void { this._health = withinInt( this._health + toInt(delta), 0n, toInt(this.info().maxHealth ?? 1), ); if (this._health === 0n) { - this.delete(true, attacker); + this.delete(true, attacker, attackerUnit); } } @@ -256,7 +259,11 @@ export class UnitImpl implements Unit { return this._deletionAt !== null && this.mg.ticks() - this._deletionAt > 0; } - delete(displayMessage?: boolean, destroyer?: Player): void { + delete( + displayMessage?: boolean, + destroyer?: Player, + destroyerUnit?: Unit, + ): void { if (!this.isActive()) { throw new Error(`cannot delete ${this} not active`); } @@ -275,6 +282,15 @@ export class UnitImpl implements Unit { } if (destroyer !== undefined) { + if ( + this._type === UnitType.Warship && + destroyerUnit !== undefined && + destroyerUnit.type() === UnitType.Warship && + destroyerUnit.owner() === destroyer && + destroyerUnit.isActive() + ) { + destroyerUnit.gainVeterancy(); + } switch (this._type) { case UnitType.TransportShip: this.mg @@ -483,4 +499,20 @@ export class UnitImpl implements Unit { this.mg.addUpdate(this.toUpdate()); } } + + veterancyLevel(): number { + return this._veterancyLevel; + } + + gainVeterancy(): void { + if (this._type !== UnitType.Warship) { + return; + } + const updated = Math.min(3, this._veterancyLevel + 1); + if (updated === this._veterancyLevel) { + return; + } + this._veterancyLevel = updated; + this.mg.addUpdate(this.toUpdate()); + } }