feat: add Ticket to Ride (USA edition) game
Implement a fully playable Ticket to Ride game featuring a pure logic engine, heuristic AI opponent, and Phaser UI scene. Key additions: - Board geometry, route definitions, destination tickets, and train card data - State management, turn flow, payment validation, and scoring (routes, tickets, longest path bonus) - AI decision-making for claiming routes, drawing cards/tickets, and endgame strategy - Frontend integration (scene registration, opponent count default, slug dispatch) - Server game registry configuration (supports 2-5 players) Follows the existing architecture separating data, logic, AI, and rendering.
This commit is contained in:
parent
2958de16ce
commit
d9a68de8e4
|
|
@ -0,0 +1,197 @@
|
|||
// TicketToRideAI.js — heuristic AI for Ticket to Ride. Pure synchronous
|
||||
// choose*() functions returning action descriptors; no Phaser, no async.
|
||||
|
||||
import {
|
||||
ROUTES, CITIES, ROUTE_ADJ, ROUTE_SCORE, TICKETS,
|
||||
} from './TicketToRideBoard.js';
|
||||
import { legalClaims, canDrawTickets } from './TicketToRideLogic.js';
|
||||
|
||||
const TICKET_BY_ID = TICKETS.reduce((m, t) => { m[t.id] = t; return m; }, {});
|
||||
|
||||
// ── connectivity helpers ───────────────────────────────────────────────────────
|
||||
function seatComponents(s, seat) {
|
||||
const parent = Array.from({ length: CITIES.length }, (_, i) => i);
|
||||
const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
|
||||
for (const rid of s.players[seat].routes) {
|
||||
const r = ROUTES[rid];
|
||||
parent[find(r.a)] = find(r.b);
|
||||
}
|
||||
return find;
|
||||
}
|
||||
|
||||
function ticketDone(find, ticket) {
|
||||
return find(ticket.a) === find(ticket.b);
|
||||
}
|
||||
|
||||
// ── shortest path over available routes ────────────────────────────────────────
|
||||
// Dijkstra weighted by route length. A route is "available" to `seat` if it is
|
||||
// unclaimed or already owned by `seat`. Returns { routeIds, cost } or null.
|
||||
export function shortestRoutePath(s, from, to, seat) {
|
||||
const dist = new Map([[from, 0]]);
|
||||
const prev = new Map(); // cityId -> { routeId, fromCity }
|
||||
const visited = new Set();
|
||||
while (true) {
|
||||
let u = null, best = Infinity;
|
||||
for (const [city, d] of dist) {
|
||||
if (!visited.has(city) && d < best) { best = d; u = city; }
|
||||
}
|
||||
if (u == null) break;
|
||||
if (u === to) break;
|
||||
visited.add(u);
|
||||
for (const e of ROUTE_ADJ.get(u) || []) {
|
||||
const owner = s.claimed[e.routeId];
|
||||
if (owner != null && owner !== seat) continue; // opponent owns it
|
||||
const nd = best + e.length;
|
||||
if (nd < (dist.get(e.other) ?? Infinity)) {
|
||||
dist.set(e.other, nd);
|
||||
prev.set(e.other, { routeId: e.routeId, fromCity: u });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dist.has(to)) return null;
|
||||
const routeIds = [];
|
||||
let cur = to;
|
||||
while (cur !== from) {
|
||||
const p = prev.get(cur);
|
||||
if (!p) break;
|
||||
routeIds.push(p.routeId);
|
||||
cur = p.fromCity;
|
||||
}
|
||||
return { routeIds, cost: dist.get(to) };
|
||||
}
|
||||
|
||||
// ── ticket selection ───────────────────────────────────────────────────────────
|
||||
// Returns the ticket ids to keep from `drawn` (at least minKeep).
|
||||
export function chooseTicketsToKeep(s, seat, drawn, minKeep) {
|
||||
const scored = drawn.map((id) => {
|
||||
const t = { ...ticketOf(id) };
|
||||
const path = shortestRoutePath(s, t.a, t.b, seat);
|
||||
const cost = path ? path.cost : 99;
|
||||
// Value: reward points, penalise the train cars required to connect.
|
||||
return { id, value: t.points - cost * 1.1, achievable: !!path, points: t.points };
|
||||
});
|
||||
scored.sort((a, b) => b.value - a.value);
|
||||
|
||||
const keep = scored.slice(0, minKeep).map((x) => x.id);
|
||||
// Keep additional tickets only if they look clearly worthwhile.
|
||||
for (const x of scored.slice(minKeep)) {
|
||||
if (x.achievable && x.value > 0) keep.push(x.id);
|
||||
}
|
||||
return keep;
|
||||
}
|
||||
|
||||
function ticketOf(id) {
|
||||
// tickets carry resolved {id,a,b,points}; the AI receives ids during setup.
|
||||
return TICKET_BY_ID[id];
|
||||
}
|
||||
|
||||
// ── per-turn action ─────────────────────────────────────────────────────────────
|
||||
// Returns one of:
|
||||
// { type:'claimRoute', routeId, payment }
|
||||
// { type:'drawTrain', source:'faceUp'|'deck', index? }
|
||||
// { type:'drawTickets' }
|
||||
export function chooseAction(s, seat) {
|
||||
const player = s.players[seat];
|
||||
const find = seatComponents(s, seat);
|
||||
const endgame = s.finalTurnsLeft != null;
|
||||
|
||||
// 1. Wanted routes: union of shortest paths for unfulfilled kept tickets.
|
||||
const wanted = new Set();
|
||||
const wantedColors = new Map(); // color -> desirability weight
|
||||
for (const t of player.tickets) {
|
||||
if (ticketDone(find, t)) continue;
|
||||
const path = shortestRoutePath(s, t.a, t.b, seat);
|
||||
if (!path) continue;
|
||||
for (const rid of path.routeIds) {
|
||||
if (s.claimed[rid] != null) continue; // already owned (by us) — skip
|
||||
wanted.add(rid);
|
||||
const col = ROUTES[rid].color;
|
||||
if (col !== 'gray') wantedColors.set(col, (wantedColors.get(col) || 0) + ROUTES[rid].length);
|
||||
}
|
||||
}
|
||||
|
||||
const claims = legalClaims(s, seat);
|
||||
const claimById = new Map(claims.map((c) => [c.routeId, c]));
|
||||
|
||||
const scoreClaim = (routeId) => {
|
||||
const r = ROUTES[routeId];
|
||||
return ROUTE_SCORE[r.length] + (wanted.has(routeId) ? 8 : 0) + r.length * 0.6;
|
||||
};
|
||||
|
||||
// 2. Claim a wanted route if we can afford one.
|
||||
const claimableWanted = claims.filter((c) => wanted.has(c.routeId));
|
||||
if (claimableWanted.length) {
|
||||
const best = claimableWanted.reduce((a, b) => (scoreClaim(b.routeId) > scoreClaim(a.routeId) ? b : a));
|
||||
return { type: 'claimRoute', routeId: best.routeId, payment: best.payment };
|
||||
}
|
||||
|
||||
// 3. Endgame: grab points wherever possible, otherwise draw cards.
|
||||
if (endgame) {
|
||||
if (claims.length) {
|
||||
const best = claims.reduce((a, b) => (scoreClaim(b.routeId) > scoreClaim(a.routeId) ? b : a));
|
||||
return { type: 'claimRoute', routeId: best.routeId, payment: best.payment };
|
||||
}
|
||||
return drawDecision(s, seat, wantedColors);
|
||||
}
|
||||
|
||||
// 4. Still have goals but can't afford them yet -> accumulate cards.
|
||||
if (wanted.size > 0) {
|
||||
return drawDecision(s, seat, wantedColors);
|
||||
}
|
||||
|
||||
// 5. All tickets fulfilled: chase more tickets early, else bank route points.
|
||||
if (canDrawTickets(s) && player.trainsLeft > 15) {
|
||||
return { type: 'drawTickets' };
|
||||
}
|
||||
if (claims.length) {
|
||||
const best = claims.reduce((a, b) => (scoreClaim(b.routeId) > scoreClaim(a.routeId) ? b : a));
|
||||
return { type: 'claimRoute', routeId: best.routeId, payment: best.payment };
|
||||
}
|
||||
return drawDecision(s, seat, wantedColors);
|
||||
}
|
||||
|
||||
// First train-card draw of the turn: prefer a useful face-up card or locomotive.
|
||||
function drawDecision(s, seat, wantedColors) {
|
||||
// A face-up locomotive (first draw) is strong — it counts as the whole turn.
|
||||
const locoIdx = s.faceUp.indexOf('locomotive');
|
||||
if (locoIdx !== -1) return { type: 'drawTrain', source: 'faceUp', index: locoIdx };
|
||||
|
||||
const idx = bestFaceUpColorIndex(s, wantedColors);
|
||||
if (idx !== -1) return { type: 'drawTrain', source: 'faceUp', index: idx };
|
||||
return { type: 'drawTrain', source: 'deck' };
|
||||
}
|
||||
|
||||
// Second draw of the turn (cannot take a face-up locomotive here).
|
||||
export function chooseSecondDraw(s, seat) {
|
||||
const player = s.players[seat];
|
||||
const find = seatComponents(s, seat);
|
||||
const wantedColors = new Map();
|
||||
for (const t of player.tickets) {
|
||||
if (ticketDone(find, t)) continue;
|
||||
const path = shortestRoutePath(s, t.a, t.b, seat);
|
||||
if (!path) continue;
|
||||
for (const rid of path.routeIds) {
|
||||
const col = ROUTES[rid].color;
|
||||
if (col !== 'gray' && s.claimed[rid] == null) wantedColors.set(col, (wantedColors.get(col) || 0) + 1);
|
||||
}
|
||||
}
|
||||
const idx = bestFaceUpColorIndex(s, wantedColors);
|
||||
if (idx !== -1) return { type: 'drawTrain', source: 'faceUp', index: idx };
|
||||
return { type: 'drawTrain', source: 'deck' };
|
||||
}
|
||||
|
||||
// Index of the most useful non-locomotive face-up card, or -1.
|
||||
function bestFaceUpColorIndex(s, wantedColors) {
|
||||
let bestIdx = -1, bestW = 0;
|
||||
s.faceUp.forEach((c, i) => {
|
||||
if (c === 'locomotive') return;
|
||||
const w = (wantedColors.get(c) || 0) + 0.1; // any colour has slight value
|
||||
if (w > bestW) { bestW = w; bestIdx = i; }
|
||||
});
|
||||
// Only divert to a face-up card if it is genuinely wanted; otherwise blind-draw
|
||||
// (keeps more of the deck unseen). Take it when it matches a goal colour.
|
||||
if (bestIdx !== -1 && bestW > 0.5) return bestIdx;
|
||||
return -1;
|
||||
}
|
||||
|
||||
export { ticketOf };
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
// 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
|
||||
];
|
||||
|
||||
// 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) {
|
||||
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 });
|
||||
});
|
||||
} else {
|
||||
out.push({ id: out.length, a, b, length, color: colour, doubleGroup: null, parallelSide: 0 });
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
// Returns one slot per train-length: { cx, cy, angle, w, h } rotated to the A→B
|
||||
// line. parallelSide shifts the whole strip perpendicular so double routes sit
|
||||
// side-by-side.
|
||||
export function routeSegments(route) {
|
||||
const A = CITIES[route.a];
|
||||
const B = CITIES[route.b];
|
||||
const dx = B.x - A.x;
|
||||
const dy = B.y - A.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len, uy = dy / len; // unit vector along the line
|
||||
const px = -uy, py = ux; // unit perpendicular
|
||||
const off = route.doubleGroup ? (route.parallelSide === 0 ? -DOUBLE_OFFSET : DOUBLE_OFFSET) : 0;
|
||||
const span = len - CITY_MARGIN * 2;
|
||||
const n = route.length;
|
||||
const carLen = (span - CAR_GAP * (n - 1)) / n;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const segs = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = CITY_MARGIN + carLen / 2 + i * (carLen + CAR_GAP);
|
||||
segs.push({
|
||||
cx: A.x + ux * t + px * off,
|
||||
cy: A.y + uy * t + py * off,
|
||||
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 dx = B.x - A.x, dy = B.y - A.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const px = -dy / len, py = dx / len;
|
||||
const off = route.doubleGroup ? (route.parallelSide === 0 ? -DOUBLE_OFFSET : DOUBLE_OFFSET) : 0;
|
||||
return {
|
||||
x: (A.x + B.x) / 2 + px * off,
|
||||
y: (A.y + B.y) / 2 + py * off,
|
||||
angle: Math.atan2(dy, dx),
|
||||
length: len - 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 = [
|
||||
[110, 210], [150, 140], [340, 120], [640, 128], [770, 150],
|
||||
[905, 178], [1060, 178], [1190, 168], [1285, 205],
|
||||
[1390, 270], [1365, 335], [1325, 385], [1300, 430], [1290, 480],
|
||||
[1255, 545], [1235, 625], [1255, 705], [1265, 815], [1230, 855],
|
||||
[1180, 840], [1050, 822], [920, 812], [820, 832], [760, 802],
|
||||
[640, 762], [560, 732], [520, 712], [420, 690], [300, 642],
|
||||
[220, 682], [160, 562], [120, 522], [110, 402], [132, 342], [118, 262],
|
||||
];
|
||||
|
||||
// Subtle Great Lakes hint between Duluth, Sault Ste Marie, Toronto and Chicago.
|
||||
export const GREAT_LAKES = [
|
||||
[800, 300], [905, 250], [1000, 270], [1010, 340], [930, 380], [840, 360],
|
||||
];
|
||||
|
|
@ -0,0 +1,779 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { auth } from '../../services/auth.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import {
|
||||
CITIES, ROUTES, TICKETS, TRAIN_COLORS, CARD_COLOR_HEX, CARD_LABEL,
|
||||
PLAYER_COLORS, ROUTE_SCORE, LONGEST_PATH_BONUS,
|
||||
routeSegments, routeMidpoint, LAND_OUTLINE, GREAT_LAKES,
|
||||
} from './TicketToRideBoard.js';
|
||||
import * as L from './TicketToRideLogic.js';
|
||||
import * as AI from './TicketToRideAI.js';
|
||||
|
||||
const D = {
|
||||
sea: 0, land: 1, lakes: 2, route: 6, train: 10, city: 14, label: 15,
|
||||
hover: 18, zone: 19, hud: 30, market: 34, panel: 60, modal: 70, banner: 80,
|
||||
};
|
||||
|
||||
const HAND_ORDER = [...TRAIN_COLORS, 'locomotive'];
|
||||
|
||||
// Right-band + bottom-strip layout (1920×1080). The map occupies x<1400.
|
||||
const RB = { x0: 1408, cx: 1660, w: 512 };
|
||||
const OPP_X = 1452, OPP_Y0 = 70, OPP_STEP = 96, OPP_R = 32;
|
||||
const MK_X = 1470, MK_Y0 = 392, MK_STEP = 86, CARD_W = 78, CARD_H = 74;
|
||||
const PILE_X = 1640;
|
||||
const BOT_Y = 980;
|
||||
|
||||
export default class TicketToRideGame extends Phaser.Scene {
|
||||
constructor() { super('TicketToRideGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game;
|
||||
this.opponents = data.opponents ?? [];
|
||||
this.playfield = data.playfield ?? null;
|
||||
this.gs = null;
|
||||
this.busy = false;
|
||||
this.opponentPortraits = [];
|
||||
this.modalObjs = [];
|
||||
this.marketObjs = [];
|
||||
this.handObjs = [];
|
||||
this.hoverRouteId = null;
|
||||
this.bannerShownFor = -1;
|
||||
}
|
||||
|
||||
create() {
|
||||
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch (_) { /* no music json */ }
|
||||
this.buildParticleTexture();
|
||||
this.buildBackdrop();
|
||||
this.buildStaticBoard();
|
||||
this.buildRightPanel();
|
||||
this.buildHUD();
|
||||
this.startNewMatch();
|
||||
}
|
||||
|
||||
buildParticleTexture() {
|
||||
const g = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
g.fillStyle(0xffffff, 1); g.fillCircle(5, 5, 5);
|
||||
g.generateTexture('ttrParticle', 10, 10);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
// ── backdrop: sea + land silhouette ─────────────────────────────────────────
|
||||
buildBackdrop() {
|
||||
if (this.playfield?.key && this.textures.exists(this.playfield.key)) {
|
||||
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, this.playfield.key)
|
||||
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.sea - 1);
|
||||
}
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x10384a).setDepth(D.sea);
|
||||
|
||||
const land = this.add.graphics().setDepth(D.land);
|
||||
const pts = LAND_OUTLINE.map(([x, y]) => ({ x, y }));
|
||||
land.fillStyle(0xcdb585, 1);
|
||||
land.fillPoints(pts, true);
|
||||
land.lineStyle(5, 0x8a6f3e, 0.9);
|
||||
land.strokePoints(pts, true);
|
||||
// soft inland shading
|
||||
land.fillStyle(0xd8c498, 0.4);
|
||||
land.fillPoints(pts.map((p) => ({ x: p.x, y: p.y - 6 })), true);
|
||||
|
||||
const lakes = this.add.graphics().setDepth(D.lakes);
|
||||
lakes.fillStyle(0x2f6f8a, 0.85);
|
||||
lakes.fillPoints(GREAT_LAKES.map(([x, y]) => ({ x, y })), true);
|
||||
}
|
||||
|
||||
// ── cities + route segments ─────────────────────────────────────────────────
|
||||
buildStaticBoard() {
|
||||
// Precompute segment + midpoint geometry once.
|
||||
this.segCache = ROUTES.map((r) => routeSegments(r));
|
||||
this.midCache = ROUTES.map((r) => routeMidpoint(r));
|
||||
|
||||
this.routeGfx = this.add.graphics().setDepth(D.route);
|
||||
this.hoverGfx = this.add.graphics().setDepth(D.hover);
|
||||
|
||||
// Invisible rotated hit-zone per route.
|
||||
this.routeZones = ROUTES.map((r, id) => {
|
||||
const m = this.midCache[id];
|
||||
const zone = this.add.zone(m.x, m.y, Math.max(m.length, 24), m.width + 12)
|
||||
.setRotation(m.angle)
|
||||
.setInteractive({ useHandCursor: true })
|
||||
.setDepth(D.zone);
|
||||
zone.on('pointerover', () => this.onRouteHover(id));
|
||||
zone.on('pointerout', () => this.onRouteHover(null));
|
||||
zone.on('pointerdown', () => this.onRouteClick(id));
|
||||
return zone;
|
||||
});
|
||||
|
||||
// Cities (static): dot + label.
|
||||
for (const c of CITIES) {
|
||||
const g = this.add.graphics().setDepth(D.city);
|
||||
g.fillStyle(0x2a2118, 1); g.fillCircle(c.x, c.y, 9);
|
||||
g.fillStyle(0xfdf3d8, 1); g.fillCircle(c.x, c.y, 5.5);
|
||||
g.lineStyle(2, 0x2a2118, 1); g.strokeCircle(c.x, c.y, 9);
|
||||
this.add.text(c.x, c.y - 16, c.name, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#241a0c',
|
||||
backgroundColor: 'rgba(253,243,216,0.7)', padding: { x: 4, y: 1 },
|
||||
}).setOrigin(0.5, 1).setDepth(D.label);
|
||||
}
|
||||
}
|
||||
|
||||
// ── right band: market, piles, ticket deck, opponent panels ───────────────────
|
||||
buildRightPanel() {
|
||||
this.add.rectangle(RB.cx, GAME_HEIGHT / 2, RB.w, GAME_HEIGHT, COLORS.panel, 0.9).setDepth(D.hud - 2);
|
||||
this.add.rectangle(RB.x0, GAME_HEIGHT / 2, 4, GAME_HEIGHT, COLORS.accent, 0.6).setDepth(D.hud - 2);
|
||||
|
||||
// Opponent panels (seats 1..n-1) — built in buildOpponentPanels once names exist.
|
||||
this.oppPanelText = [];
|
||||
|
||||
// Market label.
|
||||
this.add.text(RB.x0 + 24, MK_Y0 - 56, 'Train Card Market', {
|
||||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.accentHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.hud);
|
||||
|
||||
// Draw pile (face-down) — clickable.
|
||||
this.deckCard = this.add.graphics().setDepth(D.hud);
|
||||
this.drawPileFace(this.deckCard, PILE_X, MK_Y0, 'Deck');
|
||||
this.deckCount = this.add.text(PILE_X, MK_Y0 + 4, '', {
|
||||
fontFamily: 'Righteous', fontSize: '24px', color: '#fdf3d8',
|
||||
}).setOrigin(0.5).setDepth(D.hud + 1);
|
||||
this.add.text(PILE_X, MK_Y0 - CARD_H / 2 - 14, 'Draw', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.hud);
|
||||
this.makeZone(PILE_X, MK_Y0, CARD_W, CARD_H, () => this.onDeckClick());
|
||||
|
||||
// Discard pile (display only).
|
||||
const discY = MK_Y0 + MK_STEP * 1.5;
|
||||
this.discardCard = this.add.graphics().setDepth(D.hud);
|
||||
this.drawPileFace(this.discardCard, PILE_X, discY, 'Discard', 0x3a2f22);
|
||||
this.discardCount = this.add.text(PILE_X, discY + 4, '', {
|
||||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.hud + 1);
|
||||
this.add.text(PILE_X, discY - CARD_H / 2 - 14, 'Discard', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.hud);
|
||||
|
||||
// Ticket deck — clickable.
|
||||
const tY = MK_Y0 + MK_STEP * 3;
|
||||
this.ticketCard = this.add.graphics().setDepth(D.hud);
|
||||
this.ticketCard.fillStyle(0x4a7c3a, 1);
|
||||
this.ticketCard.fillRoundedRect(PILE_X - CARD_W / 2, tY - CARD_H / 2, CARD_W, CARD_H, 8);
|
||||
this.ticketCard.lineStyle(3, 0xfdf3d8, 0.9);
|
||||
this.ticketCard.strokeRoundedRect(PILE_X - CARD_W / 2, tY - CARD_H / 2, CARD_W, CARD_H, 8);
|
||||
this.add.text(PILE_X, tY - 14, '🎫', { fontSize: '26px' }).setOrigin(0.5).setDepth(D.hud + 1);
|
||||
this.ticketCount = this.add.text(PILE_X, tY + 16, '', {
|
||||
fontFamily: 'Righteous', fontSize: '16px', color: '#fdf3d8',
|
||||
}).setOrigin(0.5).setDepth(D.hud + 1);
|
||||
this.add.text(PILE_X, tY - CARD_H / 2 - 14, 'Destination Tickets', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.hud);
|
||||
this.makeZone(PILE_X, tY, CARD_W, CARD_H, () => this.onTicketDeckClick());
|
||||
}
|
||||
|
||||
drawPileFace(g, x, y, _label, fill = 0x243b52) {
|
||||
g.fillStyle(fill, 1);
|
||||
g.fillRoundedRect(x - CARD_W / 2, y - CARD_H / 2, CARD_W, CARD_H, 8);
|
||||
g.lineStyle(3, 0xfdf3d8, 0.85);
|
||||
g.strokeRoundedRect(x - CARD_W / 2, y - CARD_H / 2, CARD_W, CARD_H, 8);
|
||||
}
|
||||
|
||||
makeZone(x, y, w, h, fn) {
|
||||
const z = this.add.zone(x, y, w, h).setInteractive({ useHandCursor: true }).setDepth(D.hud + 4);
|
||||
z.on('pointerdown', fn);
|
||||
return z;
|
||||
}
|
||||
|
||||
buildOpponentPanels() {
|
||||
this.opponentPortraits.forEach((p) => p.destroy?.());
|
||||
this.opponentPortraits = [];
|
||||
this.oppPanelText.forEach((t) => t.destroy());
|
||||
this.oppPanelText = [];
|
||||
this.oppRingGfx?.destroy();
|
||||
this.oppRingGfx = this.add.graphics().setDepth(D.hud + 3);
|
||||
|
||||
for (let seat = 1; seat < this.gs.playerCount; seat++) {
|
||||
const y = OPP_Y0 + (seat - 1) * OPP_STEP;
|
||||
const opp = this.opponents[seat - 1];
|
||||
const portrait = createOpponentPortrait(this, opp, OPP_X, y, OPP_R, D.hud, { playIntro: seat === 1 });
|
||||
this.opponentPortraits.push(portrait);
|
||||
const col = PLAYER_COLORS[this.gs.players[seat].colorIndex];
|
||||
this.add.circle(OPP_X, y, OPP_R + 4, col.hex, 0).setStrokeStyle(3, col.hex, 0.95).setDepth(D.hud + 2);
|
||||
this.add.text(OPP_X + OPP_R + 16, y - 20, this.pname(seat), {
|
||||
fontFamily: 'Righteous', fontSize: '17px', color: COLORS.textHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.hud + 1);
|
||||
const info = this.add.text(OPP_X + OPP_R + 16, y + 6, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex, lineSpacing: 2,
|
||||
}).setOrigin(0, 0.5).setDepth(D.hud + 1);
|
||||
this.oppPanelText[seat] = info;
|
||||
}
|
||||
}
|
||||
|
||||
// ── bottom HUD: human portrait, hand, status, leave ───────────────────────────
|
||||
buildHUD() {
|
||||
this.add.rectangle(GAME_WIDTH / 2, BOT_Y + 30, GAME_WIDTH, 200, COLORS.panel, 0.92).setDepth(D.hud - 1);
|
||||
this.add.rectangle(GAME_WIDTH / 2, BOT_Y - 70, GAME_WIDTH, 4, COLORS.accent, 0.6).setDepth(D.hud - 1);
|
||||
|
||||
createPlayerPortrait(this, 80, BOT_Y, 46, D.hud, 'TicketToRideGame');
|
||||
this.add.text(80, BOT_Y + 60, auth.user?.username ?? 'You', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.hud);
|
||||
this.playerScoreText = this.add.text(80, BOT_Y - 64, '0', {
|
||||
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.hud + 1);
|
||||
this.add.text(80, BOT_Y - 90, 'SCORE', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '11px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.hud + 1);
|
||||
|
||||
this.add.text(180, BOT_Y - 70, 'Your Trains', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.hud);
|
||||
this.trainsText = this.add.text(300, BOT_Y - 70, '', {
|
||||
fontFamily: 'Righteous', fontSize: '16px', color: COLORS.textHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.hud);
|
||||
|
||||
this.ticketsBtn = new Button(this, 1760, BOT_Y, 'My Tickets', () => this.showMyTickets(),
|
||||
{ width: 200, height: 50, fontSize: 20 }).setDepth(D.hud);
|
||||
|
||||
this.statusText = this.add.text(GAME_WIDTH / 2, 38, '', {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
|
||||
backgroundColor: '#111923cc', padding: { x: 18, y: 8 },
|
||||
}).setOrigin(0.5).setDepth(D.banner);
|
||||
|
||||
this.logText = this.add.text(GAME_WIDTH / 2, 1068, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5, 1).setDepth(D.hud);
|
||||
|
||||
new Button(this, 80, 40, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 120, height: 40, fontSize: 17 }).setDepth(D.banner);
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
playerColor(seat) { return PLAYER_COLORS[this.gs.players[seat].colorIndex]; }
|
||||
pname(seat) { return L.playerName(this.gs, seat); }
|
||||
|
||||
textColorFor(colorKey) {
|
||||
return (colorKey === 'white' || colorKey === 'yellow') ? '#241a0c' : '#fdf3d8';
|
||||
}
|
||||
|
||||
// ── new match / turn driver ───────────────────────────────────────────────────
|
||||
startNewMatch() {
|
||||
this.busy = false;
|
||||
this.closeModal();
|
||||
const playerCount = Math.min(5, Math.max(2, 1 + this.opponents.length));
|
||||
this.gs = L.createInitialState(playerCount);
|
||||
const names = ['You', ...this.opponents.map((o) => o?.name ?? 'CPU')];
|
||||
L.setPlayerNames(this.gs, names);
|
||||
this.buildOpponentPanels();
|
||||
this.renderAll();
|
||||
this.time.delayedCall(600, () => this.advance());
|
||||
}
|
||||
|
||||
async advance() {
|
||||
const s = this.gs;
|
||||
this.renderAll();
|
||||
if (!s) return;
|
||||
if (s.phase === 'gameOver') { this.onGameOver(); return; }
|
||||
|
||||
if (s.pendingTickets) {
|
||||
if (s.pendingTickets.seat === 0) this.promptTicketKeep();
|
||||
else await this.aiResolveTickets();
|
||||
return;
|
||||
}
|
||||
|
||||
if (s.currentPlayer === 0) {
|
||||
// Human turn — controls are always-on zones guarded by canHumanAct().
|
||||
return;
|
||||
}
|
||||
await this.aiTurn();
|
||||
}
|
||||
|
||||
canHumanAct() {
|
||||
const s = this.gs;
|
||||
return !this.busy && s && (s.phase === 'turn' || s.phase === 'lastRound')
|
||||
&& s.currentPlayer === 0 && !s.pendingTickets && s.winner == null;
|
||||
}
|
||||
|
||||
applyHuman(newGs, sfx) {
|
||||
this.gs = newGs;
|
||||
if (sfx) playSound(this, sfx);
|
||||
this.advance();
|
||||
}
|
||||
|
||||
// ── human input handlers ────────────────────────────────────────────────────
|
||||
onMarketClick(i) {
|
||||
if (!this.canHumanAct()) return;
|
||||
if (i >= this.gs.faceUp.length) return;
|
||||
if (this.gs.faceUp[i] === 'locomotive' && this.gs.drawnThisTurn !== 0) {
|
||||
this.flash('Take a face-up locomotive only as your first draw'); return;
|
||||
}
|
||||
this.applyHuman(L.drawTrainCard(this.gs, 0, 'faceUp', i), SFX.CARD_DEAL);
|
||||
}
|
||||
|
||||
onDeckClick() {
|
||||
if (!this.canHumanAct()) return;
|
||||
if (!L.canDrawTrains(this.gs)) { this.flash('No cards left to draw'); return; }
|
||||
this.applyHuman(L.drawTrainCard(this.gs, 0, 'deck'), SFX.CARD_DEAL);
|
||||
}
|
||||
|
||||
onTicketDeckClick() {
|
||||
if (!this.canHumanAct()) return;
|
||||
if (this.gs.drawnThisTurn > 0) { this.flash('Finish drawing train cards this turn'); return; }
|
||||
if (!L.canDrawTickets(this.gs)) { this.flash('No tickets left'); return; }
|
||||
this.gs = L.drawTickets(this.gs, 0);
|
||||
this.advance();
|
||||
}
|
||||
|
||||
onRouteHover(id) {
|
||||
if (id != null && (!this.canHumanAct() || this.gs.drawnThisTurn > 0 || !L.routeClaimable(this.gs, 0, id))) {
|
||||
id = null;
|
||||
}
|
||||
this.hoverRouteId = id;
|
||||
this.renderHover();
|
||||
}
|
||||
|
||||
onRouteClick(id) {
|
||||
if (!this.canHumanAct()) return;
|
||||
if (this.gs.drawnThisTurn > 0) { this.flash('You are drawing cards this turn'); return; }
|
||||
if (!L.routeClaimable(this.gs, 0, id)) { this.flash('That route is not available'); return; }
|
||||
const opts = L.paymentOptions(this.gs.players[0], ROUTES[id]);
|
||||
if (opts.length === 0) { this.flash('Not enough matching cards'); return; }
|
||||
if (opts.length === 1) { this.applyHuman(L.claimRoute(this.gs, 0, id, opts[0]), SFX.CARD_PLACE); return; }
|
||||
this.promptClaimPayment(id, opts);
|
||||
}
|
||||
|
||||
// ── AI driver ─────────────────────────────────────────────────────────────────
|
||||
async aiTurn() {
|
||||
this.busy = true;
|
||||
const seat = this.gs.currentPlayer;
|
||||
this.showTurnBanner(`${this.pname(seat)}'s Turn`);
|
||||
await this.delay(650);
|
||||
|
||||
const a = AI.chooseAction(this.gs, seat);
|
||||
if (a.type === 'claimRoute') {
|
||||
this.gs = L.claimRoute(this.gs, seat, a.routeId, a.payment);
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
this.opponentPortraits[seat - 1]?.playEmotion?.('happy');
|
||||
} else if (a.type === 'drawTickets') {
|
||||
this.gs = L.drawTickets(this.gs, seat);
|
||||
// pendingTickets now set for this AI seat — advance() routes to aiResolveTickets.
|
||||
} else { // drawTrain
|
||||
this.gs = L.drawTrainCard(this.gs, seat, a.source, a.index);
|
||||
playSound(this, SFX.CARD_DEAL);
|
||||
this.renderAll();
|
||||
// Second draw if the turn is still in progress.
|
||||
if ((this.gs.phase === 'turn' || this.gs.phase === 'lastRound')
|
||||
&& this.gs.currentPlayer === seat && this.gs.drawnThisTurn === 1 && !this.gs.pendingTickets) {
|
||||
await this.delay(450);
|
||||
const b = AI.chooseSecondDraw(this.gs, seat);
|
||||
this.gs = L.drawTrainCard(this.gs, seat, b.source, b.index);
|
||||
playSound(this, SFX.CARD_DEAL);
|
||||
}
|
||||
}
|
||||
|
||||
this.renderAll();
|
||||
await this.delay(450);
|
||||
this.busy = false;
|
||||
this.advance();
|
||||
}
|
||||
|
||||
async aiResolveTickets() {
|
||||
this.busy = true;
|
||||
await this.delay(550);
|
||||
const pend = this.gs.pendingTickets;
|
||||
const keep = AI.chooseTicketsToKeep(this.gs, pend.seat, pend.drawn, pend.minKeep);
|
||||
this.gs = L.resolveTicketKeep(this.gs, pend.seat, keep);
|
||||
this.busy = false;
|
||||
this.advance();
|
||||
}
|
||||
|
||||
// ── rendering ─────────────────────────────────────────────────────────────────
|
||||
renderAll() {
|
||||
if (!this.gs) return;
|
||||
this.renderRoutes();
|
||||
this.renderHover();
|
||||
this.renderMarket();
|
||||
this.renderHand();
|
||||
this.renderPiles();
|
||||
this.renderPanels();
|
||||
this.renderStatus();
|
||||
}
|
||||
|
||||
renderRoutes() {
|
||||
const g = this.routeGfx;
|
||||
g.clear();
|
||||
ROUTES.forEach((r, id) => {
|
||||
const owner = this.gs.claimed[id];
|
||||
const segs = this.segCache[id];
|
||||
if (owner != null) {
|
||||
const col = this.playerColor(owner);
|
||||
for (const seg of segs) this.drawCar(g, seg, col.hex, col.hexDark, 0xffffff);
|
||||
} else {
|
||||
const fill = CARD_COLOR_HEX[r.color] ?? CARD_COLOR_HEX.gray;
|
||||
for (const seg of segs) this.drawCar(g, seg, fill, 0x2a2118, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderHover() {
|
||||
const g = this.hoverGfx;
|
||||
g.clear();
|
||||
if (this.hoverRouteId == null) return;
|
||||
for (const seg of this.segCache[this.hoverRouteId]) {
|
||||
this.drawCar(g, seg, 0xffe680, 0xffffff, 0xffffff);
|
||||
}
|
||||
}
|
||||
|
||||
drawCar(g, seg, fill, stroke, halo) {
|
||||
const w = seg.w, h = seg.h;
|
||||
g.save();
|
||||
g.translateCanvas(seg.cx, seg.cy);
|
||||
g.rotateCanvas(seg.angle);
|
||||
if (halo != null) { g.fillStyle(halo, 1); g.fillRoundedRect(-w / 2 - 2, -h / 2 - 2, w + 4, h + 4, 4); }
|
||||
g.fillStyle(fill, 1);
|
||||
g.fillRoundedRect(-w / 2, -h / 2, w, h, 3);
|
||||
g.lineStyle(1.5, stroke, 0.9);
|
||||
g.strokeRoundedRect(-w / 2, -h / 2, w, h, 3);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
renderMarket() {
|
||||
this.marketObjs.forEach((o) => o.destroy());
|
||||
this.marketObjs = [];
|
||||
const myTurn = this.canHumanAct();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const y = MK_Y0 + i * MK_STEP;
|
||||
const color = this.gs.faceUp[i];
|
||||
if (color == null) {
|
||||
const g = this.add.graphics().setDepth(D.market);
|
||||
g.lineStyle(2, COLORS.mutedHex, 0.5);
|
||||
g.strokeRoundedRect(MK_X - CARD_W / 2, y - CARD_H / 2, CARD_W, CARD_H, 8);
|
||||
this.marketObjs.push(g);
|
||||
continue;
|
||||
}
|
||||
this.marketObjs.push(...this.makeCardFace(MK_X, y, CARD_W, CARD_H, color, D.market, ''));
|
||||
const z = this.add.zone(MK_X, y, CARD_W, CARD_H).setDepth(D.market + 2);
|
||||
if (myTurn) z.setInteractive({ useHandCursor: true });
|
||||
z.on('pointerdown', () => this.onMarketClick(i));
|
||||
this.marketObjs.push(z);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the display objects for a card face (graphics + label text).
|
||||
makeCardFace(x, y, w, h, colorKey, depth, label) {
|
||||
const objs = [];
|
||||
const g = this.add.graphics().setDepth(depth);
|
||||
const fill = CARD_COLOR_HEX[colorKey] ?? 0x888888;
|
||||
g.fillStyle(fill, 1);
|
||||
g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 8);
|
||||
if (colorKey === 'locomotive') {
|
||||
// rainbow accent stripe for the wild card
|
||||
const cols = [0xd23b3b, 0xe0b000, 0x2e7d32, 0x2d6cdf, 0x8e44ad];
|
||||
cols.forEach((c, i) => {
|
||||
g.fillStyle(c, 0.9);
|
||||
g.fillRect(x - w / 2 + 6 + i * ((w - 12) / cols.length), y - 8, (w - 12) / cols.length, 16);
|
||||
});
|
||||
}
|
||||
g.lineStyle(2.5, 0xfdf3d8, 0.9);
|
||||
g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 8);
|
||||
objs.push(g);
|
||||
const txt = colorKey === 'locomotive' ? 'LOCO' : CARD_LABEL[colorKey];
|
||||
objs.push(this.add.text(x, y + h / 2 - 12, label || txt, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '13px', color: this.textColorFor(colorKey),
|
||||
}).setOrigin(0.5).setDepth(depth + 1));
|
||||
return objs;
|
||||
}
|
||||
|
||||
renderHand() {
|
||||
this.handObjs.forEach((o) => o.destroy());
|
||||
this.handObjs = [];
|
||||
const hand = this.gs.players[0].hand;
|
||||
const w = 66, h = 84, step = 88, x0 = 400;
|
||||
HAND_ORDER.forEach((colorKey, i) => {
|
||||
const x = x0 + i * step;
|
||||
const count = hand[colorKey];
|
||||
const objs = this.makeCardFace(x, BOT_Y + 2, w, h, colorKey, D.hud, '');
|
||||
if (count === 0) objs.forEach((o) => o.setAlpha(0.3));
|
||||
this.handObjs.push(...objs);
|
||||
this.handObjs.push(this.add.text(x, BOT_Y - h / 2 - 8, `×${count}`, {
|
||||
fontFamily: 'Righteous', fontSize: '18px', color: count > 0 ? COLORS.textHex : COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.hud + 1));
|
||||
});
|
||||
}
|
||||
|
||||
renderPiles() {
|
||||
this.deckCount.setText(String(this.gs.trainDeck.length));
|
||||
this.discardCount.setText(String(this.gs.discard.length));
|
||||
this.ticketCount.setText(`${this.gs.ticketDeck.length} left`);
|
||||
}
|
||||
|
||||
renderPanels() {
|
||||
this.playerScoreText.setText(String(L.publicScore(this.gs, 0)));
|
||||
this.trainsText.setText(String(this.gs.players[0].trainsLeft));
|
||||
|
||||
this.oppRingGfx.clear();
|
||||
for (let seat = 1; seat < this.gs.playerCount; seat++) {
|
||||
const p = this.gs.players[seat];
|
||||
const info = this.oppPanelText[seat];
|
||||
if (info) {
|
||||
info.setText(`Trains ${p.trainsLeft} Cards ${L.handCount(p)}\nTickets ${p.tickets.length} Score ${L.publicScore(this.gs, seat)}`);
|
||||
}
|
||||
if (this.gs.currentPlayer === seat) {
|
||||
const y = OPP_Y0 + (seat - 1) * OPP_STEP;
|
||||
this.oppRingGfx.lineStyle(4, COLORS.goldHex, 1);
|
||||
this.oppRingGfx.strokeCircle(OPP_X, y, OPP_R + 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderStatus() {
|
||||
const s = this.gs;
|
||||
let msg = '';
|
||||
if (s.phase === 'gameOver') msg = '';
|
||||
else if (s.pendingTickets && s.pendingTickets.seat === 0) msg = '';
|
||||
else if (s.currentPlayer === 0) {
|
||||
if (s.drawnThisTurn === 1) msg = 'Draw one more train card (deck or face-up).';
|
||||
else msg = 'Your turn — claim a route, draw train cards, or draw tickets.';
|
||||
if (s.phase === 'lastRound') msg = 'FINAL ROUND! ' + msg;
|
||||
} else {
|
||||
msg = `${this.pname(s.currentPlayer)} is thinking…`;
|
||||
}
|
||||
this.statusText.setText(msg);
|
||||
this.logText.setText(s.log[s.log.length - 1] ?? '');
|
||||
}
|
||||
|
||||
flash(message) {
|
||||
const t = this.add.text(GAME_WIDTH / 2, 100, message, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: '#ffd86b',
|
||||
backgroundColor: '#111923ee', padding: { x: 14, y: 8 },
|
||||
}).setOrigin(0.5).setDepth(D.banner + 2);
|
||||
this.tweens.add({ targets: t, alpha: 0, y: 70, delay: 1100, duration: 500, onComplete: () => t.destroy() });
|
||||
}
|
||||
|
||||
showTurnBanner(text) {
|
||||
const banner = this.add.text(700, 120, text, {
|
||||
fontFamily: 'Righteous', fontSize: '32px', color: COLORS.textHex,
|
||||
backgroundColor: '#111923ee', padding: { x: 24, y: 12 },
|
||||
}).setOrigin(0.5).setDepth(D.banner);
|
||||
banner.setAlpha(0);
|
||||
this.tweens.add({
|
||||
targets: banner, alpha: 1, y: 140, duration: 260, ease: 'Back.easeOut',
|
||||
onComplete: () => this.time.delayedCall(800, () => this.tweens.add({
|
||||
targets: banner, alpha: 0, y: 120, duration: 200, onComplete: () => banner.destroy(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// ── modals ─────────────────────────────────────────────────────────────────────
|
||||
closeModal() {
|
||||
this.modalObjs.forEach((o) => o.destroy());
|
||||
this.modalObjs = [];
|
||||
}
|
||||
|
||||
promptTicketKeep() {
|
||||
this.closeModal();
|
||||
const pend = this.gs.pendingTickets;
|
||||
const setup = pend.context === 'setup';
|
||||
const selected = new Set(pend.drawn); // pre-keep everything
|
||||
const PX = 700, PW = 720;
|
||||
const rowH = 70;
|
||||
const PH = 200 + pend.drawn.length * rowH;
|
||||
const PY = 540;
|
||||
|
||||
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
|
||||
.setDepth(D.modal).setInteractive();
|
||||
const panel = this.add.rectangle(PX, PY, PW, PH, 0x14202c, 0.98).setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1);
|
||||
const title = this.add.text(PX, PY - PH / 2 + 36,
|
||||
setup ? 'Keep at least 2 destination tickets' : 'Keep at least 1 destination ticket', {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.modal + 2);
|
||||
this.modalObjs.push(overlay, panel, title);
|
||||
|
||||
const rowObjs = [];
|
||||
const confirmRef = {};
|
||||
const redraw = () => {
|
||||
const ok = selected.size >= pend.minKeep;
|
||||
confirmRef.btn?.setEnabled(ok);
|
||||
};
|
||||
|
||||
pend.drawn.forEach((id, i) => {
|
||||
const t = TICKETS[id];
|
||||
const ry = PY - PH / 2 + 90 + i * rowH;
|
||||
const box = this.add.rectangle(PX, ry, PW - 80, rowH - 12, 0x223344, 1)
|
||||
.setStrokeStyle(3, COLORS.gold).setDepth(D.modal + 2).setInteractive({ useHandCursor: true });
|
||||
const label = this.add.text(PX - PW / 2 + 60, ry, `${CITIES[t.a].name} → ${CITIES[t.b].name}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.modal + 3);
|
||||
const pts = this.add.text(PX + PW / 2 - 60, ry, `${t.points} pts`, {
|
||||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
|
||||
}).setOrigin(1, 0.5).setDepth(D.modal + 3);
|
||||
const mark = this.add.text(PX - PW / 2 + 32, ry, '✓', {
|
||||
fontFamily: 'Righteous', fontSize: '22px', color: '#5fd97a',
|
||||
}).setOrigin(0.5).setDepth(D.modal + 3);
|
||||
const apply = () => {
|
||||
const on = selected.has(id);
|
||||
box.setStrokeStyle(3, on ? COLORS.gold : 0x55626f);
|
||||
box.setFillStyle(on ? 0x2c4a2c : 0x223344, 1);
|
||||
mark.setVisible(on);
|
||||
};
|
||||
box.on('pointerdown', () => {
|
||||
if (selected.has(id)) selected.delete(id); else selected.add(id);
|
||||
apply(); redraw();
|
||||
});
|
||||
apply();
|
||||
rowObjs.push(box, label, pts, mark);
|
||||
});
|
||||
this.modalObjs.push(...rowObjs);
|
||||
|
||||
confirmRef.btn = new Button(this, PX, PY + PH / 2 - 44, 'Confirm', () => {
|
||||
const keep = [...selected];
|
||||
this.closeModal();
|
||||
this.gs = L.resolveTicketKeep(this.gs, 0, keep);
|
||||
this.advance();
|
||||
}, { width: 240, height: 54, fontSize: 22 }).setDepth(D.modal + 2);
|
||||
this.modalObjs.push(confirmRef.btn);
|
||||
redraw();
|
||||
}
|
||||
|
||||
promptClaimPayment(routeId, options) {
|
||||
this.closeModal();
|
||||
this.busy = true; // block other input while choosing
|
||||
const route = ROUTES[routeId];
|
||||
const PX = 700, PW = 640;
|
||||
const PH = 180 + options.length * 70;
|
||||
const PY = 540;
|
||||
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
|
||||
.setDepth(D.modal).setInteractive();
|
||||
const panel = this.add.rectangle(PX, PY, PW, PH, 0x14202c, 0.98).setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1);
|
||||
const title = this.add.text(PX, PY - PH / 2 + 34,
|
||||
`Claim ${CITIES[route.a].name} – ${CITIES[route.b].name}\nPay with:`, {
|
||||
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex, align: 'center',
|
||||
}).setOrigin(0.5).setDepth(D.modal + 2);
|
||||
this.modalObjs.push(overlay, panel, title);
|
||||
|
||||
const finish = (payment) => {
|
||||
this.closeModal();
|
||||
this.busy = false;
|
||||
if (payment) this.applyHuman(L.claimRoute(this.gs, 0, routeId, payment), SFX.CARD_PLACE);
|
||||
else this.advance();
|
||||
};
|
||||
|
||||
options.forEach((opt, i) => {
|
||||
const parts = [];
|
||||
if (opt.colorCount > 0) parts.push(`${opt.colorCount} ${CARD_LABEL[opt.color]}`);
|
||||
if (opt.locos > 0) parts.push(`${opt.locos} Locomotive`);
|
||||
const by = PY - PH / 2 + 86 + i * 70;
|
||||
this.modalObjs.push(new Button(this, PX, by, parts.join(' + '), () => finish(opt),
|
||||
{ width: PW - 120, height: 52, fontSize: 20 }).setDepth(D.modal + 2));
|
||||
});
|
||||
this.modalObjs.push(new Button(this, PX, PY + PH / 2 - 40, 'Cancel', () => finish(null),
|
||||
{ variant: 'ghost', width: 200, height: 46, fontSize: 18 }).setDepth(D.modal + 2));
|
||||
}
|
||||
|
||||
showMyTickets() {
|
||||
this.closeModal();
|
||||
const tickets = this.gs.players[0].tickets;
|
||||
const find = (() => {
|
||||
const parent = Array.from({ length: CITIES.length }, (_, i) => i);
|
||||
const f = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
|
||||
for (const rid of this.gs.players[0].routes) { const r = ROUTES[rid]; parent[f(r.a)] = f(r.b); }
|
||||
return f;
|
||||
})();
|
||||
const PX = 700, PW = 700, rowH = 64;
|
||||
const PH = 170 + Math.max(1, tickets.length) * rowH;
|
||||
const PY = 540;
|
||||
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
|
||||
.setDepth(D.modal).setInteractive();
|
||||
const panel = this.add.rectangle(PX, PY, PW, PH, 0x14202c, 0.98).setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1);
|
||||
const title = this.add.text(PX, PY - PH / 2 + 34, 'Your Destination Tickets', {
|
||||
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.modal + 2);
|
||||
this.modalObjs.push(overlay, panel, title);
|
||||
|
||||
if (tickets.length === 0) {
|
||||
this.modalObjs.push(this.add.text(PX, PY, 'No tickets.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.modal + 2));
|
||||
}
|
||||
tickets.forEach((t, i) => {
|
||||
const ry = PY - PH / 2 + 84 + i * rowH;
|
||||
const done = this.gs.players[0].routes.length > 0 && find(t.a) === find(t.b);
|
||||
this.modalObjs.push(this.add.text(PX - PW / 2 + 40, ry, `${CITIES[t.a].name} → ${CITIES[t.b].name}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.modal + 2));
|
||||
this.modalObjs.push(this.add.text(PX + PW / 2 - 150, ry, `${t.points} pts`, {
|
||||
fontFamily: 'Righteous', fontSize: '18px', color: COLORS.goldHex,
|
||||
}).setOrigin(1, 0.5).setDepth(D.modal + 2));
|
||||
this.modalObjs.push(this.add.text(PX + PW / 2 - 40, ry, done ? '✓ done' : 'open', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: done ? '#5fd97a' : COLORS.mutedHex,
|
||||
}).setOrigin(1, 0.5).setDepth(D.modal + 2));
|
||||
});
|
||||
this.modalObjs.push(new Button(this, PX, PY + PH / 2 - 40, 'Close', () => this.closeModal(),
|
||||
{ variant: 'ghost', width: 200, height: 46, fontSize: 18 }).setDepth(D.modal + 2));
|
||||
}
|
||||
|
||||
// ── game over ─────────────────────────────────────────────────────────────────
|
||||
onGameOver() {
|
||||
this.closeModal();
|
||||
this.hoverRouteId = null; this.renderHover();
|
||||
const scores = this.gs.scores;
|
||||
const winner = this.gs.winner;
|
||||
const isHuman = winner === 0;
|
||||
this.recordHistory();
|
||||
|
||||
const PW = 820, PH = 660, PX = 700, PY = 540;
|
||||
|
||||
const fw = this.add.particles(PX, PY, 'ttrParticle', {
|
||||
speed: { min: 80, max: 460 }, lifespan: 1400, scale: { start: 1.1, end: 0 },
|
||||
alpha: { start: 1, end: 0 }, quantity: 3, frequency: 40,
|
||||
tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0x88ff44],
|
||||
angle: { min: 0, max: 360 },
|
||||
emitZone: { type: 'random', source: new Phaser.Geom.Rectangle(-PW / 2, -PH / 2, PW, PH) },
|
||||
}).setDepth(D.banner + 8);
|
||||
this.time.delayedCall(3000, () => { fw.stop(); this.time.delayedCall(1400, () => fw.destroy()); });
|
||||
|
||||
const overlay = this.add.rectangle(PX, PY, PW, PH, 0x0a0e14, 0.95).setStrokeStyle(3, COLORS.accent).setDepth(D.banner);
|
||||
const title = this.add.text(PX, PY - PH / 2 + 56, isHuman ? 'Victory!' : `${this.pname(winner)} wins`, {
|
||||
fontFamily: 'Righteous', fontSize: '46px', color: isHuman ? '#ffd700' : COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.banner + 1);
|
||||
|
||||
// Score table.
|
||||
const header = this.add.text(PX, PY - PH / 2 + 120,
|
||||
'Player Routes Tickets Longest Total', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(D.banner + 1);
|
||||
|
||||
const rows = scores.slice().sort((a, b) => b.total - a.total).map((sc) => {
|
||||
const name = this.pname(sc.seat).padEnd(12).slice(0, 12);
|
||||
const lp = `${sc.longestPath}${sc.longestBonus ? '+10' : ''}`;
|
||||
return `${name} ${String(sc.routePoints).padStart(5)} ${String(sc.ticketPoints).padStart(6)} ${lp.padStart(7)} ${String(sc.total).padStart(5)}`;
|
||||
}).join('\n');
|
||||
const body = this.add.text(PX, PY - 30, rows, {
|
||||
fontFamily: 'monospace', fontSize: '22px', color: COLORS.textHex, align: 'left', lineSpacing: 8,
|
||||
}).setOrigin(0.5).setDepth(D.banner + 1);
|
||||
|
||||
const cleanup = () => {
|
||||
overlay.destroy(); title.destroy(); header.destroy(); body.destroy();
|
||||
playAgain.destroy(); leave.destroy();
|
||||
};
|
||||
const playAgain = new Button(this, PX - 120, PY + PH / 2 - 60, 'Play Again', () => {
|
||||
cleanup(); this.startNewMatch();
|
||||
}, { width: 210, fontSize: 22 }).setDepth(D.banner + 1);
|
||||
const leave = new Button(this, PX + 120, PY + PH / 2 - 60, 'Leave', () => {
|
||||
cleanup(); this.scene.start('GameMenu');
|
||||
}, { variant: 'ghost', width: 210, fontSize: 22 }).setDepth(D.banner + 1);
|
||||
}
|
||||
|
||||
async recordHistory() {
|
||||
const totals = this.gs.scores.map((sc) => sc.total);
|
||||
const result = this.gs.winner === 0 ? 'win' : 'loss';
|
||||
try {
|
||||
await api.post('/history/single-player', {
|
||||
slug: 'tickettoride', score: totals[0], opponentScores: totals.slice(1), result,
|
||||
});
|
||||
} catch (_) { /* offline / not signed in — ignore */ }
|
||||
}
|
||||
|
||||
delay(ms) { return new Promise((res) => this.time.delayedCall(ms, res)); }
|
||||
}
|
||||
|
|
@ -0,0 +1,443 @@
|
|||
// TicketToRideLogic.js — pure state engine for Ticket to Ride (USA). No Phaser.
|
||||
// Every exported action takes a state and returns a NEW state (deep-cloned at the
|
||||
// top); internal helpers prefixed `_` mutate the already-cloned working state.
|
||||
|
||||
import {
|
||||
ROUTES, CITIES, TICKETS, TRAIN_DECK, TRAIN_COLORS, ROUTE_SCORE,
|
||||
TRAINS_PER_PLAYER, LONGEST_PATH_BONUS, FACE_UP_COUNT, ENDGAME_TRAIN_THRESHOLD,
|
||||
} from './TicketToRideBoard.js';
|
||||
|
||||
// ── small utilities ─────────────────────────────────────────────────────────
|
||||
function shuffle(arr) {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
export function cloneState(state) {
|
||||
return JSON.parse(JSON.stringify(state));
|
||||
}
|
||||
|
||||
export function emptyHand() {
|
||||
const h = { locomotive: 0 };
|
||||
for (const c of TRAIN_COLORS) h[c] = 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
export function handCount(player) {
|
||||
return player.hand.locomotive + TRAIN_COLORS.reduce((s, c) => s + player.hand[c], 0);
|
||||
}
|
||||
|
||||
function logEvent(s, msg) {
|
||||
s.log.push(msg);
|
||||
if (s.log.length > 12) s.log.shift();
|
||||
}
|
||||
|
||||
// ── deck helpers (mutate working state) ──────────────────────────────────────
|
||||
function _reshuffleIfNeeded(s) {
|
||||
if (s.trainDeck.length === 0 && s.discard.length > 0) {
|
||||
s.trainDeck = shuffle(s.discard);
|
||||
s.discard = [];
|
||||
}
|
||||
}
|
||||
|
||||
function _drawFromDeck(s) {
|
||||
_reshuffleIfNeeded(s);
|
||||
return s.trainDeck.length ? s.trainDeck.pop() : null;
|
||||
}
|
||||
|
||||
// Refill the face-up market to FACE_UP_COUNT; if 3+ of the 5 are locomotives,
|
||||
// discard all five and re-deal (official rule). Guarded against tiny decks.
|
||||
function _refillFaceUp(s) {
|
||||
let guard = 0;
|
||||
do {
|
||||
while (s.faceUp.length < FACE_UP_COUNT) {
|
||||
const c = _drawFromDeck(s);
|
||||
if (c == null) break;
|
||||
s.faceUp.push(c);
|
||||
}
|
||||
const locos = s.faceUp.filter((c) => c === 'locomotive').length;
|
||||
const available = s.faceUp.length + s.trainDeck.length + s.discard.length;
|
||||
if (locos >= 3 && s.faceUp.length === FACE_UP_COUNT && available > FACE_UP_COUNT) {
|
||||
s.discard.push(...s.faceUp);
|
||||
s.faceUp = [];
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
} while (++guard < 10);
|
||||
}
|
||||
|
||||
// ── ticket dealing ────────────────────────────────────────────────────────────
|
||||
function _dealTicketsToSeat(s, seat, count, minKeep, context) {
|
||||
const drawn = [];
|
||||
for (let i = 0; i < count && s.ticketDeck.length; i++) drawn.push(s.ticketDeck.shift());
|
||||
s.pendingTickets = { seat, drawn, minKeep: Math.min(minKeep, drawn.length), context };
|
||||
}
|
||||
|
||||
// ── initial state ──────────────────────────────────────────────────────────────
|
||||
export function createInitialState(playerCount = 4) {
|
||||
const n = Math.max(2, Math.min(5, playerCount));
|
||||
const s = {
|
||||
playerCount: n,
|
||||
phase: 'ticketSetup',
|
||||
currentPlayer: 0,
|
||||
trainDeck: shuffle(TRAIN_DECK),
|
||||
discard: [],
|
||||
faceUp: [],
|
||||
ticketDeck: shuffle(TICKETS.map((t) => t.id)),
|
||||
pendingTickets: null,
|
||||
claimed: {}, // routeId -> seat
|
||||
players: [],
|
||||
drawnThisTurn: 0,
|
||||
drewLocoFaceUp: false,
|
||||
endTriggered: false,
|
||||
finalTurnsLeft: null,
|
||||
triggerSeat: null,
|
||||
winner: null,
|
||||
scores: null,
|
||||
log: [],
|
||||
};
|
||||
|
||||
for (let seat = 0; seat < n; seat++) {
|
||||
const hand = emptyHand();
|
||||
for (let i = 0; i < 4; i++) { // deal 4 train cards
|
||||
const c = _drawFromDeck(s);
|
||||
if (c != null) hand[c]++;
|
||||
}
|
||||
s.players.push({
|
||||
seat,
|
||||
colorIndex: seat,
|
||||
isHuman: seat === 0,
|
||||
name: seat === 0 ? 'You' : `Player ${seat}`,
|
||||
hand,
|
||||
tickets: [],
|
||||
trainsLeft: TRAINS_PER_PLAYER,
|
||||
routes: [],
|
||||
});
|
||||
}
|
||||
|
||||
_refillFaceUp(s);
|
||||
_dealTicketsToSeat(s, 0, 3, 2, 'setup'); // seat 0 chooses first
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── ticket keep resolution (setup + mid-game draws) ──────────────────────────
|
||||
export function resolveTicketKeep(state, seat, keepIds) {
|
||||
const s = cloneState(state);
|
||||
const pend = s.pendingTickets;
|
||||
if (!pend || pend.seat !== seat) return s;
|
||||
|
||||
// Sanitise the request: only ids that were drawn, at least minKeep of them.
|
||||
let keep = (keepIds || []).filter((id) => pend.drawn.includes(id));
|
||||
if (keep.length < pend.minKeep) {
|
||||
for (const id of pend.drawn) {
|
||||
if (keep.length >= pend.minKeep) break;
|
||||
if (!keep.includes(id)) keep.push(id);
|
||||
}
|
||||
}
|
||||
const discardIds = pend.drawn.filter((id) => !keep.includes(id));
|
||||
|
||||
for (const id of keep) s.players[seat].tickets.push({ ...TICKETS[id] });
|
||||
s.ticketDeck.push(...discardIds); // returned to the bottom
|
||||
logEvent(s, `${playerName(s, seat)} kept ${keep.length} ticket${keep.length === 1 ? '' : 's'}.`);
|
||||
|
||||
const wasSetup = pend.context === 'setup';
|
||||
s.pendingTickets = null;
|
||||
|
||||
if (wasSetup) {
|
||||
const next = seat + 1;
|
||||
if (next < s.playerCount) {
|
||||
_dealTicketsToSeat(s, next, 3, 2, 'setup');
|
||||
} else {
|
||||
s.phase = 'turn';
|
||||
s.currentPlayer = 0;
|
||||
logEvent(s, 'Game on! Your move.');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
// Mid-game ticket draw completes the turn.
|
||||
return _endTurn(s);
|
||||
}
|
||||
|
||||
// ── train-card draws ─────────────────────────────────────────────────────────
|
||||
export function canDrawTrains(s) {
|
||||
return s.trainDeck.length + s.discard.length + s.faceUp.length > 0;
|
||||
}
|
||||
|
||||
// source: 'deck' or 'faceUp'; index used only for 'faceUp'.
|
||||
export function drawTrainCard(state, seat, source, index = 0) {
|
||||
const s = cloneState(state);
|
||||
if (!_isTurnPhase(s) || s.currentPlayer !== seat || s.pendingTickets) return s;
|
||||
if (s.drawnThisTurn >= 2) return s;
|
||||
|
||||
if (source === 'faceUp') {
|
||||
if (index < 0 || index >= s.faceUp.length) return s;
|
||||
const card = s.faceUp[index];
|
||||
if (card === 'locomotive') {
|
||||
// A face-up locomotive may only be taken as the first draw; it ends the turn.
|
||||
if (s.drawnThisTurn !== 0) return s;
|
||||
s.faceUp.splice(index, 1);
|
||||
s.players[seat].hand.locomotive++;
|
||||
s.drewLocoFaceUp = true;
|
||||
_refillFaceUp(s);
|
||||
logEvent(s, `${playerName(s, seat)} drew a locomotive.`);
|
||||
return _endTurn(s);
|
||||
}
|
||||
s.faceUp.splice(index, 1);
|
||||
s.players[seat].hand[card]++;
|
||||
s.drawnThisTurn++;
|
||||
_refillFaceUp(s);
|
||||
logEvent(s, `${playerName(s, seat)} drew a face-up card.`);
|
||||
} else {
|
||||
const card = _drawFromDeck(s);
|
||||
if (card == null) return s;
|
||||
s.players[seat].hand[card]++;
|
||||
s.drawnThisTurn++;
|
||||
logEvent(s, `${playerName(s, seat)} drew from the deck.`);
|
||||
}
|
||||
|
||||
if (s.drawnThisTurn >= 2) return _endTurn(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── route claiming ───────────────────────────────────────────────────────────
|
||||
// Returns the cheapest concrete payment {color, colorCount, locos} that claims
|
||||
// `route` from `player`'s hand, or null if unaffordable. Shared with the AI so
|
||||
// legality and AI agree. Prefers spending the fewest locomotives.
|
||||
export function cardsForRoute(player, route) {
|
||||
const opts = paymentOptions(player, route);
|
||||
return opts.length ? opts[0] : null;
|
||||
}
|
||||
|
||||
// All viable payments for a route, best (fewest locos, then most colour cards) first.
|
||||
export function paymentOptions(player, route) {
|
||||
const hand = player.hand;
|
||||
const need = route.length;
|
||||
const locos = hand.locomotive;
|
||||
const out = [];
|
||||
const colors = route.color === 'gray' ? TRAIN_COLORS : [route.color];
|
||||
for (const c of colors) {
|
||||
const have = hand[c];
|
||||
// Only colour-bearing payments here; a 0-colour payment is just pure-loco
|
||||
// (added once below) regardless of which colour slot produced it.
|
||||
if (have > 0 && have + locos >= need) {
|
||||
const colorCount = Math.min(have, need);
|
||||
out.push({ color: c, colorCount, locos: need - colorCount });
|
||||
}
|
||||
}
|
||||
// Pure-locomotive payment (always allowed if enough wilds).
|
||||
if (locos >= need) out.push({ color: null, colorCount: 0, locos: need });
|
||||
|
||||
out.sort((p, q) => (p.locos - q.locos) || (q.colorCount - p.colorCount));
|
||||
return out;
|
||||
}
|
||||
|
||||
// Is `routeId` claimable by `seat` right now, ignoring the hand (occupancy +
|
||||
// double-route lockout + trains)? Used by both legality and rendering.
|
||||
export function routeClaimable(s, seat, routeId) {
|
||||
if (s.claimed[routeId] != null) return false;
|
||||
const route = ROUTES[routeId];
|
||||
if (s.players[seat].trainsLeft < route.length) return false;
|
||||
if (route.doubleGroup) {
|
||||
for (const r of ROUTES) {
|
||||
if (r.id === routeId || r.doubleGroup !== route.doubleGroup) continue;
|
||||
const owner = s.claimed[r.id];
|
||||
if (owner === seat) return false; // can't hold both halves
|
||||
if (owner != null && s.playerCount < 4) return false; // <4 players: one locks the pair
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Every route `seat` can claim now, with its default payment.
|
||||
export function legalClaims(s, seat) {
|
||||
if (!_isTurnPhase(s) || s.currentPlayer !== seat || s.pendingTickets || s.drawnThisTurn > 0) return [];
|
||||
const out = [];
|
||||
for (const route of ROUTES) {
|
||||
if (!routeClaimable(s, seat, route.id)) continue;
|
||||
const payment = cardsForRoute(s.players[seat], route);
|
||||
if (payment) out.push({ routeId: route.id, payment });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function _validatePayment(player, route, payment) {
|
||||
if (!payment) return false;
|
||||
const { color, colorCount, locos } = payment;
|
||||
if (colorCount < 0 || locos < 0) return false;
|
||||
if (colorCount + locos !== route.length) return false;
|
||||
if (locos > player.hand.locomotive) return false;
|
||||
if (colorCount > 0) {
|
||||
if (!TRAIN_COLORS.includes(color)) return false;
|
||||
if (route.color !== 'gray' && color !== route.color) return false;
|
||||
if (player.hand[color] < colorCount) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// payment optional; if omitted the cheapest valid payment is used.
|
||||
export function claimRoute(state, seat, routeId, payment = null) {
|
||||
const s = cloneState(state);
|
||||
if (!_isTurnPhase(s) || s.currentPlayer !== seat || s.pendingTickets) return s;
|
||||
if (s.drawnThisTurn > 0) return s; // can't claim after drawing
|
||||
if (!routeClaimable(s, seat, routeId)) return s;
|
||||
|
||||
const route = ROUTES[routeId];
|
||||
const player = s.players[seat];
|
||||
const pay = payment ?? cardsForRoute(player, route);
|
||||
if (!_validatePayment(player, route, pay)) return s;
|
||||
|
||||
if (pay.colorCount > 0) { player.hand[pay.color] -= pay.colorCount; for (let i = 0; i < pay.colorCount; i++) s.discard.push(pay.color); }
|
||||
if (pay.locos > 0) { player.hand.locomotive -= pay.locos; for (let i = 0; i < pay.locos; i++) s.discard.push('locomotive'); }
|
||||
|
||||
player.trainsLeft -= route.length;
|
||||
player.routes.push(routeId);
|
||||
s.claimed[routeId] = seat;
|
||||
logEvent(s, `${playerName(s, seat)} claimed ${CITIES[route.a].name}–${CITIES[route.b].name} (+${ROUTE_SCORE[route.length]}).`);
|
||||
return _endTurn(s);
|
||||
}
|
||||
|
||||
// ── destination-ticket draws (mid-game) ────────────────────────────────────────
|
||||
export function canDrawTickets(s) {
|
||||
return s.ticketDeck.length > 0;
|
||||
}
|
||||
|
||||
export function drawTickets(state, seat) {
|
||||
const s = cloneState(state);
|
||||
if (!_isTurnPhase(s) || s.currentPlayer !== seat || s.pendingTickets) return s;
|
||||
if (s.drawnThisTurn > 0) return s; // a full action only
|
||||
if (s.ticketDeck.length === 0) return s;
|
||||
_dealTicketsToSeat(s, seat, 3, 1, 'turn');
|
||||
logEvent(s, `${playerName(s, seat)} is drawing tickets…`);
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── turn flow ────────────────────────────────────────────────────────────────
|
||||
function _isTurnPhase(s) {
|
||||
return s.phase === 'turn' || s.phase === 'lastRound';
|
||||
}
|
||||
|
||||
// Apply end-of-turn: end-game trigger, last-round countdown, hand off to next.
|
||||
function _endTurn(s) {
|
||||
const finishing = s.currentPlayer;
|
||||
const wasTriggeredBefore = s.endTriggered;
|
||||
|
||||
if (!s.endTriggered && s.players[finishing].trainsLeft <= ENDGAME_TRAIN_THRESHOLD) {
|
||||
s.endTriggered = true;
|
||||
s.finalTurnsLeft = s.playerCount; // one final turn for every player, incl. this one
|
||||
s.triggerSeat = finishing;
|
||||
s.phase = 'lastRound';
|
||||
logEvent(s, `${playerName(s, finishing)} is low on trains — final round!`);
|
||||
}
|
||||
|
||||
if (wasTriggeredBefore) {
|
||||
s.finalTurnsLeft -= 1;
|
||||
if (s.finalTurnsLeft <= 0) return _finalize(s);
|
||||
}
|
||||
|
||||
s.drawnThisTurn = 0;
|
||||
s.drewLocoFaceUp = false;
|
||||
s.currentPlayer = (finishing + 1) % s.playerCount;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── scoring ──────────────────────────────────────────────────────────────────
|
||||
export function routePoints(s, seat) {
|
||||
return s.players[seat].routes.reduce((sum, rid) => sum + ROUTE_SCORE[ROUTES[rid].length], 0);
|
||||
}
|
||||
|
||||
// Public score shown during play (route points only — tickets stay hidden).
|
||||
export function publicScore(s, seat) {
|
||||
return routePoints(s, seat);
|
||||
}
|
||||
|
||||
class UnionFind {
|
||||
constructor(n) { this.p = Array.from({ length: n }, (_, i) => i); }
|
||||
find(x) { while (this.p[x] !== x) { this.p[x] = this.p[this.p[x]]; x = this.p[x]; } return x; }
|
||||
union(a, b) { this.p[this.find(a)] = this.find(b); }
|
||||
}
|
||||
|
||||
export function ticketScore(s, seat) {
|
||||
const uf = new UnionFind(CITIES.length);
|
||||
for (const rid of s.players[seat].routes) { const r = ROUTES[rid]; uf.union(r.a, r.b); }
|
||||
let points = 0, completed = 0;
|
||||
for (const t of s.players[seat].tickets) {
|
||||
const ok = s.players[seat].routes.length > 0 && uf.find(t.a) === uf.find(t.b);
|
||||
if (ok) { points += t.points; completed++; } else { points -= t.points; }
|
||||
}
|
||||
return { points, completed };
|
||||
}
|
||||
|
||||
// Longest continuous path (trail): each claimed route used at most once, maximise
|
||||
// total train length. Per-player subgraphs are tiny, so exhaustive DFS is fine.
|
||||
export function longestPathFor(s, seat) {
|
||||
const inc = new Map(); // cityId -> [{ routeId, other, len }]
|
||||
for (const rid of s.players[seat].routes) {
|
||||
const r = ROUTES[rid];
|
||||
if (!inc.has(r.a)) inc.set(r.a, []);
|
||||
if (!inc.has(r.b)) inc.set(r.b, []);
|
||||
inc.get(r.a).push({ routeId: rid, other: r.b, len: r.length });
|
||||
inc.get(r.b).push({ routeId: rid, other: r.a, len: r.length });
|
||||
}
|
||||
let best = 0;
|
||||
let steps = 0;
|
||||
const dfs = (node, used, total) => {
|
||||
if (total > best) best = total;
|
||||
if (++steps > 200000) return; // defensive guard
|
||||
for (const e of inc.get(node) || []) {
|
||||
if (used.has(e.routeId)) continue;
|
||||
used.add(e.routeId);
|
||||
dfs(e.other, used, total + e.len);
|
||||
used.delete(e.routeId);
|
||||
}
|
||||
};
|
||||
for (const node of inc.keys()) dfs(node, new Set(), 0);
|
||||
return best;
|
||||
}
|
||||
|
||||
// Compute final scores and decide the winner. Mutates+returns the working state.
|
||||
function _finalize(s) {
|
||||
const longest = s.players.map((p) => longestPathFor(s, p.seat));
|
||||
const maxLongest = Math.max(0, ...longest);
|
||||
s.scores = s.players.map((p) => {
|
||||
const rp = routePoints(s, p.seat);
|
||||
const ts = ticketScore(s, p.seat);
|
||||
const longestBonus = (maxLongest > 0 && longest[p.seat] === maxLongest) ? LONGEST_PATH_BONUS : 0;
|
||||
return {
|
||||
seat: p.seat,
|
||||
routePoints: rp,
|
||||
ticketPoints: ts.points,
|
||||
ticketsCompleted: ts.completed,
|
||||
longestPath: longest[p.seat],
|
||||
longestBonus,
|
||||
total: rp + ts.points + longestBonus,
|
||||
};
|
||||
});
|
||||
|
||||
// Winner: highest total, tie-break by tickets completed, then longest path.
|
||||
let win = s.scores[0];
|
||||
for (const sc of s.scores) {
|
||||
if (sc.total > win.total ||
|
||||
(sc.total === win.total && sc.ticketsCompleted > win.ticketsCompleted) ||
|
||||
(sc.total === win.total && sc.ticketsCompleted === win.ticketsCompleted && sc.longestPath > win.longestPath)) {
|
||||
win = sc;
|
||||
}
|
||||
}
|
||||
s.winner = win.seat;
|
||||
s.phase = 'gameOver';
|
||||
logEvent(s, `${playerName(s, s.winner)} wins with ${win.total} points!`);
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── names ──────────────────────────────────────────────────────────────────────
|
||||
export function playerName(s, seat) {
|
||||
return s.players[seat]?.name ?? (seat === 0 ? 'You' : `Player ${seat}`);
|
||||
}
|
||||
export function setPlayerNames(state, names) {
|
||||
state.players.forEach((p, i) => { p.name = names[i] ?? (i === 0 ? 'You' : `Player ${i}`); });
|
||||
}
|
||||
|
||||
export { ROUTES, CITIES, TICKETS, ROUTE_SCORE, TRAIN_COLORS };
|
||||
|
|
@ -25,6 +25,7 @@ import RouletteGame from './games/roulette/RouletteGame.js';
|
|||
import MexicanTrainGame from './games/mexicantrain/MexicanTrainGame.js';
|
||||
import HeartsGame from './games/hearts/HeartsGame.js';
|
||||
import CatanGame from './games/catan/CatanGame.js';
|
||||
import TicketToRideGame from './games/tickettoride/TicketToRideGame.js';
|
||||
import NertsGame from './games/nerts/NertsGame.js';
|
||||
import BingoGame from './games/bingo/BingoGame.js';
|
||||
import BaccaratGame from './games/baccarat/BaccaratGame.js';
|
||||
|
|
@ -74,6 +75,7 @@ const config = {
|
|||
MexicanTrainGame,
|
||||
HeartsGame,
|
||||
CatanGame,
|
||||
TicketToRideGame,
|
||||
NertsGame,
|
||||
BingoGame,
|
||||
BaccaratGame,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -107,7 +107,9 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
this.buildOpponentGrid(opponents, oppScrollH);
|
||||
|
||||
const max = this.gameDef.maxOpponents ?? 1;
|
||||
const defaultCount = this.gameDef.slug === 'nerts' ? 1 : max;
|
||||
const defaultCount = this.gameDef.slug === 'nerts' ? 1
|
||||
: this.gameDef.slug === 'tickettoride' ? Math.min(3, max)
|
||||
: max;
|
||||
this._initializing = true;
|
||||
this.cards.slice(0, defaultCount).forEach(({ opp, el }) => this.toggleOpponent(opp, el));
|
||||
this._initializing = false;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ registerGame({ slug: 'roulette', name: 'Roulette', category: 'casino', minPlayer
|
|||
registerGame({ slug: 'mexicantrain', name: 'Mexican Train', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
||||
registerGame({ slug: 'hearts', name: 'Hearts', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
|
||||
registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });
|
||||
registerGame({ slug: 'tickettoride', name: 'Ticket to Ride', category: 'tabletop', cardGame: true, minPlayers: 2, maxPlayers: 5, minOpponents: 1, maxOpponents: 4 });
|
||||
registerGame({ slug: 'nerts', name: 'Nerts', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
||||
registerGame({ slug: 'bingo', name: 'Bingo', category: 'casino', minPlayers: 2, maxPlayers: 11, minOpponents: 1, maxOpponents: 10 });
|
||||
registerGame({ slug: 'baccarat', name: 'Baccarat', category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 7, minOpponents: 1, maxOpponents: 6 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue