// 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 LA–El 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 ];