From 54b4c5cdd8e6fd8474e5d5c861fd36b348b99769 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Sat, 10 Jan 2026 21:46:28 +0900 Subject: [PATCH] Added local attack --- src/client/ClientGameRunner.ts | 34 ++++++++++------ src/client/Transport.ts | 2 + src/client/attackSource.ts | 40 +++++++++++++++++++ .../graphics/layers/PlayerActionHandler.ts | 7 +++- .../graphics/layers/RadialMenuElements.ts | 13 +++++- src/core/Schemas.ts | 1 + src/core/execution/AttackExecution.ts | 17 ++++++++ src/core/execution/ExecutionManager.ts | 2 +- 8 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 src/client/attackSource.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 995decabe..946c5851f 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -47,6 +47,7 @@ import { Transport, } from "./Transport"; import { createCanvas } from "./Utils"; +import { resolveAttackSourceTile } from "./attackSource"; import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; import { GoToPlayerEvent } from "./graphics/layers/Leaderboard"; import SoundManager from "./sound/SoundManager"; @@ -523,12 +524,7 @@ export class ClientGameRunner { this.myPlayer.actions(tile).then((actions) => { if (this.myPlayer === null) return; if (actions.canAttack) { - this.eventBus.emit( - new SendAttackIntentEvent( - this.gameView.owner(tile).id(), - this.myPlayer.troops() * this.renderer.uiState.attackRatio, - ), - ); + void this.sendAttackIntent(tile); } else if (this.canAutoBoat(actions, tile)) { this.sendBoatAttackIntent(tile); } @@ -639,12 +635,7 @@ export class ClientGameRunner { this.myPlayer.actions(tile).then((actions) => { if (this.myPlayer === null) return; if (actions.canAttack) { - this.eventBus.emit( - new SendAttackIntentEvent( - this.gameView.owner(tile).id(), - this.myPlayer.troops() * this.renderer.uiState.attackRatio, - ), - ); + void this.sendAttackIntent(tile); } }); } @@ -693,6 +684,25 @@ export class ClientGameRunner { }); } + private async sendAttackIntent(tile: TileRef) { + if (!this.myPlayer) return; + + const targetId = this.gameView.owner(tile).id(); + const sourceTile = await resolveAttackSourceTile( + this.gameView, + this.myPlayer, + targetId, + tile, + ); + this.eventBus.emit( + new SendAttackIntentEvent( + targetId, + this.myPlayer.troops() * this.renderer.uiState.attackRatio, + sourceTile, + ), + ); + } + private canAutoBoat(actions: PlayerActions, tile: TileRef): boolean { if (!this.gameView.isLand(tile)) return false; diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 1f35131a4..57f557773 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -76,6 +76,7 @@ export class SendAttackIntentEvent implements GameEvent { constructor( public readonly targetID: PlayerID | null, public readonly troops: number, + public readonly sourceTile: TileRef | null = null, ) {} } @@ -491,6 +492,7 @@ export class Transport { clientID: this.lobbyConfig.clientID, targetID: event.targetID, troops: event.troops, + sourceTile: event.sourceTile, }); } diff --git a/src/client/attackSource.ts b/src/client/attackSource.ts new file mode 100644 index 000000000..0b16c0e16 --- /dev/null +++ b/src/client/attackSource.ts @@ -0,0 +1,40 @@ +import { PlayerID } from "../core/game/Game"; +import { TileRef } from "../core/game/GameMap"; +import { GameView, PlayerView } from "../core/game/GameView"; + +export async function resolveAttackSourceTile( + game: GameView, + player: PlayerView, + targetId: PlayerID | null, + clickedTile: TileRef, +): Promise { + const { borderTiles } = await player.borderTiles(); + let bestTile: TileRef | null = null; + let bestDistance = Infinity; + + for (const borderTile of borderTiles) { + if (!bordersTarget(game, borderTile, targetId)) { + continue; + } + const distance = game.manhattanDist(borderTile, clickedTile); + if (distance < bestDistance) { + bestDistance = distance; + bestTile = borderTile; + } + } + + return bestTile; +} + +function bordersTarget( + game: GameView, + borderTile: TileRef, + targetId: PlayerID | null, +): boolean { + for (const neighbor of game.neighbors(borderTile)) { + if (game.owner(neighbor).id() === targetId) { + return true; + } + } + return false; +} diff --git a/src/client/graphics/layers/PlayerActionHandler.ts b/src/client/graphics/layers/PlayerActionHandler.ts index 672cc2baf..b9bf885d2 100644 --- a/src/client/graphics/layers/PlayerActionHandler.ts +++ b/src/client/graphics/layers/PlayerActionHandler.ts @@ -30,11 +30,16 @@ export class PlayerActionHandler { return await player.actions(tile); } - handleAttack(player: PlayerView, targetId: string | null) { + handleAttack( + player: PlayerView, + targetId: string | null, + sourceTile: TileRef | null = null, + ) { this.eventBus.emit( new SendAttackIntentEvent( targetId, this.uiState.attackRatio * player.troops(), + sourceTile, ), ); } diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 6f72f7149..88e11aace 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -3,6 +3,7 @@ import { AllPlayers, PlayerActions, UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { Emoji, flattenedEmojiTable } from "../../../core/Util"; +import { resolveAttackSourceTile } from "../../attackSource"; import { renderNumber, translateText } from "../../Utils"; import { UIState } from "../UIState"; import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu"; @@ -584,7 +585,7 @@ export const centerButtonElement: CenterButtonElement = { return !params.playerActions.canAttack; }, - action: (params: MenuElementParams) => { + action: async (params: MenuElementParams) => { if (params.game.inSpawnPhase()) { params.playerActionHandler.handleSpawn(params.tile); } else { @@ -599,9 +600,17 @@ export const centerButtonElement: CenterButtonElement = { ); } } else { + const targetId = params.selected?.id() ?? null; + const sourceTile = await resolveAttackSourceTile( + params.game, + params.myPlayer, + targetId, + params.tile, + ); params.playerActionHandler.handleAttack( params.myPlayer, - params.selected?.id() ?? null, + targetId, + sourceTile, ); } } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 15927fa56..133dc290f 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -255,6 +255,7 @@ export const AttackIntentSchema = BaseIntentSchema.extend({ type: z.literal("attack"), targetID: ID.nullable(), troops: z.number().nonnegative().nullable(), + sourceTile: z.number().nullable().optional(), }); export const SpawnIntentSchema = BaseIntentSchema.extend({ diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 3adf24f75..4d721df4e 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -97,6 +97,8 @@ export class AttackExecution implements Execution { return; } + this.sourceTile = this.resolveSourceTile(); + this.startTroops ??= this.mg .config() .attackAmount(this._owner, this.target); @@ -311,6 +313,21 @@ export class AttackExecution implements Execution { } } + private resolveSourceTile(): TileRef | null { + if (this.sourceTile === null) { + return null; + } + if (this.mg.owner(this.sourceTile) !== this._owner) { + return null; + } + for (const neighbor of this.mg.neighbors(this.sourceTile)) { + if (this.mg.owner(neighbor) === this.target) { + return this.sourceTile; + } + } + return null; + } + private addNeighbors(tile: TileRef) { if (this.attack === null) { throw new Error("Attack not initialized"); diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index a161e5eb1..6baba4e4b 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -60,7 +60,7 @@ export class Executor { intent.troops, player, intent.targetID, - null, + intent.sourceTile ?? null, ); } case "cancel_attack":