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.

<img width="1058" height="680" alt="image"
src="https://github.com/user-attachments/assets/493737b9-7aff-4ee2-88ea-7638f6af7c91"
/>

<img width="361" height="317" alt="image"
src="https://github.com/user-attachments/assets/24a71a7a-1ba1-4c88-a89e-876127024148"
/>

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
This commit is contained in:
DevelopingTom
2025-08-29 01:04:28 +02:00
committed by evanpelle
parent 121ac0eede
commit fc9eb2bec0
3 changed files with 149 additions and 18 deletions
+52 -15
View File
@@ -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();
}
}
@@ -9,6 +9,15 @@ const railTypeToFunctionMap: Record<RailType, () => number[][]> = {
[RailType.VERTICAL]: verticalRailroadRects,
};
const railTypeToBridgeFunctionMap: Record<RailType, () => 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;
}
+15 -3
View File
@@ -4,6 +4,7 @@ import { AStar, PathFindResultType } from "./AStar";
import { GraphAdapter, SerialAStar } from "./SerialAStar";
export class GameMapAdapter implements GraphAdapter<TileRef> {
private readonly waterPenalty = 3;
constructor(
private gameMap: GameMap,
private waterPath: boolean,
@@ -14,7 +15,12 @@ export class GameMapAdapter implements GraphAdapter<TileRef> {
}
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<TileRef> {
}
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<TileRef> {