fertig-classic-games/public/src/games/tickettoride/TicketToRideBoard.js

421 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// TicketToRideBoard.js — pure data + geometry for Ticket to Ride (USA edition).
// No Phaser imports, no game state. Everything here is computed once at module
// load and shared by Logic, AI, and the scene so they agree on one model.
//
// Coordinate space is the 1920×1080 canvas. The map occupies the left/centre
// (x < ~1500, y < ~850); the right column is reserved for the card market and
// piles, and the bottom strip for the human's hand.
// ── Cities ──────────────────────────────────────────────────────────────────
// 36 cities, positioned to mirror the real US/Canada geography of the board.
export const CITIES = [
{ id: 0, name: 'Vancouver', x: 160, y: 170 },
{ id: 1, name: 'Seattle', x: 180, y: 260 },
{ id: 2, name: 'Portland', x: 150, y: 355 },
{ id: 3, name: 'San Francisco', x: 120, y: 520 },
{ id: 4, name: 'Los Angeles', x: 200, y: 665 },
{ id: 5, name: 'Calgary', x: 340, y: 150 },
{ id: 6, name: 'Winnipeg', x: 640, y: 160 },
{ id: 7, name: 'Helena', x: 450, y: 330 },
{ id: 8, name: 'Duluth', x: 740, y: 300 },
{ id: 9, name: 'Salt Lake City', x: 340, y: 475 },
{ id: 10, name: 'Las Vegas', x: 290, y: 590 },
{ id: 11, name: 'Phoenix', x: 380, y: 675 },
{ id: 12, name: 'Santa Fe', x: 490, y: 585 },
{ id: 13, name: 'Denver', x: 510, y: 485 },
{ id: 14, name: 'El Paso', x: 540, y: 705 },
{ id: 15, name: 'Omaha', x: 740, y: 430 },
{ id: 16, name: 'Kansas City', x: 775, y: 505 },
{ id: 17, name: 'Oklahoma City', x: 730, y: 625 },
{ id: 18, name: 'Dallas', x: 740, y: 720 },
{ id: 19, name: 'Houston', x: 800, y: 800 },
{ id: 20, name: 'Little Rock', x: 845, y: 645 },
{ id: 21, name: 'Chicago', x: 915, y: 400 },
{ id: 22, name: 'Saint Louis', x: 880, y: 545 },
{ id: 23, name: 'Sault Ste Marie', x: 940, y: 235 },
{ id: 24, name: 'Nashville', x: 975, y: 595 },
{ id: 25, name: 'New Orleans', x: 915, y: 765 },
{ id: 26, name: 'Atlanta', x: 1045, y: 645 },
{ id: 27, name: 'Toronto', x: 1090, y: 300 },
{ id: 28, name: 'Montreal', x: 1230, y: 215 },
{ id: 29, name: 'Boston', x: 1360, y: 290 },
{ id: 30, name: 'New York', x: 1300, y: 365 },
{ id: 31, name: 'Pittsburgh', x: 1110, y: 425 },
{ id: 32, name: 'Washington', x: 1280, y: 455 },
{ id: 33, name: 'Raleigh', x: 1170, y: 560 },
{ id: 34, name: 'Charleston', x: 1230, y: 660 },
{ id: 35, name: 'Miami', x: 1235, y: 825 },
];
export function cityAt(id) { return CITIES[id]; }
export function cityId(name) { return CITIES.find((c) => c.name === name)?.id ?? -1; }
// ── Train-card colours ────────────────────────────────────────────────────────
// The 8 train colours (TTR's "pink" is rendered here as purple), plus 'gray'
// for wild routes (claimable with any single colour) and 'locomotive' wilds.
export const TRAIN_COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'black', 'white'];
export const CARD_COLOR_HEX = {
red: 0xd23b3b,
orange: 0xe08a1e,
yellow: 0xe0b000,
green: 0x2e7d32,
blue: 0x2d6cdf,
purple: 0x8e44ad,
black: 0x2b2b2b,
white: 0xe8e4d8,
gray: 0x9a8f7d, // neutral route colour
locomotive: 0xdda0dd, // wild — rendered with a rainbow accent in the scene
};
export const CARD_LABEL = {
red: 'Red', orange: 'Orange', yellow: 'Yellow', green: 'Green',
blue: 'Blue', purple: 'Purple', black: 'Black', white: 'White',
locomotive: 'Locomotive',
};
// 110-card train deck: 12 of each of the 8 colours + 14 locomotive wilds.
export const TRAIN_DECK = [
...TRAIN_COLORS.flatMap((c) => Array(12).fill(c)),
...Array(14).fill('locomotive'),
];
// ── Player colours ─────────────────────────────────────────────────────────────
export const PLAYER_COLORS = [
{ key: 'blue', hex: 0x2d6cdf, hexDark: 0x1c4490, name: 'Blue' },
{ key: 'red', hex: 0xd23b3b, hexDark: 0x8f2424, name: 'Red' },
{ key: 'green', hex: 0x2e9e4f, hexDark: 0x1d6633, name: 'Green' },
{ key: 'yellow', hex: 0xe0b000, hexDark: 0x9c7a00, name: 'Yellow' },
{ key: 'black', hex: 0x3a3a3a, hexDark: 0x161616, name: 'Black' },
];
// ── Scoring + counts ────────────────────────────────────────────────────────────
export const ROUTE_SCORE = { 1: 1, 2: 2, 3: 4, 4: 7, 5: 10, 6: 15 };
export const TRAINS_PER_PLAYER = 45;
export const LONGEST_PATH_BONUS = 10;
export const FACE_UP_COUNT = 5;
export const ENDGAME_TRAIN_THRESHOLD = 2; // a turn ending with <= this triggers the last round
// ── Routes ───────────────────────────────────────────────────────────────────
// Authored compactly as [cityA, cityB, length, colour]. A double route between
// the same two cities uses [cityA, cityB, length, [colour1, colour2]] and is
// expanded into two parallel ROUTE entries sharing a doubleGroup id.
// 'pink' in the physical game maps to 'purple' here.
const ROUTE_DEFS = [
[0, 5, 3, 'gray'], // Vancouver Calgary
[0, 1, 1, ['gray', 'gray']], // Vancouver Seattle
[1, 5, 4, 'gray'], // Seattle Calgary
[1, 2, 1, ['gray', 'gray']], // Seattle Portland
[2, 3, 5, ['green', 'purple']], // Portland San Francisco
[2, 9, 6, 'blue'], // Portland Salt Lake City
[5, 6, 6, 'white'], // Calgary Winnipeg
[5, 7, 4, 'gray'], // Calgary Helena
[1, 7, 6, 'yellow'], // Seattle Helena
[3, 9, 5, ['orange', 'white']], // San Francisco Salt Lake City
[3, 4, 3, ['yellow', 'purple']], // San Francisco Los Angeles
[9, 7, 3, 'purple'], // Salt Lake City Helena
[9, 10, 3, 'orange'], // Salt Lake City Las Vegas
[9, 13, 3, ['red', 'yellow']], // Salt Lake City Denver
[4, 10, 2, 'gray'], // Los Angeles Las Vegas
[4, 11, 3, 'gray'], // Los Angeles Phoenix
[4, 14, 6, 'black'], // Los Angeles El Paso
[7, 6, 4, 'blue'], // Helena Winnipeg
[7, 8, 6, 'orange'], // Helena Duluth
[7, 13, 4, 'green'], // Helena Denver
[11, 13, 5, 'white'], // Phoenix Denver
[11, 12, 3, 'gray'], // Phoenix Santa Fe
[11, 14, 3, 'gray'], // Phoenix El Paso
[13, 12, 2, 'gray'], // Denver Santa Fe
[12, 14, 2, 'gray'], // Santa Fe El Paso
[13, 15, 4, 'purple'], // Denver Omaha
[13, 16, 4, ['black', 'orange']], // Denver Kansas City
[13, 17, 4, 'red'], // Denver Oklahoma City
[12, 17, 3, 'blue'], // Santa Fe Oklahoma City
[14, 17, 5, 'yellow'], // El Paso Oklahoma City
[14, 18, 4, 'red'], // El Paso Dallas
[14, 19, 6, 'green'], // El Paso Houston
[6, 23, 6, 'gray'], // Winnipeg Sault Ste Marie
[6, 8, 4, 'black'], // Winnipeg Duluth
[8, 23, 3, 'gray'], // Duluth Sault Ste Marie
[8, 15, 2, ['gray', 'gray']], // Duluth Omaha
[8, 21, 3, 'red'], // Duluth Chicago
[8, 27, 6, 'purple'], // Duluth Toronto
[15, 16, 1, ['gray', 'gray']], // Omaha Kansas City
[15, 21, 4, 'blue'], // Omaha Chicago
[16, 17, 2, ['gray', 'gray']], // Kansas City Oklahoma City
[16, 22, 2, ['blue', 'purple']], // Kansas City Saint Louis
[17, 20, 2, 'gray'], // Oklahoma City Little Rock
[17, 18, 2, ['gray', 'gray']], // Oklahoma City Dallas
[18, 19, 1, ['gray', 'gray']], // Dallas Houston
[19, 25, 2, 'gray'], // Houston New Orleans
[20, 22, 2, 'gray'], // Little Rock Saint Louis
[20, 24, 3, 'white'], // Little Rock Nashville
[20, 25, 3, 'green'], // Little Rock New Orleans
[22, 21, 2, ['green', 'white']], // Saint Louis Chicago
[22, 31, 5, 'green'], // Saint Louis Pittsburgh
[22, 24, 2, 'gray'], // Saint Louis Nashville
[21, 31, 3, ['orange', 'black']], // Chicago Pittsburgh
[21, 27, 4, 'white'], // Chicago Toronto
[23, 27, 2, 'gray'], // Sault Ste Marie Toronto
[23, 28, 5, 'black'], // Sault Ste Marie Montreal
[27, 28, 3, 'gray'], // Toronto Montreal
[27, 31, 2, 'gray'], // Toronto Pittsburgh
[28, 29, 2, ['gray', 'gray']], // Montreal Boston
[28, 30, 3, 'blue'], // Montreal New York
[29, 30, 2, ['gray', 'gray']], // Boston New York
[30, 31, 2, ['white', 'green']], // New York Pittsburgh
[30, 32, 2, ['orange', 'black']], // New York Washington
[31, 32, 2, 'gray'], // Pittsburgh Washington
[31, 33, 2, 'gray'], // Pittsburgh Raleigh
[31, 24, 4, 'yellow'], // Pittsburgh Nashville
[24, 33, 3, 'black'], // Nashville Raleigh
[24, 26, 1, 'gray'], // Nashville Atlanta
[32, 33, 2, ['gray', 'gray']], // Washington Raleigh
[33, 26, 2, ['gray', 'gray']], // Raleigh Atlanta
[33, 34, 2, 'gray'], // Raleigh Charleston
[26, 34, 2, 'gray'], // Atlanta Charleston
[26, 35, 5, 'blue'], // Atlanta Miami
[26, 25, 4, ['yellow', 'orange']], // Atlanta New Orleans
[34, 35, 4, 'purple'], // Charleston Miami
[25, 35, 6, 'red'], // New Orleans Miami
];
// Optional curve overrides for routes that should arc rather than run straight.
// Key is 'minCityId-maxCityId'. dir is a cardinal screen direction; pct is the
// control-point displacement as a fraction of the straight-line length.
const ROUTE_CURVES = {
'4-14': { dir: 'down', pct: 0.35 }, // Los Angeles El Paso
'5-6': { dir: 'up', pct: 0.25 }, // Calgary Winnipeg
'3-4': { dir: 'left', pct: 0.20 }, // San Francisco Los Angeles
'2-3': { dir: 'left', pct: 0.20 }, // Portland San Francisco
'11-13': { dir: 'left', pct: 0.20 }, // Phoenix Denver
'9-10': { dir: 'right', pct: 0.20 }, // Salt Lake City Las Vegas
'13-17': { dir: 'down', pct: 0.25 }, // Denver Oklahoma City
'13-16': { dir: 'down', pct: 0.10 }, // Denver Kansas City
'13-15': { dir: 'up', pct: 0.10 }, // Denver Omaha
'21-31': { dir: 'up', pct: 0.10 }, // Chicago Pittsburgh
'25-35': { dir: 'up', pct: 0.35 }, // New Orleans Miami
'26-35': { dir: 'right', pct: 0.20 }, // Atlanta Miami
'14-19': { dir: 'down', pct: 0.25 }, // El Paso Houston
'12-17': { dir: 'down', pct: 0.20 }, // Santa Fe Oklahoma City
'1-5': { dir: 'down', pct: 0.30 }, // Seattle Calgary
'23-28': { dir: 'up', pct: 0.25 }, // Sault Ste Marie Montreal
'27-28': { dir: 'up', pct: 0.20 }, // Toronto Montreal
'28-30': { dir: 'left', pct: 0.25 }, // Montreal New York
};
// Expand ROUTE_DEFS into the flat ROUTES array, generating ids, double-route
// grouping, and parallelSide so the two strips of a double render side-by-side.
export const ROUTES = (() => {
const out = [];
for (const [a, b, length, colour] of ROUTE_DEFS) {
const curveKey = `${Math.min(a, b)}-${Math.max(a, b)}`;
const curve = ROUTE_CURVES[curveKey] ?? null;
if (Array.isArray(colour)) {
const group = `${a}-${b}`;
colour.forEach((c, i) => {
out.push({ id: out.length, a, b, length, color: c, doubleGroup: group, parallelSide: i, curve });
});
} else {
out.push({ id: out.length, a, b, length, color: colour, doubleGroup: null, parallelSide: 0, curve });
}
}
return out;
})();
// ── Destination tickets ──────────────────────────────────────────────────────
// The 30 USA destination tickets [cityNameA, cityNameB, points].
const TICKET_DEFS = [
['Los Angeles', 'New York', 21],
['Duluth', 'Houston', 8],
['Sault Ste Marie', 'Nashville', 8],
['New York', 'Atlanta', 6],
['Portland', 'Nashville', 17],
['Vancouver', 'Montreal', 20],
['Duluth', 'El Paso', 10],
['Toronto', 'Miami', 10],
['Portland', 'Phoenix', 11],
['Dallas', 'New York', 11],
['Calgary', 'Salt Lake City', 7],
['Calgary', 'Phoenix', 13],
['Los Angeles', 'Miami', 20],
['Winnipeg', 'Little Rock', 11],
['San Francisco', 'Atlanta', 17],
['Kansas City', 'Houston', 5],
['Los Angeles', 'Chicago', 16],
['Denver', 'Pittsburgh', 11],
['Chicago', 'Santa Fe', 9],
['Vancouver', 'Santa Fe', 13],
['Boston', 'Miami', 12],
['Chicago', 'New Orleans', 7],
['Montreal', 'Atlanta', 9],
['Seattle', 'New York', 22],
['Denver', 'El Paso', 4],
['Helena', 'Los Angeles', 8],
['Winnipeg', 'Houston', 12],
['Montreal', 'New Orleans', 13],
['Sault Ste Marie', 'Oklahoma City', 9],
['Seattle', 'Los Angeles', 9],
];
export const TICKETS = TICKET_DEFS.map(([nameA, nameB, points], id) => ({
id, a: cityId(nameA), b: cityId(nameB), points,
}));
// ── Adjacency index (for pathfinding / connectivity) ───────────────────────────
// cityId -> [{ routeId, other, length, color, doubleGroup }]
function buildAdj(cities, routes) {
const adj = new Map();
for (const c of cities) adj.set(c.id, []);
for (const r of routes) {
adj.get(r.a).push({ routeId: r.id, other: r.b, length: r.length, color: r.color, doubleGroup: r.doubleGroup });
adj.get(r.b).push({ routeId: r.id, other: r.a, length: r.length, color: r.color, doubleGroup: r.doubleGroup });
}
return adj;
}
export const ROUTE_ADJ = buildAdj(CITIES, ROUTES);
// ── Route segment geometry ──────────────────────────────────────────────────────
const CITY_MARGIN = 30; // keep car slots clear of the city dots
const CAR_GAP = 6; // pixel gap between adjacent cars
const CAR_WIDTH = 16; // perpendicular thickness of a car
const DOUBLE_OFFSET = 12; // perpendicular shift for each strip of a double route
const CURVE_SAMPLES = 10; // total bezier sample points (including endpoints)
// Samples a quadratic bezier through a displaced midpoint control, returning
// CURVE_SAMPLES {x,y} points that form a smooth polyline.
function bezierPolyline(A, B, curve) {
const shift = curve.pct * Math.hypot(B.x - A.x, B.y - A.y);
const C = {
x: (A.x + B.x) / 2 + (curve.dir === 'right' ? shift : curve.dir === 'left' ? -shift : 0),
y: (A.y + B.y) / 2 + (curve.dir === 'down' ? shift : curve.dir === 'up' ? -shift : 0),
};
const pts = [];
for (let i = 0; i < CURVE_SAMPLES; i++) {
const t = i / (CURVE_SAMPLES - 1);
const mt = 1 - t;
pts.push({ x: mt*mt*A.x + 2*mt*t*C.x + t*t*B.x, y: mt*mt*A.y + 2*mt*t*C.y + t*t*B.y });
}
return pts;
}
function polylineLength(pts) {
let len = 0;
for (let i = 1; i < pts.length; i++) len += Math.hypot(pts[i].x - pts[i-1].x, pts[i].y - pts[i-1].y);
return len;
}
// Returns {x, y, angle} at distance d along pts, clamped to the final segment.
function pointAtDist(pts, d) {
let acc = 0;
for (let i = 1; i < pts.length; i++) {
const segLen = Math.hypot(pts[i].x - pts[i-1].x, pts[i].y - pts[i-1].y);
if (acc + segLen >= d || i === pts.length - 1) {
const t = segLen > 0 ? Math.min((d - acc) / segLen, 1) : 0;
return {
x: pts[i-1].x + t * (pts[i].x - pts[i-1].x),
y: pts[i-1].y + t * (pts[i].y - pts[i-1].y),
angle: Math.atan2(pts[i].y - pts[i-1].y, pts[i].x - pts[i-1].x),
};
}
acc += segLen;
}
}
// Returns one slot per train-length: { cx, cy, angle, w, h }.
// Curved routes distribute cars along a bezier polyline; each car is still a
// straight rectangle aligned to its local polyline segment.
export function routeSegments(route) {
const A = CITIES[route.a];
const B = CITIES[route.b];
const off = route.doubleGroup ? (route.parallelSide === 0 ? -DOUBLE_OFFSET : DOUBLE_OFFSET) : 0;
const pts = route.curve ? bezierPolyline(A, B, route.curve) : [A, B];
const totalLen = polylineLength(pts);
const n = route.length;
const carLen = (totalLen - CITY_MARGIN * 2 - CAR_GAP * (n - 1)) / n;
const segs = [];
for (let i = 0; i < n; i++) {
const pos = pointAtDist(pts, CITY_MARGIN + carLen / 2 + i * (carLen + CAR_GAP));
segs.push({
cx: pos.x - Math.sin(pos.angle) * off,
cy: pos.y + Math.cos(pos.angle) * off,
angle: pos.angle,
w: carLen,
h: CAR_WIDTH,
});
}
return segs;
}
// Midpoint of a route's strip (used for the rotated hit-area rectangle).
export function routeMidpoint(route) {
const A = CITIES[route.a];
const B = CITIES[route.b];
const off = route.doubleGroup ? (route.parallelSide === 0 ? -DOUBLE_OFFSET : DOUBLE_OFFSET) : 0;
const pts = route.curve ? bezierPolyline(A, B, route.curve) : [A, B];
const totalLen = polylineLength(pts);
const pos = pointAtDist(pts, totalLen / 2);
return {
x: pos.x - Math.sin(pos.angle) * off,
y: pos.y + Math.cos(pos.angle) * off,
angle: pos.angle,
length: totalLen - CITY_MARGIN * 2,
width: CAR_WIDTH + 8,
};
}
// ── Land silhouette (decorative backdrop) ──────────────────────────────────────
// A simplified US lower-48 + southern Canada outline, hand-traced clockwise in
// canvas space. Drawn as a filled polygon behind the routes and cities.
export const LAND_OUTLINE = [
// Pacific NW / BC coast
[108, 218], [148, 142], [295, 118],
// Northern border east across Canada
[500, 115], [640, 128], [790, 148], [908, 170],
// Northern Ontario above Great Lakes
[1015, 160], [1095, 165], [1205, 160],
// Quebec / Northeast approach
[1315, 192], [1408, 250],
// New England coast south
[1418, 288], [1372, 328], [1342, 365],
// Mid-Atlantic (NJ bulges right, then Chesapeake indent)
[1358, 402], [1322, 450], [1312, 482],
// Cape Hatteras juts right
[1342, 520],
// SE coast toward Florida
[1298, 568], [1258, 622], [1250, 662],
// Florida east coast (peninsula runs south)
[1265, 700], [1292, 755], [1298, 800],
// Florida tip — south of Miami (1235, 825)
[1278, 845], [1238, 868],
// Florida Gulf coast back north
[1195, 858], [1155, 842],
// Gulf Coast — panhandle, Alabama, Louisiana, Texas
[1082, 828], [1010, 818], [920, 796],
[832, 826], [768, 820], [685, 825],
// Texas coast south to Rio Grande delta
[600, 832], [565, 778],
// Mexico border west — dips below the LAEl Paso route, passes through El Paso (540, 705)
[540, 720], [488, 738], [418, 755], [340, 768], [250, 748],
// Pacific coast north — San Diego → LA → SF → Oregon → WA
[192, 718], [182, 665], [158, 618],
[108, 535], [90, 478], [112, 410], [128, 372],
[148, 332], [154, 282], [148, 240],
];
// Great Lakes region: Superior (Duluth→Sault Ste Marie), Michigan, Huron, Erie, Ontario (Toronto).
export const GREAT_LAKES = [
[728, 288], // west Superior near Duluth
[775, 232], // north shore Superior west
[868, 210], // north shore Superior central
[950, 228], // northeast Superior / Sault Ste Marie
[1010, 250], // Georgian Bay / Lake Huron NE
[1088, 278], // Lake Ontario west near Toronto
[1112, 332], // Lake Ontario south shore
[1048, 362], // Lake Erie / Niagara
[968, 378], // south lakes boundary
[918, 365], // south Michigan
[888, 320], // central Lake Michigan
[862, 282], // back toward Superior
];