diff --git a/src/client/graphics/layers/DynamicUILayer.ts b/src/client/graphics/layers/DynamicUILayer.ts index b151a1b83..482ceaa97 100644 --- a/src/client/graphics/layers/DynamicUILayer.ts +++ b/src/client/graphics/layers/DynamicUILayer.ts @@ -5,6 +5,7 @@ import { BonusEventUpdate, ConquestUpdate, GameUpdateType, + TextEventUpdate, } from "src/core/game/GameUpdates"; import type { GameView, UnitView } from "../../../core/game/GameView"; import { MoveWarshipIntentEvent } from "../../Transport"; @@ -62,12 +63,32 @@ export class DynamicUILayer implements Layer { this.onBonusEvent(bonusEvent); }); + updates[GameUpdateType.TextUIEvent]?.forEach((textEvent) => { + if (textEvent === undefined) return; + this.onTextEvent(textEvent); + }); + updates[GameUpdateType.ConquestEvent]?.forEach((update) => { if (update === undefined) return; this.onConquestEvent(update); }); } + onTextEvent(textEvent: TextEventUpdate) { + // Only display text fx for the current player + if (this.game.player(textEvent.player) !== this.game.myPlayer()) { + return; + } + const tile = textEvent.tile; + const x = this.game.x(tile); + const y = this.game.y(tile) + TEXT_OFFSET_Y; + const text = textEvent.text; + + this.uiElements.push( + new TextIndicator(this.transformHandler, text, x, y, 500, 12), + ); + } + onBonusEvent(bonus: BonusEventUpdate) { // Only display text fx for the current player if (this.game.player(bonus.player) !== this.game.myPlayer()) { diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index d6281be8d..24b8c0b7c 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -2,7 +2,11 @@ import { Theme } from "../../../core/configuration/Config"; import { EventBus } from "../../../core/EventBus"; import { UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; -import { ConquestUpdate, GameUpdateType } from "../../../core/game/GameUpdates"; +import { + ConquestUpdate, + GameUpdateType, + TextEventUpdate, +} from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; import SoundManager, { SoundEffect } from "../../sound/SoundManager"; import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; @@ -56,6 +60,12 @@ export class FxLayer implements Layer { if (update === undefined) return; this.onConquestEvent(update); }); + this.game + .updatesSinceLastTick() + ?.[GameUpdateType.TextUIEvent]?.forEach((update) => { + if (update === undefined) return; + this.onBoingEvent(update); + }); } onUnitEvent(unit: UnitView) { @@ -139,6 +149,12 @@ export class FxLayer implements Layer { } } + onBoingEvent(boing: TextEventUpdate) { + const x = this.game.x(boing.tile); + const y = this.game.y(boing.tile); + this.allFx.push(new ShockwaveFx(x, y, 1000, 20)); + } + onConquestEvent(conquest: ConquestUpdate) { // Only display fx for the current player const conqueror = this.game.player(conquest.conquerorId); diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 2bf2055b7..91cbfda13 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -11,7 +11,10 @@ import { } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { UniversalPathFinding } from "../pathfinding/PathFinder"; -import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola"; +import { + BouncingParabolaUniversalPathFinder, + ParabolaUniversalPathFinder, +} from "../pathfinding/PathFinder.Parabola"; import { PathStatus } from "../pathfinding/types"; import { PseudoRandom } from "../PseudoRandom"; import { NukeType } from "../StatsSchemas"; @@ -24,7 +27,9 @@ export class NukeExecution implements Execution { private mg: Game; private nuke: Unit | null = null; private tilesToDestroyCache: Set | undefined; - private pathFinder: ParabolaUniversalPathFinder; + private pathFinder: + | BouncingParabolaUniversalPathFinder + | ParabolaUniversalPathFinder; constructor( private nukeType: NukeType, @@ -41,18 +46,32 @@ export class NukeExecution implements Execution { if (this.speed === -1) { this.speed = this.mg.config().defaultNukeSpeed(); } - this.pathFinder = UniversalPathFinding.Parabola(mg, { - increment: this.speed, - distanceBasedHeight: this.nukeType !== UnitType.MIRVWarhead, - directionUp: this.rocketDirectionUp, - }); + + const rand = new PseudoRandom(ticks); + if (rand.chance(6)) { + this.pathFinder = UniversalPathFinding.BouncingParabola( + mg, + this.player.id(), + { + increment: this.speed, + distanceBasedHeight: this.nukeType !== UnitType.MIRVWarhead, + directionUp: this.rocketDirectionUp, + }, + ); + } else { + this.pathFinder = UniversalPathFinding.Parabola(mg, { + increment: this.speed, + distanceBasedHeight: this.nukeType !== UnitType.MIRVWarhead, + directionUp: this.rocketDirectionUp, + }); + } } public target(): Player | TerraNullius { return this.mg.owner(this.dst); } - private tilesToDestroy(): Set { + private tilesToDestroy(explosionTile: TileRef): Set { if (this.tilesToDestroyCache !== undefined) { return this.tilesToDestroyCache; } @@ -63,8 +82,8 @@ export class NukeExecution implements Execution { const rand = new PseudoRandom(this.mg.ticks()); const inner2 = magnitude.inner * magnitude.inner; const outer2 = magnitude.outer * magnitude.outer; - this.tilesToDestroyCache = this.mg.bfs(this.dst, (_, n: TileRef) => { - const d2 = this.mg?.euclideanDistSquared(this.dst, n) ?? 0; + this.tilesToDestroyCache = this.mg.bfs(explosionTile, (_, n: TileRef) => { + const d2 = this.mg?.euclideanDistSquared(explosionTile, n) ?? 0; return d2 <= outer2 && (d2 <= inner2 || rand.chance(2)); }); return this.tilesToDestroyCache; @@ -193,7 +212,7 @@ export class NukeExecution implements Execution { // Move to next tile const result = this.pathFinder.next(this.src!, this.dst, this.speed); if (result.status === PathStatus.COMPLETE) { - this.detonate(); + this.detonate(result.node); return; } else if (result.status === PathStatus.NEXT) { this.updateNukeTargetable(); @@ -247,7 +266,7 @@ export class NukeExecution implements Execution { ); } - private detonate() { + private detonate(explosionTile: TileRef) { if (this.nuke === null) { throw new Error("Not initialized"); } @@ -256,7 +275,7 @@ export class NukeExecution implements Execution { const config = mg.config(); const magnitude = config.nukeMagnitudes(this.nuke.type()); - const toDestroy = this.tilesToDestroy(); + const toDestroy = this.tilesToDestroy(explosionTile); // Retrieve all impacted players and the number of tiles const tilesPerPlayers = new Map(); diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index a85912bda..5a121fa55 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -59,6 +59,7 @@ export enum GameUpdateType { Hash, UnitIncoming, BonusEvent, + TextUIEvent, RailroadDestructionEvent, RailroadConstructionEvent, RailroadSnapEvent, @@ -83,6 +84,7 @@ export type GameUpdate = | UnitIncomingUpdate | AllianceExtensionUpdate | BonusEventUpdate + | TextEventUpdate | RailroadConstructionUpdate | RailroadDestructionUpdate | RailroadSnapUpdate @@ -98,6 +100,13 @@ export interface BonusEventUpdate { troops: number; } +export interface TextEventUpdate { + type: GameUpdateType.TextUIEvent; + player: PlayerID; + tile: TileRef; + text: string; +} + export interface RailroadConstructionUpdate { type: GameUpdateType.RailroadConstructionEvent; id: number; diff --git a/src/core/pathfinding/PathFinder.Parabola.ts b/src/core/pathfinding/PathFinder.Parabola.ts index c58f49fbc..3c7dc72ca 100644 --- a/src/core/pathfinding/PathFinder.Parabola.ts +++ b/src/core/pathfinding/PathFinder.Parabola.ts @@ -1,4 +1,6 @@ +import { Game, PlayerID } from "../game/Game"; import { GameMap, TileRef } from "../game/GameMap"; +import { GameUpdateType } from "../game/GameUpdates"; import { within } from "../Util"; import { DistanceBasedBezierCurve } from "../utilities/Line"; import { PathResult, PathStatus, SteppingPathFinder } from "./types"; @@ -7,6 +9,7 @@ export interface ParabolaOptions { increment?: number; distanceBasedHeight?: boolean; directionUp?: boolean; + minHeight?: number; } const PARABOLA_MIN_HEIGHT = 50; @@ -25,6 +28,7 @@ export class ParabolaUniversalPathFinder private createCurve(from: TileRef, to: TileRef): DistanceBasedBezierCurve { const increment = this.options?.increment ?? 3; const distanceBasedHeight = this.options?.distanceBasedHeight ?? true; + const minHeight = this.options?.minHeight ?? PARABOLA_MIN_HEIGHT; const directionUp = this.options?.directionUp ?? true; const p0 = { x: this.gameMap.x(from), y: this.gameMap.y(from) }; @@ -33,7 +37,7 @@ export class ParabolaUniversalPathFinder const dy = p3.y - p0.y; const distance = Math.sqrt(dx * dx + dy * dy); const maxHeight = distanceBasedHeight - ? Math.max(distance / 3, PARABOLA_MIN_HEIGHT) + ? Math.max(distance / 3, minHeight) : 0; const heightMult = directionUp ? -1 : 1; const mapHeight = this.gameMap.height(); @@ -88,3 +92,106 @@ export class ParabolaUniversalPathFinder return this.curve?.getCurrentIndex() ?? 0; } } + +export class BouncingParabolaUniversalPathFinder + implements SteppingPathFinder +{ + private parabola: ParabolaUniversalPathFinder; + private bouncing = false; + + private fromBounce: TileRef; + private toBounce: TileRef; + private previousIndex: number = 0; + + constructor( + private mg: Game, + private playerId: PlayerID, + private options?: ParabolaOptions, + ) { + this.parabola = new ParabolaUniversalPathFinder(mg.map(), options); + } + + next(from: number, to: number, dist?: number): PathResult { + if (this.bouncing) { + return this.nextBounce(dist); + } + const result = this.parabola.next(from, to, dist); + if (result.status === PathStatus.COMPLETE) { + if (this.bounce(from, to)) { + return this.nextBounce(); + } + } + return result; + } + + private bounce(from: number, to: number): boolean { + const bounceDest = this.computeBounceDestination(from, to); + if (!bounceDest) { + return false; + } + this.previousIndex = this.parabola.currentIndex(); + this.bouncing = true; + this.fromBounce = to; + this.toBounce = bounceDest; + + this.mg.addUpdate({ + type: GameUpdateType.TextUIEvent, + player: this.playerId, + tile: to, + text: "Boing", + }); + + this.parabola = new ParabolaUniversalPathFinder(this.mg.map(), { + increment: this.options?.increment ?? 3, + distanceBasedHeight: true, + directionUp: this.options?.directionUp ?? true, + minHeight: 25, + }); + return true; + } + + private nextBounce(dist?: number): PathResult { + return this.parabola.next(this.fromBounce, this.toBounce, dist); + } + + invalidate(): void { + this.parabola.invalidate(); + } + + findPath(from: number | number[], to: number): number[] | null { + if (Array.isArray(from)) { + throw new Error( + "ParabolaUniversalPathFinder does not support multiple start points", + ); + } + const tiles = this.parabola.findPath(from, to); + const newDest = this.computeBounceDestination(from, to); + if (tiles && newDest) { + const bounceTiles = this.parabola.findPath(to, newDest); + if (bounceTiles) { + return tiles?.concat(bounceTiles); + } + } + return tiles; + } + + currentIndex(): number { + return this.parabola.currentIndex() + this.previousIndex; + } + + private computeBounceDestination(src: TileRef, dst: TileRef): TileRef | null { + const destX = this.mg.x(dst); + const destY = this.mg.y(dst); + const srcX = this.mg.x(src); + const srcY = this.mg.y(src); + const newX = Math.min( + Math.floor(destX + (destX - srcX) / 2), + this.mg.width() - 1, + ); + const newY = Math.min( + Math.floor(destY + (destY - srcY) / 2), + this.mg.height() - 1, + ); + return this.mg.isValidCoord(newX, newY) ? this.mg.ref(newX, newY) : null; + } +} diff --git a/src/core/pathfinding/PathFinder.ts b/src/core/pathfinding/PathFinder.ts index f77776c36..8081c280c 100644 --- a/src/core/pathfinding/PathFinder.ts +++ b/src/core/pathfinding/PathFinder.ts @@ -1,10 +1,11 @@ -import { Game } from "../game/Game"; +import { Game, PlayerID } from "../game/Game"; import { GameMap, TileRef } from "../game/GameMap"; import { TrainStation } from "../game/TrainStation"; import { AStarRail } from "./algorithms/AStar.Rail"; import { AStarWater } from "./algorithms/AStar.Water"; import { AirPathFinder } from "./PathFinder.Air"; import { + BouncingParabolaUniversalPathFinder, ParabolaOptions, ParabolaUniversalPathFinder, } from "./PathFinder.Parabola"; @@ -27,6 +28,14 @@ export class UniversalPathFinding { ): ParabolaUniversalPathFinder { return new ParabolaUniversalPathFinder(gameMap, options); } + + static BouncingParabola( + mg: Game, + playerId: PlayerID, + options?: ParabolaOptions, + ): BouncingParabolaUniversalPathFinder { + return new BouncingParabolaUniversalPathFinder(mg, playerId, options); + } } /**