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 <jon@rouillard.org>
This commit is contained in:
DevelopingTom
2026-02-09 13:37:27 -08:00
committed by GitHub
co-authored by jrouillard
parent 742cbf90f3
commit c6c793f6b3
17 changed files with 473 additions and 293 deletions
+2 -2
View File
@@ -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),
+1
View File
@@ -3,5 +3,6 @@ import { UnitType } from "../../core/game/Game";
export interface UIState {
attackRatio: number;
ghostStructure: UnitType | null;
overlappingRailroads: number[];
rocketDirectionUp: boolean;
}
+22 -27
View File
@@ -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");
+182 -47
View File
@@ -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<TileRef, RailRef>();
private railroads = new Map<number, RailroadView>();
// Railroads under construction
private pendingRailroads = new Set<number>();
private nextRailIndexToCheck = 0;
private railTileList: TileRef[] = [];
private railTileIndex = new Map<TileRef, number>();
@@ -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(
@@ -1,4 +1,4 @@
import { RailType } from "../../../core/game/GameUpdates";
import { RailType } from "./RailroadView";
const railTypeToFunctionMap: Record<RailType, () => number[][]> = {
[RailType.TOP_RIGHT]: topRightRailroadCornerRects,
+177
View File
@@ -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;
}
}
@@ -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);
-170
View File
@@ -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();
}
}
+1
View File
@@ -845,6 +845,7 @@ export interface BuildableUnit {
canUpgrade: number | false;
type: UnitType;
cost: Gold;
overlappingRailroads: number[];
}
export interface PlayerProfile {
+20 -16
View File
@@ -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 {
+10 -5
View File
@@ -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;
});
}
+2
View File
@@ -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[];
}
+28 -6
View File
@@ -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;
+4 -8
View File
@@ -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);
+3 -8
View File
@@ -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);
}
+6 -1
View File
@@ -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,
);
+2 -1
View File
@@ -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);