mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:50:43 +00:00
2661 PR 1/3 Warship Retreat Core, Blue UI Signal, and Transport-First Target Priority (#3498)
Part of #2661 (split into 3 PRs so they are not too large..) ## Description: Part 1/3 of #2661. This PR adds warship retreat basics, a blue retreating UI state, and updates target priority. Added: - Retreat state handling - Blue visual for retreating warships - Target priority: transport > warship > trade - Tests for retreat and target priority Example video: https://youtu.be/2hE2qeOeY48 Ship retreating: <img width="630" height="488" alt="image" src="https://github.com/user-attachments/assets/56d3e6d5-08af-453d-afe5-ee21dd6f3414" /> Ship healing: <img width="483" height="311" alt="image" src="https://github.com/user-attachments/assets/aeaf2239-bb81-444f-84ef-62dbcb48fddf" /> Back to being deployed: <img width="585" height="358" alt="image" src="https://github.com/user-attachments/assets/875828a2-8a24-4593-ac76-26426bb81057" /> ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: zixer._
This commit is contained in:
@@ -456,11 +456,17 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
private handleWarShipEvent(unit: UnitView) {
|
||||
if (unit.retreating()) {
|
||||
this.drawSprite(unit, colord("rgb(0,180,255)"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (unit.targetUnitId()) {
|
||||
this.drawSprite(unit, colord("rgb(200,0,0)"));
|
||||
} else {
|
||||
this.drawSprite(unit);
|
||||
return;
|
||||
}
|
||||
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
|
||||
private handleShellEvent(unit: UnitView) {
|
||||
|
||||
@@ -153,6 +153,7 @@ export interface Config {
|
||||
warshipPatrolRange(): number;
|
||||
warshipShellAttackRate(): number;
|
||||
warshipTargettingRange(): number;
|
||||
warshipRetreatHealthThreshold(): number;
|
||||
defensePostShellAttackRate(): number;
|
||||
defensePostTargettingRange(): number;
|
||||
// 0-1
|
||||
|
||||
@@ -969,6 +969,10 @@ export class DefaultConfig implements Config {
|
||||
return 20;
|
||||
}
|
||||
|
||||
warshipRetreatHealthThreshold(): number {
|
||||
return 750;
|
||||
}
|
||||
|
||||
defensePostShellAttackRate(): number {
|
||||
return 100;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TileRef } from "../game/GameMap";
|
||||
import { WaterPathFinder } from "../pathfinding/PathFinder";
|
||||
import { PathStatus } from "../pathfinding/types";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { findMinimumBy } from "../Util";
|
||||
import { ShellExecution } from "./ShellExecution";
|
||||
|
||||
export class WarshipExecution implements Execution {
|
||||
@@ -20,6 +21,8 @@ export class WarshipExecution implements Execution {
|
||||
private pathfinder: WaterPathFinder;
|
||||
private lastShellAttack = 0;
|
||||
private alreadySentShell = new Set<Unit>();
|
||||
private retreatPortTile: TileRef | undefined;
|
||||
private retreatingForRepair = false;
|
||||
|
||||
constructor(
|
||||
private input: (UnitParams<UnitType.Warship> & OwnerComp) | Unit,
|
||||
@@ -55,24 +58,166 @@ export class WarshipExecution implements Execution {
|
||||
this.warship.delete();
|
||||
return;
|
||||
}
|
||||
const healthBeforeHealing = this.warship.health();
|
||||
|
||||
const hasPort = this.warship.owner().unitCount(UnitType.Port) > 0;
|
||||
if (hasPort) {
|
||||
this.warship.modifyHealth(1);
|
||||
this.healWarship();
|
||||
|
||||
if (this.handleRepairRetreat()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 1: Check if need to heal before doing anything else
|
||||
if (this.shouldStartRepairRetreat(healthBeforeHealing)) {
|
||||
this.startRepairRetreat();
|
||||
if (this.handleRepairRetreat()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.warship.setTargetUnit(this.findTargetUnit());
|
||||
|
||||
// Always patrol for movement
|
||||
this.patrol();
|
||||
|
||||
// Priority 1: Shoot transport ship if in range
|
||||
if (this.warship.targetUnit()?.type() === UnitType.TransportShip) {
|
||||
this.shootTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 2: Fight enemy warship if in range
|
||||
if (this.warship.targetUnit()?.type() === UnitType.Warship) {
|
||||
this.shootTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 3: Hunt trade ship only if not healing and no enemy warship
|
||||
if (this.warship.targetUnit()?.type() === UnitType.TradeShip) {
|
||||
this.huntDownTradeShip();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.patrol();
|
||||
private healWarship(): void {
|
||||
if (this.warship.owner().unitCount(UnitType.Port) > 0) {
|
||||
this.warship.modifyHealth(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.warship.targetUnit() !== undefined) {
|
||||
this.shootTarget();
|
||||
private isFullyHealed(): boolean {
|
||||
const maxHealth = this.mg.config().unitInfo(UnitType.Warship).maxHealth;
|
||||
if (typeof maxHealth !== "number") {
|
||||
return true;
|
||||
}
|
||||
return this.warship.health() >= maxHealth;
|
||||
}
|
||||
|
||||
private shouldStartRepairRetreat(
|
||||
healthBeforeHealing = this.warship.health(),
|
||||
): boolean {
|
||||
if (this.retreatingForRepair) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
healthBeforeHealing >= this.mg.config().warshipRetreatHealthThreshold()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Only retreat if there's a friendly port
|
||||
const ports = this.warship.owner().units(UnitType.Port);
|
||||
return ports.length > 0;
|
||||
}
|
||||
|
||||
private findNearestPort(): TileRef | undefined {
|
||||
const ports = this.warship.owner().units(UnitType.Port);
|
||||
if (ports.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const warshipTile = this.warship.tile();
|
||||
const warshipComponent = this.mg.getWaterComponent(warshipTile);
|
||||
if (warshipComponent === null) {
|
||||
throw new Error(`Warship at tile ${warshipTile} has no water component`);
|
||||
}
|
||||
|
||||
const nearest = findMinimumBy(
|
||||
ports,
|
||||
(port) => this.mg.euclideanDistSquared(warshipTile, port.tile()),
|
||||
(port) => {
|
||||
const portComponent = this.mg.getWaterComponent(port.tile());
|
||||
if (portComponent === null) {
|
||||
throw new Error(`Port at tile ${port.tile()} has no water component`);
|
||||
}
|
||||
return portComponent === warshipComponent;
|
||||
},
|
||||
);
|
||||
|
||||
return nearest?.tile();
|
||||
}
|
||||
|
||||
private startRepairRetreat(): void {
|
||||
const portTile = this.findNearestPort();
|
||||
if (portTile === undefined) {
|
||||
return;
|
||||
}
|
||||
this.retreatingForRepair = true;
|
||||
this.retreatPortTile = portTile;
|
||||
this.warship.setRetreating(true);
|
||||
this.warship.setTargetUnit(undefined);
|
||||
}
|
||||
|
||||
private cancelRepairRetreat(clearTargetTile = true): void {
|
||||
this.retreatingForRepair = false;
|
||||
this.warship.setRetreating(false);
|
||||
this.retreatPortTile = undefined;
|
||||
if (clearTargetTile) {
|
||||
this.warship.setTargetTile(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private handleRepairRetreat(): boolean {
|
||||
if (!this.retreatingForRepair) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isFullyHealed()) {
|
||||
this.cancelRepairRetreat();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.warship.setTargetUnit(undefined);
|
||||
|
||||
const retreatPortTile = this.retreatPortTile;
|
||||
if (retreatPortTile === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.warship.tile() === retreatPortTile) {
|
||||
this.warship.setTargetTile(undefined);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.warship.setTargetTile(retreatPortTile);
|
||||
const result = this.pathfinder.next(this.warship.tile(), retreatPortTile);
|
||||
switch (result.status) {
|
||||
case PathStatus.COMPLETE:
|
||||
this.warship.move(result.node);
|
||||
if (result.node === retreatPortTile) {
|
||||
this.warship.setTargetTile(undefined);
|
||||
}
|
||||
break;
|
||||
case PathStatus.NEXT:
|
||||
this.warship.move(result.node);
|
||||
break;
|
||||
case PathStatus.NOT_FOUND:
|
||||
this.retreatPortTile = this.findNearestPort();
|
||||
if (this.retreatPortTile === undefined) {
|
||||
this.cancelRepairRetreat();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private findTargetUnit(): Unit | undefined {
|
||||
|
||||
@@ -614,6 +614,7 @@ export interface Unit {
|
||||
// Health
|
||||
hasHealth(): boolean;
|
||||
retreating(): boolean;
|
||||
setRetreating(retreating: boolean): void;
|
||||
orderBoatRetreat(): void;
|
||||
health(): number;
|
||||
modifyHealth(delta: number, attacker?: Player): void;
|
||||
|
||||
@@ -116,8 +116,11 @@ export class UnitView {
|
||||
return this.data.troops;
|
||||
}
|
||||
retreating(): boolean {
|
||||
if (this.type() !== UnitType.TransportShip) {
|
||||
throw Error("Must be a transport ship");
|
||||
if (
|
||||
this.type() !== UnitType.TransportShip &&
|
||||
this.type() !== UnitType.Warship
|
||||
) {
|
||||
throw Error("Must be a transport ship or warship");
|
||||
}
|
||||
return this.data.retreating;
|
||||
}
|
||||
|
||||
@@ -226,6 +226,7 @@ export class UnitImpl implements Unit {
|
||||
0n,
|
||||
toInt(this.info().maxHealth ?? 1),
|
||||
);
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
if (this._health === 0n) {
|
||||
this.delete(true, attacker);
|
||||
}
|
||||
@@ -331,16 +332,20 @@ export class UnitImpl implements Unit {
|
||||
return this._retreating;
|
||||
}
|
||||
|
||||
orderBoatRetreat() {
|
||||
if (this.type() !== UnitType.TransportShip) {
|
||||
throw new Error(`Cannot retreat ${this.type()}`);
|
||||
}
|
||||
if (!this._retreating) {
|
||||
this._retreating = true;
|
||||
setRetreating(retreating: boolean): void {
|
||||
if (this._retreating !== retreating) {
|
||||
this._retreating = retreating;
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
orderBoatRetreat() {
|
||||
if (this.type() !== UnitType.TransportShip) {
|
||||
throw new Error("Cannot retreat " + this.type());
|
||||
}
|
||||
this.setRetreating(true);
|
||||
}
|
||||
|
||||
isUnderConstruction(): boolean {
|
||||
return this._underConstruction;
|
||||
}
|
||||
|
||||
@@ -205,6 +205,37 @@ describe("Warship", () => {
|
||||
expect(tradeShip.owner().id()).toBe(player2.id());
|
||||
});
|
||||
|
||||
test("Warship prioritizes transport ships over warships", async () => {
|
||||
game.config().warshipShellAttackRate = () => Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const warship = player1.buildUnit(
|
||||
UnitType.Warship,
|
||||
game.ref(coastX + 1, 10),
|
||||
{
|
||||
patrolTile: game.ref(coastX + 1, 10),
|
||||
},
|
||||
);
|
||||
player2.buildUnit(UnitType.Warship, game.ref(coastX + 2, 10), {
|
||||
patrolTile: game.ref(coastX + 2, 10),
|
||||
});
|
||||
player2.buildUnit(UnitType.TransportShip, game.ref(coastX + 1, 11), {
|
||||
targetTile: game.ref(coastX + 1, 11),
|
||||
});
|
||||
|
||||
game.addExecution(new WarshipExecution(warship));
|
||||
|
||||
let selectedType: UnitType | undefined = undefined;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
game.executeNextTick();
|
||||
selectedType = warship.targetUnit()?.type();
|
||||
if (selectedType === UnitType.TransportShip) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(selectedType).toBe(UnitType.TransportShip);
|
||||
});
|
||||
|
||||
test("Warship does not target trade ships in different water components", async () => {
|
||||
// build port so warship can target trade ships
|
||||
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
||||
@@ -284,4 +315,42 @@ describe("Warship", () => {
|
||||
|
||||
expect(exec.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
test("Warship retreats when pre-heal health is below threshold", async () => {
|
||||
const maxHealth = game.config().unitInfo(UnitType.Warship).maxHealth;
|
||||
if (typeof maxHealth !== "number") {
|
||||
expect(typeof maxHealth).toBe("number");
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
if (maxHealth <= 599) {
|
||||
expect(maxHealth).toBeGreaterThan(599);
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
game.config().warshipRetreatHealthThreshold = () => 600;
|
||||
|
||||
const homePort = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
||||
const warship = player1.buildUnit(
|
||||
UnitType.Warship,
|
||||
game.ref(coastX + 1, 11),
|
||||
{
|
||||
patrolTile: game.ref(coastX + 1, 11),
|
||||
},
|
||||
);
|
||||
game.addExecution(new WarshipExecution(warship));
|
||||
|
||||
game.executeNextTick();
|
||||
warship.modifyHealth(-(maxHealth - 599));
|
||||
|
||||
game.executeNextTick();
|
||||
|
||||
expect(warship.retreating()).toBe(true);
|
||||
const distanceToPort = game.euclideanDistSquared(
|
||||
warship.tile(),
|
||||
homePort.tile(),
|
||||
);
|
||||
expect(
|
||||
distanceToPort <= 25 || warship.targetTile() === homePort.tile(),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user