diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 98213d866..aeb553593 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -135,6 +135,13 @@ export class SendHashEvent implements GameEvent { ) {} } +export class MoveWarshipIntentEvent implements GameEvent { + constructor( + public readonly unitId: number, + public readonly tile: number, + ) {} +} + export class Transport { private socket: WebSocket; @@ -194,6 +201,9 @@ export class Transport { this.eventBus.on(CancelAttackIntentEvent, (e) => this.onCancelAttackIntentEvent(e), ); + this.eventBus.on(MoveWarshipIntentEvent, (e) => { + this.onMoveWarshipEvent(e); + }); } private startPing() { @@ -522,6 +532,16 @@ export class Transport { }); } + private onMoveWarshipEvent(event: MoveWarshipIntentEvent) { + this.sendIntent({ + type: "move_warship", + clientID: this.lobbyConfig.clientID, + playerID: this.lobbyConfig.playerID, + unitId: event.unitId, + tile: event.tile, + }); + } + private sendIntent(intent: Intent) { if (this.isLocal || this.socket.readyState === WebSocket.OPEN) { const msg = ClientIntentMessageSchema.parse({ diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 8be306b10..0e06dba0a 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -17,6 +17,7 @@ import { } from "../../../core/game/GameMap"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { TransformHandler } from "../TransformHandler"; +import { MoveWarshipIntentEvent } from "../../Transport"; enum Relationship { Self, @@ -44,7 +45,7 @@ export class UnitLayer implements Layer { private selectedUnit: UnitView | null = null; // Configuration for unit selection - private readonly WARSHIP_SELECTION_RADIUS = 3; // Radius in game cells for warship selection hit zone + private readonly WARSHIP_SELECTION_RADIUS = 10; // Radius in game cells for warship selection hit zone constructor( private game: GameView, @@ -121,19 +122,19 @@ export class UnitLayer implements Layer { // Find warships near this cell, sorted by distance const nearbyWarships = this.findWarshipsNearCell(cell); - if (nearbyWarships.length > 0) { + if (this.selectedUnit) { + const clickRef = this.game.ref(cell.x, cell.y); + if (this.game.isOcean(clickRef)) { + this.eventBus.emit( + new MoveWarshipIntentEvent(this.selectedUnit.id(), clickRef), + ); + } + // Deselect + this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); + } else if (nearbyWarships.length > 0) { // Toggle selection of the closest warship const clickedUnit = nearbyWarships[0]; - if (this.selectedUnit === clickedUnit) { - // Deselect if already selected - this.eventBus.emit(new UnitSelectionEvent(clickedUnit, false)); - } else { - // Select the new unit - this.eventBus.emit(new UnitSelectionEvent(clickedUnit, true)); - } - } else if (this.selectedUnit) { - // If clicked elsewhere and there's a selection, deselect it - this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); + this.eventBus.emit(new UnitSelectionEvent(clickedUnit, true)); } } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index ebbf61329..bef3eea85 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -24,7 +24,8 @@ export type Intent = | DonateIntent | TargetTroopRatioIntent | BuildUnitIntent - | EmbargoIntent; + | EmbargoIntent + | MoveWarshipIntent; export type AttackIntent = z.infer; export type CancelAttackIntent = z.infer; @@ -43,6 +44,7 @@ export type TargetTroopRatioIntent = z.infer< typeof TargetTroopRatioIntentSchema >; export type BuildUnitIntent = z.infer; +export type MoveWarshipIntent = z.infer; export type Turn = z.infer; export type GameConfig = z.infer; @@ -164,6 +166,7 @@ const BaseIntentSchema = z.object({ "troop_ratio", "build_unit", "embargo", + "move_warship", ]), clientID: ID, playerID: ID, @@ -261,6 +264,12 @@ export const CancelAttackIntentSchema = BaseIntentSchema.extend({ attackID: z.string(), }); +export const MoveWarshipIntentSchema = BaseIntentSchema.extend({ + type: z.literal("move_warship"), + unitId: z.number(), + tile: z.number(), +}); + const IntentSchema = z.union([ AttackIntentSchema, CancelAttackIntentSchema, @@ -275,6 +284,7 @@ const IntentSchema = z.union([ TargetTroopRatioIntentSchema, BuildUnitIntentSchema, EmbargoIntentSchema, + MoveWarshipIntentSchema, ]); export const TurnSchema = z.object({ diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 224418df8..90ed18044 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -36,6 +36,7 @@ import { fixProfaneUsername, isProfaneUsername } from "../validations/username"; import { NoOpExecution } from "./NoOpExecution"; import { EmbargoExecution } from "./EmbargoExecution"; import { RetreatExecution } from "./RetreatExecution"; +import { MoveWarshipExecution } from "./MoveWarshipExecution"; export class Executor { // private random = new PseudoRandom(999) @@ -83,6 +84,8 @@ export class Executor { } case "cancel_attack": return new RetreatExecution(intent.playerID, intent.attackID); + case "move_warship": + return new MoveWarshipExecution(intent.unitId, intent.tile); case "spawn": return new SpawnExecution( new PlayerInfo( diff --git a/src/core/execution/MoveWarshipExecution.ts b/src/core/execution/MoveWarshipExecution.ts new file mode 100644 index 000000000..711c051ab --- /dev/null +++ b/src/core/execution/MoveWarshipExecution.ts @@ -0,0 +1,40 @@ +import { Execution, Game, Player, PlayerID } from "../game/Game"; + +const cancelDelay = 2; + +export class MoveWarshipExecution implements Execution { + private active = true; + private mg: Game; + + constructor( + public readonly unitId: number, + public readonly position: number, + ) {} + + init(mg: Game, ticks: number): void { + this.mg = mg; + } + + tick(ticks: number): void { + const warship = this.mg.units().find((u) => u.id() == this.unitId); + if (!warship) { + console.log("MoveWarshipExecution: warship is already dead"); + return; + } + warship.setMoveTarget(this.position); + this.active = false; + } + + owner(): Player { + const warship = this.mg.units().find((u) => u.id() == this.unitId); + return warship ? warship.owner() : null; + } + + isActive(): boolean { + return this.active; + } + + activeDuringSpawnPhase(): boolean { + return false; + } +} diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index 901fe6fd5..aa0ce3033 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -55,6 +55,70 @@ export class WarshipExecution implements Execution { this.random = new PseudoRandom(mg.ticks()); } + // Only for warships with "moveTarget" set + goToMoveTarget(target: TileRef): boolean { + // Patrol unless we are hunting down a tradeship + const result = this.pathfinder.nextTile(this.warship.tile(), target); + switch (result.type) { + case PathFindResultType.Completed: + this.warship.setMoveTarget(null); + return; + case PathFindResultType.NextTile: + this.warship.move(result.tile); + break; + case PathFindResultType.Pending: + break; + case PathFindResultType.PathNotFound: + consolex.log(`path not found to target`); + break; + } + } + + private shoot() { + if (this.mg.ticks() - this.lastShellAttack > this.shellAttackRate) { + this.lastShellAttack = this.mg.ticks(); + this.mg.addExecution( + new ShellExecution( + this.warship.tile(), + this.warship.owner(), + this.warship, + this.target, + ), + ); + if (!this.target.hasHealth()) { + // Don't send multiple shells to target that can be oneshotted + this.alreadySentShell.add(this.target); + this.target = null; + return; + } + } + } + + private patrol() { + this.warship.setWarshipTarget(this.target); + if (this.target == null || this.target.type() != UnitType.TradeShip) { + // Patrol unless we are hunting down a tradeship + const result = this.pathfinder.nextTile( + this.warship.tile(), + this.patrolTile, + ); + switch (result.type) { + case PathFindResultType.Completed: + this.patrolTile = this.randomTile(); + break; + case PathFindResultType.NextTile: + this.warship.move(result.tile); + break; + case PathFindResultType.Pending: + return; + case PathFindResultType.PathNotFound: + consolex.log(`path not found to patrol tile`); + this.patrolTile = this.randomTile(); + break; + } + } + } + tick(ticks: number): void { if (this.warship == null) { const spawn = this._owner.canBuild(UnitType.Warship, this.patrolTile); @@ -110,28 +174,17 @@ export class WarshipExecution implements Execution { return distSortUnit(this.mg, this.warship)(a, b); })[0] ?? null; - this.warship.setWarshipTarget(this.target); - if (this.target == null || this.target.type() != UnitType.TradeShip) { - // Patrol unless we are hunting down a tradeship - const result = this.pathfinder.nextTile( - this.warship.tile(), - this.patrolTile, - ); - switch (result.type) { - case PathFindResultType.Completed: - this.patrolTile = this.randomTile(); - break; - case PathFindResultType.NextTile: - this.warship.move(result.tile); - break; - case PathFindResultType.Pending: - return; - case PathFindResultType.PathNotFound: - consolex.log(`path not found to patrol tile`); - this.patrolTile = this.randomTile(); - break; + if (this.warship.moveTarget()) { + this.goToMoveTarget(this.warship.moveTarget()); + // If we have a "move target" then we cannot target trade ships as it + // requires moving. + if (this.target && this.target.type() == UnitType.TradeShip) { + this.target = null; } + } else if (!this.target || this.target.type() != UnitType.TradeShip) { + this.patrol(); } + if ( this.target == null || !this.target.isActive() || @@ -141,25 +194,16 @@ export class WarshipExecution implements Execution { this.target = null; return; } + + this.warship.setWarshipTarget(this.target); + + // If we have a move target we do not want to go after trading ships + if (!this.target) { + return; + } + if (this.target.type() != UnitType.TradeShip) { - if (this.mg.ticks() - this.lastShellAttack > this.shellAttackRate) { - this.lastShellAttack = this.mg.ticks(); - this.mg.addExecution( - new ShellExecution( - this.warship.tile(), - this.warship.owner(), - this.warship, - this.target, - ), - ); - if (!this.target.hasHealth()) { - // Don't send multiple shells to target that can be oneshotted - this.alreadySentShell.add(this.target); - this.target = null; - return; - } - } - // Only hunt down tradeships + this.shoot(); return; } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 0271a50cd..25b676d5e 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -238,6 +238,9 @@ export interface Unit { dstPort(): Unit; // Only for trade ships detonationDst(): TileRef; // Only for nukes + setMoveTarget(cell: TileRef): void; + moveTarget(): TileRef | null; + // Mutations setTroops(troops: number): void; delete(displayerMessage?: boolean): void; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 4c3039336..43cb48c14 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -1,4 +1,4 @@ -import { MessageType, nukeTypes, UnitSpecificInfos } from "./Game"; +import { MessageType, UnitSpecificInfos } from "./Game"; import { UnitUpdate } from "./GameUpdates"; import { GameUpdateType } from "./GameUpdates"; import { simpleHash, toInt, within, withinInt } from "../Util"; @@ -14,6 +14,7 @@ export class UnitImpl implements Unit { private _lastTile: TileRef = null; // Currently only warship use it private _target: Unit = null; + private _moveTarget: TileRef = null; private _constructionType: UnitType = undefined; @@ -184,4 +185,12 @@ export class UnitImpl implements Unit { setDstPort(dstPort: Unit): void { this._dstPort = dstPort; } + + setMoveTarget(moveTarget: TileRef) { + this._moveTarget = moveTarget; + } + + moveTarget(): TileRef | null { + return this._moveTarget; + } }