From fc9eb2bec033797ffb2e3bb3860ab88662321e43 Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Fri, 29 Aug 2025 01:04:28 +0200 Subject: [PATCH] Build bridges to connect stations across water (#1961) Derived from [LeviathanLevi PR](https://github.com/openfrontio/OpenFrontIO/pull/1847) Connect stations over water by automatically building bridges Changes: - Railroad construction to water is allowed from shore lines - Railroad construction from water is allowed to shore lines too This creates bridges a few tiles long. image image fixes #1837 - [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 regression is found: IngloriousTom --- src/client/graphics/layers/RailroadLayer.ts | 67 +++++++++++---- src/client/graphics/layers/RailroadSprites.ts | 82 +++++++++++++++++++ src/core/pathfinding/MiniAStar.ts | 18 +++- 3 files changed, 149 insertions(+), 18 deletions(-) diff --git a/src/client/graphics/layers/RailroadLayer.ts b/src/client/graphics/layers/RailroadLayer.ts index 1a77cb77a..741df944b 100644 --- a/src/client/graphics/layers/RailroadLayer.ts +++ b/src/client/graphics/layers/RailroadLayer.ts @@ -8,9 +8,9 @@ import { RailTile, RailType, } from "../../../core/game/GameUpdates"; -import { GameView, PlayerView } from "../../../core/game/GameView"; +import { GameView } from "../../../core/game/GameView"; import { Layer } from "./Layer"; -import { getRailroadRects } from "./RailroadSprites"; +import { getBridgeRects, getRailroadRects } from "./RailroadSprites"; type RailRef = { tile: RailTile; @@ -138,31 +138,68 @@ export class RailroadLayer implements Layer { if (!ref || ref.numOccurence <= 0) { this.existingRailroads.delete(railRoad.tile); this.railTileList = this.railTileList.filter((t) => t !== railRoad.tile); - this.context.clearRect( - this.game.x(railRoad.tile) * 2 - 1, - this.game.y(railRoad.tile) * 2 - 1, - 3, - 3, - ); + if (this.context === undefined) throw new Error("Not initialized"); + if (this.game.isWater(railRoad.tile)) { + this.context.clearRect( + this.game.x(railRoad.tile) * 2 - 2, + this.game.y(railRoad.tile) * 2 - 2, + 5, + 6, + ); + } else { + this.context.clearRect( + this.game.x(railRoad.tile) * 2 - 1, + this.game.y(railRoad.tile) * 2 - 1, + 3, + 3, + ); + } } } paintRail(railRoad: RailTile) { - const x = this.game.x(railRoad.tile); - const y = this.game.y(railRoad.tile); - const owner = this.game.owner(railRoad.tile); - const recipient = owner.isPlayer() ? (owner as PlayerView) : null; + if (this.context === undefined) throw new Error("Not initialized"); + const { tile } = railRoad; + const { railType } = railRoad; + 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); + } + const owner = this.game.owner(tile); + const recipient = owner.isPlayer() ? owner : null; const color = recipient ? this.theme.railroadColor(recipient) : new Colord({ r: 255, g: 255, b: 255, a: 1 }); this.context.fillStyle = color.toRgbString(); - this.paintRailRects(x, y, railRoad.railType); + this.paintRailRects(this.context, x, y, railType); } - private paintRailRects(x: number, y: number, direction: RailType) { + private paintRailRects( + context: CanvasRenderingContext2D, + x: number, + y: number, + direction: RailType, + ) { const railRects = getRailroadRects(direction); for (const [dx, dy, w, h] of railRects) { - this.context.fillRect(x * 2 + dx, y * 2 + dy, w, h); + context.fillRect(x * 2 + dx, y * 2 + dy, w, h); } } + + private paintBridge( + context: CanvasRenderingContext2D, + x: number, + y: number, + direction: RailType, + ) { + context.save(); + context.fillStyle = "rgb(197,69,72)"; + const bridgeRects = getBridgeRects(direction); + for (const [dx, dy, w, h] of bridgeRects) { + context.fillRect(x * 2 + dx, y * 2 + dy, w, h); + } + context.restore(); + } } diff --git a/src/client/graphics/layers/RailroadSprites.ts b/src/client/graphics/layers/RailroadSprites.ts index d76a41804..d0734acad 100644 --- a/src/client/graphics/layers/RailroadSprites.ts +++ b/src/client/graphics/layers/RailroadSprites.ts @@ -9,6 +9,15 @@ const railTypeToFunctionMap: Record number[][]> = { [RailType.VERTICAL]: verticalRailroadRects, }; +const railTypeToBridgeFunctionMap: Record number[][]> = { + [RailType.TOP_RIGHT]: topRightBridgeCornerRects, + [RailType.BOTTOM_LEFT]: bottomLeftBridgeCornerRects, + [RailType.TOP_LEFT]: topLeftBridgeCornerRects, + [RailType.BOTTOM_RIGHT]: bottomRightBridgeCornerRects, + [RailType.HORIZONTAL]: horizontalBridge, + [RailType.VERTICAL]: verticalBridge, +}; + export function getRailroadRects(type: RailType): number[][] { const railRects = railTypeToFunctionMap[type]; if (!railRects) { @@ -77,3 +86,76 @@ function bottomLeftRailroadCornerRects(): number[][] { ]; return rects; } + +export function getBridgeRects(type: RailType): number[][] { + const bridgeRects = railTypeToBridgeFunctionMap[type]; + if (!bridgeRects) { + // Should never happen + throw new Error(`Unsupported RailType: ${type}`); + } + return bridgeRects(); +} + +function horizontalBridge(): number[][] { + // x/y/w/h + return [ + [-1, -2, 3, 1], + [-1, 2, 3, 1], + [-1, 3, 1, 1], + [1, 3, 1, 1], + ]; +} + +function verticalBridge(): number[][] { + // x/y/w/h + return [ + [-2, -2, 1, 3], + [2, -2, 1, 3], + ]; +} +// ⌞ +function topRightBridgeCornerRects(): number[][] { + return [ + [-2, -2, 1, 2], + [-1, 0, 1, 1], + [0, 1, 1, 1], + [1, 2, 2, 1], + [2, -2, 1, 1], + ]; +} +// ⌝ +function bottomLeftBridgeCornerRects(): number[][] { + // x/y/w/h + const rects = [ + [-2, -2, 2, 1], + [0, -1, 1, 1], + [1, 0, 1, 1], + [2, 1, 1, 2], + [-2, 2, 1, 1], + ]; + return rects; +} +// ⌟ +function topLeftBridgeCornerRects(): number[][] { + // x/y/w/h + const rects = [ + [-2, -2, 1, 1], + [-2, 2, 2, 1], + [0, 1, 1, 1], + [1, 0, 1, 1], + [2, -2, 1, 2], + ]; + return rects; +} +// ⌜ +function bottomRightBridgeCornerRects(): number[][] { + // x/y/w/h + const rects = [ + [-2, 1, 1, 2], + [-1, 0, 1, 1], + [0, -1, 1, 1], + [1, -2, 2, 1], + [2, 2, 1, 1], + ]; + return rects; +} diff --git a/src/core/pathfinding/MiniAStar.ts b/src/core/pathfinding/MiniAStar.ts index a355afd41..e6a74f6b2 100644 --- a/src/core/pathfinding/MiniAStar.ts +++ b/src/core/pathfinding/MiniAStar.ts @@ -4,6 +4,7 @@ import { AStar, PathFindResultType } from "./AStar"; import { GraphAdapter, SerialAStar } from "./SerialAStar"; export class GameMapAdapter implements GraphAdapter { + private readonly waterPenalty = 3; constructor( private gameMap: GameMap, private waterPath: boolean, @@ -14,7 +15,12 @@ export class GameMapAdapter implements GraphAdapter { } cost(node: TileRef): number { - return this.gameMap.cost(node); + let base = this.gameMap.cost(node); + // Avoid crossing water when possible + if (!this.waterPath && this.gameMap.isWater(node)) { + base += this.waterPenalty; + } + return base; } position(node: TileRef): { x: number; y: number } { @@ -22,8 +28,14 @@ export class GameMapAdapter implements GraphAdapter { } isTraversable(from: TileRef, to: TileRef): boolean { - const isWater = this.gameMap.isWater(to); - return this.waterPath ? isWater : !isWater; + const toWater = this.gameMap.isWater(to); + if (this.waterPath) { + return toWater; + } + // Allow water access from/to shore + const fromShore = this.gameMap.isShoreline(from); + const toShore = this.gameMap.isShoreline(to); + return !toWater || fromShore || toShore; } } export class MiniAStar implements AStar {