Add conquest FX (#1390)

## Description:

Add an animation when the player conquer another one.
The FX consists of two parts: short animation, and the gold won.

It is only displayed to the conquering player, so everybody knows who
won the money if multiple people fighted over the last pixel of a
player.

Changes:
  - Add new update `ConquestUpdate`
  - Add new fx `ConquestFx`
  - Merge conquest logic in `Game`


https://github.com/user-attachments/assets/9f985e41-baa4-48a6-927e-3216274f758c

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:

IngloriousTom
This commit is contained in:
DevelopingTom
2025-07-26 01:52:05 +02:00
committed by GitHub
parent 71fe6a81a0
commit c738648460
12 changed files with 126 additions and 34 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 627 B

@@ -1,4 +1,5 @@
import miniBigSmoke from "../../../resources/sprites/bigsmoke.png";
import conquestSword from "../../../resources/sprites/conquestSword.png";
import dust from "../../../resources/sprites/dust.png";
import miniExplosion from "../../../resources/sprites/miniExplosion.png";
import miniFire from "../../../resources/sprites/minifire.png";
@@ -115,6 +116,15 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
originX: 23,
originY: 19,
},
[FxType.Conquest]: {
url: conquestSword,
frameWidth: 21,
frameCount: 10,
frameDuration: 90,
looping: false,
originX: 10,
originY: 16,
},
};
export class AnimatedSpriteLoader {
+46
View File
@@ -0,0 +1,46 @@
import { ConquestUpdate } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { renderNumber } from "../../Utils";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "./Fx";
import { FadeFx, SpriteFx } from "./SpriteFx";
import { TextFx } from "./TextFx";
/**
* Conquest FX:
* - conquest sprite
* - gold displayed
*/
export function conquestFxFactory(
animatedSpriteLoader: AnimatedSpriteLoader,
conquest: ConquestUpdate,
game: GameView,
): Fx[] {
const conquestFx: Fx[] = [];
const conquered = game.player(conquest.conqueredId);
const x = conquered.nameLocation().x;
const y = conquered.nameLocation().y;
const swordAnimation = new SpriteFx(
animatedSpriteLoader,
x,
y,
FxType.Conquest,
2500,
);
const fadeAnimation = new FadeFx(swordAnimation, 0.1, 0.6);
conquestFx.push(fadeAnimation);
const shortenedGold = renderNumber(conquest.gold);
const goldText = new TextFx(
`+ ${shortenedGold}`,
x,
y + 8,
2500,
0,
"11px sans-serif",
);
conquestFx.push(goldText);
return conquestFx;
}
+1
View File
@@ -14,4 +14,5 @@ export enum FxType {
SAMExplosion = "SAMExplosion",
UnderConstruction = "UnderConstruction",
Dust = "Dust",
Conquest = "Conquest",
}
+6 -4
View File
@@ -45,15 +45,16 @@ export class FadeFx implements Fx {
export class SpriteFx implements Fx {
protected animatedSprite: AnimatedSprite | null;
protected elapsedTime = 0;
protected duration = 1000;
protected duration: number;
protected waitToTheEnd = false;
constructor(
animatedSpriteLoader: AnimatedSpriteLoader,
protected x: number,
protected y: number,
fxType: FxType,
duration?: number,
private owner?: PlayerView,
private theme?: Theme,
owner?: PlayerView,
theme?: Theme,
) {
this.animatedSprite = animatedSpriteLoader.createAnimatedSprite(
fxType,
@@ -63,6 +64,7 @@ export class SpriteFx implements Fx {
if (!this.animatedSprite) {
console.error("Could not load animated sprite", fxType);
} else {
this.waitToTheEnd = duration ? true : false;
this.duration = duration ?? this.animatedSprite.lifeTime() ?? 1000;
}
}
@@ -73,7 +75,7 @@ export class SpriteFx implements Fx {
this.elapsedTime += frameTime;
if (this.elapsedTime >= this.duration) return false;
if (!this.animatedSprite.isActive()) return false;
if (!this.animatedSprite.isActive() && !this.waitToTheEnd) return false;
const t = this.elapsedTime / this.duration;
this.animatedSprite.update(frameTime);
+1 -1
View File
@@ -9,12 +9,12 @@ export class TextFx implements Fx {
private y: number,
private duration: number,
private riseDistance: number = 30,
private font: string = "11px sans-serif",
private color: { r: number; g: number; b: number } = {
r: 255,
g: 255,
b: 255,
},
private font: string = "11px sans-serif",
) {}
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
+23
View File
@@ -2,12 +2,14 @@ import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import {
BonusEventUpdate,
ConquestUpdate,
GameUpdateType,
RailroadUpdate,
} from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import { renderNumber } from "../../Utils";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { conquestFxFactory } from "../fx/ConquestFx";
import { Fx, FxType } from "../fx/Fx";
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
import { SpriteFx } from "../fx/SpriteFx";
@@ -55,6 +57,12 @@ export class FxLayer implements Layer {
if (update === undefined) return;
this.onRailroadEvent(update);
});
this.game
.updatesSinceLastTick()
?.[GameUpdateType.ConquestEvent]?.forEach((update) => {
if (update === undefined) return;
this.onConquestEvent(update);
});
}
onBonusEvent(bonus: BonusEventUpdate) {
@@ -164,6 +172,21 @@ export class FxLayer implements Layer {
}
}
onConquestEvent(conquest: ConquestUpdate) {
// Only display fx for the current player
const conqueror = this.game.player(conquest.conquerorId);
if (conqueror !== this.game.myPlayer()) {
return;
}
const conquestFx = conquestFxFactory(
this.animatedSpriteLoader,
conquest,
this.game,
);
this.allFx = this.allFx.concat(conquestFx);
}
onWarshipEvent(unit: UnitView) {
if (!unit.isActive()) {
const x = this.game.x(unit.lastTile());
+2 -12
View File
@@ -1,4 +1,4 @@
import { renderNumber, renderTroops } from "../../client/Utils";
import { renderTroops } from "../../client/Utils";
import {
Attack,
Execution,
@@ -331,17 +331,7 @@ export class AttackExecution implements Execution {
private handleDeadDefender() {
if (!(this.target.isPlayer() && this.target.numTilesOwned() < 100)) return;
const gold = this.target.gold();
this.mg.displayMessage(
`Conquered ${this.target.displayName()} received ${renderNumber(
gold,
)} gold`,
MessageType.CONQUERED_PLAYER,
this._owner.id(),
gold,
);
this.target.removeGold(gold);
this._owner.addGold(gold);
this.mg.conquerPlayer(this._owner, this.target);
for (let i = 0; i < 10; i++) {
for (const tile of this.target.tiles()) {
+2 -16
View File
@@ -1,6 +1,5 @@
import { renderNumber } from "../../client/Utils";
import { Config } from "../configuration/Config";
import { Execution, Game, MessageType, Player, UnitType } from "../game/Game";
import { Execution, Game, Player, UnitType } from "../game/Game";
import { GameImpl } from "../game/GameImpl";
import { TileRef } from "../game/GameMap";
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
@@ -199,20 +198,7 @@ export class PlayerExecution implements Execution {
const tiles = this.mg.bfs(firstTile, filter);
if (this.player.numTilesOwned() === tiles.size) {
const gold = this.player.gold();
this.mg.displayMessage(
`Conquered ${this.player.displayName()} received ${renderNumber(
gold,
)} gold`,
MessageType.CONQUERED_PLAYER,
capturing.id(),
gold,
);
capturing.addGold(gold);
this.player.removeGold(gold);
// Record stats
this.mg.stats().goldWar(capturing, this.player, gold);
this.mg.conquerPlayer(capturing, this.player);
}
for (const tile of tiles) {
+1
View File
@@ -696,6 +696,7 @@ export interface Game extends GameMap {
addUpdate(update: GameUpdate): void;
railNetwork(): RailNetwork;
conquerPlayer(conqueror: Player, conquered: Player);
}
export interface PlayerActions {
+23
View File
@@ -1,3 +1,4 @@
import { renderNumber } from "../../client/Utils";
import { Config } from "../configuration/Config";
import { AllPlayersStats, ClientID, Winner } from "../Schemas";
import { simpleHash } from "../Util";
@@ -875,6 +876,28 @@ export class GameImpl implements Game {
railNetwork(): RailNetwork {
return this._railNetwork;
}
conquerPlayer(conqueror: Player, conquered: Player) {
const gold = conquered.gold();
this.displayMessage(
`Conquered ${conquered.displayName()} received ${renderNumber(
gold,
)} gold`,
MessageType.CONQUERED_PLAYER,
conqueror.id(),
gold,
);
conqueror.addGold(gold);
conquered.removeGold(gold);
this.addUpdate({
type: GameUpdateType.ConquestEvent,
conquerorId: conqueror.id(),
conqueredId: conquered.id(),
gold,
});
// Record stats
this.stats().goldWar(conqueror, conquered, gold);
}
}
// Or a more dynamic approach that will catch new enum values:
+11 -1
View File
@@ -44,6 +44,7 @@ export enum GameUpdateType {
UnitIncoming,
BonusEvent,
RailroadEvent,
ConquestEvent,
}
export type GameUpdate =
@@ -63,7 +64,8 @@ export type GameUpdate =
| UnitIncomingUpdate
| AllianceExtensionUpdate
| BonusEventUpdate
| RailroadUpdate;
| RailroadUpdate
| ConquestUpdate;
export interface BonusEventUpdate {
type: GameUpdateType.BonusEvent;
@@ -86,12 +88,20 @@ export interface RailTile {
tile: TileRef;
railType: RailType;
}
export interface RailroadUpdate {
type: GameUpdateType.RailroadEvent;
isActive: boolean;
railTiles: RailTile[];
}
export interface ConquestUpdate {
type: GameUpdateType.ConquestEvent;
conquerorId: PlayerID;
conqueredId: PlayerID;
gold: Gold;
}
export interface TileUpdateWrapper {
type: GameUpdateType.Tile;
update: TileUpdate;