114 lines
5.3 KiB
JavaScript
114 lines
5.3 KiB
JavaScript
// Rummikub — static data, theme and table geometry. No Phaser, no game state:
|
||
// imported by the solver, the logic engine, the AI, the Phaser scene and the
|
||
// headless verify harness alike.
|
||
//
|
||
// Classic Rummikub: 106 tiles (numbers 1–13 in four colours, two copies each =
|
||
// 104, plus two jokers). 2–4 players, 14 tiles per rack, race to empty your rack
|
||
// by laying down valid sets (groups & runs). First table play must total ≥30.
|
||
|
||
export const ICON_FRAME = 68;
|
||
|
||
// ── Rules ────────────────────────────────────────────────────────────────────
|
||
export const COLORS_ORDER = ['red', 'blue', 'black', 'orange'];
|
||
export const MIN_NUM = 1;
|
||
export const MAX_NUM = 13;
|
||
export const COPIES = 2; // two copies of every numbered tile
|
||
export const JOKER_COUNT = 2; // two jokers
|
||
export const TILE_COUNT = 106; // 13 × 4 × 2 + 2
|
||
export const RACK_START = 14; // tiles dealt to each player
|
||
export const INITIAL_MELD_MIN = 30; // a player's first table play must total ≥30
|
||
export const JOKER_PENALTY = 30; // a joker left on the rack at game end
|
||
export const MIN_SET = 3; // a valid group/run is at least three tiles
|
||
export const MAX_PLAYERS = 4;
|
||
|
||
// ── Tiles ────────────────────────────────────────────────────────────────────
|
||
// A tile is a plain descriptor: { id, color, number, isJoker }. A joker carries
|
||
// color:null / number:null until it is "represented" inside a committed set; the
|
||
// solver attaches a transient { rcolor, rnumber } assignment for display only.
|
||
|
||
/** Numeric value of a tile for scoring / the initial-meld minimum. Jokers are
|
||
* contextual and must be valued via the solver's set assignment. */
|
||
export function tileValue(tile) {
|
||
if (tile.isJoker) return 0;
|
||
return tile.number;
|
||
}
|
||
|
||
/** Display hex for a tile's number, by colour. */
|
||
export function colorHex(color) {
|
||
switch (color) {
|
||
case 'red': return '#d23b32';
|
||
case 'blue': return '#2f6fb0';
|
||
case 'black': return '#222018';
|
||
case 'orange': return '#e08a1e';
|
||
default: return '#222018';
|
||
}
|
||
}
|
||
|
||
/** Stable rack sort: by colour order, then number; jokers last. */
|
||
export function sortRackTiles(tiles) {
|
||
const ci = (c) => { const i = COLORS_ORDER.indexOf(c); return i < 0 ? 99 : i; };
|
||
return tiles.slice().sort((a, b) => {
|
||
if (a.isJoker !== b.isJoker) return a.isJoker ? 1 : -1;
|
||
if (a.isJoker && b.isJoker) return 0;
|
||
return ci(a.color) - ci(b.color) || a.number - b.number;
|
||
});
|
||
}
|
||
|
||
// ── Theme (classic ivory tiles, warm wood rack, green felt) ───────────────────
|
||
export const THEME = {
|
||
feltTop: 0x1c6b3a, // lit centre of the felt
|
||
feltMid: 0x125230, // mid green
|
||
feltEdge: 0x07351c, // deep green at the edge
|
||
rail: 0x5a3a1c, // walnut wood rack
|
||
railHi: 0x7a5230, // lit wood grain
|
||
railDark: 0x32200f, // wood shadow
|
||
brass: 0xc9a227, // brass trim
|
||
brassHi: 0xf0d77a, // brass highlight
|
||
gold: 0xd4a017,
|
||
tileFace: 0xf6efdb, // cream ivory tile face
|
||
tileFaceHi:0xfffaf0, // tile sheen
|
||
tileSide: 0xb9ae90, // extruded side / shadow
|
||
tileEdge: 0x9c8f6a, // tile border
|
||
tileSel: 0xfff4cf, // lifted/selected tile tint
|
||
validGlow: 0x55d96a, // a complete, valid set
|
||
invalidGlow: 0xe0556a, // an incomplete / illegal set
|
||
trayFill: 0x0c3f22, // table set backing tray
|
||
trayEdge: 0x1f6b3c,
|
||
text: 0xf2ead8,
|
||
jokerHex: '#8e44ad',
|
||
};
|
||
|
||
// ── Table geometry ────────────────────────────────────────────────────────────
|
||
// Seat 0 is always the human (full face-up rack along the bottom). The AI seats
|
||
// fill the top, then left, then right depending on the player count. Returns the
|
||
// anchor each seat's face-down stack + count label is drawn at, plus the central
|
||
// table band and the human rack strip.
|
||
export function buildTableLayout(playerCount, { width, height }) {
|
||
const cx = width / 2;
|
||
const seats = {
|
||
0: { x: cx, y: height - 150, dir: 'h', side: 'bottom' },
|
||
};
|
||
// AI seats by count: 2p → top; 3p → top + left; 4p → top + left + right.
|
||
const order = [];
|
||
if (playerCount >= 2) order.push({ seat: 1, x: cx, y: 200, dir: 'h', side: 'top' });
|
||
if (playerCount >= 3) order.push({ seat: 2, x: 150, y: 470, dir: 'v', side: 'left' });
|
||
if (playerCount >= 4) order.push({ seat: 3, x: width - 150, y: 470, dir: 'v', side: 'right' });
|
||
for (const o of order) seats[o.seat] = o;
|
||
|
||
// Side opponents at x≈150 (left) and x≈1770 (right) need clear space.
|
||
// Portrait ring + name text extends ~180px inward from each side opponent.
|
||
const lm = playerCount >= 3 ? 350 : 120; // left margin
|
||
const rm = playerCount >= 4 ? 350 : 120; // right margin
|
||
|
||
const tableX = lm, tableY = 300, tableW = width - lm - rm, tableH = 430;
|
||
|
||
return {
|
||
cx,
|
||
seats,
|
||
// Pool sits just outside the table band to the right, near the bottom edge.
|
||
pool: { x: tableX + tableW + 70, y: tableY + tableH - 20 },
|
||
table: { x: tableX, y: tableY, w: tableW, h: tableH },
|
||
rack: { x: 120, y: height - 168, w: width - 240, h: 150 },
|
||
};
|
||
}
|