diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 0a0e8b5cf..b6a05264c 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -265,11 +265,11 @@ export function createRenderer( const layers: Layer[] = [ new TerrainLayer(game, transformHandler), new TerritoryLayer(game, eventBus, transformHandler, userSettings), - new RailroadLayer(game, eventBus, transformHandler), + new RailroadLayer(game, eventBus, transformHandler, uiState), structureLayer, samRadiusLayer, new UnitLayer(game, eventBus, transformHandler), - new FxLayer(game, transformHandler), + new FxLayer(game, eventBus, transformHandler), new UILayer(game, eventBus, transformHandler), new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState), new StructureIconsLayer(game, eventBus, uiState, transformHandler), diff --git a/src/client/graphics/UIState.ts b/src/client/graphics/UIState.ts index f47acef88..f10094e0a 100644 --- a/src/client/graphics/UIState.ts +++ b/src/client/graphics/UIState.ts @@ -3,5 +3,6 @@ import { UnitType } from "../../core/game/Game"; export interface UIState { attackRatio: number; ghostStructure: UnitType | null; + overlappingRailroads: number[]; rocketDirectionUp: boolean; } diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index b8ca35c8c..d6281be8d 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -1,10 +1,8 @@ import { Theme } from "../../../core/configuration/Config"; +import { EventBus } from "../../../core/EventBus"; import { UnitType } from "../../../core/game/Game"; -import { - ConquestUpdate, - GameUpdateType, - RailroadUpdate, -} from "../../../core/game/GameUpdates"; +import { TileRef } from "../../../core/game/GameMap"; +import { ConquestUpdate, GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; import SoundManager, { SoundEffect } from "../../sound/SoundManager"; import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; @@ -15,6 +13,7 @@ import { SpriteFx } from "../fx/SpriteFx"; import { UnitExplosionFx } from "../fx/UnitExplosionFx"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; +import { RailTileChangedEvent } from "./RailroadLayer"; export class FxLayer implements Layer { private canvas: HTMLCanvasElement; private context: CanvasRenderingContext2D; @@ -30,6 +29,7 @@ export class FxLayer implements Layer { constructor( private game: GameView, + private eventBus: EventBus, private transformHandler: TransformHandler, ) { this.theme = this.game.config().theme(); @@ -50,12 +50,6 @@ export class FxLayer implements Layer { if (unitView === undefined) return; this.onUnitEvent(unitView); }); - this.game - .updatesSinceLastTick() - ?.[GameUpdateType.RailroadEvent]?.forEach((update) => { - if (update === undefined) return; - this.onRailroadEvent(update); - }); this.game .updatesSinceLastTick() ?.[GameUpdateType.ConquestEvent]?.forEach((update) => { @@ -129,22 +123,19 @@ export class FxLayer implements Layer { } } - onRailroadEvent(railroad: RailroadUpdate) { - const railTiles = railroad.railTiles; - for (const rail of railTiles) { - // No need for pseudorandom, this is fx - const chanceFx = Math.floor(Math.random() * 3); - if (chanceFx === 0) { - const x = this.game.x(rail.tile); - const y = this.game.y(rail.tile); - const animation = new SpriteFx( - this.animatedSpriteLoader, - x, - y, - FxType.Dust, - ); - this.allFx.push(animation); - } + onRailroadEvent(tile: TileRef) { + // No need for pseudorandom, this is fx + const chanceFx = Math.floor(Math.random() * 3); + if (chanceFx === 0) { + const x = this.game.x(tile); + const y = this.game.y(tile); + const animation = new SpriteFx( + this.animatedSpriteLoader, + x, + y, + FxType.Dust, + ); + this.allFx.push(animation); } } @@ -240,6 +231,10 @@ export class FxLayer implements Layer { async init() { this.redraw(); + + this.eventBus.on(RailTileChangedEvent, (e) => { + this.onRailroadEvent(e.tile); + }); try { this.animatedSpriteLoader.loadAllAnimatedSpriteImages(); console.log("FX sprites loaded successfully"); diff --git a/src/client/graphics/layers/RailroadLayer.ts b/src/client/graphics/layers/RailroadLayer.ts index 40157cec2..f0c2f073d 100644 --- a/src/client/graphics/layers/RailroadLayer.ts +++ b/src/client/graphics/layers/RailroadLayer.ts @@ -1,33 +1,49 @@ import { colord } from "colord"; -import { Theme } from "../../../core/configuration/Config"; -import { EventBus } from "../../../core/EventBus"; -import { PlayerID } from "../../../core/game/Game"; +import { EventBus, GameEvent } from "../../../core/EventBus"; +import { PlayerID, UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameUpdateType, - RailroadUpdate, - RailTile, - RailType, + RailroadConstructionUpdate, + RailroadDestructionUpdate, + RailroadSnapUpdate, } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; import { AlternateViewEvent } from "../../InputHandler"; import { TransformHandler } from "../TransformHandler"; +import { UIState } from "../UIState"; import { Layer } from "./Layer"; import { getBridgeRects, getRailroadRects } from "./RailroadSprites"; +import { + computeRailTiles, + RailroadView, + RailTile, + RailType, +} from "./RailroadView"; type RailRef = { tile: RailTile; numOccurence: number; lastOwnerId: PlayerID | null; }; +const SNAPPABLE_STRUCTURES: UnitType[] = [ + UnitType.Port, + UnitType.City, + UnitType.Factory, +]; +export class RailTileChangedEvent implements GameEvent { + constructor(public tile: TileRef) {} +} export class RailroadLayer implements Layer { private canvas: HTMLCanvasElement; private context: CanvasRenderingContext2D; - private theme: Theme; private alternativeView = false; // Save the number of railroads per tiles. Delete when it reaches 0 private existingRailroads = new Map(); + private railroads = new Map(); + // Railroads under construction + private pendingRailroads = new Set(); private nextRailIndexToCheck = 0; private railTileList: TileRef[] = []; private railTileIndex = new Map(); @@ -38,20 +54,52 @@ export class RailroadLayer implements Layer { private game: GameView, private eventBus: EventBus, private transformHandler: TransformHandler, - ) { - this.theme = game.config().theme(); - } + private uiState: UIState, + ) {} shouldTransform(): boolean { return true; } tick() { + this.updatePendingRailroads(); const updates = this.game.updatesSinceLastTick(); - const railUpdates = - updates !== null ? updates[GameUpdateType.RailroadEvent] : []; - for (const rail of railUpdates) { - this.handleRailroadRendering(rail); + if (!updates) return; + // The event has to be handled in this specific order: construction / snap / destruction + // Otherwise some ID may not be available yet/anymore + updates[GameUpdateType.RailroadConstructionEvent]?.forEach((update) => { + if (update === undefined) return; + this.onRailroadConstruction(update); + }); + updates[GameUpdateType.RailroadSnapEvent]?.forEach((update) => { + if (update === undefined) return; + this.onRailroadSnapEvent(update); + }); + updates[GameUpdateType.RailroadDestructionEvent]?.forEach((update) => { + if (update === undefined) return; + this.onRailroadDestruction(update); + }); + } + + updatePendingRailroads() { + for (const id of this.pendingRailroads) { + const pending = this.railroads.get(id); + if (pending === undefined) { + // Rail deleted or snapped before the end of the animation + this.pendingRailroads.delete(id); + continue; + } + const newTiles = pending.tick(); + if (newTiles.length === 0) { + // Animation complete + this.pendingRailroads.delete(id); + continue; + } + + for (const railTile of newTiles) { + this.paintRailTile(railTile); + this.eventBus.emit(new RailTileChangedEvent(railTile.tile)); + } } } @@ -120,6 +168,32 @@ export class RailroadLayer implements Layer { } } + private highlightOverlappingRailroads(context: CanvasRenderingContext2D) { + if ( + this.uiState.ghostStructure === null || + !SNAPPABLE_STRUCTURES.includes(this.uiState.ghostStructure) + ) + return; + if ( + this.uiState.overlappingRailroads === undefined || + this.uiState.overlappingRailroads.length === 0 + ) + return; + const offsetX = -this.game.width() / 2; + const offsetY = -this.game.height() / 2; + context.fillStyle = "rgba(0, 255, 0, 0.4)"; + for (const id of this.uiState.overlappingRailroads) { + const rail = this.railroads.get(id); + if (rail) { + for (const railTile of rail.drawnTiles()) { + const x = this.game.x(railTile.tile); + const y = this.game.y(railTile.tile); + context.fillRect(x + offsetX - 1, y + offsetY - 1, 2.5, 2.5); + } + } + } + } + renderLayer(context: CanvasRenderingContext2D) { const scale = this.transformHandler.scale; if (scale <= 1) { @@ -154,6 +228,7 @@ export class RailroadLayer implements Layer { context.save(); context.globalAlpha = alpha; + this.highlightOverlappingRailroads(context); context.drawImage( this.canvas, srcX, @@ -168,55 +243,115 @@ export class RailroadLayer implements Layer { context.restore(); } - private handleRailroadRendering(railUpdate: RailroadUpdate) { - for (const railRoad of railUpdate.railTiles) { - if (railUpdate.isActive) { - this.paintRailroad(railRoad); - } else { - this.clearRailroad(railRoad); - } + private onRailroadSnapEvent(update: RailroadSnapUpdate) { + const original = this.railroads.get(update.originalId); + if (!original) { + console.warn("Could not snap railroad: ", update.originalId); + return; } + if (!original.isComplete()) { + // The animation is not complete but we don't want to compute where the animation should resume + // Just draw every remaining rails at once + this.drawRemainingTiles(original); + } + + // No need to compute the directions here, the rails are already painted + const directions1: RailTile[] = update.tiles1.map((tile) => ({ + tile, + type: RailType.HORIZONTAL, + })); + const directions2: RailTile[] = update.tiles2.map((tile) => ({ + tile, + type: RailType.HORIZONTAL, + })); + // The rails are already painted, consider them complete + this.railroads.set( + update.newId1, + new RailroadView(update.newId1, directions1, true), + ); + this.railroads.set( + update.newId2, + new RailroadView(update.newId2, directions2, true), + ); + + this.railroads.delete(update.originalId); } - private paintRailroad(railRoad: RailTile) { - const currentOwner = this.game.owner(railRoad.tile)?.id() ?? null; - const railTile = this.existingRailroads.get(railRoad.tile); + private drawRemainingTiles(railroad: RailroadView) { + for (const tile of railroad.remainingTiles()) { + this.paintRail(tile); + } + this.pendingRailroads.delete(railroad.id); + } - if (railTile) { - railTile.numOccurence++; - railTile.tile = railRoad; - railTile.lastOwnerId = currentOwner; + private onRailroadConstruction(railUpdate: RailroadConstructionUpdate) { + const railTiles = computeRailTiles(this.game, railUpdate.tiles); + const rail = new RailroadView(railUpdate.id, railTiles); + this.addRailroad(rail); + } + + private onRailroadDestruction(railUpdate: RailroadDestructionUpdate) { + const railroad = this.railroads.get(railUpdate.id); + if (!railroad) { + console.warn("Can't remove unexisting railroad: ", railUpdate.id); + return; + } + this.removeRailroad(railroad); + } + + private addRailroad(railroad: RailroadView) { + this.railroads.set(railroad.id, railroad); + this.pendingRailroads.add(railroad.id); + } + + private removeRailroad(railroad: RailroadView) { + this.pendingRailroads.delete(railroad.id); + for (const railTile of railroad.drawnTiles()) { + this.clearRailroad(railTile.tile); + this.eventBus.emit(new RailTileChangedEvent(railTile.tile)); + } + this.railroads.delete(railroad.id); + } + + private paintRailTile(railTile: RailTile) { + const currentOwner = this.game.owner(railTile.tile)?.id() ?? null; + const railRef = this.existingRailroads.get(railTile.tile); + + if (railRef) { + railRef.numOccurence++; + railRef.tile = railTile; + railRef.lastOwnerId = currentOwner; } else { - this.existingRailroads.set(railRoad.tile, { - tile: railRoad, + this.existingRailroads.set(railTile.tile, { + tile: railTile, numOccurence: 1, lastOwnerId: currentOwner, }); - this.railTileIndex.set(railRoad.tile, this.railTileList.length); - this.railTileList.push(railRoad.tile); - this.paintRail(railRoad); + this.railTileIndex.set(railTile.tile, this.railTileList.length); + this.railTileList.push(railTile.tile); + this.paintRail(railTile); } } - private clearRailroad(railRoad: RailTile) { - const ref = this.existingRailroads.get(railRoad.tile); + private clearRailroad(railroad: TileRef) { + const ref = this.existingRailroads.get(railroad); if (ref) ref.numOccurence--; if (!ref || ref.numOccurence <= 0) { - this.existingRailroads.delete(railRoad.tile); - this.removeRailTile(railRoad.tile); + this.existingRailroads.delete(railroad); + this.removeRailTile(railroad); if (this.context === undefined) throw new Error("Not initialized"); - if (this.game.isWater(railRoad.tile)) { + if (this.game.isWater(railroad)) { this.context.clearRect( - this.game.x(railRoad.tile) * 2 - 2, - this.game.y(railRoad.tile) * 2 - 2, + this.game.x(railroad) * 2 - 2, + this.game.y(railroad) * 2 - 2, 5, 6, ); } else { this.context.clearRect( - this.game.x(railRoad.tile) * 2 - 1, - this.game.y(railRoad.tile) * 2 - 1, + this.game.x(railroad) * 2 - 1, + this.game.y(railroad) * 2 - 1, 3, 3, ); @@ -242,15 +377,15 @@ export class RailroadLayer implements Layer { } } - paintRail(railRoad: RailTile) { + paintRail(railTile: RailTile) { if (this.context === undefined) throw new Error("Not initialized"); - const { tile } = railRoad; - const { railType } = railRoad; + const { tile } = railTile; + const { type } = railTile; const x = this.game.x(tile); const y = this.game.y(tile); // If rail tile is over water, paint a bridge underlay first if (this.game.isWater(tile)) { - this.paintBridge(this.context, x, y, railType); + this.paintBridge(this.context, x, y, type); } const owner = this.game.owner(tile); const recipient = owner.isPlayer() ? owner : null; @@ -263,7 +398,7 @@ export class RailroadLayer implements Layer { } this.context.fillStyle = color.toRgbString(); - this.paintRailRects(this.context, x, y, railType); + this.paintRailRects(this.context, x, y, type); } private paintRailRects( diff --git a/src/client/graphics/layers/RailroadSprites.ts b/src/client/graphics/layers/RailroadSprites.ts index d0734acad..d7a1b1097 100644 --- a/src/client/graphics/layers/RailroadSprites.ts +++ b/src/client/graphics/layers/RailroadSprites.ts @@ -1,4 +1,4 @@ -import { RailType } from "../../../core/game/GameUpdates"; +import { RailType } from "./RailroadView"; const railTypeToFunctionMap: Record number[][]> = { [RailType.TOP_RIGHT]: topRightRailroadCornerRects, diff --git a/src/client/graphics/layers/RailroadView.ts b/src/client/graphics/layers/RailroadView.ts new file mode 100644 index 000000000..e4802fc94 --- /dev/null +++ b/src/client/graphics/layers/RailroadView.ts @@ -0,0 +1,177 @@ +import { TileRef } from "../../../core/game/GameMap"; +import { GameView } from "../../../core/game/GameView"; + +export enum RailType { + VERTICAL, + HORIZONTAL, + TOP_LEFT, + TOP_RIGHT, + BOTTOM_LEFT, + BOTTOM_RIGHT, +} + +export type RailTile = { + tile: TileRef; + type: RailType; +}; + +export function computeRailTiles(game: GameView, tiles: TileRef[]): RailTile[] { + if (tiles.length === 0) return []; + if (tiles.length === 1) { + return [{ tile: tiles[0], type: RailType.VERTICAL }]; + } + const railTypes: RailTile[] = []; + // Inverse direction computation for the first tile + railTypes.push({ + tile: tiles[0], + type: computeExtremityDirection(game, tiles[0], tiles[1]), + }); + for (let i = 1; i < tiles.length - 1; i++) { + const direction = computeDirection( + game, + tiles[i - 1], + tiles[i], + tiles[i + 1], + ); + railTypes.push({ tile: tiles[i], type: direction }); + } + railTypes.push({ + tile: tiles[tiles.length - 1], + type: computeExtremityDirection( + game, + tiles[tiles.length - 1], + tiles[tiles.length - 2], + ), + }); + return railTypes; +} + +function computeExtremityDirection( + game: GameView, + tile: TileRef, + next: TileRef, +): RailType { + const x = game.x(tile); + const y = game.y(tile); + const nextX = game.x(next); + const nextY = game.y(next); + + const dx = nextX - x; + const dy = nextY - y; + + if (dx === 0 && dy === 0) return RailType.VERTICAL; // No movement + + if (dx === 0) { + return RailType.VERTICAL; + } else if (dy === 0) { + return RailType.HORIZONTAL; + } + return RailType.VERTICAL; +} + +export function computeDirection( + game: GameView, + prev: TileRef, + current: TileRef, + next: TileRef, +): RailType { + const x1 = game.x(prev); + const y1 = game.y(prev); + const x2 = game.x(current); + const y2 = game.y(current); + const x3 = game.x(next); + const y3 = game.y(next); + + const dx1 = x2 - x1; + const dy1 = y2 - y1; + const dx2 = x3 - x2; + const dy2 = y3 - y2; + + // Straight line + if (dx1 === dx2 && dy1 === dy2) { + if (dx1 !== 0) return RailType.HORIZONTAL; + if (dy1 !== 0) return RailType.VERTICAL; + } + + // Turn (corner) cases + if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) { + // Now figure out which type of corner + if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT; + if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT; + if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT; + if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT; + + if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT; + if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT; + if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT; + if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT; + } + console.warn(`Invalid rail segment: ${dx1}:${dy1}, ${dx2}:${dy2}`); + return RailType.VERTICAL; +} + +/** + * A list of tile that can be incrementally painted each tick + */ +export class RailroadView { + private headIndex: number = 0; + private tailIndex: number; + private increment: number = 3; + constructor( + public id: number, + private railTiles: RailTile[], + complete: boolean = false, + ) { + // If the railroad is considered complete, no drawing or animation is required + this.tailIndex = complete ? 0 : railTiles.length; + } + + isComplete(): boolean { + return this.headIndex >= this.tailIndex; + } + + tiles(): RailTile[] { + return this.railTiles; + } + + remainingTiles(): RailTile[] { + if (this.isComplete()) { + // Animation complete, no tiles need to be painted + return []; + } + return this.railTiles.slice(this.headIndex, this.tailIndex); + } + + drawnTiles(): RailTile[] { + if (this.isComplete()) { + // Animation complete, every tiles have been painted + return this.tiles(); + } + let drawnTiles = this.railTiles.slice(0, this.headIndex); + drawnTiles = drawnTiles.concat(this.railTiles.slice(this.tailIndex)); + return drawnTiles; + } + + tick(): RailTile[] { + if (this.isComplete()) return []; + let updatedRailTiles: RailTile[]; + // Check if remaining tiles can be done all at once + if (this.tailIndex - this.headIndex <= 2 * this.increment) { + updatedRailTiles = this.railTiles.slice(this.headIndex, this.tailIndex); + } else { + updatedRailTiles = [ + ...this.railTiles.slice( + this.headIndex, + this.headIndex + this.increment, + ), + ...this.railTiles.slice( + this.tailIndex - this.increment, + this.tailIndex, + ), + ]; + } + this.headIndex = Math.min(this.headIndex + this.increment, this.tailIndex); + this.tailIndex = Math.max(this.tailIndex - this.increment, this.headIndex); + return updatedRailTiles; + } +} diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 6f57e10c9..9cb3cd3af 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -333,10 +333,15 @@ export class StructureIconsLayer implements Layer { new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }), ]; } + // No overlapping when a structure is upgradable + this.uiState.overlappingRailroads = []; } else if (unit.canBuild === false) { this.ghostUnit.container.filters = [ new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), ]; + this.uiState.overlappingRailroads = []; + } else { + this.uiState.overlappingRailroads = unit.overlappingRailroads; } const scale = this.transformHandler.scale; @@ -450,7 +455,13 @@ export class StructureIconsLayer implements Layer { priceGroup: ghost.priceGroup, priceBox: ghost.priceBox, range: null, - buildableUnit: { type, canBuild: false, canUpgrade: false, cost: 0n }, + buildableUnit: { + type, + canBuild: false, + canUpgrade: false, + cost: 0n, + overlappingRailroads: [], + }, }; const showPrice = this.game.config().userSettings().cursorCostLabel(); this.updateGhostPrice(0, showPrice); diff --git a/src/core/execution/RailroadExecution.ts b/src/core/execution/RailroadExecution.ts deleted file mode 100644 index 97bc8d1d9..000000000 --- a/src/core/execution/RailroadExecution.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { Execution, Game } from "../game/Game"; -import { TileRef } from "../game/GameMap"; -import { GameUpdateType, RailTile, RailType } from "../game/GameUpdates"; -import { Railroad } from "../game/Railroad"; - -export class RailroadExecution implements Execution { - private mg: Game; - private active: boolean = true; - private headIndex: number = 0; - private tailIndex: number = 0; - private increment: number = 3; - private railTiles: RailTile[] = []; - constructor(private railRoad: Railroad) { - this.tailIndex = railRoad.tiles.length; - } - - isActive(): boolean { - return this.active; - } - - init(mg: Game, ticks: number): void { - this.mg = mg; - const tiles = this.railRoad.tiles; - // Inverse direction computation for the first tile - this.railTiles.push({ - tile: tiles[0], - railType: - tiles.length > 0 - ? this.computeExtremityDirection(tiles[0], tiles[1]) - : RailType.VERTICAL, - }); - for (let i = 1; i < tiles.length - 1; i++) { - const direction = this.computeDirection( - tiles[i - 1], - tiles[i], - tiles[i + 1], - ); - this.railTiles.push({ tile: tiles[i], railType: direction }); - } - this.railTiles.push({ - tile: tiles[tiles.length - 1], - railType: - tiles.length > 0 - ? this.computeExtremityDirection( - tiles[tiles.length - 1], - tiles[tiles.length - 2], - ) - : RailType.VERTICAL, - }); - } - - private computeExtremityDirection(tile: TileRef, next: TileRef): RailType { - const x = this.mg.x(tile); - const y = this.mg.y(tile); - const nextX = this.mg.x(next); - const nextY = this.mg.y(next); - - const dx = nextX - x; - const dy = nextY - y; - - if (dx === 0 && dy === 0) return RailType.VERTICAL; // No movement - - if (dx === 0) { - return RailType.VERTICAL; - } else if (dy === 0) { - return RailType.HORIZONTAL; - } - return RailType.VERTICAL; - } - - private computeDirection( - prev: TileRef, - current: TileRef, - next: TileRef, - ): RailType { - if (this.mg === null) { - throw new Error("Not initialized"); - } - const x1 = this.mg.x(prev); - const y1 = this.mg.y(prev); - const x2 = this.mg.x(current); - const y2 = this.mg.y(current); - const x3 = this.mg.x(next); - const y3 = this.mg.y(next); - - const dx1 = x2 - x1; - const dy1 = y2 - y1; - const dx2 = x3 - x2; - const dy2 = y3 - y2; - - // Straight line - if (dx1 === dx2 && dy1 === dy2) { - if (dx1 !== 0) return RailType.HORIZONTAL; - if (dy1 !== 0) return RailType.VERTICAL; - } - - // Turn (corner) cases - if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) { - // Now figure out which type of corner - if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT; - if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT; - if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT; - if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT; - - if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT; - if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT; - if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT; - if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT; - } - console.warn(`Invalid rail segment: ${dx1}:${dy1}, ${dx2}:${dy2}`); - return RailType.VERTICAL; - } - - tick(ticks: number): void { - if (this.mg === null) { - throw new Error("Not initialized"); - } - if (!this.activeSourceOrDestination()) { - this.active = false; - return; - } - if (this.headIndex > this.tailIndex) { - // Construction complete - this.constructionComplete(); - return; - } - - let updatedRailTiles: RailTile[]; - // Check if remaining tiles can be done all at once - if (this.tailIndex - this.headIndex <= 2 * this.increment) { - updatedRailTiles = this.railTiles.slice(this.headIndex, this.tailIndex); - this.constructionComplete(); - } else { - updatedRailTiles = this.railTiles.slice( - this.headIndex, - this.headIndex + this.increment, - ); - updatedRailTiles = updatedRailTiles.concat( - this.railTiles.slice(this.tailIndex - this.increment, this.tailIndex), - ); - this.headIndex += this.increment; - this.tailIndex -= this.increment; - } - if (updatedRailTiles) { - this.mg.addUpdate({ - type: GameUpdateType.RailroadEvent, - isActive: true, - railTiles: updatedRailTiles, - }); - } - } - - activeDuringSpawnPhase(): boolean { - return false; - } - - private activeSourceOrDestination(): boolean { - return this.railRoad.from.isActive() && this.railRoad.to.isActive(); - } - - private constructionComplete() { - this.redrawBuildings(); - this.active = false; - } - - private redrawBuildings() { - if (this.railRoad.from.unit.isActive()) this.railRoad.from.unit.touch(); - if (this.railRoad.to.unit.isActive()) this.railRoad.to.unit.touch(); - } -} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index cc2842a22..66c84a844 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -845,6 +845,7 @@ export interface BuildableUnit { canUpgrade: number | false; type: UnitType; cost: Gold; + overlappingRailroads: number[]; } export interface PlayerProfile { diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index a8c6c53f6..b8786e14b 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -44,7 +44,9 @@ export enum GameUpdateType { Hash, UnitIncoming, BonusEvent, - RailroadEvent, + RailroadDestructionEvent, + RailroadConstructionEvent, + RailroadSnapEvent, ConquestEvent, EmbargoEvent, GamePaused, @@ -67,7 +69,9 @@ export type GameUpdate = | UnitIncomingUpdate | AllianceExtensionUpdate | BonusEventUpdate - | RailroadUpdate + | RailroadConstructionUpdate + | RailroadDestructionUpdate + | RailroadSnapUpdate | ConquestUpdate | EmbargoUpdate | GamePausedUpdate; @@ -80,24 +84,24 @@ export interface BonusEventUpdate { troops: number; } -export enum RailType { - VERTICAL, - HORIZONTAL, - TOP_LEFT, - TOP_RIGHT, - BOTTOM_LEFT, - BOTTOM_RIGHT, +export interface RailroadConstructionUpdate { + type: GameUpdateType.RailroadConstructionEvent; + id: number; + tiles: TileRef[]; } -export interface RailTile { - tile: TileRef; - railType: RailType; +export interface RailroadDestructionUpdate { + type: GameUpdateType.RailroadDestructionEvent; + id: number; } -export interface RailroadUpdate { - type: GameUpdateType.RailroadEvent; - isActive: boolean; - railTiles: RailTile[]; +export interface RailroadSnapUpdate { + type: GameUpdateType.RailroadSnapEvent; + originalId: number; + newId1: number; + newId2: number; + tiles1: TileRef[]; + tiles2: TileRef[]; } export interface ConquestUpdate { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index bb445bab6..dd72a3307 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -960,20 +960,25 @@ export class PlayerImpl implements Player { const validTiles = tile !== null ? this.validStructureSpawnTiles(tile) : []; return Object.values(UnitType).map((u) => { let canUpgrade: number | false = false; + let canBuild: TileRef | false = false; if (!this.mg.inSpawnPhase()) { const existingUnit = tile !== null && this.findUnitToUpgrade(u, tile); if (existingUnit !== false) { canUpgrade = existingUnit.id(); } + if (tile !== null) { + canBuild = this.canBuild(u, tile, validTiles); + } } return { type: u, - canBuild: - this.mg.inSpawnPhase() || tile === null - ? false - : this.canBuild(u, tile, validTiles), - canUpgrade: canUpgrade, + canBuild, + canUpgrade, cost: this.mg.config().unitInfo(u).cost(this.mg, this), + overlappingRailroads: + canBuild !== false + ? this.mg.railNetwork().overlappingRailroads(canBuild) + : [], } as BuildableUnit; }); } diff --git a/src/core/game/RailNetwork.ts b/src/core/game/RailNetwork.ts index 64d55c187..52d9fbe0d 100644 --- a/src/core/game/RailNetwork.ts +++ b/src/core/game/RailNetwork.ts @@ -1,4 +1,5 @@ import { Unit } from "./Game"; +import { TileRef } from "./GameMap"; import { StationManager } from "./RailNetworkImpl"; import { TrainStation } from "./TrainStation"; @@ -7,4 +8,5 @@ export interface RailNetwork { removeStation(unit: Unit): void; findStationsPath(from: TrainStation, to: TrainStation): TrainStation[]; stationManager(): StationManager; + overlappingRailroads(tile: TileRef): number[]; } diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index b56599beb..61227c6a9 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -1,7 +1,7 @@ -import { RailroadExecution } from "../execution/RailroadExecution"; import { PathFinding } from "../pathfinding/PathFinder"; import { Game, Unit, UnitType } from "./Game"; import { TileRef } from "./GameMap"; +import { GameUpdateType } from "./GameUpdates"; import { RailNetwork } from "./RailNetwork"; import { Railroad } from "./Railroad"; import { RailSpatialGrid } from "./RailroadSpatialGrid"; @@ -85,6 +85,7 @@ export class RailNetworkImpl implements RailNetwork { private stationRadius: number = 3; private gridCellSize: number = 4; private railGrid: RailSpatialGrid; + private nextId: number = 0; constructor( private game: Game, @@ -141,6 +142,7 @@ export class RailNetworkImpl implements RailNetwork { for (const rail of rails) { const from = rail.from; const to = rail.to; + const originalId = rail.id; const closestRailIndex = rail.getClosestTileIndex( this.game, station.tile(), @@ -158,11 +160,13 @@ export class RailNetworkImpl implements RailNetwork { from, station, rail.tiles.slice(0, closestRailIndex), + this.nextId++, ); const newRailTo = new Railroad( station, to, rail.tiles.slice(closestRailIndex), + this.nextId++, ); // New station is connected to both new rails @@ -179,6 +183,14 @@ export class RailNetworkImpl implements RailNetwork { cluster.addStation(station); editedClusters.add(cluster); } + this.game.addUpdate({ + type: GameUpdateType.RailroadSnapEvent, + originalId, + newId1: newRailFrom.id, + newId2: newRailTo.id, + tiles1: newRailFrom.tiles, + tiles2: newRailTo.tiles, + }); } // If multiple clusters own the new station, merge them into a single cluster if (editedClusters.size > 1) { @@ -187,6 +199,12 @@ export class RailNetworkImpl implements RailNetwork { return editedClusters.size !== 0; } + overlappingRailroads(tile: TileRef): number[] { + return [...this.railGrid.query(tile, this.stationRadius)].map( + (railroad: Railroad) => railroad.id, + ); + } + private connectToNearbyStations(station: TrainStation) { const neighbors = this.game.nearbyUnits( station.tile(), @@ -256,11 +274,15 @@ export class RailNetworkImpl implements RailNetwork { private connect(from: TrainStation, to: TrainStation) { const path = this.pathService.findTilePath(from.tile(), to.tile()); if (path.length > 0 && path.length < this.game.config().railroadMaxSize()) { - const railRoad = new Railroad(from, to, path); - this.game.addExecution(new RailroadExecution(railRoad)); - from.addRailroad(railRoad); - to.addRailroad(railRoad); - this.railGrid.register(railRoad); + const railroad = new Railroad(from, to, path, this.nextId++); + this.game.addUpdate({ + type: GameUpdateType.RailroadConstructionEvent, + id: railroad.id, + tiles: railroad.tiles, + }); + from.addRailroad(railroad); + to.addRailroad(railroad); + this.railGrid.register(railroad); return true; } return false; diff --git a/src/core/game/Railroad.ts b/src/core/game/Railroad.ts index 00b5d9c37..e7bb4e793 100644 --- a/src/core/game/Railroad.ts +++ b/src/core/game/Railroad.ts @@ -1,6 +1,6 @@ import { Game } from "./Game"; import { TileRef } from "./GameMap"; -import { GameUpdateType, RailTile, RailType } from "./GameUpdates"; +import { GameUpdateType } from "./GameUpdates"; import { TrainStation } from "./TrainStation"; export class Railroad { @@ -8,17 +8,13 @@ export class Railroad { public from: TrainStation, public to: TrainStation, public tiles: TileRef[], + public id: number, ) {} delete(game: Game) { - const railTiles: RailTile[] = this.tiles.map((tile) => ({ - tile, - railType: RailType.VERTICAL, - })); game.addUpdate({ - type: GameUpdateType.RailroadEvent, - isActive: false, - railTiles, + type: GameUpdateType.RailroadDestructionEvent, + id: this.id, }); this.from.removeRailroad(this); this.to.removeRailroad(this); diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts index 6e9b57f0e..6a38c9f8f 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -2,7 +2,7 @@ import { TrainExecution } from "../execution/TrainExecution"; import { PseudoRandom } from "../PseudoRandom"; import { Game, Player, Unit, UnitType } from "./Game"; import { TileRef } from "./GameMap"; -import { GameUpdateType, RailTile, RailType } from "./GameUpdates"; +import { GameUpdateType } from "./GameUpdates"; import { Railroad } from "./Railroad"; /** @@ -92,14 +92,9 @@ export class TrainStation { (r) => r.from === station || r.to === station, ); if (toRemove) { - const railTiles: RailTile[] = toRemove.tiles.map((tile) => ({ - tile, - railType: RailType.VERTICAL, - })); this.mg.addUpdate({ - type: GameUpdateType.RailroadEvent, - isActive: false, - railTiles, + type: GameUpdateType.RailroadDestructionEvent, + id: toRemove.id, }); this.removeRailroad(toRemove); } diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 18f0c6497..6176d2771 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -34,7 +34,12 @@ describe("InputHandler AutoUpgrade", () => { eventBus = new EventBus(); inputHandler = new InputHandler( - { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true }, + { + attackRatio: 20, + ghostStructure: null, + rocketDirectionUp: true, + overlappingRailroads: [], + }, mockCanvas, eventBus, ); diff --git a/tests/core/game/TrainStation.test.ts b/tests/core/game/TrainStation.test.ts index 48045b253..ba6329145 100644 --- a/tests/core/game/TrainStation.test.ts +++ b/tests/core/game/TrainStation.test.ts @@ -1,3 +1,4 @@ +import { GameUpdateType } from "src/core/game/GameUpdates"; import { vi, type Mocked } from "vitest"; import { TrainExecution } from "../../../src/core/execution/TrainExecution"; import { Game, Player, Unit, UnitType } from "../../../src/core/game/Game"; @@ -112,7 +113,7 @@ describe("TrainStation", () => { expect(game.addUpdate).toHaveBeenCalledWith( expect.objectContaining({ - isActive: false, + type: GameUpdateType.RailroadDestructionEvent, }), ); expect(stationA.getRailroads().size).toBe(0);