123 lines
7.1 KiB
JavaScript
123 lines
7.1 KiB
JavaScript
// Mahjong (Hong Kong style) — static catalog. No Phaser, no game state.
|
||
// Tile kinds, label-art mapping, the faan scoring table (single source of
|
||
// truth for both the engine and the in-game reference panel), and the
|
||
// faan → base-points ladder.
|
||
//
|
||
// Tiles are encoded as small integers ("kinds") so the engine can count and
|
||
// decompose hands with flat arrays:
|
||
// 0..8 bamboo 1-9 9..17 circle 1-9 18..26 character 1-9
|
||
// 27..30 winds E S W N 31..33 dragons R G W
|
||
// 34..37 flowers (seat E S W N) 38..41 seasons (seat E S W N)
|
||
|
||
// pinyin sheet index for character (萬) tiles 1..9 — mirrors Mahjong Match.
|
||
const CHAR_LABEL = [13, 14, 15, 7, 8, 9, 10, 11, 12];
|
||
|
||
export const BAMBOO = 0, CIRCLE = 9, CHAR = 18;
|
||
export const WIND_KINDS = [27, 28, 29, 30]; // E S W N
|
||
export const DRAGON_KINDS = [31, 32, 33]; // red green white
|
||
export const FIRST_BONUS = 34; // flowers then seasons
|
||
export const KIND_COUNT = 42; // 34 playing + 8 bonus
|
||
|
||
export const isSuited = (k) => k < 27;
|
||
export const isHonor = (k) => k >= 27 && k < 34;
|
||
export const isBonus = (k) => k >= FIRST_BONUS;
|
||
export const suitOf = (k) => (k < 27 ? Math.floor(k / 9) : -1); // 0 bam, 1 cir, 2 char
|
||
export const rankOf = (k) => (k % 9) + 1; // suited kinds only
|
||
export const isTerminal = (k) => isSuited(k) && (rankOf(k) === 1 || rankOf(k) === 9);
|
||
|
||
export const WIND_NAMES = ['East', 'South', 'West', 'North'];
|
||
export const SUIT_NAMES = ['Bamboo', 'Circle', 'Character'];
|
||
|
||
// One entry per kind: { kind, id, label (texture key | null), name }.
|
||
export const TILES = (() => {
|
||
const t = [];
|
||
const suits = [['bamboo', 'Bamboo'], ['circle', 'Circle'], ['char', 'Character']];
|
||
suits.forEach(([id, name], s) => {
|
||
for (let n = 1; n <= 9; n++) {
|
||
const label = id === 'char' ? `mahjong-pinyin${CHAR_LABEL[n - 1]}` : `mahjong-${id}${n}`;
|
||
t.push({ kind: s * 9 + n - 1, id: `${id}${n}`, label, name: `${name} ${n}` });
|
||
}
|
||
});
|
||
const winds = [['east', 4], ['south', 3], ['west', 6], ['north', 5]];
|
||
winds.forEach(([w, pinyin], i) => {
|
||
t.push({ kind: 27 + i, id: `wind-${w}`, label: `mahjong-pinyin${pinyin}`, name: `${WIND_NAMES[i]} Wind` });
|
||
});
|
||
t.push({ kind: 31, id: 'dragon-red', label: 'mahjong-pinyin1', name: 'Red Dragon' });
|
||
t.push({ kind: 32, id: 'dragon-green', label: 'mahjong-pinyin2', name: 'Green Dragon' });
|
||
t.push({ kind: 33, id: 'dragon-white', label: null, name: 'White Dragon' }); // drawn procedurally
|
||
const flowers = ['orchid', 'peony', 'chrysanthemum', 'lotus'];
|
||
flowers.forEach((f, i) => {
|
||
t.push({ kind: 34 + i, id: f, label: `mahjong-${f}`, name: `Flower (${WIND_NAMES[i]})` });
|
||
});
|
||
const seasons = ['spring', 'summer', 'fall', 'winter'];
|
||
seasons.forEach((s, i) => {
|
||
t.push({ kind: 38 + i, id: s, label: `mahjong-${s}`, name: `Season (${WIND_NAMES[i]})` });
|
||
});
|
||
return t;
|
||
})();
|
||
|
||
// Full 144-tile wall: 4 copies of each playing tile, 1 of each bonus tile.
|
||
export function buildWall() {
|
||
const wall = [];
|
||
for (let k = 0; k < FIRST_BONUS; k++) wall.push(k, k, k, k);
|
||
for (let k = FIRST_BONUS; k < KIND_COUNT; k++) wall.push(k);
|
||
return wall;
|
||
}
|
||
|
||
// ── Scoring ──────────────────────────────────────────────────────────────────
|
||
// Classic HK Old Style faan values with a 1-faan minimum: a "chicken hand"
|
||
// (no faan at all) may not declare a win, but bonus-tile and contextual faan
|
||
// count toward the minimum, so hands stay fast. `excludes` lists faan ids the
|
||
// engine must NOT also award when this row applies (prevents double counting,
|
||
// e.g. Great Dragons already contains its three dragon pungs).
|
||
|
||
export const MIN_FAAN = 1;
|
||
export const LIMIT_FAAN = 13;
|
||
|
||
export const FAAN_TABLE = [
|
||
// hand patterns
|
||
{ id: 'common-hand', label: 'Common Hand', faan: 1, desc: 'Four chows and a pair of suited tiles' },
|
||
{ id: 'all-pungs', label: 'All Pungs', faan: 3, desc: 'Four pungs or kongs and a pair' },
|
||
{ id: 'mixed-one-suit', label: 'Mixed One Suit', faan: 3, desc: 'One suit plus honor tiles only' },
|
||
{ id: 'pure-one-suit', label: 'Pure One Suit', faan: 6, desc: 'One suit only, no honors',
|
||
excludes: ['mixed-one-suit'] },
|
||
{ id: 'small-dragons', label: 'Small Dragons', faan: 5, desc: 'Two dragon pungs and a dragon pair',
|
||
excludes: ['dragon-pung'] },
|
||
{ id: 'great-dragons', label: 'Great Dragons', faan: 8, desc: 'All three dragon pungs',
|
||
excludes: ['dragon-pung', 'small-dragons'] },
|
||
{ id: 'small-winds', label: 'Small Winds', faan: 6, desc: 'Three wind pungs and a wind pair',
|
||
excludes: ['seat-wind', 'round-wind'] },
|
||
{ id: 'great-winds', label: 'Great Winds', faan: 13, desc: 'All four wind pungs',
|
||
excludes: ['seat-wind', 'round-wind', 'small-winds', 'all-pungs'] },
|
||
{ id: 'all-honors', label: 'All Honors', faan: 13, desc: 'Winds and dragons only',
|
||
excludes: ['all-pungs', 'mixed-one-suit'] },
|
||
{ id: 'thirteen-orphans', label: 'Thirteen Orphans', faan: 13, desc: 'One of every 1, 9 and honor, plus a duplicate' },
|
||
// per-meld faan
|
||
{ id: 'dragon-pung', label: 'Dragon Pung', faan: 1, desc: 'Each pung or kong of a dragon' },
|
||
{ id: 'seat-wind', label: 'Seat Wind Pung', faan: 1, desc: 'Pung or kong of your seat wind' },
|
||
{ id: 'round-wind', label: 'Round Wind Pung', faan: 1, desc: 'Pung or kong of East, the round wind' },
|
||
// win context
|
||
{ id: 'self-draw', label: 'Self Draw', faan: 1, desc: 'Winning tile drawn from the wall' },
|
||
{ id: 'concealed', label: 'Concealed Hand', faan: 1, desc: 'No melds claimed from discards' },
|
||
{ id: 'last-tile', label: 'Last Wall Tile', faan: 1, desc: 'Win on the very last wall tile' },
|
||
{ id: 'kong-draw', label: 'Kong Replacement', faan: 1, desc: 'Win on the tile drawn after a kong' },
|
||
// bonus tiles
|
||
{ id: 'seat-flower', label: 'Own Flower / Season', faan: 1, desc: 'Each flower or season matching your seat' },
|
||
{ id: 'flower-set', label: 'All Four Flowers', faan: 2, desc: 'The complete flower set' },
|
||
{ id: 'season-set', label: 'All Four Seasons', faan: 2, desc: 'The complete season set' },
|
||
{ id: 'no-bonus', label: 'No Bonus Tiles', faan: 1, desc: 'Hand finished with no flowers or seasons' },
|
||
];
|
||
|
||
export const FAAN_BY_ID = Object.fromEntries(FAAN_TABLE.map((r) => [r.id, r]));
|
||
|
||
// Base points per faan (half-doubling ladder, capped at the 13-faan limit).
|
||
export const BASE_POINTS = [1, 2, 4, 8, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384];
|
||
export const basePoints = (faan) => BASE_POINTS[Math.min(faan, LIMIT_FAAN)];
|
||
|
||
// Payments: winner by discard collects 2× base from the discarder alone;
|
||
// a self-drawn winner collects 1× base from each of the three others.
|
||
|
||
// Seat colours (shared visual language with the other tabletop games).
|
||
export const PLAYER_COLORS = [0xd0473a, 0x4a90d9, 0x49a25a, 0xe2b53c];
|
||
export const PLAYER_COLOR_HEX = ['#d0473a', '#4a90d9', '#49a25a', '#e2b53c'];
|