From c6c793f6b3f7211b571403b1ac78a70f2709ac54 Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Mon, 9 Feb 2026 22:37:27 +0100 Subject: [PATCH] Highlight hovering railroad (#3156) ## Description: ![rail_snap](https://github.com/user-attachments/assets/1dc66dc8-5df8-4826-8a8e-521d72a1f8aa) The `RailroadLayer` simply displays tiles as instructed by the core worker. While it's practical for the layer to only care about the tiles, it also means it has no understanding of railroads as entities (their paths, connections, or identities). It also means that the core worker is responsible for rendering tasks such as tile orientation and construction animation, which is not expected. To support ID-based events and better separation of concerns, the rendering layer needs to be aware of complete railroads. With this change, the core worker can send the tiles once and subsequently reference railroads only by ID for all other events. #### Changes: - `RailroadLayer` now stores full railroad data instead of only individual tiles - `RailroadLayer` is responsible for animating newly built railroads - Add a new `RailroadSnapUpdate` sent when a new structure is built over an existing railroad. This event is used by `RailroadLayer` to keep railroad ID in sync. - When hovering over a railroad, the render worker is querying the core worker about overlapping railroads. Alternatively, RailroadLayer could compute overlaps itself now that it has full railroad knowledge, but this logic would need to be duplicated and kept in sync across workers. Keeping a single source of truth in the core worker is preferred. #### Edgecases: - When a structure snaps over a railroad, the original railroad is split into two new railroads. If the construction animation is still in progress, instead of resuming the animation at the correct point on the new railroads, all remaining tiles are rendered immediately - Previously, `RailroadUpdate` handled both construction and destruction. This no longer works with `RailroadSnapUpdate`, as event ordering is now pretty important and IDs may be lost before they are consumed. To address this, RailroadUpdate is split in two: `RailroadConstructionUpdate` and `RailroadDestructionUpdate`. ## 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: IngloriousTom --------- Co-authored-by: jrouillard --- src/client/graphics/GameRenderer.ts | 4 +- src/client/graphics/UIState.ts | 1 + src/client/graphics/layers/FxLayer.ts | 49 ++-- src/client/graphics/layers/RailroadLayer.ts | 229 ++++++++++++++---- src/client/graphics/layers/RailroadSprites.ts | 2 +- src/client/graphics/layers/RailroadView.ts | 177 ++++++++++++++ .../graphics/layers/StructureIconsLayer.ts | 13 +- src/core/execution/RailroadExecution.ts | 170 ------------- src/core/game/Game.ts | 1 + src/core/game/GameUpdates.ts | 36 +-- src/core/game/PlayerImpl.ts | 15 +- src/core/game/RailNetwork.ts | 2 + src/core/game/RailNetworkImpl.ts | 34 ++- src/core/game/Railroad.ts | 12 +- src/core/game/TrainStation.ts | 11 +- tests/InputHandler.test.ts | 7 +- tests/core/game/TrainStation.test.ts | 3 +- 17 files changed, 473 insertions(+), 293 deletions(-) create mode 100644 src/client/graphics/layers/RailroadView.ts delete mode 100644 src/core/execution/RailroadExecution.ts 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);