| 1 | // Tile codes 0..41 |
| 2 | // 0..8 萬 (characters) 1-9 |
| 3 | // 9..17 條 (bamboo) 1-9 |
| 4 | // 18..26 筒 (dots) 1-9 |
| 5 | // 27..30 winds 東 南 西 北 |
| 6 | // 31..33 dragons 中 發 白 |
| 7 | // 34..37 seasons 春 夏 秋 冬 (seat 0..3) |
| 8 | // 38..41 flowers 梅 蘭 菊 竹 (seat 0..3) |
| 9 | |
| 10 | export type Tile = number; |
| 11 | |
| 12 | export const NUM_SUITED = 27; |
| 13 | export const NUM_BASIC = 34; // everything except flowers |
| 14 | export const FIRST_FLOWER = 34; |
| 15 | export const NUM_TILES = 42; |
| 16 | /** Tiles in a full wall: 34 basic × 4 + 8 flowers. */ |
| 17 | export const WALL_SIZE = 144; |
| 18 | /** Stacks of two, 18 to a side of the square. */ |
| 19 | export const WALL_STACKS = WALL_SIZE / 2; |
| 20 | export const STACKS_PER_SIDE = WALL_STACKS / 4; |
| 21 | |
| 22 | export const EAST = 27; |
| 23 | export const SOUTH = 28; |
| 24 | export const WEST = 29; |
| 25 | export const NORTH = 30; |
| 26 | export const RED = 31; |
| 27 | export const GREEN = 32; |
| 28 | export const WHITE = 33; |
| 29 | |
| 30 | export type SuitKind = 'm' | 's' | 'p' | 'wind' | 'dragon' | 'flower'; |
| 31 | |
| 32 | export function suitOf(t: Tile): SuitKind { |
| 33 | if (t < 9) return 'm'; |
| 34 | if (t < 18) return 's'; |
| 35 | if (t < 27) return 'p'; |
| 36 | if (t < 31) return 'wind'; |
| 37 | if (t < 34) return 'dragon'; |
| 38 | return 'flower'; |
| 39 | } |
| 40 | |
| 41 | export const isFlower = (t: Tile) => t >= FIRST_FLOWER; |
| 42 | export const isHonor = (t: Tile) => t >= NUM_SUITED && t < NUM_BASIC; |
| 43 | export const isSuited = (t: Tile) => t < NUM_SUITED; |
| 44 | /** 1..9 for suited tiles, 0 otherwise */ |
| 45 | export const rankOf = (t: Tile) => (isSuited(t) ? (t % 9) + 1 : 0); |
| 46 | export const isTerminal = (t: Tile) => isSuited(t) && (rankOf(t) === 1 || rankOf(t) === 9); |
| 47 | |
| 48 | /** The seat (0..3) a flower belongs to. */ |
| 49 | export const flowerSeat = (t: Tile) => (t - FIRST_FLOWER) % 4; |
| 50 | /** 0 = 春夏秋冬 set, 1 = 梅蘭菊竹 set */ |
| 51 | export const flowerSet = (t: Tile) => (t - FIRST_FLOWER < 4 ? 0 : 1); |
| 52 | |
| 53 | const DIGITS = ['一', '二', '三', '四', '五', '六', '七', '八', '九']; |
| 54 | const HONORS = ['東', '南', '西', '北', '中', '發', '白']; |
| 55 | const FLOWERS = ['春', '夏', '秋', '冬', '梅', '蘭', '菊', '竹']; |
| 56 | const SUIT_MARK = { m: '萬', s: '條', p: '筒' } as const; |
| 57 | |
| 58 | /** Big glyph shown on the tile face. */ |
| 59 | export function faceOf(t: Tile): { main: string; sub?: string } { |
| 60 | const s = suitOf(t); |
| 61 | if (s === 'm' || s === 's' || s === 'p') { |
| 62 | return { main: DIGITS[rankOf(t) - 1], sub: SUIT_MARK[s] }; |
| 63 | } |
| 64 | if (s === 'flower') return { main: FLOWERS[t - FIRST_FLOWER] }; |
| 65 | return { main: HONORS[t - NUM_SUITED] }; |
| 66 | } |
| 67 | |
| 68 | /** Short text label, e.g. "五萬", "東", "春". */ |
| 69 | export function labelOf(t: Tile): string { |
| 70 | const f = faceOf(t); |
| 71 | return f.main + (f.sub ?? ''); |
| 72 | } |
| 73 | |
| 74 | export const WIND_NAMES = ['東', '南', '西', '北']; |
| 75 | export const WIND_EN = ['East', 'South', 'West', 'North']; |
| 76 | |
| 77 | /** |
| 78 | * Column of each tile in the sprite sheet (gnome-mahjongg's "postmodern" |
| 79 | * theme, 43 columns × 2 rows; the second row is the highlighted variant). |
| 80 | * Its order is dots, winds, dragons, characters, bamboo, seasons, flowers — |
| 81 | * not ours, so this is the lookup between the two. |
| 82 | */ |
| 83 | export const SPRITE_COL: number[] = [ |
| 84 | 15, 16, 17, 18, 19, 20, 21, 22, 23, // 萬 1-9 |
| 85 | 24, 25, 26, 27, 28, 29, 30, 31, 32, // 條 1-9 |
| 86 | 0, 1, 2, 3, 4, 5, 6, 7, 8, // 筒 1-9 |
| 87 | 12, 11, 10, 9, // 東 南 西 北 |
| 88 | 13, 14, 37, // 中 發 白 |
| 89 | 33, 34, 35, 36, // 春 夏 秋 冬 |
| 90 | 38, 39, 40, 41, // 梅 蘭 菊 竹 |
| 91 | ]; |
| 92 | export const SPRITE_COLS = 43; |
| 93 | |
| 94 | /** A full Taiwanese wall: 4× each basic tile + 1× each flower = 144. */ |
| 95 | export function buildWall(): Tile[] { |
| 96 | const w: Tile[] = []; |
| 97 | for (let t = 0; t < NUM_BASIC; t++) for (let i = 0; i < 4; i++) w.push(t); |
| 98 | for (let t = FIRST_FLOWER; t < NUM_TILES; t++) w.push(t); |
| 99 | return w; |
| 100 | } |
| 101 | |
| 102 | /** mulberry32 — small deterministic PRNG so hands can be replayed from a seed. */ |
| 103 | export function makeRng(seed: number) { |
| 104 | let a = seed >>> 0; |
| 105 | return () => { |
| 106 | a = (a + 0x6d2b79f5) | 0; |
| 107 | let t = Math.imul(a ^ (a >>> 15), 1 | a); |
| 108 | t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; |
| 109 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; |
| 110 | }; |
| 111 | } |
| 112 | |
| 113 | export function shuffle<T>(arr: T[], rng: () => number): T[] { |
| 114 | const a = arr.slice(); |
| 115 | for (let i = a.length - 1; i > 0; i--) { |
| 116 | const j = Math.floor(rng() * (i + 1)); |
| 117 | [a[i], a[j]] = [a[j], a[i]]; |
| 118 | } |
| 119 | return a; |
| 120 | } |
| 121 | |
| 122 | export function sortTiles(tiles: Tile[]): Tile[] { |
| 123 | return tiles.slice().sort((a, b) => a - b); |
| 124 | } |
| 125 | |
| 126 | export function toCounts(tiles: Tile[]): number[] { |
| 127 | const c = new Array(NUM_BASIC).fill(0); |
| 128 | for (const t of tiles) if (t < NUM_BASIC) c[t]++; |
| 129 | return c; |
| 130 | } |