Improve railroad visibility: own-rail contrast color and thickness setting

Local-player rails previously rendered in the white focused-border color
from the palette, making them hard to see on light territory. Rails now
use a dedicated local rail color: white normally, flipped to black when
the territory backdrop is too light for white to read against (patterns
average their primary/secondary brightness).

Also add a railThickness render setting (0.5-3, default 1), exposed in
the Graphics Settings modal and the debug GUI, and persisted via
GraphicsOverrides. In the medium-zoom LOD, rails are now drawn as
screen-space anti-aliased lines around each tile's rail centerline,
accumulated from the 3x3 neighborhood so thick lines spill cleanly into
neighboring tiles; detailed mode scales its sub-grid band widths.

- PlayerView: compute railColor() (white/black by backdrop brightness)
- RailroadPass/shader: uLocalPlayerID, uLocalRailColor, uRailThickness
- render-settings.json, RenderSettings, GraphicsOverrides,
  RenderOverrides: new railroad.railThickness knob
- GraphicsSettingsModal: "Train track thickness" slider (+ en.json keys)
- tests: schema + apply coverage for railroad overrides
This commit is contained in:
evanpelle
2026-06-10 18:56:29 -07:00
parent b0e7d04f6e
commit 9189aac687
14 changed files with 253 additions and 44 deletions
+2
View File
@@ -958,6 +958,8 @@
"territory_alpha_desc": "How opaque the territory fill is (lower lets terrain show through)",
"rail_distance_label": "Train track draw distance",
"rail_distance_desc": "How far zoomed out train tracks remain visible",
"rail_thickness_label": "Train track thickness",
"rail_thickness_desc": "How wide train tracks are drawn",
"section_effects": "Effects",
"reset_label": "Reset to defaults",
"reset_desc": "Clear all graphics overrides"
+6 -1
View File
@@ -109,10 +109,15 @@ export class WebGLFrameBuilder {
}
private syncLocalPlayer(gameView: GameView): void {
const sid = gameView.myPlayer()?.smallID() ?? 0;
const me = gameView.myPlayer();
const sid = me?.smallID() ?? 0;
if (sid === this.localPlayerSmallID) return;
this.localPlayerSmallID = sid;
this.view.setLocalPlayerID(sid);
if (me) {
const rail = me.railColor().toRgb();
this.view.setLocalRailColor(rail.r / 255, rail.g / 255, rail.b / 255);
}
}
/**
@@ -46,6 +46,10 @@ const RAIL_ZOOM_MIN = 0;
const RAIL_ZOOM_MAX = 10;
const RAIL_ZOOM_STEP = 0.1;
const RAIL_THICKNESS_MIN = 0.5;
const RAIL_THICKNESS_MAX = 3;
const RAIL_THICKNESS_STEP = 0.1;
export class ShowGraphicsSettingsModalEvent {
constructor(
public readonly isVisible: boolean = true,
@@ -222,6 +226,13 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
);
}
private currentRailThickness(): number {
return (
this.userSettings.graphicsOverrides().railroad?.railThickness ??
renderDefaults.railroad.railThickness
);
}
private onHighlightFillChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
this.patchMapOverlay({ highlightFillBrighten: value });
@@ -253,6 +264,11 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
this.patchRailroad({ railMinZoom: RAIL_ZOOM_MAX - drawDistance });
}
private onRailThicknessChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
this.patchRailroad({ railThickness: value });
}
private currentClassicIcons(): boolean {
return (
this.userSettings.graphicsOverrides().structure?.classicIcons ?? false
@@ -322,6 +338,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
const territorySat = this.currentTerritorySat();
const territoryAlpha = this.currentTerritoryAlpha();
const railDrawDistance = RAIL_ZOOM_MAX - this.currentRailMinZoom();
const railThickness = this.currentRailThickness();
return html`
<div
@@ -608,6 +625,31 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
</div>
</div>
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
<div class="flex-1">
<div class="font-medium">
${translateText("graphics_setting.rail_thickness_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.rail_thickness_desc")}
</div>
<input
type="range"
min=${RAIL_THICKNESS_MIN}
max=${RAIL_THICKNESS_MAX}
step=${RAIL_THICKNESS_STEP}
.value=${String(railThickness)}
@input=${this.onRailThicknessChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400 w-12 text-right">
${railThickness.toFixed(1)}
</div>
</div>
<div
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-2"
>
+4
View File
@@ -384,6 +384,10 @@ export class GameView {
setLocalPlayerID(id: number): void {
this.renderer?.setLocalPlayerID(id);
}
/** Rail color for the local player (01 RGB). */
setLocalRailColor(r: number, g: number, b: number): void {
this.renderer?.setLocalRailColor(r, g, b);
}
setAltView(active: boolean): void {
this.renderer?.setAltView(active);
}
@@ -26,6 +26,7 @@ export const GraphicsOverridesSchema = z
railroad: z
.object({
railMinZoom: z.number(),
railThickness: z.number(),
})
.partial(),
passEnabled: z
+3
View File
@@ -45,6 +45,9 @@ export function applyGraphicsOverrides(
if (overrides.railroad?.railMinZoom !== undefined) {
settings.railroad.railMinZoom = overrides.railroad.railMinZoom;
}
if (overrides.railroad?.railThickness !== undefined) {
settings.railroad.railThickness = overrides.railroad.railThickness;
}
if (overrides.passEnabled?.fx !== undefined) {
settings.passEnabled.fx = overrides.passEnabled.fx;
}
+2
View File
@@ -112,6 +112,8 @@ export interface RenderSettings {
railFadeRange: number;
railDetailZoom: number;
railAlpha: number;
/** Track width multiplier (1 = default width). */
railThickness: number;
};
structure: {
iconSize: number;
+5
View File
@@ -1026,6 +1026,11 @@ export class GPURenderer {
this.samRadiusPass.setLocalPlayer(id);
this.affiliationPalette.setLocalPlayer(id);
this.unitPass.setLocalPlayer(id);
this.railroadPass.setLocalPlayer(id);
}
setLocalRailColor(r: number, g: number, b: number): void {
this.railroadPass.setLocalRailColor(r, g, b);
}
setSAMRadiusVisible(visible: boolean): void {
+9
View File
@@ -201,6 +201,15 @@ export function buildTree(s: RenderSettings, d: RenderSettings): DebugNode[] {
"Detail Zoom",
),
slider(s.railroad, "railAlpha", d.railroad, 0, 1, 0.01, "Alpha"),
slider(
s.railroad,
"railThickness",
d.railroad,
0.5,
3,
0.1,
"Thickness",
),
]),
]),
@@ -99,7 +99,10 @@ export class RailroadPass {
private uRailDetailZoom: WebGLUniformLocation;
private uRailAlpha: WebGLUniformLocation;
private uRailFade: WebGLUniformLocation;
private uRailThickness: WebGLUniformLocation;
private uGhostOwnerID: WebGLUniformLocation;
private uLocalPlayerID: WebGLUniformLocation;
private uLocalRailColor: WebGLUniformLocation;
private mapW: number;
private mapH: number;
@@ -112,6 +115,9 @@ export class RailroadPass {
private ghostRailDirty = false;
private ghostOwnerID = 0;
private localPlayerID = 0;
private localRailColor: [number, number, number] = [0.75, 0.75, 0.75];
constructor(
private gl: WebGL2RenderingContext,
mapW: number,
@@ -147,7 +153,19 @@ export class RailroadPass {
)!;
this.uRailAlpha = gl.getUniformLocation(this.program, "uRailAlpha")!;
this.uRailFade = gl.getUniformLocation(this.program, "uRailFade")!;
this.uRailThickness = gl.getUniformLocation(
this.program,
"uRailThickness",
)!;
this.uGhostOwnerID = gl.getUniformLocation(this.program, "uGhostOwnerID")!;
this.uLocalPlayerID = gl.getUniformLocation(
this.program,
"uLocalPlayerID",
)!;
this.uLocalRailColor = gl.getUniformLocation(
this.program,
"uLocalRailColor",
)!;
// Texture unit bindings + ghost defaults
gl.useProgram(this.program);
@@ -199,6 +217,15 @@ export class RailroadPass {
this.railroadDirty = true;
}
setLocalPlayer(smallID: number): void {
this.localPlayerID = smallID;
}
/** Rail color for the local player (01 RGB). */
setLocalRailColor(r: number, g: number, b: number): void {
this.localRailColor = [r, g, b];
}
/**
* Sub-upload terrain bytes for tiles that changed (water-nuke conversions).
* Keeps the R8UI water-detection texture in sync with the simulation.
@@ -321,7 +348,15 @@ export class RailroadPass {
gl.uniform1f(this.uRailDetailZoom, rs.railDetailZoom);
gl.uniform1f(this.uRailAlpha, rs.railAlpha);
gl.uniform1f(this.uRailFade, fade);
gl.uniform1f(this.uRailThickness, rs.railThickness);
gl.uniform1f(this.uGhostOwnerID, this.ghostOwnerID);
gl.uniform1f(this.uLocalPlayerID, this.localPlayerID);
gl.uniform3f(
this.uLocalRailColor,
this.localRailColor[0],
this.localRailColor[1],
this.localRailColor[2],
);
// Bind textures: 0=railroad, 1=tile, 2=palette, 3=terrain, 4=ghostRail
gl.activeTexture(gl.TEXTURE0);
+2 -1
View File
@@ -106,7 +106,8 @@
"railMinZoom": 4,
"railFadeRange": 2,
"railDetailZoom": 6,
"railAlpha": 1
"railAlpha": 1,
"railThickness": 1
},
"structure": {
"iconSize": 50,
@@ -13,7 +13,10 @@ uniform float uZoom;
uniform float uRailDetailZoom;
uniform float uRailAlpha;
uniform float uRailFade; // Zoom-based fade multiplier (0..1)
uniform float uRailThickness; // Track width multiplier (1 = default)
uniform float uGhostOwnerID; // Player smallID for ghost rail color
uniform float uLocalPlayerID; // Local player smallID (0 = none)
uniform vec3 uLocalRailColor; // Rail color for the local player's rails
in vec2 vWorldPos;
out vec4 fragColor;
@@ -54,35 +57,55 @@ bool isBridgePixel(uint rt, ivec2 lp) {
return false;
}
// Compute rail pixel coverage for a given rail type at fractional tile position.
// Returns 0.0 for miss, 1.0 for hit (detailed mode), or AA coverage (line mode).
float railCoverage(uint rt, vec2 f) {
// Detailed-mode coverage: 3x3 sub-grid with cross-ties, rail band width
// scaled by uRailThickness (clamped so the two bands never overlap fully).
float railDetailCoverage(uint rt, vec2 f) {
if (rt == 0u) return 0.0;
if (uZoom >= uRailDetailZoom) {
// Detailed mode: 3x3 sub-grid with cross-ties
float T = 1.0 / 3.0;
float T2 = 2.0 / 3.0;
bool center = (f.x >= T && f.x < T2 && f.y >= T && f.y < T2);
bool hit = false;
if (rt == 1u) {
hit = (f.x < T) || (f.x >= T2) || center;
} else if (rt == 2u) {
hit = (f.y < T) || (f.y >= T2) || center;
} else if (rt == 3u) {
hit = (f.y < T) || (f.x < T) || center;
} else if (rt == 4u) {
hit = (f.y < T) || (f.x >= T2) || center;
} else if (rt == 5u) {
hit = (f.y >= T2) || (f.x < T) || center;
} else if (rt == 6u) {
hit = (f.y >= T2) || (f.x >= T2) || center;
}
return hit ? 1.0 : 0.0;
} else {
// Simplified mode: fill entire tile (tiles are small at this zoom)
return 1.0;
float T = 1.0 / 3.0;
float T2 = 2.0 / 3.0;
float w = min(T * uRailThickness, 0.5);
bool center = (f.x >= T && f.x < T2 && f.y >= T && f.y < T2);
bool hit = false;
if (rt == 1u) {
hit = (f.x < w) || (f.x >= 1.0 - w) || center;
} else if (rt == 2u) {
hit = (f.y < w) || (f.y >= 1.0 - w) || center;
} else if (rt == 3u) {
hit = (f.y < w) || (f.x < w) || center;
} else if (rt == 4u) {
hit = (f.y < w) || (f.x >= 1.0 - w) || center;
} else if (rt == 5u) {
hit = (f.y >= 1.0 - w) || (f.x < w) || center;
} else if (rt == 6u) {
hit = (f.y >= 1.0 - w) || (f.x >= 1.0 - w) || center;
}
return hit ? 1.0 : 0.0;
}
float segDist(vec2 p, vec2 a, vec2 b) {
vec2 ab = b - a;
float t = clamp(dot(p - a, ab) / dot(ab, ab), 0.0, 1.0);
return length(p - a - ab * t);
}
// Distance from tile-local point p to the rail centerline of type rt.
// Straights span the tile; corners are two half-segments meeting at center.
float railLineDist(uint rt, vec2 p) {
if (rt == 1u) return segDist(p, vec2(0.5, 0.0), vec2(0.5, 1.0));
if (rt == 2u) return segDist(p, vec2(0.0, 0.5), vec2(1.0, 0.5));
vec2 c = vec2(0.5);
vec2 e1 = vec2(0.5, (rt == 3u || rt == 4u) ? 0.0 : 1.0); // top or bottom edge
vec2 e2 = vec2((rt == 3u || rt == 5u) ? 0.0 : 1.0, 0.5); // left or right edge
return min(segDist(p, c, e1), segDist(p, c, e2));
}
// Line-mode coverage: screen-space anti-aliased line of width uRailThickness
// (in tiles) around the rail centerline of tile-local point p.
float railLineCoverage(uint rt, vec2 p) {
if (rt == 0u || rt > 6u) return 0.0;
float halfW = 0.5 * uRailThickness;
float aa = 0.5 / uZoom; // ~1 screen pixel in tile units
return 1.0 - smoothstep(halfW - aa, halfW + aa, railLineDist(rt, p));
}
void main() {
@@ -95,29 +118,34 @@ void main() {
uint ghostRailType = texelFetch(uGhostRailTex, tc, 0).r;
vec2 f = fract(vWorldPos);
// Compute coverage for real and ghost rails
float realCov = railCoverage(railType, f);
bool detailMode = uZoom >= uRailDetailZoom;
// Compute coverage for real and ghost rails. In line mode, real coverage is
// accumulated from the 3x3 neighborhood below so thick lines can spill into
// neighboring tiles.
float realCov = detailMode ? railDetailCoverage(railType, f) : 0.0;
// Ghost only renders where there is no real rail (values 1-6 = ghost path)
// Value 7 = highlight marker (existing rail turns green)
float ghostCov = (ghostRailType >= 1u && ghostRailType <= 6u && railType == 0u)
? railCoverage(ghostRailType, f)
? (detailMode ? railDetailCoverage(ghostRailType, f) : railLineCoverage(ghostRailType, f))
: 0.0;
bool highlighted = (ghostRailType == 7u && railType != 0u);
bool hitRail = (realCov * uRailAlpha > 0.001);
bool hitGhost = (ghostCov * uRailAlpha > 0.001);
// --- Bridge: check 3x3 neighborhood for water+rail tiles ---
// --- 3x3 neighborhood: bridges (both modes) + line coverage (line mode) ---
bool hitBridge = false;
ivec2 fp = ivec2(floor(vWorldPos * 2.0)); // fragment pos in game's 2x-pixel grid
for (int dy = -1; dy <= 1 && !hitBridge; dy++) {
for (int dx = -1; dx <= 1 && !hitBridge; dx++) {
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
ivec2 ntc = tc + ivec2(dx, dy);
if (ntc.x < 0 || ntc.y < 0 || ntc.x >= int(uMapSize.x) || ntc.y >= int(uMapSize.y))
continue;
uint nRail = texelFetch(uRailroadTex, ntc, 0).r;
if (nRail == 0u) continue;
if (!detailMode) {
realCov = max(realCov, railLineCoverage(nRail, vWorldPos - vec2(ntc)));
}
if (hitBridge) continue;
uint nTerr = texelFetch(uTerrainTex, ntc, 0).r;
if ((nTerr & 0x80u) != 0u) continue; // land tile, no bridge
ivec2 lp = fp - ntc * 2;
@@ -125,6 +153,9 @@ void main() {
}
}
bool hitRail = (realCov * uRailAlpha > 0.001);
bool hitGhost = (ghostCov * uRailAlpha > 0.001);
if (!hitBridge && !hitRail && !hitGhost) discard;
// --- Color output ---
@@ -134,9 +165,13 @@ void main() {
float railAlpha = uRailAlpha * realCov;
uint tileRaw = texelFetch(uTileTex, tc, 0).r;
uint owner = tileRaw & uint(OWNER_MASK);
vec3 railColor = owner != 0u
? texture(uPalette, vec2((float(owner) + 0.5) / float(PALETTE_SIZE), 0.75)).rgb
: vec3(0.75);
// Local rails use uLocalRailColor (white, or black over light territory)
// instead of the palette border row.
vec3 railColor = owner == 0u
? vec3(0.75)
: (owner == uint(uLocalPlayerID)
? uLocalRailColor
: texture(uPalette, vec2((float(owner) + 0.5) / float(PALETTE_SIZE), 0.75)).rgb);
// Overlapping railroad highlight — green tint
if (highlighted) railColor = vec3(0.2, 0.85, 0.3);
if (hitBridge) {
@@ -146,9 +181,11 @@ void main() {
}
} else if (hitGhost) {
float ghostAlpha = uRailAlpha * ghostCov * 0.5;
vec3 ghostColor = uGhostOwnerID > 0.0
? texture(uPalette, vec2((uGhostOwnerID + 0.5) / float(PALETTE_SIZE), 0.75)).rgb
: vec3(0.75);
vec3 ghostColor = uGhostOwnerID <= 0.0
? vec3(0.75)
: (uGhostOwnerID == uLocalPlayerID
? uLocalRailColor
: texture(uPalette, vec2((uGhostOwnerID + 0.5) / float(PALETTE_SIZE), 0.75)).rgb);
fragColor = vec4(ghostColor, ghostAlpha * uRailFade);
} else {
fragColor = vec4(bridgeColor, uRailFade);
+16
View File
@@ -107,6 +107,7 @@ export class PlayerView {
private _territoryColor: Colord;
private _borderColor: Colord;
private _railColor: Colord;
// Update here to include structure light and dark colors
private _structureColors: { light: Colord; dark: Colord };
@@ -173,6 +174,17 @@ export class PlayerView {
maybeFocusedBorderColor.toHex(),
);
// Rail color (only used for the local player's rails): white for
// visibility, flipped to black when the territory is too light for white
// to read against it. Patterns paint both colors, so average them.
const railBackdropBrightness = pattern
? (this._territoryColor.brightness() + this._borderColor.brightness()) / 2
: this._territoryColor.brightness();
this._railColor =
railBackdropBrightness > 0.8
? colord("rgb(0,0,0)")
: theme.focusedBorderColor();
const baseRgb = this._borderColor.toRgb();
this._borderColorNeutral = this._borderColor;
@@ -253,6 +265,10 @@ export class PlayerView {
return this._structureColors;
}
railColor(): Colord {
return this._railColor;
}
/**
* Border color for a tile:
* - Tints by neighbor relations (embargo → red, friendly → green, else neutral).
+47
View File
@@ -54,6 +54,18 @@ describe("GraphicsOverridesSchema", () => {
}
});
test("accepts partial railroad overrides", () => {
const cases = [
{ railroad: {} },
{ railroad: { railMinZoom: 2 } },
{ railroad: { railThickness: 1.5 } },
{ railroad: { railMinZoom: 0, railThickness: 3 } },
];
for (const c of cases) {
expect(GraphicsOverridesSchema.safeParse(c).success).toBe(true);
}
});
test("rejects wrong field types", () => {
expect(
GraphicsOverridesSchema.safeParse({ name: { nameScaleFactor: "big" } })
@@ -72,6 +84,16 @@ describe("GraphicsOverridesSchema", () => {
mapOverlay: { territorySaturation: "full" },
}).success,
).toBe(false);
expect(
GraphicsOverridesSchema.safeParse({
railroad: { railMinZoom: "far" },
}).success,
).toBe(false);
expect(
GraphicsOverridesSchema.safeParse({
railroad: { railThickness: "wide" },
}).success,
).toBe(false);
});
});
@@ -214,6 +236,31 @@ describe("applyGraphicsOverrides", () => {
expect(mo.territoryDefenseDarken).toBe(defaults.territoryDefenseDarken);
});
test("applies railMinZoom override (including 0)", () => {
expect(gen({ railroad: { railMinZoom: 7 } }).railroad.railMinZoom).toBe(7);
expect(gen({ railroad: { railMinZoom: 0 } }).railroad.railMinZoom).toBe(0);
});
test("applies railThickness override (including values below 1)", () => {
expect(
gen({ railroad: { railThickness: 2.5 } }).railroad.railThickness,
).toBe(2.5);
expect(
gen({ railroad: { railThickness: 0.5 } }).railroad.railThickness,
).toBe(0.5);
});
test("railroad override leaves other railroad fields at defaults", () => {
const defaults = createRenderSettings().railroad;
const r = gen({ railroad: { railThickness: 2 } }).railroad;
expect(r.railMinZoom).toBe(defaults.railMinZoom);
expect(r.railFadeRange).toBe(defaults.railFadeRange);
expect(r.railDetailZoom).toBe(defaults.railDetailZoom);
expect(r.railAlpha).toBe(defaults.railAlpha);
const z = gen({ railroad: { railMinZoom: 1 } }).railroad;
expect(z.railThickness).toBe(defaults.railThickness);
});
test("classicIcons + name overrides compose independently", () => {
const s = gen({
name: { darkNames: true, nameScaleFactor: 0.9 },