collin/mahjong · 70c6c51e
Initial commit: 台灣麻將 hotseat table
Collin Richards · 2026-08-07 09:12 UTC · 70c6c51e71b2e032d9fb0042e260d72b165fe1ff · browse files
added.gitignore+24 −0
| 1 | + | # Logs | |
| 2 | + | logs | |
| 3 | + | *.log | |
| 4 | + | npm-debug.log* | |
| 5 | + | yarn-debug.log* | |
| 6 | + | yarn-error.log* | |
| 7 | + | pnpm-debug.log* | |
| 8 | + | lerna-debug.log* | |
| 9 | + | ||
| 10 | + | node_modules | |
| 11 | + | dist | |
| 12 | + | dist-ssr | |
| 13 | + | *.local | |
| 14 | + | ||
| 15 | + | # Editor directories and files | |
| 16 | + | .vscode/* | |
| 17 | + | !.vscode/extensions.json | |
| 18 | + | .idea | |
| 19 | + | .DS_Store | |
| 20 | + | *.suo | |
| 21 | + | *.ntvs* | |
| 22 | + | *.njsproj | |
| 23 | + | *.sln | |
| 24 | + | *.sw? |
added.oxlintrc.json+8 −0
| 1 | + | { | |
| 2 | + | "$schema": "./node_modules/oxlint/configuration_schema.json", | |
| 3 | + | "plugins": ["react", "typescript", "oxc"], | |
| 4 | + | "rules": { | |
| 5 | + | "react/rules-of-hooks": "error", | |
| 6 | + | "react/only-export-components": ["warn", { "allowConstantExport": true }] | |
| 7 | + | } | |
| 8 | + | } |
addedREADME.md+162 −0
| 1 | + | # 台灣麻將 — four-player hotseat on one touchscreen | |
| 2 | + | ||
| 3 | + | Sixteen-tile Taiwanese mahjong for four people sitting around a single laptop. | |
| 4 | + | All four hands are on screen at once, each rotated to face its own edge — put a | |
| 5 | + | piece of card or plastic over your strip so the others can't see your tiles. | |
| 6 | + | Every label is Chinese with English underneath; the tiles themselves stay | |
| 7 | + | Chinese, because that is what the tiles say. | |
| 8 | + | ||
| 9 | + | ``` | |
| 10 | + | npm install | |
| 11 | + | npm run dev # then open the browser full-screen (F11) | |
| 12 | + | npm test # rules-engine tests, incl. 200 randomly-played hands | |
| 13 | + | ``` | |
| 14 | + | ||
| 15 | + | ## Screen layout | |
| 16 | + | ||
| 17 | + | ``` | |
| 18 | + | ┌──── 對家 (rotated 180°) ────┐ | |
| 19 | + | 上家 (rotated 90°) │ wall / discards │ 下家 (rotated −90°) | |
| 20 | + | └──── 自家 (upright, near you) ┘ | |
| 21 | + | ``` | |
| 22 | + | ||
| 23 | + | Seat 0 is the bottom edge, and play runs 0 → 1 (right) → 2 (top) → 3 (left), | |
| 24 | + | which is the normal counter-clockwise 東南西北 order. | |
| 25 | + | ||
| 26 | + | Each strip shows, from the player's edge inwards: nameplate (seat wind, 莊 | |
| 27 | + | marker, 聽 badge, chip count) → concealed hand → action buttons → melds and | |
| 28 | + | flowers → that player's discard row. | |
| 29 | + | ||
| 30 | + | ## Playing | |
| 31 | + | ||
| 32 | + | - **The tile you just drew** is held apart from the sorted hand with a gold ring | |
| 33 | + | and a 摸 label, instead of being sorted invisibly into it. It merges into the | |
| 34 | + | hand once you discard. 補花 replacements and kong replacements are marked the | |
| 35 | + | same way. | |
| 36 | + | - **The wall** is drawn in the centre as a square: four staggered sides of 18 | |
| 37 | + | stacks of two, the way it's built on a real table. The gold stack is the break | |
| 38 | + | point you draw from, stacks shrink to a single tile and then vanish as they're | |
| 39 | + | used, and the dimmed tail is the 16-tile 底牌 that ends the hand — which is | |
| 40 | + | also the end kong and flower replacements are taken from, so the square is | |
| 41 | + | eaten from both directions at once. | |
| 42 | + | - **Discard** — tap a tile to lift it, tap again (or the 打出 button) to throw it. | |
| 43 | + | Action buttons sit on the right of your own strip, within thumb reach. | |
| 44 | + | - **Arranging** — drag any tile in your hand to reorder it; 理牌 sorts it back | |
| 45 | + | into suit order. Hands are never auto-sorted after the deal, so an arrangement | |
| 46 | + | you set up survives draws, claims and kongs. | |
| 47 | + | - **Rules** — the `?` on your nameplate opens a bilingual rules panel anchored to | |
| 48 | + | your own edge of the table and rotated to face you. | |
| 49 | + | - **Claiming** — after a discard, every seat that *can* claim gets 胡 / 槓 / 碰 / | |
| 50 | + | 吃 / 過 buttons on their own strip, and they resolve by priority (胡 > 槓/碰 > | |
| 51 | + | 吃; ties go to the player nearest the discarder in turn order). | |
| 52 | + | **The table never blocks on a claim.** The next player gets a 摸牌 button and | |
| 53 | + | may draw whenever they like, which shuts the window on anyone who hasn't | |
| 54 | + | called — exactly like shouting 碰 before the next player picks up. A claim | |
| 55 | + | already declared still stands, so calling in time always wins the tile. If | |
| 56 | + | everyone answers first, play advances on its own without the extra tap. | |
| 57 | + | - **On your turn** — 自摸, 暗槓 and 加槓 appear automatically when legal. | |
| 58 | + | 加槓 offers everyone else a 搶槓 chance. | |
| 59 | + | - **Undo** — a 復原 button in the middle of the table takes back the last | |
| 60 | + | action, naming what it will undo (`復原 玩家 2 打 五萬`). It sits in the | |
| 61 | + | centre rather than on a seat because whoever *spots* the mis-tap should be | |
| 62 | + | able to reach it. Twenty actions deep, cleared when the next hand is dealt. | |
| 63 | + | Snapshots live outside the saved state, so an undo does not survive a | |
| 64 | + | refresh: what you can take back is what happened while everyone was still | |
| 65 | + | watching it happen. | |
| 66 | + | - **Sound** — off by default. A dry clack as a tile goes down, and a distinct | |
| 67 | + | two-note chime when a claim window opens, which is how a slow player notices | |
| 68 | + | their 碰 is available before the next player draws it shut. Everything is | |
| 69 | + | synthesised with a few oscillators (`src/game/sound.ts`) rather than sampled, | |
| 70 | + | so there is nothing to load and the cue lands on the same frame as the tile. | |
| 71 | + | Toggle it from the 🔊 button next to 復原, or from the settings screen. | |
| 72 | + | - **Settings** — 設定 from the lobby, or from the game-over panel: player names, | |
| 73 | + | 底 / 台 / starting chips, the house-rule switches below, and sound. Kept in | |
| 74 | + | `localStorage` separately from the save, so they carry over to the next game. | |
| 75 | + | Stakes are locked once a game is under way — they'd otherwise rewrite chips | |
| 76 | + | already won. | |
| 77 | + | ||
| 78 | + | ## Saving | |
| 79 | + | ||
| 80 | + | The game state is plain data, so a save is just its JSON in `localStorage`, | |
| 81 | + | rewritten after every move. Close the lid, refresh, or run the battery flat and | |
| 82 | + | nothing is lost — the lobby offers **繼續對局 Resume** alongside **開新局 New | |
| 83 | + | game**, showing the round, hand number, chip counts and when it was saved. A | |
| 84 | + | save is validated before it is offered (four players, 144 tiles accounted for), | |
| 85 | + | so a truncated or hand-edited one is ignored rather than loaded into a broken | |
| 86 | + | table. `VERSION` in `save.ts` retires old saves if the state shape changes. | |
| 87 | + | ||
| 88 | + | ## Rules implemented | |
| 89 | + | ||
| 90 | + | - 144 tiles (four of each suit/honour, eight flowers), 16-tile hands, dealer | |
| 91 | + | draws the 17th. | |
| 92 | + | - 補花 at the deal and on every drawn flower, replacements from the back of the | |
| 93 | + | wall. | |
| 94 | + | - 吃 only from 上家; 碰/槓/胡 from anyone. 明槓, 暗槓, 加槓, 搶槓, 槓上開花. | |
| 95 | + | - 流局 when 16 tiles remain (`Rules.wallReserve`); the dealer keeps the deal on a | |
| 96 | + | draw or on a dealer win (連莊), otherwise the deal passes and the round wind | |
| 97 | + | advances every four passes. A full 四圈 game is 16 dealer passes. | |
| 98 | + | ||
| 99 | + | ### House rules (switches in `Rules`, all on the settings screen) | |
| 100 | + | ||
| 101 | + | - **過水 `sacredDiscard`** (on) — pass on a tile you could have won with and it | |
| 102 | + | is dead to you until your own next draw; the seat shows a 過水 badge listing | |
| 103 | + | what it is locked out of, so the missing 胡 button is never a mystery. A draw | |
| 104 | + | from either end of the wall lifts it. **`sacredClearedByClaim`** (off) decides | |
| 105 | + | whether taking a 吃 / 碰 / 槓 lifts it too — tables genuinely differ. | |
| 106 | + | - **一炮多響 `multipleWinners`** (off) — one discard pays out to every seat that | |
| 107 | + | calls on it, each settled separately against the discarder. With it off the | |
| 108 | + | tile goes to the caller nearest the discarder. Either way the table now waits | |
| 109 | + | for other seats that *can* win before settling, so the nearest seat wins the | |
| 110 | + | tile rather than the quickest hand — and the 摸牌 button still closes the | |
| 111 | + | window on anyone dithering. | |
| 112 | + | - **包牌 `liability`** (on) — feeding the pung that completes a visible 大三元 | |
| 113 | + | or 大四喜 makes the feeder answer for the whole hand, in place of all three | |
| 114 | + | payers. Only the seat that fed the *last* of those sets is on the hook, and | |
| 115 | + | only if it came off a discard: a hand that assembled them itself, or closed | |
| 116 | + | the set with a 暗槓, has nobody to blame. | |
| 117 | + | ||
| 118 | + | ### 台 scoring (`src/game/tai.ts`) | |
| 119 | + | ||
| 120 | + | 自摸 1 · 門清 1 · 門清自摸 +1 · 全求人 2 · 平胡 2 · 五門齊 2 · 正花 1 each · | |
| 121 | + | 花槓 2 · 八仙過海 8 · 圈風 / 門風 1 each · 三元牌 1 each · 小三元 4 · 大三元 8 · | |
| 122 | + | 小四喜 8 · 大四喜 16 · 碰碰胡 4 · 混一色 4 · 清一色 8 · 字一色 16 · | |
| 123 | + | 三暗刻 2 / 四暗刻 5 / 五暗刻 8 · 獨聽 · 單釣 1 · 搶槓 1 · 槓上開花 1 · | |
| 124 | + | 海底撈月 1 · 河底撈魚 1 · 天胡 16 · 地胡 16 · 人胡 8 · 莊家 1 (連N拉N → 2N+1) | |
| 125 | + | ||
| 126 | + | Ambiguous hands are decomposed every legal way and scored at the best reading. | |
| 127 | + | ||
| 128 | + | **Payment** (`Game.settle`): one unit is `底 + 台 × 台值` | |
| 129 | + | (`DEFAULT_RULES` = 底 3, 台 1, 100 chips each). | |
| 130 | + | 放槍一家付 — the discarder alone pays one unit; on 自摸 all three pay one unit | |
| 131 | + | each. 拉莊 is billed to the dealer alone when the dealer is a payer, and added | |
| 132 | + | to the whole hand when the dealer wins. | |
| 133 | + | ||
| 134 | + | House rules vary a lot; the tai table and payments are plain data/functions in | |
| 135 | + | `tai.ts` and `types.ts` if yours differ. | |
| 136 | + | ||
| 137 | + | ## Tile art | |
| 138 | + | ||
| 139 | + | `public/tiles/tiles.svg` is the **postmodern** tileset from | |
| 140 | + | [gnome-mahjongg](https://gitlab.gnome.org/GNOME/gnome-mahjongg), extracted from | |
| 141 | + | the installed binary's GResource — a 43×2 sprite sheet (second row is the | |
| 142 | + | highlighted variant, used for a lifted tile). `public/tiles/back.png` is the | |
| 143 | + | blank tile from its **smooth** theme, tinted jade, since a solitaire game has no | |
| 144 | + | face-down art of its own. | |
| 145 | + | ||
| 146 | + | That art is **GPL-2.0-or-later**. Fine for playing at home; if you ever | |
| 147 | + | distribute this app, either honour the GPL or swap the two files for art of your | |
| 148 | + | own — `SPRITE_COL` in `tiles.ts` is the only mapping that would need updating. | |
| 149 | + | ||
| 150 | + | Known gaps and house rules that aren't implemented yet are listed in | |
| 151 | + | [TODO.md](TODO.md). | |
| 152 | + | ||
| 153 | + | ## Layout | |
| 154 | + | ||
| 155 | + | ``` | |
| 156 | + | public/tiles/ sprite sheet + tile back | |
| 157 | + | src/game/tiles.ts tile codes, wall, shuffle, sprite mapping | |
| 158 | + | src/game/hu.ts hand decomposition, 聽 detection, wait shapes | |
| 159 | + | src/game/tai.ts 台 scoring | |
| 160 | + | src/game/engine.ts state machine: deal, turns, claim resolution, settlement | |
| 161 | + | src/ui/ TileView, Hand (drag), Seat (rotated strip), Center, Help | |
| 162 | + | ``` |
addedTODO.md+23 −0
| 1 | + | # TODO | |
| 2 | + | ||
| 3 | + | Ordered by how much they'd matter at a real table. | |
| 4 | + | ||
| 5 | + | ## 1. Undo across a hand boundary | |
| 6 | + | ||
| 7 | + | `undo()` is cleared by `startHand`, so a 下一局 tapped by mistake — and that | |
| 8 | + | button sits right under someone's thumb — cannot be taken back. The deal is | |
| 9 | + | already reshuffled by then. Would need the snapshot from before `nextHand` kept, | |
| 10 | + | and the seed recorded, so the same wall could be rebuilt. | |
| 11 | + | ||
| 12 | + | ## 2. Settings mid-game | |
| 13 | + | ||
| 14 | + | 設定 is reachable from the lobby and the game-over panel, but not during a 將, so | |
| 15 | + | a player who wants to fix a typo'd name has to wait it out. The `?` panel is | |
| 16 | + | already anchored per-seat and rotated to face its own edge; settings could share | |
| 17 | + | that treatment. | |
| 18 | + | ||
| 19 | + | ## 3. 花槓 and other flower house rules | |
| 20 | + | ||
| 21 | + | Flowers score, but some tables pay 花槓 out the moment it completes rather than | |
| 22 | + | at settlement, and some deal 補花 differently at the start. Both are `Rules` | |
| 23 | + | flags plus a payment hook. |
addedindex.html+13 −0
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="zh-TW"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="UTF-8" /> | |
| 5 | + | <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> | |
| 6 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> | |
| 7 | + | <title>台灣麻將</title> | |
| 8 | + | </head> | |
| 9 | + | <body> | |
| 10 | + | <div id="root"></div> | |
| 11 | + | <script type="module" src="/src/main.tsx"></script> | |
| 12 | + | </body> | |
| 13 | + | </html> |
addedpackage-lock.json+1720 −0
| 1 | + | { | |
| 2 | + | "name": "mahjong", | |
| 3 | + | "version": "0.0.0", | |
| 4 | + | "lockfileVersion": 3, | |
| 5 | + | "requires": true, | |
| 6 | + | "packages": { | |
| 7 | + | "": { | |
| 8 | + | "name": "mahjong", | |
| 9 | + | "version": "0.0.0", | |
| 10 | + | "dependencies": { | |
| 11 | + | "react": "^19.2.8", | |
| 12 | + | "react-dom": "^19.2.8" | |
| 13 | + | }, | |
| 14 | + | "devDependencies": { | |
| 15 | + | "@types/node": "^24.13.3", | |
| 16 | + | "@types/react": "^19.2.17", | |
| 17 | + | "@types/react-dom": "^19.2.3", | |
| 18 | + | "@vitejs/plugin-react": "^6.0.4", | |
| 19 | + | "oxlint": "^1.75.0", | |
| 20 | + | "typescript": "~6.0.2", | |
| 21 | + | "vite": "^8.2.0", | |
| 22 | + | "vitest": "^4.1.10" | |
| 23 | + | } | |
| 24 | + | }, | |
| 25 | + | "node_modules/@jridgewell/sourcemap-codec": { | |
| 26 | + | "version": "1.5.5", | |
| 27 | + | "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", | |
| 28 | + | "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", | |
| 29 | + | "dev": true, | |
| 30 | + | "license": "MIT" | |
| 31 | + | }, | |
| 32 | + | "node_modules/@oxc-project/types": { | |
| 33 | + | "version": "0.143.0", | |
| 34 | + | "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", | |
| 35 | + | "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", | |
| 36 | + | "dev": true, | |
| 37 | + | "license": "MIT", | |
| 38 | + | "funding": { | |
| 39 | + | "url": "https://github.com/sponsors/Boshen" | |
| 40 | + | } | |
| 41 | + | }, | |
| 42 | + | "node_modules/@oxlint/binding-android-arm-eabi": { | |
| 43 | + | "version": "1.77.0", | |
| 44 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.77.0.tgz", | |
| 45 | + | "integrity": "sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==", | |
| 46 | + | "cpu": [ | |
| 47 | + | "arm" | |
| 48 | + | ], | |
| 49 | + | "dev": true, | |
| 50 | + | "license": "MIT", | |
| 51 | + | "optional": true, | |
| 52 | + | "os": [ | |
| 53 | + | "android" | |
| 54 | + | ], | |
| 55 | + | "engines": { | |
| 56 | + | "node": "^20.19.0 || >=22.12.0" | |
| 57 | + | } | |
| 58 | + | }, | |
| 59 | + | "node_modules/@oxlint/binding-android-arm64": { | |
| 60 | + | "version": "1.77.0", | |
| 61 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.77.0.tgz", | |
| 62 | + | "integrity": "sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==", | |
| 63 | + | "cpu": [ | |
| 64 | + | "arm64" | |
| 65 | + | ], | |
| 66 | + | "dev": true, | |
| 67 | + | "license": "MIT", | |
| 68 | + | "optional": true, | |
| 69 | + | "os": [ | |
| 70 | + | "android" | |
| 71 | + | ], | |
| 72 | + | "engines": { | |
| 73 | + | "node": "^20.19.0 || >=22.12.0" | |
| 74 | + | } | |
| 75 | + | }, | |
| 76 | + | "node_modules/@oxlint/binding-darwin-arm64": { | |
| 77 | + | "version": "1.77.0", | |
| 78 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.77.0.tgz", | |
| 79 | + | "integrity": "sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==", | |
| 80 | + | "cpu": [ | |
| 81 | + | "arm64" | |
| 82 | + | ], | |
| 83 | + | "dev": true, | |
| 84 | + | "license": "MIT", | |
| 85 | + | "optional": true, | |
| 86 | + | "os": [ | |
| 87 | + | "darwin" | |
| 88 | + | ], | |
| 89 | + | "engines": { | |
| 90 | + | "node": "^20.19.0 || >=22.12.0" | |
| 91 | + | } | |
| 92 | + | }, | |
| 93 | + | "node_modules/@oxlint/binding-darwin-x64": { | |
| 94 | + | "version": "1.77.0", | |
| 95 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.77.0.tgz", | |
| 96 | + | "integrity": "sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==", | |
| 97 | + | "cpu": [ | |
| 98 | + | "x64" | |
| 99 | + | ], | |
| 100 | + | "dev": true, | |
| 101 | + | "license": "MIT", | |
| 102 | + | "optional": true, | |
| 103 | + | "os": [ | |
| 104 | + | "darwin" | |
| 105 | + | ], | |
| 106 | + | "engines": { | |
| 107 | + | "node": "^20.19.0 || >=22.12.0" | |
| 108 | + | } | |
| 109 | + | }, | |
| 110 | + | "node_modules/@oxlint/binding-freebsd-x64": { | |
| 111 | + | "version": "1.77.0", | |
| 112 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.77.0.tgz", | |
| 113 | + | "integrity": "sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==", | |
| 114 | + | "cpu": [ | |
| 115 | + | "x64" | |
| 116 | + | ], | |
| 117 | + | "dev": true, | |
| 118 | + | "license": "MIT", | |
| 119 | + | "optional": true, | |
| 120 | + | "os": [ | |
| 121 | + | "freebsd" | |
| 122 | + | ], | |
| 123 | + | "engines": { | |
| 124 | + | "node": "^20.19.0 || >=22.12.0" | |
| 125 | + | } | |
| 126 | + | }, | |
| 127 | + | "node_modules/@oxlint/binding-linux-arm-gnueabihf": { | |
| 128 | + | "version": "1.77.0", | |
| 129 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.77.0.tgz", | |
| 130 | + | "integrity": "sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==", | |
| 131 | + | "cpu": [ | |
| 132 | + | "arm" | |
| 133 | + | ], | |
| 134 | + | "dev": true, | |
| 135 | + | "license": "MIT", | |
| 136 | + | "optional": true, | |
| 137 | + | "os": [ | |
| 138 | + | "linux" | |
| 139 | + | ], | |
| 140 | + | "engines": { | |
| 141 | + | "node": "^20.19.0 || >=22.12.0" | |
| 142 | + | } | |
| 143 | + | }, | |
| 144 | + | "node_modules/@oxlint/binding-linux-arm-musleabihf": { | |
| 145 | + | "version": "1.77.0", | |
| 146 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.77.0.tgz", | |
| 147 | + | "integrity": "sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==", | |
| 148 | + | "cpu": [ | |
| 149 | + | "arm" | |
| 150 | + | ], | |
| 151 | + | "dev": true, | |
| 152 | + | "license": "MIT", | |
| 153 | + | "optional": true, | |
| 154 | + | "os": [ | |
| 155 | + | "linux" | |
| 156 | + | ], | |
| 157 | + | "engines": { | |
| 158 | + | "node": "^20.19.0 || >=22.12.0" | |
| 159 | + | } | |
| 160 | + | }, | |
| 161 | + | "node_modules/@oxlint/binding-linux-arm64-gnu": { | |
| 162 | + | "version": "1.77.0", | |
| 163 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.77.0.tgz", | |
| 164 | + | "integrity": "sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==", | |
| 165 | + | "cpu": [ | |
| 166 | + | "arm64" | |
| 167 | + | ], | |
| 168 | + | "dev": true, | |
| 169 | + | "libc": [ | |
| 170 | + | "glibc" | |
| 171 | + | ], | |
| 172 | + | "license": "MIT", | |
| 173 | + | "optional": true, | |
| 174 | + | "os": [ | |
| 175 | + | "linux" | |
| 176 | + | ], | |
| 177 | + | "engines": { | |
| 178 | + | "node": "^20.19.0 || >=22.12.0" | |
| 179 | + | } | |
| 180 | + | }, | |
| 181 | + | "node_modules/@oxlint/binding-linux-arm64-musl": { | |
| 182 | + | "version": "1.77.0", | |
| 183 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.77.0.tgz", | |
| 184 | + | "integrity": "sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==", | |
| 185 | + | "cpu": [ | |
| 186 | + | "arm64" | |
| 187 | + | ], | |
| 188 | + | "dev": true, | |
| 189 | + | "libc": [ | |
| 190 | + | "musl" | |
| 191 | + | ], | |
| 192 | + | "license": "MIT", | |
| 193 | + | "optional": true, | |
| 194 | + | "os": [ | |
| 195 | + | "linux" | |
| 196 | + | ], | |
| 197 | + | "engines": { | |
| 198 | + | "node": "^20.19.0 || >=22.12.0" | |
| 199 | + | } | |
| 200 | + | }, | |
| 201 | + | "node_modules/@oxlint/binding-linux-ppc64-gnu": { | |
| 202 | + | "version": "1.77.0", | |
| 203 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.77.0.tgz", | |
| 204 | + | "integrity": "sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==", | |
| 205 | + | "cpu": [ | |
| 206 | + | "ppc64" | |
| 207 | + | ], | |
| 208 | + | "dev": true, | |
| 209 | + | "libc": [ | |
| 210 | + | "glibc" | |
| 211 | + | ], | |
| 212 | + | "license": "MIT", | |
| 213 | + | "optional": true, | |
| 214 | + | "os": [ | |
| 215 | + | "linux" | |
| 216 | + | ], | |
| 217 | + | "engines": { | |
| 218 | + | "node": "^20.19.0 || >=22.12.0" | |
| 219 | + | } | |
| 220 | + | }, | |
| 221 | + | "node_modules/@oxlint/binding-linux-riscv64-gnu": { | |
| 222 | + | "version": "1.77.0", | |
| 223 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.77.0.tgz", | |
| 224 | + | "integrity": "sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==", | |
| 225 | + | "cpu": [ | |
| 226 | + | "riscv64" | |
| 227 | + | ], | |
| 228 | + | "dev": true, | |
| 229 | + | "libc": [ | |
| 230 | + | "glibc" | |
| 231 | + | ], | |
| 232 | + | "license": "MIT", | |
| 233 | + | "optional": true, | |
| 234 | + | "os": [ | |
| 235 | + | "linux" | |
| 236 | + | ], | |
| 237 | + | "engines": { | |
| 238 | + | "node": "^20.19.0 || >=22.12.0" | |
| 239 | + | } | |
| 240 | + | }, | |
| 241 | + | "node_modules/@oxlint/binding-linux-riscv64-musl": { | |
| 242 | + | "version": "1.77.0", | |
| 243 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.77.0.tgz", | |
| 244 | + | "integrity": "sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==", | |
| 245 | + | "cpu": [ | |
| 246 | + | "riscv64" | |
| 247 | + | ], | |
| 248 | + | "dev": true, | |
| 249 | + | "libc": [ | |
| 250 | + | "musl" | |
| 251 | + | ], | |
| 252 | + | "license": "MIT", | |
| 253 | + | "optional": true, | |
| 254 | + | "os": [ | |
| 255 | + | "linux" | |
| 256 | + | ], | |
| 257 | + | "engines": { | |
| 258 | + | "node": "^20.19.0 || >=22.12.0" | |
| 259 | + | } | |
| 260 | + | }, | |
| 261 | + | "node_modules/@oxlint/binding-linux-s390x-gnu": { | |
| 262 | + | "version": "1.77.0", | |
| 263 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.77.0.tgz", | |
| 264 | + | "integrity": "sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==", | |
| 265 | + | "cpu": [ | |
| 266 | + | "s390x" | |
| 267 | + | ], | |
| 268 | + | "dev": true, | |
| 269 | + | "libc": [ | |
| 270 | + | "glibc" | |
| 271 | + | ], | |
| 272 | + | "license": "MIT", | |
| 273 | + | "optional": true, | |
| 274 | + | "os": [ | |
| 275 | + | "linux" | |
| 276 | + | ], | |
| 277 | + | "engines": { | |
| 278 | + | "node": "^20.19.0 || >=22.12.0" | |
| 279 | + | } | |
| 280 | + | }, | |
| 281 | + | "node_modules/@oxlint/binding-linux-x64-gnu": { | |
| 282 | + | "version": "1.77.0", | |
| 283 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.77.0.tgz", | |
| 284 | + | "integrity": "sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==", | |
| 285 | + | "cpu": [ | |
| 286 | + | "x64" | |
| 287 | + | ], | |
| 288 | + | "dev": true, | |
| 289 | + | "libc": [ | |
| 290 | + | "glibc" | |
| 291 | + | ], | |
| 292 | + | "license": "MIT", | |
| 293 | + | "optional": true, | |
| 294 | + | "os": [ | |
| 295 | + | "linux" | |
| 296 | + | ], | |
| 297 | + | "engines": { | |
| 298 | + | "node": "^20.19.0 || >=22.12.0" | |
| 299 | + | } | |
| 300 | + | }, | |
| 301 | + | "node_modules/@oxlint/binding-linux-x64-musl": { | |
| 302 | + | "version": "1.77.0", | |
| 303 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.77.0.tgz", | |
| 304 | + | "integrity": "sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==", | |
| 305 | + | "cpu": [ | |
| 306 | + | "x64" | |
| 307 | + | ], | |
| 308 | + | "dev": true, | |
| 309 | + | "libc": [ | |
| 310 | + | "musl" | |
| 311 | + | ], | |
| 312 | + | "license": "MIT", | |
| 313 | + | "optional": true, | |
| 314 | + | "os": [ | |
| 315 | + | "linux" | |
| 316 | + | ], | |
| 317 | + | "engines": { | |
| 318 | + | "node": "^20.19.0 || >=22.12.0" | |
| 319 | + | } | |
| 320 | + | }, | |
| 321 | + | "node_modules/@oxlint/binding-openharmony-arm64": { | |
| 322 | + | "version": "1.77.0", | |
| 323 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.77.0.tgz", | |
| 324 | + | "integrity": "sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==", | |
| 325 | + | "cpu": [ | |
| 326 | + | "arm64" | |
| 327 | + | ], | |
| 328 | + | "dev": true, | |
| 329 | + | "license": "MIT", | |
| 330 | + | "optional": true, | |
| 331 | + | "os": [ | |
| 332 | + | "openharmony" | |
| 333 | + | ], | |
| 334 | + | "engines": { | |
| 335 | + | "node": "^20.19.0 || >=22.12.0" | |
| 336 | + | } | |
| 337 | + | }, | |
| 338 | + | "node_modules/@oxlint/binding-win32-arm64-msvc": { | |
| 339 | + | "version": "1.77.0", | |
| 340 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.77.0.tgz", | |
| 341 | + | "integrity": "sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==", | |
| 342 | + | "cpu": [ | |
| 343 | + | "arm64" | |
| 344 | + | ], | |
| 345 | + | "dev": true, | |
| 346 | + | "license": "MIT", | |
| 347 | + | "optional": true, | |
| 348 | + | "os": [ | |
| 349 | + | "win32" | |
| 350 | + | ], | |
| 351 | + | "engines": { | |
| 352 | + | "node": "^20.19.0 || >=22.12.0" | |
| 353 | + | } | |
| 354 | + | }, | |
| 355 | + | "node_modules/@oxlint/binding-win32-ia32-msvc": { | |
| 356 | + | "version": "1.77.0", | |
| 357 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.77.0.tgz", | |
| 358 | + | "integrity": "sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==", | |
| 359 | + | "cpu": [ | |
| 360 | + | "ia32" | |
| 361 | + | ], | |
| 362 | + | "dev": true, | |
| 363 | + | "license": "MIT", | |
| 364 | + | "optional": true, | |
| 365 | + | "os": [ | |
| 366 | + | "win32" | |
| 367 | + | ], | |
| 368 | + | "engines": { | |
| 369 | + | "node": "^20.19.0 || >=22.12.0" | |
| 370 | + | } | |
| 371 | + | }, | |
| 372 | + | "node_modules/@oxlint/binding-win32-x64-msvc": { | |
| 373 | + | "version": "1.77.0", | |
| 374 | + | "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.77.0.tgz", | |
| 375 | + | "integrity": "sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==", | |
| 376 | + | "cpu": [ | |
| 377 | + | "x64" | |
| 378 | + | ], | |
| 379 | + | "dev": true, | |
| 380 | + | "license": "MIT", | |
| 381 | + | "optional": true, | |
| 382 | + | "os": [ | |
| 383 | + | "win32" | |
| 384 | + | ], | |
| 385 | + | "engines": { | |
| 386 | + | "node": "^20.19.0 || >=22.12.0" | |
| 387 | + | } | |
| 388 | + | }, | |
| 389 | + | "node_modules/@rolldown/binding-android-arm64": { | |
| 390 | + | "version": "1.2.3", | |
| 391 | + | "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", | |
| 392 | + | "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", | |
| 393 | + | "cpu": [ | |
| 394 | + | "arm64" | |
| 395 | + | ], | |
| 396 | + | "dev": true, | |
| 397 | + | "license": "MIT", | |
| 398 | + | "optional": true, | |
| 399 | + | "os": [ | |
| 400 | + | "android" | |
| 401 | + | ], | |
| 402 | + | "engines": { | |
| 403 | + | "node": "^20.19.0 || >=22.12.0" | |
| 404 | + | } | |
| 405 | + | }, | |
| 406 | + | "node_modules/@rolldown/binding-darwin-arm64": { | |
| 407 | + | "version": "1.2.3", | |
| 408 | + | "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", | |
| 409 | + | "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", | |
| 410 | + | "cpu": [ | |
| 411 | + | "arm64" | |
| 412 | + | ], | |
| 413 | + | "dev": true, | |
| 414 | + | "license": "MIT", | |
| 415 | + | "optional": true, | |
| 416 | + | "os": [ | |
| 417 | + | "darwin" | |
| 418 | + | ], | |
| 419 | + | "engines": { | |
| 420 | + | "node": "^20.19.0 || >=22.12.0" | |
| 421 | + | } | |
| 422 | + | }, | |
| 423 | + | "node_modules/@rolldown/binding-darwin-x64": { | |
| 424 | + | "version": "1.2.3", | |
| 425 | + | "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", | |
| 426 | + | "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", | |
| 427 | + | "cpu": [ | |
| 428 | + | "x64" | |
| 429 | + | ], | |
| 430 | + | "dev": true, | |
| 431 | + | "license": "MIT", | |
| 432 | + | "optional": true, | |
| 433 | + | "os": [ | |
| 434 | + | "darwin" | |
| 435 | + | ], | |
| 436 | + | "engines": { | |
| 437 | + | "node": "^20.19.0 || >=22.12.0" | |
| 438 | + | } | |
| 439 | + | }, | |
| 440 | + | "node_modules/@rolldown/binding-freebsd-x64": { | |
| 441 | + | "version": "1.2.3", | |
| 442 | + | "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", | |
| 443 | + | "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", | |
| 444 | + | "cpu": [ | |
| 445 | + | "x64" | |
| 446 | + | ], | |
| 447 | + | "dev": true, | |
| 448 | + | "license": "MIT", | |
| 449 | + | "optional": true, | |
| 450 | + | "os": [ | |
| 451 | + | "freebsd" | |
| 452 | + | ], | |
| 453 | + | "engines": { | |
| 454 | + | "node": "^20.19.0 || >=22.12.0" | |
| 455 | + | } | |
| 456 | + | }, | |
| 457 | + | "node_modules/@rolldown/binding-linux-arm-gnueabihf": { | |
| 458 | + | "version": "1.2.3", | |
| 459 | + | "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", | |
| 460 | + | "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", | |
| 461 | + | "cpu": [ | |
| 462 | + | "arm" | |
| 463 | + | ], | |
| 464 | + | "dev": true, | |
| 465 | + | "license": "MIT", | |
| 466 | + | "optional": true, | |
| 467 | + | "os": [ | |
| 468 | + | "linux" | |
| 469 | + | ], | |
| 470 | + | "engines": { | |
| 471 | + | "node": "^20.19.0 || >=22.12.0" | |
| 472 | + | } | |
| 473 | + | }, | |
| 474 | + | "node_modules/@rolldown/binding-linux-arm64-gnu": { | |
| 475 | + | "version": "1.2.3", | |
| 476 | + | "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", | |
| 477 | + | "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", | |
| 478 | + | "cpu": [ | |
| 479 | + | "arm64" | |
| 480 | + | ], | |
| 481 | + | "dev": true, | |
| 482 | + | "libc": [ | |
| 483 | + | "glibc" | |
| 484 | + | ], | |
| 485 | + | "license": "MIT", | |
| 486 | + | "optional": true, | |
| 487 | + | "os": [ | |
| 488 | + | "linux" | |
| 489 | + | ], | |
| 490 | + | "engines": { | |
| 491 | + | "node": "^20.19.0 || >=22.12.0" | |
| 492 | + | } | |
| 493 | + | }, | |
| 494 | + | "node_modules/@rolldown/binding-linux-arm64-musl": { | |
| 495 | + | "version": "1.2.3", | |
| 496 | + | "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", | |
| 497 | + | "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", | |
| 498 | + | "cpu": [ | |
| 499 | + | "arm64" | |
| 500 | + | ], | |
| 501 | + | "dev": true, | |
| 502 | + | "libc": [ | |
| 503 | + | "musl" | |
| 504 | + | ], | |
| 505 | + | "license": "MIT", | |
| 506 | + | "optional": true, | |
| 507 | + | "os": [ | |
| 508 | + | "linux" | |
| 509 | + | ], | |
| 510 | + | "engines": { | |
| 511 | + | "node": "^20.19.0 || >=22.12.0" | |
| 512 | + | } | |
| 513 | + | }, | |
| 514 | + | "node_modules/@rolldown/binding-linux-ppc64-gnu": { | |
| 515 | + | "version": "1.2.3", | |
| 516 | + | "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", | |
| 517 | + | "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", | |
| 518 | + | "cpu": [ | |
| 519 | + | "ppc64" | |
| 520 | + | ], | |
| 521 | + | "dev": true, | |
| 522 | + | "libc": [ | |
| 523 | + | "glibc" | |
| 524 | + | ], | |
| 525 | + | "license": "MIT", | |
| 526 | + | "optional": true, | |
| 527 | + | "os": [ | |
| 528 | + | "linux" | |
| 529 | + | ], | |
| 530 | + | "engines": { | |
| 531 | + | "node": "^20.19.0 || >=22.12.0" | |
| 532 | + | } | |
| 533 | + | }, | |
| 534 | + | "node_modules/@rolldown/binding-linux-s390x-gnu": { | |
| 535 | + | "version": "1.2.3", | |
| 536 | + | "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", | |
| 537 | + | "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", | |
| 538 | + | "cpu": [ | |
| 539 | + | "s390x" | |
| 540 | + | ], | |
| 541 | + | "dev": true, | |
| 542 | + | "libc": [ | |
| 543 | + | "glibc" | |
| 544 | + | ], | |
| 545 | + | "license": "MIT", | |
| 546 | + | "optional": true, | |
| 547 | + | "os": [ | |
| 548 | + | "linux" | |
| 549 | + | ], | |
| 550 | + | "engines": { | |
| 551 | + | "node": "^20.19.0 || >=22.12.0" | |
| 552 | + | } | |
| 553 | + | }, | |
| 554 | + | "node_modules/@rolldown/binding-linux-x64-gnu": { | |
| 555 | + | "version": "1.2.3", | |
| 556 | + | "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", | |
| 557 | + | "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", | |
| 558 | + | "cpu": [ | |
| 559 | + | "x64" | |
| 560 | + | ], | |
| 561 | + | "dev": true, | |
| 562 | + | "libc": [ | |
| 563 | + | "glibc" | |
| 564 | + | ], | |
| 565 | + | "license": "MIT", | |
| 566 | + | "optional": true, | |
| 567 | + | "os": [ | |
| 568 | + | "linux" | |
| 569 | + | ], | |
| 570 | + | "engines": { | |
| 571 | + | "node": "^20.19.0 || >=22.12.0" | |
| 572 | + | } | |
| 573 | + | }, | |
| 574 | + | "node_modules/@rolldown/binding-linux-x64-musl": { | |
| 575 | + | "version": "1.2.3", | |
| 576 | + | "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", | |
| 577 | + | "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", | |
| 578 | + | "cpu": [ | |
| 579 | + | "x64" | |
| 580 | + | ], | |
| 581 | + | "dev": true, | |
| 582 | + | "libc": [ | |
| 583 | + | "musl" | |
| 584 | + | ], | |
| 585 | + | "license": "MIT", | |
| 586 | + | "optional": true, | |
| 587 | + | "os": [ | |
| 588 | + | "linux" | |
| 589 | + | ], | |
| 590 | + | "engines": { | |
| 591 | + | "node": "^20.19.0 || >=22.12.0" | |
| 592 | + | } | |
| 593 | + | }, | |
| 594 | + | "node_modules/@rolldown/binding-openharmony-arm64": { | |
| 595 | + | "version": "1.2.3", | |
| 596 | + | "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", | |
| 597 | + | "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", | |
| 598 | + | "cpu": [ | |
| 599 | + | "arm64" | |
| 600 | + | ], | |
| 601 | + | "dev": true, | |
| 602 | + | "license": "MIT", | |
| 603 | + | "optional": true, | |
| 604 | + | "os": [ | |
| 605 | + | "openharmony" | |
| 606 | + | ], | |
| 607 | + | "engines": { | |
| 608 | + | "node": "^20.19.0 || >=22.12.0" | |
| 609 | + | } | |
| 610 | + | }, | |
| 611 | + | "node_modules/@rolldown/binding-win32-arm64-msvc": { | |
| 612 | + | "version": "1.2.3", | |
| 613 | + | "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", | |
| 614 | + | "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", | |
| 615 | + | "cpu": [ | |
| 616 | + | "arm64" | |
| 617 | + | ], | |
| 618 | + | "dev": true, | |
| 619 | + | "license": "MIT", | |
| 620 | + | "optional": true, | |
| 621 | + | "os": [ | |
| 622 | + | "win32" | |
| 623 | + | ], | |
| 624 | + | "engines": { | |
| 625 | + | "node": "^20.19.0 || >=22.12.0" | |
| 626 | + | } | |
| 627 | + | }, | |
| 628 | + | "node_modules/@rolldown/binding-win32-x64-msvc": { | |
| 629 | + | "version": "1.2.3", | |
| 630 | + | "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", | |
| 631 | + | "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", | |
| 632 | + | "cpu": [ | |
| 633 | + | "x64" | |
| 634 | + | ], | |
| 635 | + | "dev": true, | |
| 636 | + | "license": "MIT", | |
| 637 | + | "optional": true, | |
| 638 | + | "os": [ | |
| 639 | + | "win32" | |
| 640 | + | ], | |
| 641 | + | "engines": { | |
| 642 | + | "node": "^20.19.0 || >=22.12.0" | |
| 643 | + | } | |
| 644 | + | }, | |
| 645 | + | "node_modules/@rolldown/pluginutils": { | |
| 646 | + | "version": "1.0.1", | |
| 647 | + | "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", | |
| 648 | + | "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", | |
| 649 | + | "dev": true, | |
| 650 | + | "license": "MIT" | |
| 651 | + | }, | |
| 652 | + | "node_modules/@standard-schema/spec": { | |
| 653 | + | "version": "1.1.0", | |
| 654 | + | "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", | |
| 655 | + | "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", | |
| 656 | + | "dev": true, | |
| 657 | + | "license": "MIT" | |
| 658 | + | }, | |
| 659 | + | "node_modules/@types/chai": { | |
| 660 | + | "version": "5.2.3", | |
| 661 | + | "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", | |
| 662 | + | "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", | |
| 663 | + | "dev": true, | |
| 664 | + | "license": "MIT", | |
| 665 | + | "dependencies": { | |
| 666 | + | "@types/deep-eql": "*", | |
| 667 | + | "assertion-error": "^2.0.1" | |
| 668 | + | } | |
| 669 | + | }, | |
| 670 | + | "node_modules/@types/deep-eql": { | |
| 671 | + | "version": "4.0.2", | |
| 672 | + | "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", | |
| 673 | + | "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", | |
| 674 | + | "dev": true, | |
| 675 | + | "license": "MIT" | |
| 676 | + | }, | |
| 677 | + | "node_modules/@types/estree": { | |
| 678 | + | "version": "1.0.9", | |
| 679 | + | "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", | |
| 680 | + | "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", | |
| 681 | + | "dev": true, | |
| 682 | + | "license": "MIT" | |
| 683 | + | }, | |
| 684 | + | "node_modules/@types/node": { | |
| 685 | + | "version": "24.13.3", | |
| 686 | + | "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", | |
| 687 | + | "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", | |
| 688 | + | "dev": true, | |
| 689 | + | "license": "MIT", | |
| 690 | + | "dependencies": { | |
| 691 | + | "undici-types": "~7.18.0" | |
| 692 | + | } | |
| 693 | + | }, | |
| 694 | + | "node_modules/@types/react": { | |
| 695 | + | "version": "19.2.18", | |
| 696 | + | "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", | |
| 697 | + | "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", | |
| 698 | + | "dev": true, | |
| 699 | + | "license": "MIT", | |
| 700 | + | "dependencies": { | |
| 701 | + | "csstype": "^3.2.2" | |
| 702 | + | } | |
| 703 | + | }, | |
| 704 | + | "node_modules/@types/react-dom": { | |
| 705 | + | "version": "19.2.4", | |
| 706 | + | "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", | |
| 707 | + | "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", | |
| 708 | + | "dev": true, | |
| 709 | + | "license": "MIT", | |
| 710 | + | "peerDependencies": { | |
| 711 | + | "@types/react": "^19.2.0" | |
| 712 | + | } | |
| 713 | + | }, | |
| 714 | + | "node_modules/@vitejs/plugin-react": { | |
| 715 | + | "version": "6.0.5", | |
| 716 | + | "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", | |
| 717 | + | "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", | |
| 718 | + | "dev": true, | |
| 719 | + | "license": "MIT", | |
| 720 | + | "dependencies": { | |
| 721 | + | "@rolldown/pluginutils": "^1.0.1" | |
| 722 | + | }, | |
| 723 | + | "engines": { | |
| 724 | + | "node": "^20.19.0 || >=22.12.0" | |
| 725 | + | }, | |
| 726 | + | "peerDependencies": { | |
| 727 | + | "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", | |
| 728 | + | "babel-plugin-react-compiler": "^1.0.0", | |
| 729 | + | "vite": "^8.0.0" | |
| 730 | + | }, | |
| 731 | + | "peerDependenciesMeta": { | |
| 732 | + | "@rolldown/plugin-babel": { | |
| 733 | + | "optional": true | |
| 734 | + | }, | |
| 735 | + | "babel-plugin-react-compiler": { | |
| 736 | + | "optional": true | |
| 737 | + | } | |
| 738 | + | } | |
| 739 | + | }, | |
| 740 | + | "node_modules/@vitest/expect": { | |
| 741 | + | "version": "4.1.10", | |
| 742 | + | "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", | |
| 743 | + | "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", | |
| 744 | + | "dev": true, | |
| 745 | + | "license": "MIT", | |
| 746 | + | "dependencies": { | |
| 747 | + | "@standard-schema/spec": "^1.1.0", | |
| 748 | + | "@types/chai": "^5.2.2", | |
| 749 | + | "@vitest/spy": "4.1.10", | |
| 750 | + | "@vitest/utils": "4.1.10", | |
| 751 | + | "chai": "^6.2.2", | |
| 752 | + | "tinyrainbow": "^3.1.0" | |
| 753 | + | }, | |
| 754 | + | "funding": { | |
| 755 | + | "url": "https://opencollective.com/vitest" | |
| 756 | + | } | |
| 757 | + | }, | |
| 758 | + | "node_modules/@vitest/mocker": { | |
| 759 | + | "version": "4.1.10", | |
| 760 | + | "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", | |
| 761 | + | "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", | |
| 762 | + | "dev": true, | |
| 763 | + | "license": "MIT", | |
| 764 | + | "dependencies": { | |
| 765 | + | "@vitest/spy": "4.1.10", | |
| 766 | + | "estree-walker": "^3.0.3", | |
| 767 | + | "magic-string": "^0.30.21" | |
| 768 | + | }, | |
| 769 | + | "funding": { | |
| 770 | + | "url": "https://opencollective.com/vitest" | |
| 771 | + | }, | |
| 772 | + | "peerDependencies": { | |
| 773 | + | "msw": "^2.4.9", | |
| 774 | + | "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" | |
| 775 | + | }, | |
| 776 | + | "peerDependenciesMeta": { | |
| 777 | + | "msw": { | |
| 778 | + | "optional": true | |
| 779 | + | }, | |
| 780 | + | "vite": { | |
| 781 | + | "optional": true | |
| 782 | + | } | |
| 783 | + | } | |
| 784 | + | }, | |
| 785 | + | "node_modules/@vitest/pretty-format": { | |
| 786 | + | "version": "4.1.10", | |
| 787 | + | "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", | |
| 788 | + | "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", | |
| 789 | + | "dev": true, | |
| 790 | + | "license": "MIT", | |
| 791 | + | "dependencies": { | |
| 792 | + | "tinyrainbow": "^3.1.0" | |
| 793 | + | }, | |
| 794 | + | "funding": { | |
| 795 | + | "url": "https://opencollective.com/vitest" | |
| 796 | + | } | |
| 797 | + | }, | |
| 798 | + | "node_modules/@vitest/runner": { | |
| 799 | + | "version": "4.1.10", | |
| 800 | + | "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", | |
| 801 | + | "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", | |
| 802 | + | "dev": true, | |
| 803 | + | "license": "MIT", | |
| 804 | + | "dependencies": { | |
| 805 | + | "@vitest/utils": "4.1.10", | |
| 806 | + | "pathe": "^2.0.3" | |
| 807 | + | }, | |
| 808 | + | "funding": { | |
| 809 | + | "url": "https://opencollective.com/vitest" | |
| 810 | + | } | |
| 811 | + | }, | |
| 812 | + | "node_modules/@vitest/snapshot": { | |
| 813 | + | "version": "4.1.10", | |
| 814 | + | "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", | |
| 815 | + | "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", | |
| 816 | + | "dev": true, | |
| 817 | + | "license": "MIT", | |
| 818 | + | "dependencies": { | |
| 819 | + | "@vitest/pretty-format": "4.1.10", | |
| 820 | + | "@vitest/utils": "4.1.10", | |
| 821 | + | "magic-string": "^0.30.21", | |
| 822 | + | "pathe": "^2.0.3" | |
| 823 | + | }, | |
| 824 | + | "funding": { | |
| 825 | + | "url": "https://opencollective.com/vitest" | |
| 826 | + | } | |
| 827 | + | }, | |
| 828 | + | "node_modules/@vitest/spy": { | |
| 829 | + | "version": "4.1.10", | |
| 830 | + | "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", | |
| 831 | + | "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", | |
| 832 | + | "dev": true, | |
| 833 | + | "license": "MIT", | |
| 834 | + | "funding": { | |
| 835 | + | "url": "https://opencollective.com/vitest" | |
| 836 | + | } | |
| 837 | + | }, | |
| 838 | + | "node_modules/@vitest/utils": { | |
| 839 | + | "version": "4.1.10", | |
| 840 | + | "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", | |
| 841 | + | "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", | |
| 842 | + | "dev": true, | |
| 843 | + | "license": "MIT", | |
| 844 | + | "dependencies": { | |
| 845 | + | "@vitest/pretty-format": "4.1.10", | |
| 846 | + | "convert-source-map": "^2.0.0", | |
| 847 | + | "tinyrainbow": "^3.1.0" | |
| 848 | + | }, | |
| 849 | + | "funding": { | |
| 850 | + | "url": "https://opencollective.com/vitest" | |
| 851 | + | } | |
| 852 | + | }, | |
| 853 | + | "node_modules/assertion-error": { | |
| 854 | + | "version": "2.0.1", | |
| 855 | + | "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", | |
| 856 | + | "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", | |
| 857 | + | "dev": true, | |
| 858 | + | "license": "MIT", | |
| 859 | + | "engines": { | |
| 860 | + | "node": ">=12" | |
| 861 | + | } | |
| 862 | + | }, | |
| 863 | + | "node_modules/chai": { | |
| 864 | + | "version": "6.2.2", | |
| 865 | + | "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", | |
| 866 | + | "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", | |
| 867 | + | "dev": true, | |
| 868 | + | "license": "MIT", | |
| 869 | + | "engines": { | |
| 870 | + | "node": ">=18" | |
| 871 | + | } | |
| 872 | + | }, | |
| 873 | + | "node_modules/convert-source-map": { | |
| 874 | + | "version": "2.0.0", | |
| 875 | + | "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", | |
| 876 | + | "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", | |
| 877 | + | "dev": true, | |
| 878 | + | "license": "MIT" | |
| 879 | + | }, | |
| 880 | + | "node_modules/csstype": { | |
| 881 | + | "version": "3.2.3", | |
| 882 | + | "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", | |
| 883 | + | "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", | |
| 884 | + | "dev": true, | |
| 885 | + | "license": "MIT" | |
| 886 | + | }, | |
| 887 | + | "node_modules/detect-libc": { | |
| 888 | + | "version": "2.1.2", | |
| 889 | + | "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", | |
| 890 | + | "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", | |
| 891 | + | "dev": true, | |
| 892 | + | "license": "Apache-2.0", | |
| 893 | + | "engines": { | |
| 894 | + | "node": ">=8" | |
| 895 | + | } | |
| 896 | + | }, | |
| 897 | + | "node_modules/es-module-lexer": { | |
| 898 | + | "version": "2.3.1", | |
| 899 | + | "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", | |
| 900 | + | "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", | |
| 901 | + | "dev": true, | |
| 902 | + | "license": "MIT" | |
| 903 | + | }, | |
| 904 | + | "node_modules/estree-walker": { | |
| 905 | + | "version": "3.0.3", | |
| 906 | + | "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", | |
| 907 | + | "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", | |
| 908 | + | "dev": true, | |
| 909 | + | "license": "MIT", | |
| 910 | + | "dependencies": { | |
| 911 | + | "@types/estree": "^1.0.0" | |
| 912 | + | } | |
| 913 | + | }, | |
| 914 | + | "node_modules/expect-type": { | |
| 915 | + | "version": "1.4.0", | |
| 916 | + | "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", | |
| 917 | + | "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", | |
| 918 | + | "dev": true, | |
| 919 | + | "license": "Apache-2.0", | |
| 920 | + | "engines": { | |
| 921 | + | "node": ">=12.0.0" | |
| 922 | + | } | |
| 923 | + | }, | |
| 924 | + | "node_modules/fdir": { | |
| 925 | + | "version": "6.5.0", | |
| 926 | + | "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", | |
| 927 | + | "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", | |
| 928 | + | "dev": true, | |
| 929 | + | "license": "MIT", | |
| 930 | + | "engines": { | |
| 931 | + | "node": ">=12.0.0" | |
| 932 | + | }, | |
| 933 | + | "peerDependencies": { | |
| 934 | + | "picomatch": "^3 || ^4" | |
| 935 | + | }, | |
| 936 | + | "peerDependenciesMeta": { | |
| 937 | + | "picomatch": { | |
| 938 | + | "optional": true | |
| 939 | + | } | |
| 940 | + | } | |
| 941 | + | }, | |
| 942 | + | "node_modules/fsevents": { | |
| 943 | + | "version": "2.3.3", | |
| 944 | + | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", | |
| 945 | + | "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", | |
| 946 | + | "dev": true, | |
| 947 | + | "hasInstallScript": true, | |
| 948 | + | "license": "MIT", | |
| 949 | + | "optional": true, | |
| 950 | + | "os": [ | |
| 951 | + | "darwin" | |
| 952 | + | ], | |
| 953 | + | "engines": { | |
| 954 | + | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" | |
| 955 | + | } | |
| 956 | + | }, | |
| 957 | + | "node_modules/lightningcss": { | |
| 958 | + | "version": "1.33.0", | |
| 959 | + | "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", | |
| 960 | + | "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", | |
| 961 | + | "dev": true, | |
| 962 | + | "license": "MPL-2.0", | |
| 963 | + | "dependencies": { | |
| 964 | + | "detect-libc": "^2.0.3" | |
| 965 | + | }, | |
| 966 | + | "engines": { | |
| 967 | + | "node": ">= 12.0.0" | |
| 968 | + | }, | |
| 969 | + | "funding": { | |
| 970 | + | "type": "opencollective", | |
| 971 | + | "url": "https://opencollective.com/parcel" | |
| 972 | + | }, | |
| 973 | + | "optionalDependencies": { | |
| 974 | + | "lightningcss-android-arm64": "1.33.0", | |
| 975 | + | "lightningcss-darwin-arm64": "1.33.0", | |
| 976 | + | "lightningcss-darwin-x64": "1.33.0", | |
| 977 | + | "lightningcss-freebsd-x64": "1.33.0", | |
| 978 | + | "lightningcss-linux-arm-gnueabihf": "1.33.0", | |
| 979 | + | "lightningcss-linux-arm64-gnu": "1.33.0", | |
| 980 | + | "lightningcss-linux-arm64-musl": "1.33.0", | |
| 981 | + | "lightningcss-linux-x64-gnu": "1.33.0", | |
| 982 | + | "lightningcss-linux-x64-musl": "1.33.0", | |
| 983 | + | "lightningcss-win32-arm64-msvc": "1.33.0", | |
| 984 | + | "lightningcss-win32-x64-msvc": "1.33.0" | |
| 985 | + | } | |
| 986 | + | }, | |
| 987 | + | "node_modules/lightningcss-android-arm64": { | |
| 988 | + | "version": "1.33.0", | |
| 989 | + | "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", | |
| 990 | + | "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", | |
| 991 | + | "cpu": [ | |
| 992 | + | "arm64" | |
| 993 | + | ], | |
| 994 | + | "dev": true, | |
| 995 | + | "license": "MPL-2.0", | |
| 996 | + | "optional": true, | |
| 997 | + | "os": [ | |
| 998 | + | "android" | |
| 999 | + | ], | |
| 1000 | + | "engines": { | |
| 1001 | + | "node": ">= 12.0.0" | |
| 1002 | + | }, | |
| 1003 | + | "funding": { | |
| 1004 | + | "type": "opencollective", | |
| 1005 | + | "url": "https://opencollective.com/parcel" | |
| 1006 | + | } | |
| 1007 | + | }, | |
| 1008 | + | "node_modules/lightningcss-darwin-arm64": { | |
| 1009 | + | "version": "1.33.0", | |
| 1010 | + | "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", | |
| 1011 | + | "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", | |
| 1012 | + | "cpu": [ | |
| 1013 | + | "arm64" | |
| 1014 | + | ], | |
| 1015 | + | "dev": true, | |
| 1016 | + | "license": "MPL-2.0", | |
| 1017 | + | "optional": true, | |
| 1018 | + | "os": [ | |
| 1019 | + | "darwin" | |
| 1020 | + | ], | |
| 1021 | + | "engines": { | |
| 1022 | + | "node": ">= 12.0.0" | |
| 1023 | + | }, | |
| 1024 | + | "funding": { | |
| 1025 | + | "type": "opencollective", | |
| 1026 | + | "url": "https://opencollective.com/parcel" | |
| 1027 | + | } | |
| 1028 | + | }, | |
| 1029 | + | "node_modules/lightningcss-darwin-x64": { | |
| 1030 | + | "version": "1.33.0", | |
| 1031 | + | "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", | |
| 1032 | + | "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", | |
| 1033 | + | "cpu": [ | |
| 1034 | + | "x64" | |
| 1035 | + | ], | |
| 1036 | + | "dev": true, | |
| 1037 | + | "license": "MPL-2.0", | |
| 1038 | + | "optional": true, | |
| 1039 | + | "os": [ | |
| 1040 | + | "darwin" | |
| 1041 | + | ], | |
| 1042 | + | "engines": { | |
| 1043 | + | "node": ">= 12.0.0" | |
| 1044 | + | }, | |
| 1045 | + | "funding": { | |
| 1046 | + | "type": "opencollective", | |
| 1047 | + | "url": "https://opencollective.com/parcel" | |
| 1048 | + | } | |
| 1049 | + | }, | |
| 1050 | + | "node_modules/lightningcss-freebsd-x64": { | |
| 1051 | + | "version": "1.33.0", | |
| 1052 | + | "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", | |
| 1053 | + | "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", | |
| 1054 | + | "cpu": [ | |
| 1055 | + | "x64" | |
| 1056 | + | ], | |
| 1057 | + | "dev": true, | |
| 1058 | + | "license": "MPL-2.0", | |
| 1059 | + | "optional": true, | |
| 1060 | + | "os": [ | |
| 1061 | + | "freebsd" | |
| 1062 | + | ], | |
| 1063 | + | "engines": { | |
| 1064 | + | "node": ">= 12.0.0" | |
| 1065 | + | }, | |
| 1066 | + | "funding": { | |
| 1067 | + | "type": "opencollective", | |
| 1068 | + | "url": "https://opencollective.com/parcel" | |
| 1069 | + | } | |
| 1070 | + | }, | |
| 1071 | + | "node_modules/lightningcss-linux-arm-gnueabihf": { | |
| 1072 | + | "version": "1.33.0", | |
| 1073 | + | "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", | |
| 1074 | + | "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", | |
| 1075 | + | "cpu": [ | |
| 1076 | + | "arm" | |
| 1077 | + | ], | |
| 1078 | + | "dev": true, | |
| 1079 | + | "license": "MPL-2.0", | |
| 1080 | + | "optional": true, | |
| 1081 | + | "os": [ | |
| 1082 | + | "linux" | |
| 1083 | + | ], | |
| 1084 | + | "engines": { | |
| 1085 | + | "node": ">= 12.0.0" | |
| 1086 | + | }, | |
| 1087 | + | "funding": { | |
| 1088 | + | "type": "opencollective", | |
| 1089 | + | "url": "https://opencollective.com/parcel" | |
| 1090 | + | } | |
| 1091 | + | }, | |
| 1092 | + | "node_modules/lightningcss-linux-arm64-gnu": { | |
| 1093 | + | "version": "1.33.0", | |
| 1094 | + | "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", | |
| 1095 | + | "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", | |
| 1096 | + | "cpu": [ | |
| 1097 | + | "arm64" | |
| 1098 | + | ], | |
| 1099 | + | "dev": true, | |
| 1100 | + | "libc": [ | |
| 1101 | + | "glibc" | |
| 1102 | + | ], | |
| 1103 | + | "license": "MPL-2.0", | |
| 1104 | + | "optional": true, | |
| 1105 | + | "os": [ | |
| 1106 | + | "linux" | |
| 1107 | + | ], | |
| 1108 | + | "engines": { | |
| 1109 | + | "node": ">= 12.0.0" | |
| 1110 | + | }, | |
| 1111 | + | "funding": { | |
| 1112 | + | "type": "opencollective", | |
| 1113 | + | "url": "https://opencollective.com/parcel" | |
| 1114 | + | } | |
| 1115 | + | }, | |
| 1116 | + | "node_modules/lightningcss-linux-arm64-musl": { | |
| 1117 | + | "version": "1.33.0", | |
| 1118 | + | "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", | |
| 1119 | + | "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", | |
| 1120 | + | "cpu": [ | |
| 1121 | + | "arm64" | |
| 1122 | + | ], | |
| 1123 | + | "dev": true, | |
| 1124 | + | "libc": [ | |
| 1125 | + | "musl" | |
| 1126 | + | ], | |
| 1127 | + | "license": "MPL-2.0", | |
| 1128 | + | "optional": true, | |
| 1129 | + | "os": [ | |
| 1130 | + | "linux" | |
| 1131 | + | ], | |
| 1132 | + | "engines": { | |
| 1133 | + | "node": ">= 12.0.0" | |
| 1134 | + | }, | |
| 1135 | + | "funding": { | |
| 1136 | + | "type": "opencollective", | |
| 1137 | + | "url": "https://opencollective.com/parcel" | |
| 1138 | + | } | |
| 1139 | + | }, | |
| 1140 | + | "node_modules/lightningcss-linux-x64-gnu": { | |
| 1141 | + | "version": "1.33.0", | |
| 1142 | + | "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", | |
| 1143 | + | "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", | |
| 1144 | + | "cpu": [ | |
| 1145 | + | "x64" | |
| 1146 | + | ], | |
| 1147 | + | "dev": true, | |
| 1148 | + | "libc": [ | |
| 1149 | + | "glibc" | |
| 1150 | + | ], | |
| 1151 | + | "license": "MPL-2.0", | |
| 1152 | + | "optional": true, | |
| 1153 | + | "os": [ | |
| 1154 | + | "linux" | |
| 1155 | + | ], | |
| 1156 | + | "engines": { | |
| 1157 | + | "node": ">= 12.0.0" | |
| 1158 | + | }, | |
| 1159 | + | "funding": { | |
| 1160 | + | "type": "opencollective", | |
| 1161 | + | "url": "https://opencollective.com/parcel" | |
| 1162 | + | } | |
| 1163 | + | }, | |
| 1164 | + | "node_modules/lightningcss-linux-x64-musl": { | |
| 1165 | + | "version": "1.33.0", | |
| 1166 | + | "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", | |
| 1167 | + | "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", | |
| 1168 | + | "cpu": [ | |
| 1169 | + | "x64" | |
| 1170 | + | ], | |
| 1171 | + | "dev": true, | |
| 1172 | + | "libc": [ | |
| 1173 | + | "musl" | |
| 1174 | + | ], | |
| 1175 | + | "license": "MPL-2.0", | |
| 1176 | + | "optional": true, | |
| 1177 | + | "os": [ | |
| 1178 | + | "linux" | |
| 1179 | + | ], | |
| 1180 | + | "engines": { | |
| 1181 | + | "node": ">= 12.0.0" | |
| 1182 | + | }, | |
| 1183 | + | "funding": { | |
| 1184 | + | "type": "opencollective", | |
| 1185 | + | "url": "https://opencollective.com/parcel" | |
| 1186 | + | } | |
| 1187 | + | }, | |
| 1188 | + | "node_modules/lightningcss-win32-arm64-msvc": { | |
| 1189 | + | "version": "1.33.0", | |
| 1190 | + | "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", | |
| 1191 | + | "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", | |
| 1192 | + | "cpu": [ | |
| 1193 | + | "arm64" | |
| 1194 | + | ], | |
| 1195 | + | "dev": true, | |
| 1196 | + | "license": "MPL-2.0", | |
| 1197 | + | "optional": true, | |
| 1198 | + | "os": [ | |
| 1199 | + | "win32" | |
| 1200 | + | ], | |
| 1201 | + | "engines": { | |
| 1202 | + | "node": ">= 12.0.0" | |
| 1203 | + | }, | |
| 1204 | + | "funding": { | |
| 1205 | + | "type": "opencollective", | |
| 1206 | + | "url": "https://opencollective.com/parcel" | |
| 1207 | + | } | |
| 1208 | + | }, | |
| 1209 | + | "node_modules/lightningcss-win32-x64-msvc": { | |
| 1210 | + | "version": "1.33.0", | |
| 1211 | + | "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", | |
| 1212 | + | "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", | |
| 1213 | + | "cpu": [ | |
| 1214 | + | "x64" | |
| 1215 | + | ], | |
| 1216 | + | "dev": true, | |
| 1217 | + | "license": "MPL-2.0", | |
| 1218 | + | "optional": true, | |
| 1219 | + | "os": [ | |
| 1220 | + | "win32" | |
| 1221 | + | ], | |
| 1222 | + | "engines": { | |
| 1223 | + | "node": ">= 12.0.0" | |
| 1224 | + | }, | |
| 1225 | + | "funding": { | |
| 1226 | + | "type": "opencollective", | |
| 1227 | + | "url": "https://opencollective.com/parcel" | |
| 1228 | + | } | |
| 1229 | + | }, | |
| 1230 | + | "node_modules/magic-string": { | |
| 1231 | + | "version": "0.30.21", | |
| 1232 | + | "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", | |
| 1233 | + | "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", | |
| 1234 | + | "dev": true, | |
| 1235 | + | "license": "MIT", | |
| 1236 | + | "dependencies": { | |
| 1237 | + | "@jridgewell/sourcemap-codec": "^1.5.5" | |
| 1238 | + | } | |
| 1239 | + | }, | |
| 1240 | + | "node_modules/nanoid": { | |
| 1241 | + | "version": "3.3.17", | |
| 1242 | + | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", | |
| 1243 | + | "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", | |
| 1244 | + | "dev": true, | |
| 1245 | + | "funding": [ | |
| 1246 | + | { | |
| 1247 | + | "type": "github", | |
| 1248 | + | "url": "https://github.com/sponsors/ai" | |
| 1249 | + | } | |
| 1250 | + | ], | |
| 1251 | + | "license": "MIT", | |
| 1252 | + | "bin": { | |
| 1253 | + | "nanoid": "bin/nanoid.cjs" | |
| 1254 | + | }, | |
| 1255 | + | "engines": { | |
| 1256 | + | "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" | |
| 1257 | + | } | |
| 1258 | + | }, | |
| 1259 | + | "node_modules/obug": { | |
| 1260 | + | "version": "2.1.4", | |
| 1261 | + | "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", | |
| 1262 | + | "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", | |
| 1263 | + | "dev": true, | |
| 1264 | + | "funding": [ | |
| 1265 | + | "https://github.com/sponsors/sxzz", | |
| 1266 | + | "https://opencollective.com/debug" | |
| 1267 | + | ], | |
| 1268 | + | "license": "MIT", | |
| 1269 | + | "engines": { | |
| 1270 | + | "node": ">=12.20.0" | |
| 1271 | + | } | |
| 1272 | + | }, | |
| 1273 | + | "node_modules/oxlint": { | |
| 1274 | + | "version": "1.77.0", | |
| 1275 | + | "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.77.0.tgz", | |
| 1276 | + | "integrity": "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==", | |
| 1277 | + | "dev": true, | |
| 1278 | + | "license": "MIT", | |
| 1279 | + | "bin": { | |
| 1280 | + | "oxlint": "bin/oxlint" | |
| 1281 | + | }, | |
| 1282 | + | "engines": { | |
| 1283 | + | "node": "^20.19.0 || >=22.12.0" | |
| 1284 | + | }, | |
| 1285 | + | "funding": { | |
| 1286 | + | "url": "https://github.com/sponsors/Boshen" | |
| 1287 | + | }, | |
| 1288 | + | "optionalDependencies": { | |
| 1289 | + | "@oxlint/binding-android-arm-eabi": "1.77.0", | |
| 1290 | + | "@oxlint/binding-android-arm64": "1.77.0", | |
| 1291 | + | "@oxlint/binding-darwin-arm64": "1.77.0", | |
| 1292 | + | "@oxlint/binding-darwin-x64": "1.77.0", | |
| 1293 | + | "@oxlint/binding-freebsd-x64": "1.77.0", | |
| 1294 | + | "@oxlint/binding-linux-arm-gnueabihf": "1.77.0", | |
| 1295 | + | "@oxlint/binding-linux-arm-musleabihf": "1.77.0", | |
| 1296 | + | "@oxlint/binding-linux-arm64-gnu": "1.77.0", | |
| 1297 | + | "@oxlint/binding-linux-arm64-musl": "1.77.0", | |
| 1298 | + | "@oxlint/binding-linux-ppc64-gnu": "1.77.0", | |
| 1299 | + | "@oxlint/binding-linux-riscv64-gnu": "1.77.0", | |
| 1300 | + | "@oxlint/binding-linux-riscv64-musl": "1.77.0", | |
| 1301 | + | "@oxlint/binding-linux-s390x-gnu": "1.77.0", | |
| 1302 | + | "@oxlint/binding-linux-x64-gnu": "1.77.0", | |
| 1303 | + | "@oxlint/binding-linux-x64-musl": "1.77.0", | |
| 1304 | + | "@oxlint/binding-openharmony-arm64": "1.77.0", | |
| 1305 | + | "@oxlint/binding-win32-arm64-msvc": "1.77.0", | |
| 1306 | + | "@oxlint/binding-win32-ia32-msvc": "1.77.0", | |
| 1307 | + | "@oxlint/binding-win32-x64-msvc": "1.77.0" | |
| 1308 | + | }, | |
| 1309 | + | "peerDependencies": { | |
| 1310 | + | "oxlint-tsgolint": ">=7.0.2001", | |
| 1311 | + | "vite-plus": "*" | |
| 1312 | + | }, | |
| 1313 | + | "peerDependenciesMeta": { | |
| 1314 | + | "oxlint-tsgolint": { | |
| 1315 | + | "optional": true | |
| 1316 | + | }, | |
| 1317 | + | "vite-plus": { | |
| 1318 | + | "optional": true | |
| 1319 | + | } | |
| 1320 | + | } | |
| 1321 | + | }, | |
| 1322 | + | "node_modules/pathe": { | |
| 1323 | + | "version": "2.0.3", | |
| 1324 | + | "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", | |
| 1325 | + | "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", | |
| 1326 | + | "dev": true, | |
| 1327 | + | "license": "MIT" | |
| 1328 | + | }, | |
| 1329 | + | "node_modules/picocolors": { | |
| 1330 | + | "version": "1.1.1", | |
| 1331 | + | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", | |
| 1332 | + | "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", | |
| 1333 | + | "dev": true, | |
| 1334 | + | "license": "ISC" | |
| 1335 | + | }, | |
| 1336 | + | "node_modules/picomatch": { | |
| 1337 | + | "version": "4.0.5", | |
| 1338 | + | "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", | |
| 1339 | + | "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", | |
| 1340 | + | "dev": true, | |
| 1341 | + | "license": "MIT", | |
| 1342 | + | "engines": { | |
| 1343 | + | "node": ">=12" | |
| 1344 | + | }, | |
| 1345 | + | "funding": { | |
| 1346 | + | "url": "https://github.com/sponsors/jonschlinkert" | |
| 1347 | + | } | |
| 1348 | + | }, | |
| 1349 | + | "node_modules/postcss": { | |
| 1350 | + | "version": "8.5.26", | |
| 1351 | + | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", | |
| 1352 | + | "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", | |
| 1353 | + | "dev": true, | |
| 1354 | + | "funding": [ | |
| 1355 | + | { | |
| 1356 | + | "type": "opencollective", | |
| 1357 | + | "url": "https://opencollective.com/postcss/" | |
| 1358 | + | }, | |
| 1359 | + | { | |
| 1360 | + | "type": "tidelift", | |
| 1361 | + | "url": "https://tidelift.com/funding/github/npm/postcss" | |
| 1362 | + | }, | |
| 1363 | + | { | |
| 1364 | + | "type": "github", | |
| 1365 | + | "url": "https://github.com/sponsors/ai" | |
| 1366 | + | } | |
| 1367 | + | ], | |
| 1368 | + | "license": "MIT", | |
| 1369 | + | "dependencies": { | |
| 1370 | + | "nanoid": "^3.3.17", | |
| 1371 | + | "picocolors": "^1.1.1", | |
| 1372 | + | "source-map-js": "^1.2.1" | |
| 1373 | + | }, | |
| 1374 | + | "engines": { | |
| 1375 | + | "node": "^10 || ^12 || >=14" | |
| 1376 | + | } | |
| 1377 | + | }, | |
| 1378 | + | "node_modules/react": { | |
| 1379 | + | "version": "19.2.8", | |
| 1380 | + | "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", | |
| 1381 | + | "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", | |
| 1382 | + | "license": "MIT", | |
| 1383 | + | "engines": { | |
| 1384 | + | "node": ">=0.10.0" | |
| 1385 | + | } | |
| 1386 | + | }, | |
| 1387 | + | "node_modules/react-dom": { | |
| 1388 | + | "version": "19.2.8", | |
| 1389 | + | "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", | |
| 1390 | + | "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", | |
| 1391 | + | "license": "MIT", | |
| 1392 | + | "dependencies": { | |
| 1393 | + | "scheduler": "^0.27.0" | |
| 1394 | + | }, | |
| 1395 | + | "peerDependencies": { | |
| 1396 | + | "react": "^19.2.8" | |
| 1397 | + | } | |
| 1398 | + | }, | |
| 1399 | + | "node_modules/rolldown": { | |
| 1400 | + | "version": "1.2.3", | |
| 1401 | + | "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", | |
| 1402 | + | "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", | |
| 1403 | + | "dev": true, | |
| 1404 | + | "license": "MIT", | |
| 1405 | + | "dependencies": { | |
| 1406 | + | "@oxc-project/types": "=0.143.0", | |
| 1407 | + | "@rolldown/pluginutils": "^1.0.0" | |
| 1408 | + | }, | |
| 1409 | + | "bin": { | |
| 1410 | + | "rolldown": "bin/cli.mjs" | |
| 1411 | + | }, | |
| 1412 | + | "engines": { | |
| 1413 | + | "node": "^20.19.0 || >=22.12.0" | |
| 1414 | + | }, | |
| 1415 | + | "optionalDependencies": { | |
| 1416 | + | "@rolldown/binding-android-arm64": "1.2.3", | |
| 1417 | + | "@rolldown/binding-darwin-arm64": "1.2.3", | |
| 1418 | + | "@rolldown/binding-darwin-x64": "1.2.3", | |
| 1419 | + | "@rolldown/binding-freebsd-x64": "1.2.3", | |
| 1420 | + | "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", | |
| 1421 | + | "@rolldown/binding-linux-arm64-gnu": "1.2.3", | |
| 1422 | + | "@rolldown/binding-linux-arm64-musl": "1.2.3", | |
| 1423 | + | "@rolldown/binding-linux-ppc64-gnu": "1.2.3", | |
| 1424 | + | "@rolldown/binding-linux-s390x-gnu": "1.2.3", | |
| 1425 | + | "@rolldown/binding-linux-x64-gnu": "1.2.3", | |
| 1426 | + | "@rolldown/binding-linux-x64-musl": "1.2.3", | |
| 1427 | + | "@rolldown/binding-openharmony-arm64": "1.2.3", | |
| 1428 | + | "@rolldown/binding-win32-arm64-msvc": "1.2.3", | |
| 1429 | + | "@rolldown/binding-win32-x64-msvc": "1.2.3" | |
| 1430 | + | } | |
| 1431 | + | }, | |
| 1432 | + | "node_modules/scheduler": { | |
| 1433 | + | "version": "0.27.0", | |
| 1434 | + | "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", | |
| 1435 | + | "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", | |
| 1436 | + | "license": "MIT" | |
| 1437 | + | }, | |
| 1438 | + | "node_modules/siginfo": { | |
| 1439 | + | "version": "2.0.0", | |
| 1440 | + | "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", | |
| 1441 | + | "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", | |
| 1442 | + | "dev": true, | |
| 1443 | + | "license": "ISC" | |
| 1444 | + | }, | |
| 1445 | + | "node_modules/source-map-js": { | |
| 1446 | + | "version": "1.2.1", | |
| 1447 | + | "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", | |
| 1448 | + | "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", | |
| 1449 | + | "dev": true, | |
| 1450 | + | "license": "BSD-3-Clause", | |
| 1451 | + | "engines": { | |
| 1452 | + | "node": ">=0.10.0" | |
| 1453 | + | } | |
| 1454 | + | }, | |
| 1455 | + | "node_modules/stackback": { | |
| 1456 | + | "version": "0.0.2", | |
| 1457 | + | "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", | |
| 1458 | + | "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", | |
| 1459 | + | "dev": true, | |
| 1460 | + | "license": "MIT" | |
| 1461 | + | }, | |
| 1462 | + | "node_modules/std-env": { | |
| 1463 | + | "version": "4.2.0", | |
| 1464 | + | "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", | |
| 1465 | + | "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", | |
| 1466 | + | "dev": true, | |
| 1467 | + | "license": "MIT" | |
| 1468 | + | }, | |
| 1469 | + | "node_modules/tinybench": { | |
| 1470 | + | "version": "2.9.0", | |
| 1471 | + | "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", | |
| 1472 | + | "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", | |
| 1473 | + | "dev": true, | |
| 1474 | + | "license": "MIT" | |
| 1475 | + | }, | |
| 1476 | + | "node_modules/tinyexec": { | |
| 1477 | + | "version": "1.3.0", | |
| 1478 | + | "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", | |
| 1479 | + | "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", | |
| 1480 | + | "dev": true, | |
| 1481 | + | "license": "MIT", | |
| 1482 | + | "engines": { | |
| 1483 | + | "node": ">=18" | |
| 1484 | + | } | |
| 1485 | + | }, | |
| 1486 | + | "node_modules/tinyglobby": { | |
| 1487 | + | "version": "0.2.17", | |
| 1488 | + | "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", | |
| 1489 | + | "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", | |
| 1490 | + | "dev": true, | |
| 1491 | + | "license": "MIT", | |
| 1492 | + | "dependencies": { | |
| 1493 | + | "fdir": "^6.5.0", | |
| 1494 | + | "picomatch": "^4.0.4" | |
| 1495 | + | }, | |
| 1496 | + | "engines": { | |
| 1497 | + | "node": ">=12.0.0" | |
| 1498 | + | }, | |
| 1499 | + | "funding": { | |
| 1500 | + | "url": "https://github.com/sponsors/SuperchupuDev" | |
| 1501 | + | } | |
| 1502 | + | }, | |
| 1503 | + | "node_modules/tinyrainbow": { | |
| 1504 | + | "version": "3.1.1", | |
| 1505 | + | "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", | |
| 1506 | + | "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", | |
| 1507 | + | "dev": true, | |
| 1508 | + | "license": "MIT", | |
| 1509 | + | "engines": { | |
| 1510 | + | "node": ">=14.0.0" | |
| 1511 | + | } | |
| 1512 | + | }, | |
| 1513 | + | "node_modules/typescript": { | |
| 1514 | + | "version": "6.0.3", | |
| 1515 | + | "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", | |
| 1516 | + | "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", | |
| 1517 | + | "dev": true, | |
| 1518 | + | "license": "Apache-2.0", | |
| 1519 | + | "bin": { | |
| 1520 | + | "tsc": "bin/tsc", | |
| 1521 | + | "tsserver": "bin/tsserver" | |
| 1522 | + | }, | |
| 1523 | + | "engines": { | |
| 1524 | + | "node": ">=14.17" | |
| 1525 | + | } | |
| 1526 | + | }, | |
| 1527 | + | "node_modules/undici-types": { | |
| 1528 | + | "version": "7.18.2", | |
| 1529 | + | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", | |
| 1530 | + | "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", | |
| 1531 | + | "dev": true, | |
| 1532 | + | "license": "MIT" | |
| 1533 | + | }, | |
| 1534 | + | "node_modules/vite": { | |
| 1535 | + | "version": "8.2.0", | |
| 1536 | + | "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", | |
| 1537 | + | "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", | |
| 1538 | + | "dev": true, | |
| 1539 | + | "license": "MIT", | |
| 1540 | + | "dependencies": { | |
| 1541 | + | "lightningcss": "^1.33.0", | |
| 1542 | + | "picomatch": "^4.0.5", | |
| 1543 | + | "postcss": "^8.5.23", | |
| 1544 | + | "rolldown": "~1.2.0", | |
| 1545 | + | "tinyglobby": "^0.2.17" | |
| 1546 | + | }, | |
| 1547 | + | "bin": { | |
| 1548 | + | "vite": "bin/vite.js" | |
| 1549 | + | }, | |
| 1550 | + | "engines": { | |
| 1551 | + | "node": "^20.19.0 || >=22.12.0" | |
| 1552 | + | }, | |
| 1553 | + | "funding": { | |
| 1554 | + | "url": "https://github.com/vitejs/vite?sponsor=1" | |
| 1555 | + | }, | |
| 1556 | + | "optionalDependencies": { | |
| 1557 | + | "fsevents": "~2.3.3" | |
| 1558 | + | }, | |
| 1559 | + | "peerDependencies": { | |
| 1560 | + | "@types/node": "^20.19.0 || >=22.12.0", | |
| 1561 | + | "@vitejs/devtools": "^0.4.0", | |
| 1562 | + | "esbuild": "^0.27.0 || ^0.28.0", | |
| 1563 | + | "jiti": ">=1.21.0", | |
| 1564 | + | "less": "^4.0.0", | |
| 1565 | + | "sass": "^1.70.0", | |
| 1566 | + | "sass-embedded": "^1.70.0", | |
| 1567 | + | "stylus": ">=0.54.8", | |
| 1568 | + | "sugarss": "^5.0.0", | |
| 1569 | + | "terser": "^5.16.0", | |
| 1570 | + | "tsx": "^4.8.1", | |
| 1571 | + | "yaml": "^2.4.2" | |
| 1572 | + | }, | |
| 1573 | + | "peerDependenciesMeta": { | |
| 1574 | + | "@types/node": { | |
| 1575 | + | "optional": true | |
| 1576 | + | }, | |
| 1577 | + | "@vitejs/devtools": { | |
| 1578 | + | "optional": true | |
| 1579 | + | }, | |
| 1580 | + | "esbuild": { | |
| 1581 | + | "optional": true | |
| 1582 | + | }, | |
| 1583 | + | "jiti": { | |
| 1584 | + | "optional": true | |
| 1585 | + | }, | |
| 1586 | + | "less": { | |
| 1587 | + | "optional": true | |
| 1588 | + | }, | |
| 1589 | + | "sass": { | |
| 1590 | + | "optional": true | |
| 1591 | + | }, | |
| 1592 | + | "sass-embedded": { | |
| 1593 | + | "optional": true | |
| 1594 | + | }, | |
| 1595 | + | "stylus": { | |
| 1596 | + | "optional": true | |
| 1597 | + | }, | |
| 1598 | + | "sugarss": { | |
| 1599 | + | "optional": true | |
| 1600 | + | }, | |
| 1601 | + | "terser": { | |
| 1602 | + | "optional": true | |
| 1603 | + | }, | |
| 1604 | + | "tsx": { | |
| 1605 | + | "optional": true | |
| 1606 | + | }, | |
| 1607 | + | "yaml": { | |
| 1608 | + | "optional": true | |
| 1609 | + | } | |
| 1610 | + | } | |
| 1611 | + | }, | |
| 1612 | + | "node_modules/vitest": { | |
| 1613 | + | "version": "4.1.10", | |
| 1614 | + | "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", | |
| 1615 | + | "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", | |
| 1616 | + | "dev": true, | |
| 1617 | + | "license": "MIT", | |
| 1618 | + | "dependencies": { | |
| 1619 | + | "@vitest/expect": "4.1.10", | |
| 1620 | + | "@vitest/mocker": "4.1.10", | |
| 1621 | + | "@vitest/pretty-format": "4.1.10", | |
| 1622 | + | "@vitest/runner": "4.1.10", | |
| 1623 | + | "@vitest/snapshot": "4.1.10", | |
| 1624 | + | "@vitest/spy": "4.1.10", | |
| 1625 | + | "@vitest/utils": "4.1.10", | |
| 1626 | + | "es-module-lexer": "^2.0.0", | |
| 1627 | + | "expect-type": "^1.3.0", | |
| 1628 | + | "magic-string": "^0.30.21", | |
| 1629 | + | "obug": "^2.1.1", | |
| 1630 | + | "pathe": "^2.0.3", | |
| 1631 | + | "picomatch": "^4.0.3", | |
| 1632 | + | "std-env": "^4.0.0-rc.1", | |
| 1633 | + | "tinybench": "^2.9.0", | |
| 1634 | + | "tinyexec": "^1.0.2", | |
| 1635 | + | "tinyglobby": "^0.2.15", | |
| 1636 | + | "tinyrainbow": "^3.1.0", | |
| 1637 | + | "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", | |
| 1638 | + | "why-is-node-running": "^2.3.0" | |
| 1639 | + | }, | |
| 1640 | + | "bin": { | |
| 1641 | + | "vitest": "vitest.mjs" | |
| 1642 | + | }, | |
| 1643 | + | "engines": { | |
| 1644 | + | "node": "^20.0.0 || ^22.0.0 || >=24.0.0" | |
| 1645 | + | }, | |
| 1646 | + | "funding": { | |
| 1647 | + | "url": "https://opencollective.com/vitest" | |
| 1648 | + | }, | |
| 1649 | + | "peerDependencies": { | |
| 1650 | + | "@edge-runtime/vm": "*", | |
| 1651 | + | "@opentelemetry/api": "^1.9.0", | |
| 1652 | + | "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", | |
| 1653 | + | "@vitest/browser-playwright": "4.1.10", | |
| 1654 | + | "@vitest/browser-preview": "4.1.10", | |
| 1655 | + | "@vitest/browser-webdriverio": "4.1.10", | |
| 1656 | + | "@vitest/coverage-istanbul": "4.1.10", | |
| 1657 | + | "@vitest/coverage-v8": "4.1.10", | |
| 1658 | + | "@vitest/ui": "4.1.10", | |
| 1659 | + | "happy-dom": "*", | |
| 1660 | + | "jsdom": "*", | |
| 1661 | + | "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" | |
| 1662 | + | }, | |
| 1663 | + | "peerDependenciesMeta": { | |
| 1664 | + | "@edge-runtime/vm": { | |
| 1665 | + | "optional": true | |
| 1666 | + | }, | |
| 1667 | + | "@opentelemetry/api": { | |
| 1668 | + | "optional": true | |
| 1669 | + | }, | |
| 1670 | + | "@types/node": { | |
| 1671 | + | "optional": true | |
| 1672 | + | }, | |
| 1673 | + | "@vitest/browser-playwright": { | |
| 1674 | + | "optional": true | |
| 1675 | + | }, | |
| 1676 | + | "@vitest/browser-preview": { | |
| 1677 | + | "optional": true | |
| 1678 | + | }, | |
| 1679 | + | "@vitest/browser-webdriverio": { | |
| 1680 | + | "optional": true | |
| 1681 | + | }, | |
| 1682 | + | "@vitest/coverage-istanbul": { | |
| 1683 | + | "optional": true | |
| 1684 | + | }, | |
| 1685 | + | "@vitest/coverage-v8": { | |
| 1686 | + | "optional": true | |
| 1687 | + | }, | |
| 1688 | + | "@vitest/ui": { | |
| 1689 | + | "optional": true | |
| 1690 | + | }, | |
| 1691 | + | "happy-dom": { | |
| 1692 | + | "optional": true | |
| 1693 | + | }, | |
| 1694 | + | "jsdom": { | |
| 1695 | + | "optional": true | |
| 1696 | + | }, | |
| 1697 | + | "vite": { | |
| 1698 | + | "optional": false | |
| 1699 | + | } | |
| 1700 | + | } | |
| 1701 | + | }, | |
| 1702 | + | "node_modules/why-is-node-running": { | |
| 1703 | + | "version": "2.3.0", | |
| 1704 | + | "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", | |
| 1705 | + | "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", | |
| 1706 | + | "dev": true, | |
| 1707 | + | "license": "MIT", | |
| 1708 | + | "dependencies": { | |
| 1709 | + | "siginfo": "^2.0.0", | |
| 1710 | + | "stackback": "0.0.2" | |
| 1711 | + | }, | |
| 1712 | + | "bin": { | |
| 1713 | + | "why-is-node-running": "cli.js" | |
| 1714 | + | }, | |
| 1715 | + | "engines": { | |
| 1716 | + | "node": ">=8" | |
| 1717 | + | } | |
| 1718 | + | } | |
| 1719 | + | } | |
| 1720 | + | } |
addedpackage.json+27 −0
| 1 | + | { | |
| 2 | + | "name": "mahjong", | |
| 3 | + | "private": true, | |
| 4 | + | "version": "0.0.0", | |
| 5 | + | "type": "module", | |
| 6 | + | "scripts": { | |
| 7 | + | "dev": "vite", | |
| 8 | + | "build": "tsc -b && vite build", | |
| 9 | + | "lint": "oxlint", | |
| 10 | + | "preview": "vite preview", | |
| 11 | + | "test": "vitest run" | |
| 12 | + | }, | |
| 13 | + | "dependencies": { | |
| 14 | + | "react": "^19.2.8", | |
| 15 | + | "react-dom": "^19.2.8" | |
| 16 | + | }, | |
| 17 | + | "devDependencies": { | |
| 18 | + | "@types/node": "^24.13.3", | |
| 19 | + | "@types/react": "^19.2.17", | |
| 20 | + | "@types/react-dom": "^19.2.3", | |
| 21 | + | "@vitejs/plugin-react": "^6.0.4", | |
| 22 | + | "oxlint": "^1.75.0", | |
| 23 | + | "typescript": "~6.0.2", | |
| 24 | + | "vite": "^8.2.0", | |
| 25 | + | "vitest": "^4.1.10" | |
| 26 | + | } | |
| 27 | + | } |
addedpublic/favicon.svg+1 −0
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg> |
addedpublic/icons.svg+24 −0
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg"> | |
| 2 | + | <symbol id="bluesky-icon" viewBox="0 0 16 17"> | |
| 3 | + | <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g> | |
| 4 | + | <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs> | |
| 5 | + | </symbol> | |
| 6 | + | <symbol id="discord-icon" viewBox="0 0 20 19"> | |
| 7 | + | <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/> | |
| 8 | + | </symbol> | |
| 9 | + | <symbol id="documentation-icon" viewBox="0 0 21 20"> | |
| 10 | + | <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/> | |
| 11 | + | <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/> | |
| 12 | + | <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/> | |
| 13 | + | </symbol> | |
| 14 | + | <symbol id="github-icon" viewBox="0 0 19 19"> | |
| 15 | + | <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/> | |
| 16 | + | </symbol> | |
| 17 | + | <symbol id="social-icon" viewBox="0 0 20 20"> | |
| 18 | + | <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/> | |
| 19 | + | <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/> | |
| 20 | + | </symbol> | |
| 21 | + | <symbol id="x-icon" viewBox="0 0 19 19"> | |
| 22 | + | <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/> | |
| 23 | + | </symbol> | |
| 24 | + | </svg> |
addedpublic/tiles/back.pngbinary
Binary file
addedpublic/tiles/tiles.svg+513 −0
| 1 | + | <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + | <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> | |
| 3 | + | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="5504" height="352" viewBox="0 0 2752 176" version="1.1"> | |
| 4 | + | <defs> | |
| 5 | + | <g id="bt1"> | |
| 6 | + | <path d="M9,0C8.47,0,7.96,0.21,7.59,0.59l-7,7C0.21,7.96,0,8.47,0,9v77c0,1.1,0.9,2,2,2h53c0.53,0,1.04-0.21,1.41-0.59l7-7C63.79,80.04,64,79.53,64,79V2c0-1.1-0.9-2-2-2H9z"/> | |
| 7 | + | <path fill="#9F9F9F" d="M11.75,78.25l-9,9h9H55c0.33,0,0.65-0.13,0.88-0.37l7-7c0.23-0.23,0.37-1.3,0.37-1.63H11.75z"/> | |
| 8 | + | <path fill="#D7D7D7" d="M8.12,1.12l-7,7C0.88,8.35,0.75,8.67,0.75,9v66.25v1v9l2,2l7-7v-4v-1V0.75C9.42,0.75,8.35,0.88,8.12,1.12z"/> | |
| 9 | + | <path fill="#BFBFBF" d="M1.61,86.89l10.14-10.14v-0.5L1.34,86.66C1.42,86.75,1.51,86.82,1.61,86.89z"/> | |
| 10 | + | <path fill="#B7B7B7" d="M1.93,87.07l9.82-9.82v-0.5L1.61,86.89C1.71,86.96,1.82,87.02,1.93,87.07z"/> | |
| 11 | + | <path fill="#AFAFAF" d="M2.3,87.2l9.45-9.45v-0.5l-9.82,9.82C2.05,87.12,2.17,87.17,2.3,87.2z"/> | |
| 12 | + | <path fill="#A7A7A7" d="M11.75,77.75L2.3,87.2c0.14,0.03,0.29,0.05,0.45,0.05l9-9V77.75z"/> | |
| 13 | + | <path fill="#C7C7C7" d="M1.34,86.66l10.41-10.41h-0.5L1.11,86.39C1.18,86.49,1.25,86.58,1.34,86.66z"/> | |
| 14 | + | <path fill="#CDCDCD" d="M1.11,86.39l10.14-10.14h-0.5l-9.82,9.82C0.98,86.18,1.04,86.29,1.11,86.39z"/> | |
| 15 | + | <path fill="#D2D2D2" d="M0.93,86.07l9.82-9.82h-0.5L0.8,85.7C0.83,85.83,0.88,85.95,0.93,86.07z"/> | |
| 16 | + | <path fill="#D7D7D7" d="M10.25,76.25h-0.5l-9,9c0,0.15,0.02,0.3,0.05,0.45L10.25,76.25z"/> | |
| 17 | + | <path fill="#F6F6F6" d="M63.25,78.25V2.75c0-1.1-0.9-2-2-2h-50.5c-1.1,0-2,0.9-2,2v75.5c0,1.1,0.9,2,2,2h50.5C62.35,80.25,63.25,79.35,63.25,78.25z"/> | |
| 18 | + | <path fill="#FFFFFF" d="M8.75,78.25V2.75c0-1.1,0.9-2,2-2h-1c-1.1,0-2,0.9-2,2v75.5c0,1.1,0.9,2,2,2h1C9.65,80.25,8.75,79.35,8.75,78.25z"/> | |
| 19 | + | </g> | |
| 20 | + | <g id="bt2"> | |
| 21 | + | <path fill="#0000FF" d="M9,0C8.47,0,7.96,0.21,7.59,0.59l-7,7C0.21,7.96,0,8.47,0,9v77c0,1.1,0.9,2,2,2h53c0.53,0,1.04-0.21,1.41-0.59l7-7C63.79,80.04,64,79.53,64,79V2c0-1.1-0.9-2-2-2H9z"/> | |
| 22 | + | <path fill="#8F8FFF" d="M11.75,78.25l-9,9h9H55c0.33,0,0.65-0.13,0.88-0.37l7-7c0.23-0.23,0.37-1.3,0.37-1.63H11.75z"/> | |
| 23 | + | <path fill="#C7C7FF" d="M8.12,1.12l-7,7C0.88,8.35,0.75,8.67,0.75,9v66.25v1v9l2,2l7-7v-4v-1V0.75C9.42,0.75,8.35,0.88,8.12,1.12z"/> | |
| 24 | + | <path fill="#AFAFFF" d="M1.61,86.89l10.14-10.14v-0.5L1.34,86.66C1.42,86.75,1.51,86.82,1.61,86.89z"/> | |
| 25 | + | <path fill="#A7A7FF" d="M1.93,87.07l9.82-9.82v-0.5L1.61,86.89C1.71,86.96,1.82,87.02,1.93,87.07z"/> | |
| 26 | + | <path fill="#9F9FFF" d="M2.3,87.2l9.45-9.45v-0.5l-9.82,9.82C2.05,87.12,2.17,87.17,2.3,87.2z"/> | |
| 27 | + | <path fill="#9797FF" d="M11.75,77.75L2.3,87.2c0.14,0.03,0.29,0.05,0.45,0.05l9-9V77.75z"/> | |
| 28 | + | <path fill="#B7B7FF" d="M1.34,86.66l10.41-10.41h-0.5L1.11,86.39C1.18,86.49,1.25,86.58,1.34,86.66z"/> | |
| 29 | + | <path fill="#BDBDFF" d="M1.11,86.39l10.14-10.14h-0.5l-9.82,9.82C0.98,86.18,1.04,86.29,1.11,86.39z"/> | |
| 30 | + | <path fill="#C2C2FF" d="M0.93,86.07l9.82-9.82h-0.5L0.8,85.7C0.83,85.83,0.88,85.95,0.93,86.07z"/> | |
| 31 | + | <path fill="#C7C7FF" d="M10.25,76.25h-0.5l-9,9c0,0.15,0.02,0.3,0.05,0.45L10.25,76.25z"/> | |
| 32 | + | <path fill="#E3E3FF" d="M61.25,0.75H9.75c-1.1,0-2,0.9-2,2v75.5c0,1.1,0.9,2,2,2h51.5c1.1,0,2-0.9,2-2V2.75C63.25,1.65,62.35,0.75,61.25,0.75z"/> | |
| 33 | + | <path fill="#FFFFFF" stroke="#0000FF" stroke-width="3" d="M60,74.19c0,1.55-1.22,2.81-2.72,2.81H13.72c-1.5,0-2.72-1.26-2.72-2.81V6.81C11,5.26,12.22,4,13.72,4h43.56C58.78,4,60,5.26,60,6.81V74.19z"/> | |
| 34 | + | </g> | |
| 35 | + | <path id="bams" fill="#006C00" d="M11,13.5c0-0.92-1.25-1.71-3.06-2.09V4.44C9.75,4.06,11,3.27,11,2.35C11,1.05,8.54,0,5.5,0S0,1.05,0,2.35c0,0.92,1.25,1.71,3.06,2.09v6.97C1.25,11.79,0,12.58,0,13.5c0,0.92,1.25,1.71,3.06,2.09v6.96C1.25,22.94,0,23.73,0,24.65C0,25.95,2.46,27,5.5,27s5.5-1.05,5.5-2.35c0-0.92-1.25-1.71-3.06-2.09v-6.96C9.75,15.21,11,14.42,11,13.5z M5.5,1.47c1.35,0,2.44,0.39,2.44,0.88c0,0.41-0.78,0.75-1.83,0.85v5.94c0,0.46-0.28,0.84-0.61,0.84S4.89,9.6,4.89,9.14V3.2C3.84,3.1,3.06,2.76,3.06,2.35C3.06,1.86,4.15,1.47,5.5,1.47z M5.5,25.53c-1.35,0-2.44-0.39-2.44-0.88c0-0.41,0.78-0.75,1.83-0.85v-5.94c0-0.46,0.28-0.84,0.61-0.84s0.61,0.38,0.61,0.84v5.94c1.05,0.1,1.83,0.44,1.83,0.85C7.94,25.14,6.85,25.53,5.5,25.53z M5.5,14.38c-1.86,0-3.36-0.39-3.36-0.88c0-0.49,1.5-0.88,3.36-0.88s3.36,0.39,3.36,0.88C8.86,13.99,7.36,14.38,5.5,14.38z"/> | |
| 36 | + | <path id="bams2" fill="#9C0000" d="M11,13.5c0-0.92-1.25-1.71-3.06-2.09V4.44C9.75,4.06,11,3.27,11,2.35C11,1.05,8.54,0,5.5,0S0,1.05,0,2.35c0,0.92,1.25,1.71,3.06,2.09v6.97C1.25,11.79,0,12.58,0,13.5c0,0.92,1.25,1.71,3.06,2.09v6.96C1.25,22.94,0,23.73,0,24.65C0,25.95,2.46,27,5.5,27s5.5-1.05,5.5-2.35c0-0.92-1.25-1.71-3.06-2.09v-6.96C9.75,15.21,11,14.42,11,13.5z M5.5,1.47c1.35,0,2.44,0.39,2.44,0.88c0,0.41-0.78,0.75-1.83,0.85v5.94c0,0.46-0.28,0.84-0.61,0.84S4.89,9.6,4.89,9.14V3.2C3.84,3.1,3.06,2.76,3.06,2.35C3.06,1.86,4.15,1.47,5.5,1.47z M5.5,25.53c-1.35,0-2.44-0.39-2.44-0.88c0-0.41,0.78-0.75,1.83-0.85v-5.94c0-0.46,0.28-0.84,0.61-0.84s0.61,0.38,0.61,0.84v5.94c1.05,0.1,1.83,0.44,1.83,0.85C7.94,25.14,6.85,25.53,5.5,25.53z M5.5,14.38c-1.86,0-3.36-0.39-3.36-0.88c0-0.49,1.5-0.88,3.36-0.88s3.36,0.39,3.36,0.88C8.86,13.99,7.36,14.38,5.5,14.38z"/> | |
| 37 | + | <g id="dots"> | |
| 38 | + | <path fill="#10106C" d="M9,1.42c-4.18,0-7.58,3.4-7.58,7.58s3.4,7.58,7.58,7.58c4.18,0,7.58-3.4,7.58-7.58S13.18,1.42,9,1.42zM9,15.11c-1.36,0-2.49-0.97-2.75-2.25c0.78,0.56,1.72,0.89,2.75,0.89c1.03,0,1.97-0.34,2.75-0.89C11.49,14.14,10.36,15.11,9,15.11z M9,2.89c1.36,0,2.49,0.97,2.75,2.25C10.97,4.59,10.03,4.25,9,4.25c-1.03,0-1.97,0.34-2.75,0.89C6.51,3.86,7.64,2.89,9,2.89z M12.75,9c0,0.57-0.14,1.11-0.37,1.59C12.04,9.92,11.51,9.37,10.85,9c0.66-0.37,1.19-0.92,1.53-1.59C12.61,7.89,12.75,8.43,12.75,9z M5.25,9c0-0.57,0.14-1.11,0.37-1.59C5.96,8.08,6.49,8.63,7.15,9c-0.66,0.37-1.19,0.92-1.53,1.59C5.39,10.11,5.25,9.57,5.25,9z M11.59,11.7c-1.02-0.27-1.82-1.1-2.03-2.14c1.04,0.21,1.87,1.01,2.14,2.03C11.66,11.63,11.63,11.66,11.59,11.7z M6.3,11.59c0.27-1.02,1.1-1.82,2.15-2.03c-0.21,1.04-1.01,1.87-2.03,2.14C6.37,11.66,6.34,11.63,6.3,11.59z M6.41,6.3c1.02,0.27,1.82,1.1,2.03,2.15C7.4,8.23,6.57,7.43,6.3,6.41C6.34,6.37,6.37,6.34,6.41,6.3z M11.7,6.41c-0.27,1.02-1.1,1.82-2.14,2.03c0.21-1.05,1.01-1.87,2.03-2.15C11.63,6.34,11.66,6.37,11.7,6.41z M9,7.15C8.63,6.49,8.08,5.96,7.41,5.62C7.89,5.39,8.43,5.25,9,5.25s1.11,0.14,1.59,0.37C9.92,5.96,9.37,6.49,9,7.15z M9,10.85c0.37,0.66,0.92,1.19,1.59,1.53c-0.49,0.23-1.02,0.37-1.59,0.37s-1.11-0.14-1.59-0.37C8.08,12.04,8.63,11.51,9,10.85z M5.14,11.75C3.86,11.49,2.89,10.36,2.89,9s0.97-2.49,2.25-2.75C4.59,7.03,4.25,7.97,4.25,9C4.25,10.03,4.59,10.97,5.14,11.75z M12.86,6.25c1.28,0.26,2.25,1.39,2.25,2.75s-0.97,2.49-2.25,2.75c0.56-0.78,0.89-1.72,0.89-2.75C13.75,7.97,13.41,7.03,12.86,6.25z M15.01,6.33c-0.59-0.6-1.38-0.99-2.25-1.09c-0.11-0.88-0.49-1.66-1.09-2.25C13.15,3.65,14.35,4.85,15.01,6.33z M6.33,2.99c-0.6,0.59-0.98,1.38-1.09,2.25C4.36,5.35,3.58,5.73,2.99,6.33C3.65,4.85,4.85,3.65,6.33,2.99z M2.99,11.67c0.59,0.6,1.38,0.99,2.25,1.09c0.11,0.88,0.49,1.66,1.09,2.25C4.85,14.35,3.65,13.15,2.99,11.67z M11.67,15.01c0.6-0.59,0.99-1.38,1.09-2.25c0.88-0.11,1.66-0.49,2.25-1.09C14.35,13.15,13.15,14.35,11.67,15.01z"/> | |
| 39 | + | <path fill="#10106C" d="M9,0C4.04,0,0,4.04,0,9c0,4.96,4.04,9,9,9c4.96,0,9-4.04,9-9C18,4.04,13.96,0,9,0z M9,17c-4.41,0-8-3.59-8-8s3.59-8,8-8s8,3.59,8,8S13.41,17,9,17z"/> | |
| 40 | + | </g> | |
| 41 | + | <g id="dots2"> | |
| 42 | + | <path fill="#9C0000" d="M9,1.42c-4.18,0-7.58,3.4-7.58,7.58s3.4,7.58,7.58,7.58c4.18,0,7.58-3.4,7.58-7.58S13.18,1.42,9,1.42zM9,15.11c-1.36,0-2.49-0.97-2.75-2.25c0.78,0.56,1.72,0.89,2.75,0.89c1.03,0,1.97-0.34,2.75-0.89C11.49,14.14,10.36,15.11,9,15.11z M9,2.89c1.36,0,2.49,0.97,2.75,2.25C10.97,4.59,10.03,4.25,9,4.25c-1.03,0-1.97,0.34-2.75,0.89C6.51,3.86,7.64,2.89,9,2.89z M12.75,9c0,0.57-0.14,1.11-0.37,1.59C12.04,9.92,11.51,9.37,10.85,9c0.66-0.37,1.19-0.92,1.53-1.59C12.61,7.89,12.75,8.43,12.75,9z M5.25,9c0-0.57,0.14-1.11,0.37-1.59C5.96,8.08,6.49,8.63,7.15,9c-0.66,0.37-1.19,0.92-1.53,1.59C5.39,10.11,5.25,9.57,5.25,9z M11.59,11.7c-1.02-0.27-1.82-1.1-2.03-2.14c1.04,0.21,1.87,1.01,2.14,2.03C11.66,11.63,11.63,11.66,11.59,11.7z M6.3,11.59c0.27-1.02,1.1-1.82,2.15-2.03c-0.21,1.04-1.01,1.87-2.03,2.14C6.37,11.66,6.34,11.63,6.3,11.59z M6.41,6.3c1.02,0.27,1.82,1.1,2.03,2.15C7.4,8.23,6.57,7.43,6.3,6.41C6.34,6.37,6.37,6.34,6.41,6.3z M11.7,6.41c-0.27,1.02-1.1,1.82-2.14,2.03c0.21-1.05,1.01-1.87,2.03-2.15C11.63,6.34,11.66,6.37,11.7,6.41z M9,7.15C8.63,6.49,8.08,5.96,7.41,5.62C7.89,5.39,8.43,5.25,9,5.25s1.11,0.14,1.59,0.37C9.92,5.96,9.37,6.49,9,7.15z M9,10.85c0.37,0.66,0.92,1.19,1.59,1.53c-0.49,0.23-1.02,0.37-1.59,0.37s-1.11-0.14-1.59-0.37C8.08,12.04,8.63,11.51,9,10.85z M5.14,11.75C3.86,11.49,2.89,10.36,2.89,9s0.97-2.49,2.25-2.75C4.59,7.03,4.25,7.97,4.25,9C4.25,10.03,4.59,10.97,5.14,11.75z M12.86,6.25c1.28,0.26,2.25,1.39,2.25,2.75s-0.97,2.49-2.25,2.75c0.56-0.78,0.89-1.72,0.89-2.75C13.75,7.97,13.41,7.03,12.86,6.25z M15.01,6.33c-0.59-0.6-1.38-0.99-2.25-1.09c-0.11-0.88-0.49-1.66-1.09-2.25C13.15,3.65,14.35,4.85,15.01,6.33z M6.33,2.99c-0.6,0.59-0.98,1.38-1.09,2.25C4.36,5.35,3.58,5.73,2.99,6.33C3.65,4.85,4.85,3.65,6.33,2.99z M2.99,11.67c0.59,0.6,1.38,0.99,2.25,1.09c0.11,0.88,0.49,1.66,1.09,2.25C4.85,14.35,3.65,13.15,2.99,11.67z M11.67,15.01c0.6-0.59,0.99-1.38,1.09-2.25c0.88-0.11,1.66-0.49,2.25-1.09C14.35,13.15,13.15,14.35,11.67,15.01z"/> | |
| 43 | + | <path fill="#9C0000" d="M9,0C4.04,0,0,4.04,0,9c0,4.96,4.04,9,9,9c4.96,0,9-4.04,9-9C18,4.04,13.96,0,9,0z M9,17c-4.41,0-8-3.59-8-8s3.59-8,8-8s8,3.59,8,8S13.41,17,9,17z"/> | |
| 44 | + | </g> | |
| 45 | + | <g id="nums"> | |
| 46 | + | <path fill="#BA0000" d="M18.01,0.14c0,0-4.39,3.33-4.78,3.63c-0.32-0.33-3.2-3.27-3.2-3.27l2.22,8.92l0.6-0.03c0.75-4.76,4.35-6.63,5.33-7.05c0.14,0.2,0.26,0.42,0.26,0.72c0,0.09-0.01,0.18-0.02,0.28c-0.21,1.67-2.39,4.64-6.68,7.21l-0.31,0.18l0.81,1.01l0.2-0.06c0.06-0.02,6.59-2.06,10.36-1.88l1.14,0.05l-0.96-0.63c-0.08-0.05-1.95-1.25-3.93-1.03c0.28-0.63,2.26-2.56,4.15-4.16l0.31-0.26L18.19,0L18.01,0.14z"/> | |
| 47 | + | <path fill="#BA0000" d="M33.78,24.64c-0.17-0.17-4.21-4.28-5.63-5.01c-0.53-0.27-1.71-0.18-5.75,0.16c0,0-0.17,0.01-0.43,0.04c1.19-2.79,2.72-5.86,3.57-6.25l0.55-0.25l-2.13-2.09l-0.21,0.03c-5.11,0.87-13.08,2.09-14.11,1.9l-0.35-0.07l-0.12,0.33c-0.2,0.56,0.32,4.93,0.86,7.37C5.45,21.2,0,21.66,0,21.66l0.3,0.56l0.68,1.27l0.32-0.09c1.72-0.51,2.52-0.57,4.6-0.54c0.19,0.49,2.6,6.77,2.6,6.77l0.79-0.2c0,0-0.69-5.69-0.8-6.52c0.78,0.02,7.99,0.22,9.67,0.26c-1.09,0.92-3.56,3.02-3.56,3.02l0.4,0.71c0,0,5.15-1.76,5.41-1.85c0.25,0.1,4.3,1.65,4.3,1.65s-2.13-2.57-2.81-3.38c3.78,0.2,4.75,0.42,5.01,0.74c0.33,0.39,0.5,0.96,0.5,1.6c0,1.16-0.58,2.57-1.8,3.73c-1.82-0.46-5.17-2.34-5.21-2.36l-0.52,0.62L24.96,34l0.24-0.05c3.82-0.78,8.44-8.48,8.63-8.81L34,24.87L33.78,24.64z M21.02,16.65c-1.22,0.16-2.77,0.37-3.21,0.43c0-0.33,0-1.17,0-2c1.62-0.13,3.11-0.25,3.49-0.29C21.25,15.06,21.15,15.77,21.02,16.65z M16.09,15.22c0.02,0.84,0.04,1.75,0.05,2.06c-1.41,0.16-2.6,0.26-3.55,0.3c0.1-0.62,0.19-1.31,0.3-2.1C13.13,15.46,14.51,15.35,16.09,15.22z M12.03,20.65c0.08-0.35,0.17-0.76,0.26-1.24c1.3-0.24,2.61-0.46,3.89-0.63c0.01,0.48,0.03,1.1,0.04,1.52C15.97,20.31,14.26,20.46,12.03,20.65zM17.8,20.17c0-0.48,0-1.18,0-1.6c0.98-0.1,1.36-0.08,1.96-0.02l0.96,0.06c-0.07,0.46-0.14,0.92-0.2,1.35C19.43,20.04,18.19,20.14,17.8,20.17z"/> | |
| 48 | + | </g> | |
| 49 | + | <g id="flower"> | |
| 50 | + | <rect x="1" y="1" fill="#F8F6D8" stroke="#F8F6D8" width="43" height="67"/> | |
| 51 | + | <path fill="#D3BB54" d="M11.27,58.16l-0.71-8.35c1.76-0.67,2.92-1.76,2.83-2.86c-0.09-1.1-1.42-1.91-3.26-2.16l-0.71-8.36c1.76-0.67,2.92-1.76,2.83-2.86c-0.13-1.56-2.69-2.54-5.71-2.19c-3.02,0.35-5.37,1.89-5.23,3.44c0.09,1.1,1.42,1.91,3.25,2.16l0.71,8.36c-1.76,0.67-2.92,1.76-2.83,2.86c0.09,1.1,1.42,1.91,3.25,2.16l0.71,8.36c-1.76,0.67-2.92,1.76-2.83,2.86c0.13,1.55,2.69,2.53,5.71,2.19c3.02-0.35,5.37-1.89,5.23-3.44C14.43,59.22,13.11,58.41,11.27,58.16z M4.34,34.47c-0.05-0.58,1-1.18,2.34-1.34c1.34-0.15,2.47,0.2,2.52,0.78c0.04,0.49-0.7,0.99-1.74,1.23l0.61,7.13c0.05,0.56-0.19,1.04-0.52,1.08c-0.34,0.04-0.65-0.38-0.7-0.94l-0.61-7.13C5.19,35.28,4.38,34.96,4.34,34.47z M4.57,47.95c-0.05-0.58,1.41-1.23,3.25-1.44c1.85-0.21,3.39,0.09,3.43,0.67c0.05,0.58-1.41,1.23-3.25,1.44C6.15,48.84,4.62,48.54,4.57,47.95z M9.15,62c-1.34,0.15-2.47-0.19-2.52-0.78c-0.04-0.49,0.7-0.99,1.74-1.23l-0.61-7.13c-0.05-0.56,0.19-1.04,0.52-1.08c0.33-0.04,0.65,0.38,0.69,0.94l0.61,7.13c1.06,0,1.87,0.32,1.91,0.81C11.54,61.25,10.49,61.85,9.15,62z"/> | |
| 52 | + | <path fill="none" stroke="#7C5A2D" stroke-width="0.25" d="M11.27,58.16l-0.71-8.35c1.76-0.67,2.92-1.76,2.83-2.86c-0.09-1.1-1.42-1.91-3.26-2.16l-0.71-8.36c1.76-0.67,2.92-1.76,2.83-2.86c-0.13-1.56-2.69-2.54-5.71-2.19c-3.02,0.35-5.37,1.89-5.23,3.44c0.09,1.1,1.42,1.91,3.25,2.16l0.71,8.36c-1.76,0.67-2.92,1.76-2.83,2.86c0.09,1.1,1.42,1.91,3.25,2.16l0.71,8.36c-1.76,0.67-2.92,1.76-2.83,2.86c0.13,1.55,2.69,2.53,5.71,2.19c3.02-0.35,5.37-1.89,5.23-3.44C14.43,59.22,13.11,58.41,11.27,58.16z M4.34,34.47c-0.05-0.58,1-1.18,2.34-1.34c1.34-0.15,2.47,0.2,2.52,0.78c0.04,0.49-0.7,0.99-1.74,1.23l0.61,7.13c0.05,0.56-0.19,1.04-0.52,1.08c-0.34,0.04-0.65-0.38-0.7-0.94l-0.61-7.13C5.19,35.28,4.38,34.96,4.34,34.47z M4.57,47.95c-0.05-0.58,1.41-1.23,3.25-1.44c1.85-0.21,3.39,0.09,3.43,0.67c0.05,0.58-1.41,1.23-3.25,1.44C6.15,48.84,4.62,48.54,4.57,47.95z M9.15,62c-1.34,0.15-2.47-0.19-2.52-0.78c-0.04-0.49,0.7-0.99,1.74-1.23l-0.61-7.13c-0.05-0.56,0.19-1.04,0.52-1.08c0.33-0.04,0.65,0.38,0.69,0.94l0.61,7.13c1.06,0,1.87,0.32,1.91,0.81C11.54,61.25,10.49,61.85,9.15,62z"/> | |
| 53 | + | <path fill="#EADDA7" stroke="#7C5A2D" stroke-width="0.25" d="M5.23,67.99c2.49-8.18-3.33-18.78-3.33-18.78s10.4,10.59,7.48,18.78H5.23z"/> | |
| 54 | + | <path fill="#DBC879" d="M19.02,36.17c-2.92-0.98-5.59-0.56-5.97,0.94c-0.27,1.06,0.7,2.38,2.33,3.41l-2.03,8.06c-1.85-0.14-3.28,0.37-3.54,1.43c-0.27,1.06,0.7,2.38,2.33,3.41l-2.03,8.06c-1.84-0.14-3.27,0.37-3.54,1.43c-0.38,1.5,1.68,3.51,4.6,4.5c2.92,0.98,5.59,0.56,5.96-0.94c0.27-1.06-0.7-2.38-2.32-3.41l2.03-8.06c1.85,0.14,3.28-0.37,3.55-1.43c0.27-1.06-0.71-2.38-2.33-3.41l2.03-8.06c1.84,0.14,3.28-0.37,3.54-1.43C23.99,39.16,21.94,37.15,19.02,36.17z M14.41,57.04l-1.73,6.88c0.98,0.45,1.63,1.1,1.51,1.58c-0.14,0.56-1.31,0.66-2.6,0.23c-1.3-0.44-2.23-1.25-2.09-1.81c0.12-0.47,0.97-0.62,2.01-0.39l1.73-6.88c0.13-0.54,0.51-0.88,0.83-0.77C14.39,55.98,14.54,56.5,14.41,57.04z M18.32,52.88c-0.14,0.56-1.7,0.53-3.48-0.07c-1.78-0.6-3.11-1.54-2.97-2.11c0.14-0.56,1.7-0.53,3.48,0.07C17.13,51.37,18.46,52.32,18.32,52.88z M20.68,39.67c-0.12,0.47-0.97,0.62-2.01,0.39l-1.73,6.88c-0.13,0.54-0.51,0.88-0.83,0.77c-0.32-0.11-0.48-0.63-0.34-1.17l1.73-6.88c-0.98-0.45-1.63-1.1-1.51-1.58c0.14-0.56,1.31-0.67,2.6-0.23C19.89,38.3,20.82,39.11,20.68,39.67z"/> | |
| 55 | + | <path fill="none" stroke="#7C5A2D" stroke-width="0.25" d="M19.02,36.17c-2.92-0.98-5.59-0.56-5.97,0.94c-0.27,1.06,0.7,2.38,2.33,3.41l-2.03,8.06c-1.85-0.14-3.28,0.37-3.54,1.43c-0.27,1.06,0.7,2.38,2.33,3.41l-2.03,8.06c-1.84-0.14-3.27,0.37-3.54,1.43c-0.38,1.5,1.68,3.51,4.6,4.5c2.92,0.98,5.59,0.56,5.96-0.94c0.27-1.06-0.7-2.38-2.32-3.41l2.03-8.06c1.85,0.14,3.28-0.37,3.55-1.43c0.27-1.06-0.71-2.38-2.33-3.41l2.03-8.06c1.84,0.14,3.28-0.37,3.54-1.43C23.99,39.16,21.94,37.15,19.02,36.17z M14.41,57.04l-1.73,6.88c0.98,0.45,1.63,1.1,1.51,1.58c-0.14,0.56-1.31,0.66-2.6,0.23c-1.3-0.44-2.23-1.25-2.09-1.81c0.12-0.47,0.97-0.62,2.01-0.39l1.73-6.88c0.13-0.54,0.51-0.88,0.83-0.77C14.39,55.98,14.54,56.5,14.41,57.04z M18.32,52.88c-0.14,0.56-1.7,0.53-3.48-0.07c-1.78-0.6-3.11-1.54-2.97-2.11c0.14-0.56,1.7-0.53,3.48,0.07C17.13,51.37,18.46,52.32,18.32,52.88z M20.68,39.67c-0.12,0.47-0.97,0.62-2.01,0.39l-1.73,6.88c-0.13,0.54-0.51,0.88-0.83,0.77c-0.32-0.11-0.48-0.63-0.34-1.17l1.73-6.88c-0.98-0.45-1.63-1.1-1.51-1.58c0.14-0.56,1.31-0.67,2.6-0.23C19.89,38.3,20.82,39.11,20.68,39.67z"/> | |
| 56 | + | <path fill="#EADA9B" stroke="#7C5A2D" stroke-width="0.25" d="M19.85,67.7c-1.77-5.79,2.35-13.28,2.35-13.28s-7.35,7.49-5.29,13.28H19.85z"/> | |
| 57 | + | <path fill="#BFAA52" stroke="#7C5A2D" d="M30.33,52.2c-10.89,0-12.13-1.84-15.85-5.97c4.7-3.44,10.9-3.9,16.34,4.82L30.33,52.2z"/> | |
| 58 | + | <path fill="#DBC775" d="M27.52,50.68c-6.89,0-7.68-1.16-10.03-3.78c2.98-2.18,6.9-2.47,10.34,3.05L27.52,50.68z"/> | |
| 59 | + | <path fill="#BFAA52" stroke="#7C5A2D" d="M42.27,47.51c-0.36,2.52-6.85,4.67-9.62,5.14c-2.77,0.47,1.52-5.63,6.18-9.5C41.78,42.64,42.5,45.94,42.27,47.51z"/> | |
| 60 | + | <path fill="#DBC775" d="M37.59,49.31c-0.15,1.08-2.95,2.01-4.14,2.21c-1.19,0.2,0.66-2.42,2.66-4.09C37.38,47.21,37.69,48.63,37.59,49.31z"/> | |
| 61 | + | <path fill="#E5D389" stroke="#7C5A2D" stroke-width="0.75" d="M38.25,67.92c-7.82-3.7-5.44-11.92-5.44-18.72l-2.23-0.29c0,0-2.8,17.48,4.83,18.76L38.25,67.92z"/> | |
| 62 | + | <path fill="#BFAA52" stroke="#7C5A2D" d="M43.33,52.91v7.75c0,0-2.47,2.07-4.21,2.07c-1.73,0-6.93-9.55-6.44-10.59C33.19,51.1,42.84,50.85,43.33,52.91z"/> | |
| 63 | + | <path fill="#DBC775" d="M40.06,53.5c1.02,0.72,0,3.43,0,3.43s-1.33,0.91-2.27,0.91c-0.93,0-3.73-4.22-3.47-4.68S39.04,52.79,40.06,53.5z"/> | |
| 64 | + | <path fill="#BFAA52" stroke="#7C5A2D" d="M31.45,52.39c-12.87-1.29-10.89,4.39-14.11,10.07C27.49,65.3,28.23,57.04,31.45,52.39z"/> | |
| 65 | + | <path fill="#E2CF7F" d="M30.26,53.1c-8.21-0.82-6.95,2.8-9,6.42C27.74,61.34,28.21,56.07,30.26,53.1z"/> | |
| 66 | + | <path fill="#BFAA52" stroke="#7C5A2D" d="M32.93,50.42c0,0,3.69-9.06-0.25-11.11c-4.04-2.11-6.64-1.29-7.13,0c-0.5,1.29,3.91,12.41,5.65,12.66C32.93,52.23,32.93,50.42,32.93,50.42z"/> | |
| 67 | + | <path fill="#DBC775" d="M32.14,50.27c0,0,2.05-5.67-0.14-6.95c-2.24-1.32-3.68-0.81-3.96,0c-0.28,0.81,2.17,7.76,3.13,7.92C32.14,51.4,32.14,50.27,32.14,50.27z"/> | |
| 68 | + | <path fill="#EADDA7" stroke="#7C5A2D" stroke-width="0.25" d="M31.45,51.12c0,0-1.48-2.54-1.23-4.87"/> | |
| 69 | + | <path fill="#BFAA52" stroke="#7C5A2D" d="M30.33,52.6c0,0-4.13,5.57-2.01,5.48c2.41-0.11,2.6,0,5.1,0.46c2.57,0.47,0.2-5.55-1.6-6.32L30.33,52.6z"/> | |
| 70 | + | <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M27.27,48.13c0,0,2.02,2.93,3.35,2.93c1.33,0,0.61,2.18,0,1.56C30,51.99,28.15,50.19,27.05,50C26.23,50,26.04,47.66,27.27,48.13z"/> | |
| 71 | + | <path fill="#D3BB54" stroke="#AF9A41" d="M44,12.51c-4.46,0-8.54,2.85-8.54,6.37c0,3.52,4.08,6.37,8.54,6.37V12.51z"/> | |
| 72 | + | <path fill="#D3BB54" stroke="#AF9A41" d="M33.33,7.64c0-3.52-3.62-6.23-8.08-6.23c-4.46,0-8.08,2.71-8.08,6.23s3.62,6.37,8.08,6.37C29.71,14.01,33.33,11.16,33.33,7.64z"/> | |
| 73 | + | <ellipse fill="#D3BB54" stroke="#AF9A41" cx="29.56" cy="17.54" rx="8.08" ry="6.37"/> | |
| 74 | + | <path fill="#DBC879" stroke="#AF9A41" d="M44,16.35V1.42H30.9c-2.93,1.56-3.79,4.49-3.79,7.49c0,4.79,4.93,8.68,11.02,8.68C40.21,17.58,42.34,17.13,44,16.35z"/> | |
| 75 | + | <path fill="#D3BB54" stroke="#AF9A41" d="M31.94,15.47c0,0-0.72,1.82-3.03,1.82"/> | |
| 76 | + | <path fill="#D3BB54" stroke="#AF9A41" d="M27.19,6.62c0,0-1.73,0.57-3.17-1.02"/> | |
| 77 | + | <path fill="#663333" d="M44,0H0v69h45V0H44z M43,2c0,1.91,0,63.09,0,65c-1.86,0-39.14,0-41,0C2,65.09,2,3.91,2,2C3.86,2,41.14,2,43,2z"/> | |
| 78 | + | <path fill="#E2CF7F" d="M31.13,52.9c0,0-1.71,2.31-0.83,2.27c1-0.04,1.08,0,2.11,0.19c1.06,0.2,0.08-2.3-0.66-2.62L31.13,52.9z"/> | |
| 79 | + | </g> | |
| 80 | + | <g id="season"> | |
| 81 | + | <rect x="1" y="1" fill="#E3E4FF" width="43" height="67"/> | |
| 82 | + | <polygon fill="#CAE0FF" stroke="#99B9FF" points="4.69,42.38 9.02,39.76 10.2,44.33 6.26,48.26 8.62,49.89 10.2,48.26 10.98,51.52 14.91,51.19 14.52,47.93 17.27,49.24 18.85,47.28 14.13,44.01 13.34,39.44 19.24,41.4 20.81,45.97 23.56,44.99 22.77,42.7 26.71,44.33 28.28,41.07 23.96,39.44 27.11,38.45 25.92,36.17 20.42,37.8 14.91,35.84 20.03,32.9 25.92,33.88 26.31,31.27 23.17,30.94 26.31,28.98 23.96,26.37 20.81,28 20.81,26.04 18.06,25.71 17.27,30.61 12.55,33.23 11.77,28.66 15.7,24.41 12.95,23.1 11.38,25.06 10.58,21.47 7.05,22.12 7.45,25.39 4.69,24.08 3.12,25.71 7.45,28.66 8.23,33.55 3.12,31.92 1,27.81 1,34.92 1.16,34.86 6.26,36.82 1.55,39.76 1,39.66 1,45.06 1.16,44.99 1,45.91 1,47.31 3.51,47.6"/> | |
| 83 | + | <path fill="#99B9FF" stroke="#678ED3" d="M40.63,26.67c1.1,0,2.17-0.13,3.21-0.35V1.5h-14.3c-2.44,2.67-3.95,6.22-3.95,10.13C25.59,19.94,32.32,26.67,40.63,26.67z"/> | |
| 84 | + | <polygon fill="#C9D3F2" stroke="#9EB3E8" stroke-width="0.5" points="22.61,14.9 22.3,14.59 20.96,16.9 23.27,18.23 23.39,17.81"/> | |
| 85 | + | <polygon fill="#C9D3F2" stroke="#9EB3E8" stroke-width="0.5" points="34.45,28.87 34.03,28.98 35.36,31.29 37.67,29.96 37.35,29.65"/> | |
| 86 | + | <polygon fill="#798CD8" points="43.83,30.1 43.83,29.71 43.59,29.96"/> | |
| 87 | + | <polygon fill="#C9D3F2" stroke="#9EB3E8" stroke-width="0.5" points="23.39,5.45 23.27,5.03 20.96,6.36 22.3,8.67 22.61,8.36"/> | |
| 88 | + | <polygon fill="#99B9FF" stroke="#678ED3" points="23.31,15.6 23.31,7.66 22.61,8.36 22.3,8.67 19.34,11.63 22.3,14.59 22.61,14.9"/> | |
| 89 | + | <polygon fill="#C9D3F2" stroke="#9EB3E8" stroke-width="0.5" points="26.66,23.47 26.23,23.36 26.23,26.02 28.9,26.02 28.78,25.6"/> | |
| 90 | + | <polygon fill="#99B9FF" stroke="#678ED3" points="37.35,29.65 37.67,29.96 40.63,32.92 43.59,29.96 43.83,29.71 43.83,28.95 36.66,28.95"/> | |
| 91 | + | <polygon fill="#99B9FF" stroke="#678ED3" points="35.41,28.61 28.53,24.64 28.78,25.6 28.9,26.02 29.98,30.07 34.03,28.98 34.45,28.87"/> | |
| 92 | + | <polygon fill="#99B9FF" stroke="#678ED3" points="27.61,23.73 23.64,16.85 23.39,17.81 23.27,18.23 22.19,22.27 26.23,23.36 26.66,23.47"/> | |
| 93 | + | <polygon fill="#99B9FF" stroke="#678ED3" points="23.39,5.45 23.64,6.41 26.48,1.5 22.33,1.5 23.27,5.03 "/> | |
| 94 | + | <path fill="#99B9FF" stroke="#678ED3" d="M30.67,46.67c0,0,2.32-3,7-3c5.67,0,5.67,9.33,5.67,9.33H19.67c0,0-0.33-6.67,7.67-6.67c3.67,0,6,4,6,4"/> | |
| 95 | + | <line fill="#99B9FF" stroke="#678ED3" x1="29.5" y1="57.27" x2="24.83" y2="65.36"/> | |
| 96 | + | <line fill="#99B9FF" stroke="#678ED3" x1="34.22" y1="55.68" x2="28.68" y2="65.95"/> | |
| 97 | + | <line fill="#99B9FF" stroke="#678ED3" x1="23.33" y1="61" x2="20.5" y2="65.91"/> | |
| 98 | + | <line fill="#99B9FF" stroke="#678ED3" x1="37.46" y1="54.63" x2="33.25" y2="62.59"/> | |
| 99 | + | <line fill="#99B9FF" stroke="#678ED3" x1="40.46" y1="62.17" x2="38.13" y2="66.21"/> | |
| 100 | + | <line fill="#99B9FF" stroke="#678ED3" x1="27.11" y1="54.52" x2="24.44" y2="59.14"/> | |
| 101 | + | <line fill="#99B9FF" stroke="#678ED3" x1="31.67" y1="54" x2="30.67" y2="55.73"/> | |
| 102 | + | <line fill="#99B9FF" stroke="#678ED3" x1="40.98" y1="55.6" x2="37.81" y2="61.09"/> | |
| 103 | + | <line fill="#99B9FF" stroke="#678ED3" x1="23.84" y1="55.04" x2="22.18" y2="57.92"/> | |
| 104 | + | <line fill="#99B9FF" stroke="#678ED3" x1="21.13" y1="55.63" x2="16.91" y2="63.59"/> | |
| 105 | + | <line fill="#99B9FF" stroke="#678ED3" x1="36" y1="63.6" x2="35" y2="65.33"/> | |
| 106 | + | <path fill="#678ED3" d="M44,0H0v69h45V0H44z M43,2c0,1.91,0,63.09,0,65c-1.86,0-39.14,0-41,0C2,65.09,2,3.91,2,2C3.86,2,41.14,2,43,2z"/> | |
| 107 | + | </g> | |
| 108 | + | </defs> | |
| 109 | + | <use x="0" y="0" xlink:href="#bt1"/> | |
| 110 | + | <use x="64" y="0" xlink:href="#bt1"/> | |
| 111 | + | <use x="128" y="0" xlink:href="#bt1"/> | |
| 112 | + | <use x="192" y="0" xlink:href="#bt1"/> | |
| 113 | + | <use x="256" y="0" xlink:href="#bt1"/> | |
| 114 | + | <use x="320" y="0" xlink:href="#bt1"/> | |
| 115 | + | <use x="384" y="0" xlink:href="#bt1"/> | |
| 116 | + | <use x="448" y="0" xlink:href="#bt1"/> | |
| 117 | + | <use x="512" y="0" xlink:href="#bt1"/> | |
| 118 | + | <use x="576" y="0" xlink:href="#bt1"/> | |
| 119 | + | <use x="640" y="0" xlink:href="#bt1"/> | |
| 120 | + | <use x="704" y="0" xlink:href="#bt1"/> | |
| 121 | + | <use x="768" y="0" xlink:href="#bt1"/> | |
| 122 | + | <use x="832" y="0" xlink:href="#bt1"/> | |
| 123 | + | <use x="896" y="0" xlink:href="#bt1"/> | |
| 124 | + | <use x="960" y="0" xlink:href="#bt1"/> | |
| 125 | + | <use x="1024" y="0" xlink:href="#bt1"/> | |
| 126 | + | <use x="1088" y="0" xlink:href="#bt1"/> | |
| 127 | + | <use x="1152" y="0" xlink:href="#bt1"/> | |
| 128 | + | <use x="1216" y="0" xlink:href="#bt1"/> | |
| 129 | + | <use x="1280" y="0" xlink:href="#bt1"/> | |
| 130 | + | <use x="1344" y="0" xlink:href="#bt1"/> | |
| 131 | + | <use x="1408" y="0" xlink:href="#bt1"/> | |
| 132 | + | <use x="1472" y="0" xlink:href="#bt1"/> | |
| 133 | + | <use x="1536" y="0" xlink:href="#bt1"/> | |
| 134 | + | <use x="1600" y="0" xlink:href="#bt1"/> | |
| 135 | + | <use x="1664" y="0" xlink:href="#bt1"/> | |
| 136 | + | <use x="1728" y="0" xlink:href="#bt1"/> | |
| 137 | + | <use x="1792" y="0" xlink:href="#bt1"/> | |
| 138 | + | <use x="1856" y="0" xlink:href="#bt1"/> | |
| 139 | + | <use x="1920" y="0" xlink:href="#bt1"/> | |
| 140 | + | <use x="1984" y="0" xlink:href="#bt1"/> | |
| 141 | + | <use x="2048" y="0" xlink:href="#bt1"/> | |
| 142 | + | <use x="2112" y="0" xlink:href="#bt1"/> | |
| 143 | + | <use x="2176" y="0" xlink:href="#bt1"/> | |
| 144 | + | <use x="2240" y="0" xlink:href="#bt1"/> | |
| 145 | + | <use x="2304" y="0" xlink:href="#bt1"/> | |
| 146 | + | <use x="2368" y="0" xlink:href="#bt1"/> | |
| 147 | + | <use x="2432" y="0" xlink:href="#bt1"/> | |
| 148 | + | <use x="2496" y="0" xlink:href="#bt1"/> | |
| 149 | + | <use x="2560" y="0" xlink:href="#bt1"/> | |
| 150 | + | <use x="2624" y="0" xlink:href="#bt1"/> | |
| 151 | + | <use x="2688" y="0" xlink:href="#bt1"/> | |
| 152 | + | <use x="0" y="88" xlink:href="#bt2"/> | |
| 153 | + | <use x="64" y="88" xlink:href="#bt2"/> | |
| 154 | + | <use x="128" y="88" xlink:href="#bt2"/> | |
| 155 | + | <use x="192" y="88" xlink:href="#bt2"/> | |
| 156 | + | <use x="256" y="88" xlink:href="#bt2"/> | |
| 157 | + | <use x="320" y="88" xlink:href="#bt2"/> | |
| 158 | + | <use x="384" y="88" xlink:href="#bt2"/> | |
| 159 | + | <use x="448" y="88" xlink:href="#bt2"/> | |
| 160 | + | <use x="512" y="88" xlink:href="#bt2"/> | |
| 161 | + | <use x="576" y="88" xlink:href="#bt2"/> | |
| 162 | + | <use x="640" y="88" xlink:href="#bt2"/> | |
| 163 | + | <use x="704" y="88" xlink:href="#bt2"/> | |
| 164 | + | <use x="768" y="88" xlink:href="#bt2"/> | |
| 165 | + | <use x="832" y="88" xlink:href="#bt2"/> | |
| 166 | + | <use x="896" y="88" xlink:href="#bt2"/> | |
| 167 | + | <use x="960" y="88" xlink:href="#bt2"/> | |
| 168 | + | <use x="1024" y="88" xlink:href="#bt2"/> | |
| 169 | + | <use x="1088" y="88" xlink:href="#bt2"/> | |
| 170 | + | <use x="1152" y="88" xlink:href="#bt2"/> | |
| 171 | + | <use x="1216" y="88" xlink:href="#bt2"/> | |
| 172 | + | <use x="1280" y="88" xlink:href="#bt2"/> | |
| 173 | + | <use x="1344" y="88" xlink:href="#bt2"/> | |
| 174 | + | <use x="1408" y="88" xlink:href="#bt2"/> | |
| 175 | + | <use x="1472" y="88" xlink:href="#bt2"/> | |
| 176 | + | <use x="1536" y="88" xlink:href="#bt2"/> | |
| 177 | + | <use x="1600" y="88" xlink:href="#bt2"/> | |
| 178 | + | <use x="1664" y="88" xlink:href="#bt2"/> | |
| 179 | + | <use x="1728" y="88" xlink:href="#bt2"/> | |
| 180 | + | <use x="1792" y="88" xlink:href="#bt2"/> | |
| 181 | + | <use x="1856" y="88" xlink:href="#bt2"/> | |
| 182 | + | <use x="1920" y="88" xlink:href="#bt2"/> | |
| 183 | + | <use x="1984" y="88" xlink:href="#bt2"/> | |
| 184 | + | <use x="2048" y="88" xlink:href="#bt2"/> | |
| 185 | + | <use x="2112" y="88" xlink:href="#bt2"/> | |
| 186 | + | <use x="2176" y="88" xlink:href="#bt2"/> | |
| 187 | + | <use x="2240" y="88" xlink:href="#bt2"/> | |
| 188 | + | <use x="2304" y="88" xlink:href="#bt2"/> | |
| 189 | + | <use x="2368" y="88" xlink:href="#bt2"/> | |
| 190 | + | <use x="2432" y="88" xlink:href="#bt2"/> | |
| 191 | + | <use x="2496" y="88" xlink:href="#bt2"/> | |
| 192 | + | <use x="2560" y="88" xlink:href="#bt2"/> | |
| 193 | + | <use x="2624" y="88" xlink:href="#bt2"/> | |
| 194 | + | <use x="2688" y="88" xlink:href="#bt2"/> | |
| 195 | + | <use x="979" y="41" xlink:href="#nums"/> | |
| 196 | + | <use x="1043" y="41" xlink:href="#nums"/> | |
| 197 | + | <use x="1107" y="41" xlink:href="#nums"/> | |
| 198 | + | <use x="1171" y="41" xlink:href="#nums"/> | |
| 199 | + | <use x="1235" y="41" xlink:href="#nums"/> | |
| 200 | + | <use x="1299" y="41" xlink:href="#nums"/> | |
| 201 | + | <use x="1363" y="41" xlink:href="#nums"/> | |
| 202 | + | <use x="1427" y="41" xlink:href="#nums"/> | |
| 203 | + | <use x="1491" y="41" xlink:href="#nums"/> | |
| 204 | + | <use x="979" y="129" xlink:href="#nums"/> | |
| 205 | + | <use x="1043" y="129" xlink:href="#nums"/> | |
| 206 | + | <use x="1107" y="129" xlink:href="#nums"/> | |
| 207 | + | <use x="1171" y="129" xlink:href="#nums"/> | |
| 208 | + | <use x="1235" y="129" xlink:href="#nums"/> | |
| 209 | + | <use x="1299" y="129" xlink:href="#nums"/> | |
| 210 | + | <use x="1363" y="129" xlink:href="#nums"/> | |
| 211 | + | <use x="1427" y="129" xlink:href="#nums"/> | |
| 212 | + | <use x="1491" y="129" xlink:href="#nums"/> | |
| 213 | + | <use x="2125" y="6" xlink:href="#season"/> | |
| 214 | + | <use x="2189" y="6" xlink:href="#season"/> | |
| 215 | + | <use x="2253" y="6" xlink:href="#season"/> | |
| 216 | + | <use x="2317" y="6" xlink:href="#season"/> | |
| 217 | + | <use x="2125" y="94" xlink:href="#season"/> | |
| 218 | + | <use x="2189" y="94" xlink:href="#season"/> | |
| 219 | + | <use x="2253" y="94" xlink:href="#season"/> | |
| 220 | + | <use x="2317" y="94" xlink:href="#season"/> | |
| 221 | + | <use x="2445" y="6" xlink:href="#flower"/> | |
| 222 | + | <use x="2509" y="6" xlink:href="#flower"/> | |
| 223 | + | <use x="2573" y="6" xlink:href="#flower"/> | |
| 224 | + | <use x="2637" y="6" xlink:href="#flower"/> | |
| 225 | + | <use x="2445" y="94" xlink:href="#flower"/> | |
| 226 | + | <use x="2509" y="94" xlink:href="#flower"/> | |
| 227 | + | <use x="2573" y="94" xlink:href="#flower"/> | |
| 228 | + | <use x="2637" y="94" xlink:href="#flower"/> | |
| 229 | + | <use transform="translate(88.5,16.5) scale(1.22)" xlink:href="#dots"/> | |
| 230 | + | <use transform="translate(88.5,42.5) scale(1.22)" xlink:href="#dots"/> | |
| 231 | + | <use transform="translate(88.5,104.5) scale(1.22)" xlink:href="#dots"/> | |
| 232 | + | <use transform="translate(88.5,130.5) scale(1.22)" xlink:href="#dots"/> | |
| 233 | + | <use transform="translate(144.5,12.5)" xlink:href="#dots"/> | |
| 234 | + | <use transform="translate(144.5,100.5)" xlink:href="#dots"/> | |
| 235 | + | <use transform="translate(154.5,31.5)" xlink:href="#dots2"/> | |
| 236 | + | <use transform="translate(154.5,119.5)" xlink:href="#dots2"/> | |
| 237 | + | <use transform="translate(164.5,50.5)" xlink:href="#dots"/> | |
| 238 | + | <use transform="translate(164.5,138.5)" xlink:href="#dots"/> | |
| 239 | + | <use transform="translate(209,15) scale(.94)" xlink:href="#dots"/> | |
| 240 | + | <use transform="translate(209,49) scale(.94)" xlink:href="#dots"/> | |
| 241 | + | <use transform="translate(209,103) scale(.94)" xlink:href="#dots"/> | |
| 242 | + | <use transform="translate(209,137) scale(.94)" xlink:href="#dots"/> | |
| 243 | + | <use transform="translate(229,15) scale(.94)" xlink:href="#dots"/> | |
| 244 | + | <use transform="translate(229,49) scale(.94)" xlink:href="#dots"/> | |
| 245 | + | <use transform="translate(229,103) scale(.94)" xlink:href="#dots"/> | |
| 246 | + | <use transform="translate(229,137) scale(.94)" xlink:href="#dots"/> | |
| 247 | + | <use transform="translate(273.5,13.5) scale(.88)" xlink:href="#dots"/> | |
| 248 | + | <use transform="translate(273.5,51.5) scale(.88)" xlink:href="#dots"/> | |
| 249 | + | <use transform="translate(273.5,101.5) scale(.88)" xlink:href="#dots"/> | |
| 250 | + | <use transform="translate(273.5,139.5) scale(.88)" xlink:href="#dots"/> | |
| 251 | + | <use transform="translate(283.5,32.5) scale(.88)" xlink:href="#dots2"/> | |
| 252 | + | <use transform="translate(283.5,120.5) scale(.88)" xlink:href="#dots2"/> | |
| 253 | + | <use transform="translate(293.5,13.5) scale(.88)" xlink:href="#dots"/> | |
| 254 | + | <use transform="translate(293.5,51.5) scale(.88)" xlink:href="#dots"/> | |
| 255 | + | <use transform="translate(293.5,101.5) scale(.88)" xlink:href="#dots"/> | |
| 256 | + | <use transform="translate(293.5,139.5) scale(.88)" xlink:href="#dots"/> | |
| 257 | + | <use transform="translate(340,14) scale(.72)" xlink:href="#dots"/> | |
| 258 | + | <use transform="translate(340,37) scale(.72)" xlink:href="#dots2"/> | |
| 259 | + | <use transform="translate(340,54) scale(.72)" xlink:href="#dots2"/> | |
| 260 | + | <use transform="translate(340,102) scale(.72)" xlink:href="#dots"/> | |
| 261 | + | <use transform="translate(340,125) scale(.72)" xlink:href="#dots2"/> | |
| 262 | + | <use transform="translate(340,142) scale(.72)" xlink:href="#dots2"/> | |
| 263 | + | <use transform="translate(358,14) scale(.72)" xlink:href="#dots"/> | |
| 264 | + | <use transform="translate(358,37) scale(.72)" xlink:href="#dots2"/> | |
| 265 | + | <use transform="translate(358,54) scale(.72)" xlink:href="#dots2"/> | |
| 266 | + | <use transform="translate(358,102) scale(.72)" xlink:href="#dots"/> | |
| 267 | + | <use transform="translate(358,125) scale(.72)" xlink:href="#dots2"/> | |
| 268 | + | <use transform="translate(358,142) scale(.72)" xlink:href="#dots2"/> | |
| 269 | + | <use transform="translate(399,10) scale(.72)" xlink:href="#dots"/> | |
| 270 | + | <use transform="translate(404,41) scale(.72)" xlink:href="#dots2"/> | |
| 271 | + | <use transform="translate(404,58) scale(.72)" xlink:href="#dots2"/> | |
| 272 | + | <use transform="translate(413,17) scale(.72)" xlink:href="#dots"/> | |
| 273 | + | <use transform="translate(427,24) scale(.72)" xlink:href="#dots"/> | |
| 274 | + | <use transform="translate(404,129) scale(.72)" xlink:href="#dots2"/> | |
| 275 | + | <use transform="translate(404,146) scale(.72)" xlink:href="#dots2"/> | |
| 276 | + | <use transform="translate(399,98) scale(.72)" xlink:href="#dots"/> | |
| 277 | + | <use transform="translate(422,41) scale(.72)" xlink:href="#dots2"/> | |
| 278 | + | <use transform="translate(422,58) scale(.72)" xlink:href="#dots2"/> | |
| 279 | + | <use transform="translate(413,105) scale(.72)" xlink:href="#dots"/> | |
| 280 | + | <use transform="translate(427,112) scale(.72)" xlink:href="#dots"/> | |
| 281 | + | <use transform="translate(422,129) scale(.72)" xlink:href="#dots2"/> | |
| 282 | + | <use transform="translate(422,146) scale(.72)" xlink:href="#dots2"/> | |
| 283 | + | <use transform="translate(469,10) scale(.72)" xlink:href="#dots"/> | |
| 284 | + | <use transform="translate(469,26) scale(.72)" xlink:href="#dots"/> | |
| 285 | + | <use transform="translate(469,42) scale(.72)" xlink:href="#dots"/> | |
| 286 | + | <use transform="translate(469,58) scale(.72)" xlink:href="#dots"/> | |
| 287 | + | <use transform="translate(469,98) scale(.72)" xlink:href="#dots"/> | |
| 288 | + | <use transform="translate(469,114) scale(.72)" xlink:href="#dots"/> | |
| 289 | + | <use transform="translate(469,130) scale(.72)" xlink:href="#dots"/> | |
| 290 | + | <use transform="translate(469,146) scale(.72)" xlink:href="#dots"/> | |
| 291 | + | <use transform="translate(485,10) scale(.72)" xlink:href="#dots"/> | |
| 292 | + | <use transform="translate(485,26) scale(.72)" xlink:href="#dots"/> | |
| 293 | + | <use transform="translate(485,42) scale(.72)" xlink:href="#dots"/> | |
| 294 | + | <use transform="translate(485,58) scale(.72)" xlink:href="#dots"/> | |
| 295 | + | <use transform="translate(485,98) scale(.72)" xlink:href="#dots"/> | |
| 296 | + | <use transform="translate(485,114) scale(.72)" xlink:href="#dots"/> | |
| 297 | + | <use transform="translate(485,130) scale(.72)" xlink:href="#dots"/> | |
| 298 | + | <use transform="translate(485,146) scale(.72)" xlink:href="#dots"/> | |
| 299 | + | <use transform="translate(526,14) scale(.72)" xlink:href="#dots"/> | |
| 300 | + | <use transform="translate(526,34) scale(.72)" xlink:href="#dots2"/> | |
| 301 | + | <use transform="translate(526,54) scale(.72)" xlink:href="#dots"/> | |
| 302 | + | <use transform="translate(526,102) scale(.72)" xlink:href="#dots"/> | |
| 303 | + | <use transform="translate(526,122) scale(.72)" xlink:href="#dots2"/> | |
| 304 | + | <use transform="translate(526,142) scale(.72)" xlink:href="#dots"/> | |
| 305 | + | <use transform="translate(541,14) scale(.72)" xlink:href="#dots"/> | |
| 306 | + | <use transform="translate(541,34) scale(.72)" xlink:href="#dots2"/> | |
| 307 | + | <use transform="translate(541,54) scale(.72)" xlink:href="#dots"/> | |
| 308 | + | <use transform="translate(541,102) scale(.72)" xlink:href="#dots"/> | |
| 309 | + | <use transform="translate(541,122) scale(.72)" xlink:href="#dots2"/> | |
| 310 | + | <use transform="translate(541,142) scale(.72)" xlink:href="#dots"/> | |
| 311 | + | <use transform="translate(556,14) scale(.72)" xlink:href="#dots"/> | |
| 312 | + | <use transform="translate(556,34) scale(.72)" xlink:href="#dots2"/> | |
| 313 | + | <use transform="translate(556,54) scale(.72)" xlink:href="#dots"/> | |
| 314 | + | <use transform="translate(556,102) scale(.72)" xlink:href="#dots"/> | |
| 315 | + | <use transform="translate(556,122) scale(.72)" xlink:href="#dots2"/> | |
| 316 | + | <use transform="translate(556,142) scale(.72)" xlink:href="#dots"/> | |
| 317 | + | <use transform="translate(1565,25) scale(1.18,1.15)" xlink:href="#bams"/> | |
| 318 | + | <use transform="translate(1565,113) scale(1.18,1.15)" xlink:href="#bams"/> | |
| 319 | + | <use transform="translate(1630,9.5)" xlink:href="#bams"/> | |
| 320 | + | <use transform="translate(1630,44.5)" xlink:href="#bams"/> | |
| 321 | + | <use transform="translate(1630,97.5)" xlink:href="#bams"/> | |
| 322 | + | <use transform="translate(1630,132.5)" xlink:href="#bams"/> | |
| 323 | + | <use transform="translate(1682,44.5)" xlink:href="#bams"/> | |
| 324 | + | <use transform="translate(1682,132.5)" xlink:href="#bams"/> | |
| 325 | + | <use transform="translate(1694,9.5)" xlink:href="#bams"/> | |
| 326 | + | <use transform="translate(1694,97.5)" xlink:href="#bams"/> | |
| 327 | + | <use transform="translate(1705,44.5)" xlink:href="#bams"/> | |
| 328 | + | <use transform="translate(1705,132.5)" xlink:href="#bams"/> | |
| 329 | + | <use transform="translate(1746,9.5)" xlink:href="#bams"/> | |
| 330 | + | <use transform="translate(1746,44.5)" xlink:href="#bams"/> | |
| 331 | + | <use transform="translate(1746,97.5)" xlink:href="#bams"/> | |
| 332 | + | <use transform="translate(1746,132.5)" xlink:href="#bams"/> | |
| 333 | + | <use transform="translate(1770,9.5)" xlink:href="#bams"/> | |
| 334 | + | <use transform="translate(1770,44.5)" xlink:href="#bams"/> | |
| 335 | + | <use transform="translate(1770,97.5)" xlink:href="#bams"/> | |
| 336 | + | <use transform="translate(1770,132.5)" xlink:href="#bams"/> | |
| 337 | + | <use transform="translate(1810,10.5) scale(.91,.925)" xlink:href="#bams"/> | |
| 338 | + | <use transform="translate(1810,45.5) scale(.91,.925)" xlink:href="#bams"/> | |
| 339 | + | <use transform="translate(1810,98.5) scale(.91,.925)" xlink:href="#bams"/> | |
| 340 | + | <use transform="translate(1810,133.5) scale(.91,.925)" xlink:href="#bams"/> | |
| 341 | + | <use transform="translate(1822.5,116) scale(.91,.925)" xlink:href="#bams2"/> | |
| 342 | + | <use transform="translate(1822.5,28) scale(.91,.925)" xlink:href="#bams2"/> | |
| 343 | + | <use transform="translate(1835,10.5) scale(.91,.925)" xlink:href="#bams"/> | |
| 344 | + | <use transform="translate(1835,45.5) scale(.91,.925)" xlink:href="#bams"/> | |
| 345 | + | <use transform="translate(1835,98.5) scale(.91,.925)" xlink:href="#bams"/> | |
| 346 | + | <use transform="translate(1835,133.5) scale(.91,.925)" xlink:href="#bams"/> | |
| 347 | + | <use transform="translate(1875,12.5) scale(.82,.85)" xlink:href="#bams"/> | |
| 348 | + | <use transform="translate(1875,45.5) scale(.82,.85)" xlink:href="#bams"/> | |
| 349 | + | <use transform="translate(1875,100.5) scale(.82,.85)" xlink:href="#bams"/> | |
| 350 | + | <use transform="translate(1875,133.5) scale(.82,.85)" xlink:href="#bams"/> | |
| 351 | + | <use transform="translate(1887,12.5) scale(.82,.85)" xlink:href="#bams"/> | |
| 352 | + | <use transform="translate(1887,45.5) scale(.82,.85)" xlink:href="#bams"/> | |
| 353 | + | <use transform="translate(1887,100.5) scale(.82,.85)" xlink:href="#bams"/> | |
| 354 | + | <use transform="translate(1887,133.5) scale(.82,.85)" xlink:href="#bams"/> | |
| 355 | + | <use transform="translate(1899,12.5) scale(.82,.85)" xlink:href="#bams"/> | |
| 356 | + | <use transform="translate(1899,45.5) scale(.82,.85)" xlink:href="#bams"/> | |
| 357 | + | <use transform="translate(1899,100.5) scale(.82,.85)" xlink:href="#bams"/> | |
| 358 | + | <use transform="translate(1899,133.5) scale(.82,.85)" xlink:href="#bams"/> | |
| 359 | + | <use transform="translate(1939.5,31.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 360 | + | <use transform="translate(1951.5,31.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 361 | + | <use transform="translate(1963.5,31.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 362 | + | <use transform="translate(1939.5,50.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 363 | + | <use transform="translate(1951.5,50.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 364 | + | <use transform="translate(1963.5,50.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 365 | + | <use transform="translate(1939.5,119.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 366 | + | <use transform="translate(1951.5,119.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 367 | + | <use transform="translate(1963.5,119.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 368 | + | <use transform="translate(1939.5,138.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 369 | + | <use transform="translate(1951.5,138.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 370 | + | <use transform="translate(1963.5,138.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 371 | + | <use transform="translate(1951.5,12.5) scale(.73,.66)" xlink:href="#bams2"/> | |
| 372 | + | <use transform="translate(1951.5,100.5) scale(.73,.66)" xlink:href="#bams2"/> | |
| 373 | + | <use transform="translate(2003.5,13.5) scale(.73,.77)" xlink:href="#bams"/> | |
| 374 | + | <use transform="translate(2003.5,46.5) scale(.73,.77)" xlink:href="#bams"/> | |
| 375 | + | <use transform="translate(2003.5,101.5) scale(.73,.77)" xlink:href="#bams"/> | |
| 376 | + | <use transform="translate(2003.5,134.5) scale(.73,.77)" xlink:href="#bams"/> | |
| 377 | + | <use transform="translate(2027.5,13.5) scale(.73,.77)" xlink:href="#bams"/> | |
| 378 | + | <use transform="translate(2027.5,46.5) scale(.73,.77)" xlink:href="#bams"/> | |
| 379 | + | <use transform="translate(2027.5,101.5) scale(.73,.77)" xlink:href="#bams"/> | |
| 380 | + | <use transform="translate(2027.5,134.5) scale(.73,.77)" xlink:href="#bams"/> | |
| 381 | + | <use transform="translate(2067.5,12.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 382 | + | <use transform="translate(2067.5,31.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 383 | + | <use transform="translate(2067.5,50.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 384 | + | <use transform="translate(2067.5,100.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 385 | + | <use transform="translate(2067.5,119.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 386 | + | <use transform="translate(2067.5,138.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 387 | + | <use transform="translate(2079.5,12.5) scale(.73,.66)" xlink:href="#bams2"/> | |
| 388 | + | <use transform="translate(2079.5,31.5) scale(.73,.66)" xlink:href="#bams2"/> | |
| 389 | + | <use transform="translate(2079.5,50.5) scale(.73,.66)" xlink:href="#bams2"/> | |
| 390 | + | <use transform="translate(2079.5,100.5) scale(.73,.66)" xlink:href="#bams2"/> | |
| 391 | + | <use transform="translate(2079.5,119.5) scale(.73,.66)" xlink:href="#bams2"/> | |
| 392 | + | <use transform="translate(2079.5,138.5) scale(.73,.66)" xlink:href="#bams2"/> | |
| 393 | + | <use transform="translate(2091.5,12.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 394 | + | <use transform="translate(2091.5,31.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 395 | + | <use transform="translate(2091.5,50.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 396 | + | <use transform="translate(2091.5,100.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 397 | + | <use transform="translate(2091.5,119.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 398 | + | <use transform="translate(2091.5,138.5) scale(.73,.66)" xlink:href="#bams"/> | |
| 399 | + | <path fill="#006C00" d="M2031.75,29.81c-0.41-0.59-1.51-0.57-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.41-0.58-1.51-0.57-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.58-0.83-2.51-0.47-4.32,0.8s-2.81,2.96-2.23,3.79c0.41,0.59,1.51,0.57,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.41,0.59,1.51,0.57,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.58,0.83,2.51,0.47,4.32-0.8S2032.33,30.64,2031.75,29.81z M2021.19,22.48l-2.65-3.79c-0.67,0.38-1.29,0.49-1.47,0.22c-0.22-0.31,0.26-1.02,1.06-1.58c0.8-0.56,1.63-0.77,1.85-0.46c0.18,0.26-0.13,0.8-0.71,1.3l2.65,3.79c0.21,0.3,0.21,0.65,0.01,0.79C2021.73,22.9,2021.4,22.77,2021.19,22.48z M2021.5,26.4c-0.22-0.31,0.5-1.19,1.61-1.96c1.11-0.78,2.18-1.15,2.4-0.84c0.22,0.31-0.5,1.19-1.61,1.96C2022.79,26.33,2021.71,26.71,2021.5,26.4z M2028.87,32.67c-0.8,0.56-1.63,0.77-1.85,0.46c-0.18-0.26,0.13-0.8,0.71-1.3l-2.65-3.79c-0.21-0.29-0.21-0.65-0.01-0.79c0.2-0.14,0.53-0.02,0.74,0.28l2.65,3.79c0.67-0.38,1.29-0.49,1.47-0.22C2030.15,31.39,2029.67,32.1,2028.87,32.67z"/> | |
| 400 | + | <path fill="#006C00" d="M2021.52,16.4c-1.81-1.27-3.75-1.62-4.32-0.8c-0.41,0.58-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.35-0.53-2.75,0.06c-0.41,0.59-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.34-0.53-2.75,0.06c-0.58,0.83,0.42,2.52,2.23,3.79c1.81,1.27,3.75,1.63,4.32,0.8c0.41-0.59,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.34,0.53,2.75-0.06c0.41-0.58,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.34,0.53,2.75-0.06C2024.33,19.36,2023.33,17.67,2021.52,16.4z M2013.92,28.03l-2.65,3.79c0.58,0.5,0.9,1.04,0.71,1.31c-0.22,0.31-1.04,0.11-1.85-0.46s-1.28-1.27-1.06-1.58c0.18-0.26,0.8-0.15,1.47,0.22l2.65-3.79c0.21-0.3,0.54-0.42,0.74-0.28C2014.13,27.38,2014.13,27.74,2013.92,28.03z M2017.5,26.4c-0.22,0.31-1.29-0.07-2.4-0.84c-1.11-0.77-1.83-1.65-1.61-1.96c0.22-0.31,1.29,0.07,2.4,0.84C2017,25.21,2017.72,26.09,2017.5,26.4z M2021.93,18.91c-0.18,0.26-0.8,0.15-1.47-0.22l-2.65,3.79c-0.21,0.3-0.54,0.42-0.74,0.28c-0.2-0.14-0.2-0.49,0.01-0.79l2.65-3.79c-0.58-0.5-0.9-1.04-0.71-1.3c0.22-0.31,1.04-0.11,1.85,0.46S2022.15,18.6,2021.93,18.91z"/> | |
| 401 | + | <path fill="#006C00" d="M2031.75,117.81c-0.41-0.59-1.51-0.57-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.41-0.58-1.51-0.57-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.58-0.83-2.51-0.47-4.32,0.8c-1.81,1.27-2.81,2.96-2.23,3.79c0.41,0.59,1.51,0.57,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.41,0.58,1.51,0.56,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.58,0.83,2.51,0.47,4.32-0.8C2031.33,120.33,2032.33,118.64,2031.75,117.81z M2021.19,110.48l-2.65-3.79c-0.67,0.38-1.29,0.49-1.47,0.22c-0.22-0.31,0.26-1.02,1.06-1.58s1.63-0.77,1.85-0.46c0.18,0.26-0.13,0.8-0.71,1.3l2.65,3.79c0.21,0.3,0.21,0.65,0.01,0.79C2021.73,110.9,2021.4,110.77,2021.19,110.48z M2021.5,114.4c-0.22-0.31,0.5-1.19,1.61-1.96c1.11-0.78,2.18-1.15,2.4-0.84c0.22,0.31-0.5,1.19-1.61,1.96C2022.79,114.34,2021.71,114.71,2021.5,114.4z M2028.87,120.67c-0.8,0.56-1.63,0.77-1.85,0.46c-0.18-0.26,0.13-0.8,0.71-1.31l-2.65-3.79c-0.21-0.3-0.21-0.65-0.01-0.79c0.2-0.14,0.53-0.02,0.74,0.28l2.65,3.79c0.67-0.38,1.29-0.49,1.47-0.22C2030.15,119.39,2029.67,120.1,2028.87,120.67z"/> | |
| 402 | + | <path fill="#006C00" d="M2021.52,104.4c-1.81-1.27-3.75-1.63-4.32-0.8c-0.41,0.59-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.35-0.53-2.75,0.06c-0.41,0.59-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.34-0.53-2.75,0.06c-0.58,0.83,0.42,2.52,2.23,3.79c1.81,1.27,3.75,1.62,4.32,0.8c0.41-0.58,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.34,0.53,2.75-0.06c0.41-0.59,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.34,0.53,2.75-0.06C2024.33,107.36,2023.33,105.67,2021.52,104.4z M2013.92,116.03l-2.65,3.79c0.58,0.5,0.9,1.04,0.71,1.31c-0.22,0.31-1.04,0.11-1.85-0.46c-0.8-0.56-1.28-1.27-1.06-1.58c0.18-0.26,0.8-0.15,1.47,0.22l2.65-3.79c0.21-0.29,0.54-0.42,0.74-0.28C2014.13,115.38,2014.13,115.74,2013.92,116.03z M2017.5,114.4c-0.22,0.31-1.29-0.07-2.4-0.84c-1.11-0.77-1.83-1.65-1.61-1.96c0.22-0.31,1.29,0.07,2.4,0.84C2017,113.21,2017.72,114.09,2017.5,114.4z M2021.93,106.91c-0.18,0.26-0.8,0.15-1.47-0.22l-2.65,3.79c-0.21,0.3-0.54,0.42-0.74,0.28c-0.2-0.14-0.2-0.49,0.01-0.79l2.65-3.79c-0.58-0.5-0.9-1.04-0.71-1.3c0.22-0.31,1.04-0.11,1.85,0.46C2021.67,105.9,2022.15,106.61,2021.93,106.91z"/> | |
| 403 | + | <path fill="#006C00" d="M2023.75,148.81c-0.41-0.59-1.51-0.57-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.41-0.59-1.51-0.57-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.58-0.83-2.51-0.47-4.32,0.8s-2.81,2.96-2.23,3.79c0.41,0.59,1.51,0.57,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.41,0.58,1.51,0.56,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.58,0.83,2.51,0.47,4.32-0.8C2023.33,151.33,2024.33,149.64,2023.75,148.81z M2013.19,141.48l-2.65-3.79c-0.67,0.38-1.29,0.49-1.47,0.22c-0.22-0.31,0.26-1.02,1.06-1.58s1.63-0.77,1.85-0.46c0.18,0.26-0.13,0.8-0.71,1.3l2.65,3.79c0.21,0.29,0.21,0.65,0.01,0.79C2013.73,141.9,2013.4,141.77,2013.19,141.48z M2013.5,145.4c-0.22-0.31,0.5-1.19,1.61-1.96c1.11-0.78,2.18-1.15,2.4-0.84c0.22,0.31-0.5,1.19-1.61,1.96S2013.71,145.71,2013.5,145.4zM2020.87,151.67c-0.8,0.56-1.63,0.77-1.85,0.46c-0.18-0.26,0.13-0.8,0.71-1.3l-2.65-3.79c-0.21-0.3-0.21-0.65-0.01-0.79c0.2-0.14,0.53-0.02,0.74,0.28l2.65,3.79c0.67-0.38,1.29-0.49,1.47-0.22C2022.15,150.4,2021.67,151.1,2020.87,151.67z"/> | |
| 404 | + | <path fill="#006C00" d="M2029.52,135.4c-1.81-1.27-3.75-1.63-4.32-0.8c-0.41,0.59-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.34-0.53-2.75,0.06c-0.41,0.59-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.34-0.53-2.75,0.06c-0.58,0.83,0.42,2.52,2.23,3.79c1.81,1.27,3.75,1.62,4.32,0.8c0.41-0.58,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.35,0.53,2.75-0.06c0.41-0.58,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.34,0.53,2.75-0.06C2032.33,138.36,2031.33,136.67,2029.52,135.4z M2021.92,147.03l-2.65,3.79c0.58,0.5,0.9,1.04,0.71,1.3c-0.22,0.31-1.04,0.11-1.85-0.46c-0.8-0.56-1.28-1.27-1.06-1.58c0.18-0.26,0.8-0.15,1.47,0.22l2.65-3.79c0.21-0.3,0.54-0.42,0.74-0.28C2022.13,146.39,2022.13,146.74,2021.92,147.03z M2025.5,145.4c-0.22,0.31-1.29-0.07-2.4-0.84c-1.11-0.77-1.83-1.65-1.61-1.96c0.22-0.31,1.29,0.07,2.4,0.84C2025,144.21,2025.72,145.09,2025.5,145.4z M2029.93,137.92c-0.18,0.26-0.8,0.15-1.47-0.22l-2.65,3.79c-0.21,0.29-0.54,0.42-0.74,0.28c-0.2-0.14-0.2-0.49,0.01-0.79l2.65-3.79c-0.58-0.5-0.9-1.04-0.71-1.3c0.22-0.31,1.04-0.11,1.85,0.46C2029.67,136.9,2030.15,137.61,2029.93,137.92z"/> | |
| 405 | + | <path fill="#006C00" d="M2023.75,60.81c-0.41-0.59-1.51-0.57-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.41-0.59-1.51-0.56-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.58-0.83-2.51-0.47-4.32,0.8c-1.81,1.27-2.81,2.96-2.23,3.79c0.41,0.59,1.51,0.57,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.41,0.58,1.51,0.56,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.58,0.83,2.51,0.47,4.32-0.8C2023.33,63.33,2024.33,61.64,2023.75,60.81z M2013.19,53.48l-2.65-3.79c-0.67,0.38-1.29,0.49-1.47,0.22 c-0.22-0.31,0.26-1.02,1.06-1.58s1.63-0.77,1.85-0.46c0.18,0.26-0.13,0.8-0.71,1.3l2.65,3.79c0.21,0.29,0.21,0.65,0.01,0.79C2013.73,53.9,2013.4,53.77,2013.19,53.48z M2013.5,57.4c-0.22-0.31,0.5-1.19,1.61-1.96c1.11-0.78,2.18-1.15,2.4-0.84c0.22,0.31-0.5,1.19-1.61,1.96C2014.79,57.34,2013.71,57.71,2013.5,57.4z M2020.87,63.67c-0.8,0.56-1.63,0.77-1.85,0.46c-0.18-0.26,0.13-0.8,0.71-1.3l-2.65-3.79c-0.21-0.3-0.21-0.65-0.01-0.79c0.2-0.14,0.53-0.02,0.74,0.28l2.65,3.79c0.67-0.38,1.29-0.49,1.47-0.22C2022.15,62.4,2021.67,63.1,2020.87,63.67z"/> | |
| 406 | + | <path fill="#006C00" d="M2029.52,47.4c-1.81-1.27-3.75-1.63-4.32-0.8c-0.41,0.59-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.34-0.53-2.75,0.06c-0.41,0.59-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.34-0.53-2.75,0.06c-0.58,0.83,0.42,2.52,2.23,3.79c1.81,1.27,3.75,1.62,4.32,0.8c0.41-0.58,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.35,0.53,2.75-0.06c0.41-0.59,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.34,0.53,2.75-0.06C2032.33,50.36,2031.33,48.67,2029.52,47.4z M2021.92,59.03l-2.65,3.79c0.58,0.5,0.9,1.04,0.71,1.3c-0.22,0.31-1.04,0.11-1.85-0.46c-0.8-0.56-1.28-1.27-1.06-1.58c0.18-0.26,0.8-0.15,1.47,0.22l2.65-3.79c0.21-0.3,0.54-0.42,0.74-0.28C2022.13,58.39,2022.13,58.74,2021.92,59.03z M2025.5,57.4c-0.22,0.31-1.29-0.07-2.4-0.84c-1.11-0.77-1.83-1.65-1.61-1.96c0.22-0.31,1.29,0.07,2.4,0.84C2025,56.21,2025.72,57.09,2025.5,57.4z M2029.93,49.92c-0.18,0.26-0.8,0.15-1.47-0.22l-2.65,3.79c-0.21,0.29-0.54,0.42-0.74,0.28c-0.2-0.14-0.2-0.49,0.01-0.79l2.65-3.79c-0.58-0.5-0.9-1.04-0.71-1.3c0.22-0.31,1.04-0.11,1.85,0.46C2029.67,48.9,2030.15,49.61,2029.93,49.92z"/> | |
| 407 | + | <path fill="#20208C" d="M1171.2,12.1l6.8,19.4l0.3-0.1c3.6-1.2,13.3-4.1,15.3-2.6c0.2,0.2,0.3,0.4,0.3,0.7l-0.1,0.8l0.6-0.5c6.8-5.3,9.4-14,9.4-14.1l0-0.2l-0.1-0.1c-4.9-7.3-24.9-1.3-25.7-1l-0.2,0.1l0,0.2c0,0-0.2,1.7-0.2,2.4c-0.4-1.5-1.1-4.1-1.1-4.1l-5.6-1.3L1171.2,12.1z M1178.1,17.8c1.2-0.3,2.6-0.7,4-1c0,0,0,0,0,0s1.9,9.3,2,9.9c-0.5,0.1-3,0.8-3.6,1c-0.1-0.4-1.4-5.6-2.5-9.9C1178,17.9,1178.1,17.8,1178.1,17.8z M1189.8,15.5c0,0.1-0.4,9.6-0.4,10.2c-0.4,0.1-2.1,0.4-2.6,0.5c-0.1-0.6-1.8-9.6-1.8-9.9C1186.7,15.9,1188.3,15.7,1189.8,15.5z M1197.7,16.4c-0.2,2.3-1.1,8.1-2.6,9.9c-0.1-0.1,0-0.2-0.1-0.3c-0.5-0.5-1.6-0.7-3.3-0.6c0.3-4.5,1-7.5,1.9-7.9l0.7-0.3c0,0-3.5-1.7-3.8-1.8C1194.3,15,1197.3,15.1,1197.7,16.4z"/> | |
| 408 | + | <path fill="#20208C" d="M1003.8,18.1c-0.3,0.2-0.4,0.5-0.4,0.8c0,0.3,0.1,0.5,0.1,0.7c-2.6,0.2-19.7,1.8-23.9,1.4l-0.4,0l1.7,6l0.3-0.1c3.3-1.1,24.1-2.4,29-1.4c0.8,0.1,1.2,0,1.4-0.4c0.5-1-1.9-3.5-3.4-4.8C1007.3,19.4,1004.8,17.5,1003.8,18.1z"/> | |
| 409 | + | <path fill="#20208C" d="M1067.9,23.9c-0.3,0.2-0.4,0.5-0.4,0.9c0,0.2,0,0.5,0.1,0.7c-2.8,0.3-19.9,1.9-24.1,1.4l-0.5-0.1l1.7,6.2l0.4-0.1c3.3-1.2,24.3-2.4,29.3-1.4c0.8,0.2,1.3,0,1.5-0.4c0.7-1.3-3-4.6-3.4-4.9C1071.5,25.2,1069,23.2,1067.9,23.9z"/> | |
| 410 | + | <path fill="#20208C" d="M1060.1,12.9c-2.6,0.9-6.2,2.2-12.3,2.2l-0.7,0l2.9,4.2l16.7-2.2l0.5-3.1l-0.1-0.1C1064.7,11.2,1063,11.8,1060.1,12.9z"/> | |
| 411 | + | <path fill="#20208C" d="M1131.7,26.3c-0.3,0.2-0.4,0.5-0.4,0.9c0,0.3,0,0.5,0.1,0.7c-2.7,0.3-19.3,1.9-23.4,1.4l-0.5-0.1l1.7,6.2l0.3-0.1c3.2-1.2,23.6-2.4,28.4-1.4c0.8,0.2,1.3,0,1.5-0.4c0.6-1.3-2.9-4.6-3.3-5C1135.1,27.7,1132.7,25.6,1131.7,26.3z"/> | |
| 412 | + | <polygon fill="#20208C" points="1125.3,19 1114,20.4 1115.6,24.7 1127.6,23.3 1125.6,19 "/> | |
| 413 | + | <path fill="#20208C" d="M1122.9,8.6c-3,1.1-7,2.6-14,2.6l-0.6,0l3.2,4.9l18.9-2.6l0.6-3.6l-0.1-0.1C1128.2,6.6,1126.3,7.3,1122.9,8.6z"/> | |
| 414 | + | <path fill="#20208C" d="M1245.8,10.3l-10.3,10.5l0.4,0.5c0,0,5.6-3.2,7-4c-0.6,1.5-4.3,11.6-4.3,11.6l3.3,3.5c0,0,2.4-15.6,2.4-15.9c0.3-0.2,5.1-2.9,5.1-2.9l-3.4-3.7L1245.8,10.3z"/> | |
| 415 | + | <path fill="#20208C" d="M1258.6,10.2h-1.9c0,0-0.6,1.3-0.9,1.9c0,0-0.1-0.1-0.1-0.1l-5.9,3.5l0.2,0.7c0,0,3-0.2,3.9-0.2c-0.4,0.8-2.2,4.5-2.3,4.8c-0.4,0-6.3,0.5-6.3,0.5l-0.1,0.7l4,1.6c0,0,0.7-0.3,1.3-0.5c-0.6,1.2-2.5,5.2-2.7,5.5c-1.3,0.1-2.3,0.1-2.9,0l-0.4,0l0.5,3.7l0.3-0.1c1.4-0.2,12.2-1.6,14.1-1.7c1.8-0.1,7.3,2.1,7.4,2.1l0.7,0.3l-0.2-0.7c-0.1-0.4-1.3-3.9-2.2-4.8l-0.1-0.1l-0.1,0c-0.8-0.2-3.6,0-7.5,0.4c0.9-1,4.3-4.6,4.3-4.6l-3.2-3.4c0,0-2.9,7.8-3,8.2c-0.3,0-2.7,0.3-3.5,0.3c0.4-0.9,2.5-6.5,2.6-6.7c0.3-0.1,3.2-1.2,3.2-1.2l-0.2-0.7c0,0-1.7,0.3-2.5,0.4c0.3-0.9,1.6-4.1,1.7-4.4c0.2,0,1.2-0.1,1.2-0.1s-0.6-0.9-0.7-1.1c0.1-0.3,1.7-4.4,1.7-4.4H1258.6z"/> | |
| 416 | + | <path fill="#20208C" d="M1313.2,9.8c0,0.3,0,0.6,0,1c0,1.6-0.2,3.7-0.7,6.2c-5.9,0.7-11.1,1.5-11.2,1.5l-0.3,0V21c0,0,9.9-0.4,10.7-0.5c-0.2,0.7-1,4-1,4l0.6,0.3c0,0,2.9-4.1,3.1-4.4c11.7-0.3,14.4,0.2,15,0.5l0.6,0.4l-0.6-3.5c-0.3-1-2-2.1-12.2-1.3c0.6-0.9,3.2-4.6,3.2-4.6l-7.2-2.7L1313.2,9.8z"/> | |
| 417 | + | <path fill="#20208C" d="M1307.6,24.8c0,0,0.8,4.7-1.6,8.5l0.5,0.5c4.4-2.9,5.9-6.6,5.9-6.7l0.1-0.3l-5-2.6L1307.6,24.8z"/> | |
| 418 | + | <path fill="#20208C" d="M1317.1,23.7c0.2,0.3,5.9,7.7,7.9,9.6l0.1,0.1l0.2,0c0.4,0,0.8-0.3,1.1-0.7c0.8-1.1,1.3-3.2,1-4.3c-0.4-1.3-7.6-4.3-9.8-5.2l-1.2-0.5L1317.1,23.7z"/> | |
| 419 | + | <path fill="#20208C" d="M1387.2,11.7L1387.2,11.7c-0.2,0.4-1.5,1.9-8.3,7.6c-0.1-0.7-0.2-1.6-0.2-1.6c-0.2-1.9-0.4-4.1-0.8-6.5l-0.1-0.3l-3.2,0.3l0,0.3c0,0.6,0,1.1,0,1.7c0,3.3,0.3,6.2,0.9,8.9c-0.3,0.2-10,8.2-10,8.2l0.3,0.3l1.9,1.8c0,0,7.9-6.9,8.6-7.6c0.7,2.2,1.6,4,2.7,5c2,2,4.2,1.7,7,1.3c2-0.3,4.3-0.6,7-0.2l0.5,0.1l-0.6-2.8c0-0.6-0.3-1.1-0.7-1.5c-1.3-0.9-3.8-0.4-6.5,0.1c-1.3,0.3-2.7,0.5-3.9,0.6c-1.3-1.2-2.1-2.8-2.6-5.2c7.5-6.2,10.3-7.8,11.3-8.1l0.2-0.1l0-0.3c0-0.2,0-0.3,0-0.4c0-1.8-0.9-2.8-1.7-3C1388.5,10.4,1387.7,10.6,1387.2,11.7z"/> | |
| 420 | + | <path fill="#20208C" d="M1432.4,19c0,3.7-4.5,9.2-4.7,9.4l-0.7,0.9l1.1-0.3c5.2-1.4,8.2-5,8.3-5.1l0.2-0.2l-4.2-5.7V19z"/> | |
| 421 | + | <path fill="#20208C" d="M1437.9,11.3l-2,0.3l0.3,0.5c0.1,0.2,9.3,15.7,10.6,19.3l0.1,0.3l0.3-0.1c2.2-0.4,4-0.2,5.8,0c2.2,0.2,4.3,0.4,6.7-0.4l0.4-0.1l-0.2-0.4c-1.2-2-2.5-2.4-4.2-2.9c-3.2-0.9-8-2.3-17.3-16.3l-0.1-0.2L1437.9,11.3z"/> | |
| 422 | + | <path fill="#20208C" d="M1520.6,23.6c0,0-2.9,3.2-3.1,3.4c-3.2,0.7-8.4,1.2-8.8-0.4c-0.5-1.9,3.4-10.4,4.9-13.6l0.2-0.3l-5.6-2.8l-0.1,0.4c-0.1,0.2-0.4,1.3-0.9,3c-2,0-4.2,0-4.5,0c0-0.4-0.1-1.3-0.1-1.3l0,0c-1.4-2.3-7.4-0.7-8.1-0.6l-0.4,0.1l0.2,0.4c0.2,0.4,0.4,0.9,0.5,1.4c-0.8,0-3.8,0-3.8,0l1.9,3.6c0,0,1.8-0.3,2.4-0.4c0,0.2,0,0.5,0,0.7c0,6.2-2.7,14.1-2.7,14.2l0.6,0.3c0.3-0.4,7.9-9.5,9.3-16.4c0.2,0,2.4-0.4,4.4-0.7c-1.2,4.7-2.6,12.1-0.3,14.8c3.4,4,16.6,3.7,17.1,3.5l0.3-0.1L1520.6,23.6z"/> | |
| 423 | + | <path fill="#20208C" d="M1171.2,100.1l6.8,19.4l0.3-0.1c3.6-1.2,13.3-4.1,15.3-2.6c0.2,0.2,0.3,0.4,0.3,0.7l-0.1,0.8l0.6-0.5c6.8-5.3,9.4-14,9.4-14.1l0-0.2l-0.1-0.1c-4.9-7.3-24.9-1.3-25.7-1l-0.2,0.1l0,0.2c0,0-0.2,1.7-0.2,2.4c-0.4-1.5-1.1-4.1-1.1-4.1l-5.6-1.3L1171.2,100.1z M1178.1,105.8c1.2-0.3,2.6-0.7,4-1c0,0,0,0,0,0s1.9,9.3,2,9.9c-0.5,0.1-3,0.8-3.6,1c-0.1-0.4-1.4-5.6-2.5-9.9C1178,105.9,1178.1,105.8,1178.1,105.8z M1189.8,103.5c0,0.1-0.4,9.6-0.4,10.2c-0.4,0.1-2.1,0.4-2.6,0.5c-0.1-0.6-1.8-9.6-1.8-9.9C1186.7,103.9,1188.3,103.7,1189.8,103.5z M1197.7,104.4c-0.2,2.3-1.1,8.1-2.6,9.9c-0.1-0.1,0-0.2-0.1-0.3c-0.5-0.5-1.6-0.7-3.3-0.6c0.3-4.5,1-7.5,1.9-7.9l0.7-0.3c0,0-3.5-1.7-3.8-1.8C1194.3,103,1197.3,103.1,1197.7,104.4z"/> | |
| 424 | + | <path fill="#20208C" d="M1003.8,106.1c-0.3,0.2-0.4,0.5-0.4,0.8c0,0.3,0.1,0.5,0.1,0.7c-2.6,0.2-19.7,1.8-23.9,1.4l-0.4,0l1.7,6l0.3-0.1c3.3-1.1,24.1-2.4,29-1.4c0.8,0.1,1.2,0,1.4-0.4c0.5-1-1.9-3.5-3.4-4.8C1007.3,107.4,1004.8,105.5,1003.8,106.1z"/> | |
| 425 | + | <path fill="#20208C" d="M1067.9,111.9c-0.3,0.2-0.4,0.5-0.4,0.9c0,0.2,0,0.5,0.1,0.7c-2.8,0.3-19.9,1.9-24.1,1.4l-0.5-0.1l1.7,6.2l0.4-0.1c3.3-1.2,24.3-2.4,29.3-1.4c0.8,0.2,1.3,0,1.5-0.4c0.7-1.3-3-4.6-3.4-4.9C1071.5,113.2,1069,111.2,1067.9,111.9z"/> | |
| 426 | + | <path fill="#20208C" d="M1060.1,100.9c-2.6,0.9-6.2,2.2-12.3,2.2l-0.7,0l2.9,4.2l16.7-2.2l0.5-3.1l-0.1-0.1C1064.7,99.2,1063,99.8,1060.1,100.9z"/> | |
| 427 | + | <path fill="#20208C" d="M1131.7,114.3c-0.3,0.2-0.4,0.5-0.4,0.9c0,0.3,0,0.5,0.1,0.7c-2.7,0.3-19.3,1.9-23.4,1.4l-0.5-0.1l1.7,6.2l0.3-0.1c3.2-1.2,23.6-2.4,28.4-1.4c0.8,0.2,1.3,0,1.5-0.4c0.6-1.3-2.9-4.6-3.3-5C1135.1,115.7,1132.7,113.6,1131.7,114.3z"/> | |
| 428 | + | <polygon fill="#20208C" points="1125.3,107 1114,108.4 1115.6,112.7 1127.6,111.3 1125.6,107 "/> | |
| 429 | + | <path fill="#20208C" d="M1122.9,96.6c-3,1.1-7,2.6-14,2.6l-0.6,0l3.2,4.9l18.9-2.6l0.6-3.6l-0.1-0.1C1128.2,94.6,1126.3,95.3,1122.9,96.6z"/> | |
| 430 | + | <path fill="#20208C" d="M1245.8,98.3l-10.3,10.5l0.4,0.5c0,0,5.6-3.2,7-4c-0.6,1.5-4.3,11.6-4.3,11.6l3.3,3.5c0,0,2.4-15.6,2.4-15.9c0.3-0.2,5.1-2.9,5.1-2.9l-3.4-3.7L1245.8,98.3z"/> | |
| 431 | + | <path fill="#20208C" d="M1258.6,98.2h-1.9c0,0-0.6,1.3-0.9,1.9c0,0-0.1-0.1-0.1-0.1l-5.9,3.5l0.2,0.7c0,0,3-0.2,3.9-0.2c-0.4,0.8-2.2,4.5-2.3,4.8c-0.4,0-6.3,0.5-6.3,0.5l-0.1,0.7l4,1.6c0,0,0.7-0.3,1.3-0.5c-0.6,1.2-2.5,5.2-2.7,5.5c-1.3,0.1-2.3,0.1-2.9,0l-0.4,0l0.5,3.7l0.3-0.1c1.4-0.2,12.2-1.6,14.1-1.7c1.8-0.1,7.3,2.1,7.4,2.1l0.7,0.3l-0.2-0.7c-0.1-0.4-1.3-3.9-2.2-4.8l-0.1-0.1l-0.1,0c-0.8-0.2-3.6,0-7.5,0.4c0.9-1,4.3-4.6,4.3-4.6l-3.2-3.4c0,0-2.9,7.8-3,8.2c-0.3,0-2.7,0.3-3.5,0.3c0.4-0.9,2.5-6.5,2.6-6.7c0.3-0.1,3.2-1.2,3.2-1.2l-0.2-0.7c0,0-1.7,0.3-2.5,0.4c0.3-0.9,1.6-4.1,1.7-4.4c0.2,0,1.2-0.1,1.2-0.1s-0.6-0.9-0.7-1.1c0.1-0.3,1.7-4.4,1.7-4.4H1258.6z"/> | |
| 432 | + | <path fill="#20208C" d="M1313.2,97.8c0,0.3,0,0.6,0,1c0,1.6-0.2,3.7-0.7,6.2c-5.9,0.7-11.1,1.5-11.2,1.5l-0.3,0v2.5c0,0,9.9-0.4,10.7-0.5c-0.2,0.7-1,4-1,4l0.6,0.3c0,0,2.9-4.1,3.1-4.4c11.7-0.3,14.4,0.2,15,0.5l0.6,0.4l-0.6-3.5c-0.3-1-2-2.1-12.2-1.3c0.6-0.9,3.2-4.6,3.2-4.6l-7.2-2.7L1313.2,97.8z"/> | |
| 433 | + | <path fill="#20208C" d="M1307.6,112.8c0,0,0.8,4.7-1.6,8.5l0.5,0.5c4.4-2.9,5.9-6.6,5.9-6.7l0.1-0.3l-5-2.6L1307.6,112.8z"/> | |
| 434 | + | <path fill="#20208C" d="M1317.1,111.7c0.2,0.3,5.9,7.7,7.9,9.6l0.1,0.1l0.2,0c0.4,0,0.8-0.3,1.1-0.7c0.8-1.1,1.3-3.2,1-4.3c-0.4-1.3-7.6-4.3-9.8-5.2l-1.2-0.5L1317.1,111.7z"/> | |
| 435 | + | <path fill="#20208C" d="M1387.2,99.7L1387.2,99.7c-0.2,0.4-1.5,1.9-8.3,7.6c-0.1-0.7-0.2-1.6-0.2-1.6c-0.2-1.9-0.4-4.1-0.8-6.5l-0.1-0.3l-3.2,0.3l0,0.3c0,0.6,0,1.1,0,1.7c0,3.3,0.3,6.2,0.9,8.9c-0.3,0.2-10,8.2-10,8.2l0.3,0.3l1.9,1.8c0,0,7.9-6.9,8.6-7.6c0.7,2.2,1.6,4,2.7,5c2,2,4.2,1.7,7,1.3c2-0.3,4.3-0.6,7-0.2l0.5,0.1l-0.6-2.8c0-0.6-0.3-1.1-0.7-1.5c-1.3-0.9-3.8-0.4-6.5,0.1c-1.3,0.3-2.7,0.5-3.9,0.6c-1.3-1.2-2.1-2.8-2.6-5.2c7.5-6.2,10.3-7.8,11.3-8.1l0.2-0.1l0-0.3c0-0.2,0-0.3,0-0.4c0-1.8-0.9-2.8-1.7-3C1388.5,98.4,1387.7,98.6,1387.2,99.7z"/> | |
| 436 | + | <path fill="#20208C" d="M1432.4,107c0,3.7-4.5,9.2-4.7,9.4l-0.7,0.9l1.1-0.3c5.2-1.4,8.2-5,8.3-5.1l0.2-0.2l-4.2-5.7V107z"/> | |
| 437 | + | <path fill="#20208C" d="M1437.9,99.3l-2,0.3l0.3,0.5c0.1,0.2,9.3,15.7,10.6,19.3l0.1,0.3l0.3-0.1c2.2-0.4,4-0.2,5.8,0c2.2,0.2,4.3,0.4,6.7-0.4l0.4-0.1l-0.2-0.4c-1.2-2-2.5-2.4-4.2-2.9c-3.2-0.9-8-2.3-17.3-16.3l-0.1-0.2L1437.9,99.3z"/> | |
| 438 | + | <path fill="#20208C" d="M1520.6,111.6c0,0-2.9,3.2-3.1,3.4c-3.2,0.7-8.4,1.2-8.8-0.4c-0.5-1.9,3.4-10.4,4.9-13.6l0.2-0.3l-5.6-2.8l-0.1,0.4c-0.1,0.2-0.4,1.3-0.9,3c-2,0-4.2,0-4.5,0c0-0.4-0.1-1.3-0.1-1.3l0,0c-1.4-2.3-7.4-0.7-8.1-0.6l-0.4,0.1l0.2,0.4c0.2,0.4,0.4,0.9,0.5,1.4c-0.8,0-3.8,0-3.8,0l1.9,3.6c0,0,1.8-0.3,2.4-0.4c0,0.2,0,0.5,0,0.7c0,6.2-2.7,14.1-2.7,14.2l0.6,0.3c0.3-0.4,7.9-9.5,9.3-16.4c0.2,0,2.4-0.4,4.4-0.7c-1.2,4.7-2.6,12.1-0.3,14.8c3.4,4,16.6,3.7,17.1,3.5l0.3-0.1L1520.6,111.6z"/> | |
| 439 | + | <path fill="#101040" d="M632.3,142.7c-2-0.9-11.8,3.3-11.8,3.3l0.5-20.7c0,0,5.3-4.3,9.6-1.7l-1.2-5c-1.4-0.2-8.4,5.5-8.4,5.5s-0.6-18.8,4.8-19.9c0,0-8.9-2.6-9.7-2.2c-0.4,0.2-0.4,13.8-0.2,27.1c-3.2,3.1-5.9,6.2-7.1,7.3l0.4-28.5c0,0-4.7-2.6-5.8-1.4l0.2,14.8c-0.3-0.5-0.7-0.9-1-1.3c-1-1.3-6.1-4.2-6.1-4.2s1.7,5.6,2.5,6.6c0.7,0.9,3,2.9,4.6,2.2l0.2,16.5c-3.4,3-7.9,6.8-8.5,5.4c-1.5-3.2-4.7-3-5.2-1.5c-0.5,1.6,4.2,10.2,5.8,10.1c0.8-0.1,4.4-4.9,8-10l0.1,5.3c1.6,2.1,3.8,0.5,4.6-0.7l0.3-11c1.1-1.6,4-4.5,7.1-7.9c0.2,12.1,0.3,22.8,0.3,22.8c4.9-5.2,16.7-4,16.7-4L632.3,142.7z"/> | |
| 440 | + | <path fill="#101040" d="M688.4,14.3c-8.5,5.7-21.5,6.9-28.8,5.9l-0.9,2c11.7,3.8,26.4,1,31.2-3.2L688.4,14.3z"/> | |
| 441 | + | <path fill="#101040" d="M692.5,39.2c-3.6-0.9-7.3-1.4-10.8-1.7c0.8-2.9,1.2-6,1-9.2l-7.8,2c0.6,1.7,0.6,4.3,0.2,7c-1.6,0-3.1,0.1-4.5,0.3c0.2-2.7,0.4-3.3-0.5-6l-4.7,1.3c1,1.8,1.5,2.3,1.8,5.1c-3.4,1-5.7,1.8-6.9,3.2c-5.1,6.1,0,23.2,0,23.2s-0.2-15.4,2-17c2.2-1.6,4.7-1.8,4.7-1.8c0.1,1.5-1.6,6.7-1.6,6.7c2.2-2.7,3.2-3.6,4.3-7.2c1.6-0.5,2.5-0.8,4.1-1c-1.6,6.1-4.5,10.9-4.5,10.9l7.4,2c-4.1-0.1-13.3,5.1-13.3,5.1s16.2-4,20.8-1.1l1.2-2.9c-3.4-2.7-10.5-4.9-10.5-4.9s2.2-4.1,4.6-9.4c2.3,0.1,5.6,0.3,7.7,0.9c9.2,4.1-1.7,22.4-1.7,22.4S705.7,45.9,692.5,39.2z"/> | |
| 442 | + | <path fill="#101040" d="M735.3,14.2c0.4,0.5,0.9,2.3,0.9,8.6c0,0.9,0,1.8,0,2.8c-3.4,0.5-6.3,1-6.8,1.2l-0.6,0.2l2.8,2.7l0.2-0.1c1.1-0.3,2.6-0.5,4.2-0.7c0,0.7-0.4,7.3-0.4,7.9c-4.9,0.4-9.2,1.1-12.7,1.9c-3,0.8-4.8,2.6-5.2,5.4c-0.1,0.4-0.1,0.9-0.1,1.4c0,7.1,6.9,16.9,7,16.9l0.8,0.5l-0.3-0.9c0-0.1-2.8-9.4-2.8-15.6c0-1.8,0.2-3.3,0.8-4.3c0.4-0.6,0.8-0.9,1.4-1c1.3-0.2,2.7-0.4,4-0.6c0,0,3.1,4,3.7,4.7c-0.5,0.1-1.4,0.3-1.4,0.3l0,0.3l0.2,2.2l0.4-0.1c0.6-0.2,1.8-0.5,3.2-0.7c0,0.6,0.1,2.4,0.2,2.9c-1.7,0.5-3.2,1.1-3.8,1.4l-0.4,0.2l1.5,1.6l0.2-0.1c0.7-0.3,1.7-0.6,2.7-0.9c0,0.7,0.3,5.6,0.3,5.6l0.7,0c0,0,1.1-5.5,1.2-6c1.8-0.2,3.5-0.1,4.8,0.2l0.4,0.1v-3l-0.2-0.1c-0.8-0.4-2.3-0.4-4.5,0.1c0.1-0.7,0.4-2.1,0.5-2.5c1.6-0.1,2.9-0.2,3.8-0.1l0.5,0.1l-0.7-2.6c0,0-0.9,0-1.4,0.1c0.8-0.9,3.5-4.4,3.7-4.6c0.3,0,3.9,0.1,3.9,0.1c1.6,0.1,3,0.7,4.1,2c1.3,1.5,2,3.9,2,6.3c0,0.5,0,1.1-0.1,1.6l-0.1,0.7c-0.7,5.1-1.3,8.8-3.1,9.6c-1.2,0.5-3.1-0.3-5.9-2.4l-0.5,0.5l5.5,9.6l0.3-0.1c6.7-2.4,7.7-3.8,10.4-15.4c1.2-5.1,0.8-9.1-1.3-11.8c-2.5-3.3-6.3-3.7-6.5-3.7c-0.1,0-6.7-0.3-14.6,0.2c0.2-0.8,2-7.5,2.1-7.9c2.5,0,4.7,0.1,5.7,0.5l0.4,0.1l0.4-4.7l-0.4,0c-1.1,0-2.8,0.1-5.1,0.3c1.3-4.4,1.9-5.8,2.1-6.2l0.2-0.3l-8.1-4.7L735.3,14.2z M739.7,39.5c-0.1,0.7-0.7,4.3-0.7,4.7c0,0-0.1,0-0.1,0c0-0.1,0.1-0.4,0.1-0.4l-4.4,0.4c0,0,0,0.5,0,0.6c-0.4,0.1-1,0.2-1.4,0.2c0-0.7,0-5,0-5.1C735.4,39.8,737.5,39.6,739.7,39.5z"/> | |
| 443 | + | <path fill="#101040" d="M804.5,41.3l0.1-6.4l4.4-0.3l-0.3-2.4l-4.1,1.2l0-5.7c2.4,0.3,5,0.8,7.1,0.9c4.3,0.3-1.2,14.1-1.2,14.1s16.3-14.4,2.6-18.1c-1.4,0-4.9,0.2-8.4,0.4l0-3.9l7.5-1.9l-7.5-0.1l0-4l-4.6-3.4l0.5,8.7l-4,2.7l4.1-1l0.2,3.2c-1.8,0.2-3.4,0.3-4.3,0.5c-11.5,2.2,1,17.2,1,17.2s-4-14,0.5-15.3c0.9-0.3,1.8-0.3,2.9-0.3l0.4,7l-3,0.9l3-0.2l0.2,4.1c-9.2,12.1-19.7,16.6-19.7,16.6s10.2-1.1,20-11.9l1,18.7c-3.7-1.5-7-3.6-7-3.6s2.4,7.1,7.5,10.1l4.3-5.1c-1-0.2-2.1-0.5-3.2-0.9l0.2-18.9c2.7,3.2,9,10.5,11.7,11l9-3C821.9,51.6,807.7,43.3,804.5,41.3z"/> | |
| 444 | + | <path fill="#101040" d="M688.4,102.3c-8.5,5.7-21.5,6.9-28.8,5.9l-0.9,2c11.7,3.8,26.4,1,31.2-3.2L688.4,102.3z"/> | |
| 445 | + | <path fill="#101040" d="M692.5,127.2c-3.6-0.9-7.3-1.4-10.8-1.7c0.8-2.9,1.2-6,1-9.2l-7.8,2c0.6,1.7,0.6,4.3,0.2,7c-1.6,0-3.1,0.1-4.5,0.3c0.2-2.7,0.4-3.3-0.5-6l-4.7,1.3c1,1.8,1.5,2.3,1.8,5.1c-3.4,1-5.7,1.8-6.9,3.2c-5.1,6.1,0,23.2,0,23.2s-0.2-15.4,2-17c2.2-1.6,4.7-1.8,4.7-1.8c0.1,1.5-1.6,6.7-1.6,6.7c2.2-2.7,3.2-3.6,4.3-7.2c1.6-0.5,2.5-0.8,4.1-1c-1.6,6.1-4.5,10.9-4.5,10.9l7.4,2c-4.1-0.1-13.3,5.1-13.3,5.1s16.2-4,20.8-1.1l1.2-2.9c-3.4-2.7-10.5-4.9-10.5-4.9s2.2-4.1,4.6-9.4c2.3,0.1,5.6,0.3,7.7,0.9c9.2,4.1-1.7,22.4-1.7,22.4S705.7,133.9,692.5,127.2z"/> | |
| 446 | + | <path fill="#101040" d="M735.3,102.2c0.4,0.5,0.9,2.3,0.9,8.6c0,0.8,0,1.8,0,2.8c-3.4,0.5-6.3,1-6.8,1.2l-0.6,0.2l2.8,2.7l0.2-0.1c1.1-0.3,2.6-0.5,4.2-0.7c0,0.7-0.4,7.3-0.4,7.9c-4.9,0.4-9.2,1.1-12.7,1.9c-3,0.8-4.8,2.6-5.2,5.4c-0.1,0.4-0.1,0.9-0.1,1.4c0,7.1,6.9,16.9,7,16.9l0.8,0.5l-0.3-0.9c0-0.1-2.8-9.4-2.8-15.6c0-1.8,0.2-3.3,0.8-4.3c0.4-0.6,0.8-0.9,1.4-1c1.3-0.2,2.7-0.4,4-0.6c0,0,3.1,4,3.7,4.7c-0.5,0.1-1.4,0.3-1.4,0.3l0,0.3l0.2,2.2l0.4-0.1c0.6-0.2,1.8-0.5,3.2-0.7c0,0.6,0.1,2.4,0.2,2.9c-1.7,0.5-3.2,1.1-3.8,1.4l-0.4,0.2l1.5,1.6l0.2-0.1c0.7-0.3,1.7-0.6,2.7-0.9c0,0.7,0.3,5.6,0.3,5.6l0.7,0c0,0,1.1-5.5,1.2-6c1.8-0.2,3.5-0.1,4.8,0.2l0.4,0.1v-3l-0.2-0.1c-0.8-0.4-2.3-0.4-4.5,0.1c0.1-0.7,0.4-2.1,0.5-2.5c1.6-0.1,2.9-0.2,3.8-0.1l0.5,0.1l-0.7-2.6c0,0-0.9,0-1.4,0.1c0.8-0.9,3.5-4.4,3.7-4.6c0.3,0,3.9,0.1,3.9,0.1c1.6,0.1,3,0.7,4.1,2c1.3,1.5,2,3.9,2,6.3c0,0.5,0,1.1-0.1,1.6l-0.1,0.7c-0.7,5.1-1.3,8.8-3.1,9.6c-1.2,0.5-3.1-0.3-5.9-2.4l-0.5,0.5l5.5,9.6l0.3-0.1c6.7-2.4,7.7-3.8,10.4-15.4c1.2-5.1,0.8-9.1-1.3-11.8c-2.5-3.3-6.3-3.7-6.5-3.7c-0.1,0-6.7-0.3-14.6,0.2c0.2-0.8,2-7.5,2.1-7.9c2.5,0,4.7,0.1,5.7,0.5l0.4,0.1l0.4-4.7l-0.4,0c-1.1,0-2.8,0.1-5.1,0.3c1.3-4.4,1.9-5.8,2.1-6.2l0.2-0.3l-8.1-4.7L735.3,102.2z M739.7,127.5c-0.1,0.7-0.7,4.3-0.7,4.7c0,0-0.1,0-0.1,0c0-0.1,0.1-0.4,0.1-0.4l-4.4,0.4c0,0,0,0.5,0,0.6c-0.4,0.1-1,0.2-1.4,0.2c0-0.7,0-5,0-5.1C735.4,127.8,737.5,127.6,739.7,127.5z"/> | |
| 447 | + | <path fill="#101040" d="M804.5,129.3l0.1-6.4l4.4-0.3l-0.3-2.4l-4.1,1.2l0-5.7c2.4,0.3,5,0.8,7.1,0.9c4.3,0.3-1.2,14.1-1.2,14.1s16.3-14.4,2.6-18.1c-1.4,0-4.9,0.2-8.4,0.4l0-3.9l7.5-1.9l-7.5-0.1l0-4l-4.6-3.4l0.5,8.7l-4,2.7l4.1-1l0.2,3.2c-1.8,0.2-3.4,0.3-4.3,0.5c-11.5,2.2,1,17.2,1,17.2s-4-14,0.5-15.3c0.9-0.3,1.8-0.3,2.9-0.3l0.4,7l-3,0.9l3-0.2l0.2,4.1c-9.2,12.1-19.7,16.6-19.7,16.6s10.2-1.1,20-11.9l1,18.7c-3.7-1.5-7-3.6-7-3.6s2.4,7.1,7.5,10.1l4.3-5.1c-1-0.2-2.1-0.5-3.2-0.9l0.2-18.9c2.7,3.2,9,10.5,11.7,11l9-3C821.9,139.6,807.7,131.3,804.5,129.3z"/> | |
| 448 | + | <path fill="none" stroke="#10106C" stroke-width="0.75" d="M49.4,40.5c0,7.7-6.2,13.9-13.9,13.9c-7.7,0-13.9-6.2-13.9-13.9c0-7.7,6.2-13.9,13.9-13.9C43.2,26.6,49.4,32.8,49.4,40.5z"/> | |
| 449 | + | <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="42" cy="40.5" r="6.5"/> | |
| 450 | + | <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="29" cy="40.5" r="6.5"/> | |
| 451 | + | <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="35.5" cy="34" r="6.5"/> | |
| 452 | + | <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="35.5" cy="47" r="6.5"/> | |
| 453 | + | <path fill="none" stroke="#10106C" stroke-width="0.75" d="M44.8,40.5c0,5.1-4.2,9.3-9.3,9.3s-9.3-4.2-9.3-9.3c0-5.1,4.2-9.3,9.3-9.3S44.8,35.4,44.8,40.5z"/> | |
| 454 | + | <path fill="none" stroke="#BA0000" stroke-width="2" d="M52.2,40.5c0,9.2-7.5,16.7-16.7,16.7c-9.2,0-16.7-7.5-16.7-16.7c0-9.2,7.5-16.7,16.7-16.7C44.7,23.8,52.2,31.3,52.2,40.5z"/> | |
| 455 | + | <path fill="none" stroke="#10106C" stroke-width="0.75" d="M55,40.5C55,51.3,46.3,60,35.5,60C24.7,60,16,51.3,16,40.5C16,29.7,24.7,21,35.5,21C46.3,21,55,29.7,55,40.5z"/> | |
| 456 | + | <path fill="none" stroke="#10106C" stroke-width="0.75" d="M49.4,128.5c0,7.7-6.2,13.9-13.9,13.9c-7.7,0-13.9-6.2-13.9-13.9s6.2-13.9,13.9-13.9C43.2,114.6,49.4,120.8,49.4,128.5z"/> | |
| 457 | + | <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="42" cy="128.5" r="6.5"/> | |
| 458 | + | <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="29" cy="128.5" r="6.5"/> | |
| 459 | + | <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="35.5" cy="122" r="6.5"/> | |
| 460 | + | <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="35.5" cy="135" r="6.5"/> | |
| 461 | + | <path fill="none" stroke="#10106C" stroke-width="0.75" d="M44.8,128.5c0,5.1-4.2,9.3-9.3,9.3s-9.3-4.2-9.3-9.3c0-5.1,4.2-9.3,9.3-9.3S44.8,123.4,44.8,128.5z"/> | |
| 462 | + | <path fill="none" stroke="#BA0000" stroke-width="2" d="M52.2,128.5c0,9.2-7.5,16.7-16.7,16.7c-9.2,0-16.7-7.5-16.7-16.7c0-9.2,7.5-16.7,16.7-16.7C44.7,111.8,52.2,119.3,52.2,128.5z"/> | |
| 463 | + | <path fill="none" stroke="#10106C" stroke-width="0.75" d="M55,128.5c0,10.8-8.7,19.5-19.5,19.5c-10.8,0-19.5-8.7-19.5-19.5c0-10.8,8.7-19.5,19.5-19.5C46.3,109,55,117.7,55,128.5z"/> | |
| 464 | + | <path fill="#BA0000" d="M861.8,10.2c-0.4,0.4-0.5,1-0.5,1.7c0,1.3,0.4,2.9,0.6,3.5c0,0,0.3,7,0.4,7.8c-0.9,0.1-2.5,0.2-3.1,0.4l-0.8,0c-1.7,0.1-3.3,0.2-4,0.9l-0.3,0.3l0.3,0.4c0.5,0.6,0.7,2.3,0.9,4.1c0.5,3.7,1.1,8.3,4.1,10.1l0.6,0.3L860,39c0,0,0.1-0.3,0.1-0.7c0-0.3-0.1-0.8-0.2-1.5c0.8,0.1,2.5,0.3,3.1,0.3c0.1,0.8,2.7,33.4,2.7,33.4l1,0c0,0,1.1-32.2,1.1-33.2c0.7,0,3.8,0.1,4.7,0.2c-0.2,0.6-1,3.1-1,3.1l1.4-1.3c0.4-0.3,9.1-8.2,7.9-13.4c-0.4-1.7-1.8-2.9-4.1-3.5l-0.1,0h-0.1c-1.3,0-5.3,0.1-8.3,0.3c0.1-2.6,0.1-4,0.1-4.7c0-0.6,0-0.7,0-0.8c0,0,0-0.1,0-0.1c0-0.5,0.5-1.2,0.9-1.7c0.4-0.6,0.9-1.1,0.9-1.7c0-0.2-0.1-0.4-0.2-0.6c-0.6-0.9-5-3.2-6.9-3.4C862.4,9.8,862,9.9,861.8,10.2z M861.9,26.6c0,0,0.2,0,0.5,0c0,0.9,0.3,7.2,0.4,8.1c-0.8-0.1-2.7-0.3-3.3-0.3c-0.3-1.3-0.4-2.7-0.5-4.1c-0.1-1.2-0.2-2.4-0.4-3.5C859.5,26.8,861.9,26.6,861.9,26.6z M868,29.7c0,0,0.1-2.4,0.1-3.2c1.3,0.2,3,0.3,4.6,0.3c0,0,1.2,0,1.8,0c0,0.4-0.1,0.9-0.1,0.9c-0.2,2.2-0.5,4.8-1.4,7.4c-0.6,0-4.3-0.2-5.1-0.3C867.9,34.1,868,29.7,868,29.7z"/> | |
| 465 | + | <path fill="#BA0000" d="M861.8,98.2c-0.4,0.4-0.5,1-0.5,1.7c0,1.3,0.4,2.9,0.6,3.5c0,0,0.3,7,0.4,7.8c-0.9,0.1-2.5,0.2-3.1,0.4l-0.8,0c-1.7,0.1-3.3,0.2-4,0.9l-0.3,0.3l0.3,0.4c0.5,0.6,0.7,2.3,0.9,4.1c0.5,3.7,1.1,8.3,4.1,10.1l0.6,0.3l0.2-0.6c0,0,0.1-0.3,0.1-0.7c0-0.3-0.1-0.8-0.2-1.5c0.8,0.1,2.5,0.3,3.1,0.3c0.1,0.8,2.7,33.4,2.7,33.4l1,0c0,0,1.1-32.2,1.1-33.2c0.7,0,3.8,0.1,4.7,0.2c-0.2,0.6-1,3.1-1,3.1l1.4-1.3c0.4-0.3,9.1-8.2,7.9-13.4c-0.4-1.7-1.8-2.9-4.1-3.5l-0.1,0h-0.1c-1.3,0-5.3,0.1-8.3,0.3c0.1-2.6,0.1-4,0.1-4.7c0-0.6,0-0.7,0-0.8c0,0,0-0.1,0-0.1c0-0.5,0.5-1.2,0.9-1.7c0.4-0.6,0.9-1.1,0.9-1.7c0-0.2-0.1-0.4-0.2-0.6c-0.6-0.9-5-3.2-6.9-3.4C862.4,97.8,862,97.9,861.8,98.2z M861.9,114.6c0,0,0.2,0,0.5,0c0,0.9,0.3,7.2,0.4,8.1c-0.8-0.1-2.7-0.3-3.3-0.3c-0.3-1.3-0.4-2.7-0.5-4.1c-0.1-1.2-0.2-2.4-0.4-3.5C859.5,114.8,861.9,114.6,861.9,114.6z M868,117.7c0,0,0.1-2.4,0.1-3.2c1.3,0.2,3,0.3,4.6,0.3c0,0,1.2,0,1.8,0c0,0.4-0.1,0.9-0.1,0.9c-0.2,2.2-0.5,4.8-1.4,7.4c-0.6,0-4.3-0.2-5.1-0.3C867.9,122.1,868,117.7,868,117.7z"/> | |
| 466 | + | <path fill="#004C00" d="M925,47.8c0,0.1-1.4,5.1-5.1,7.1c-0.5-0.6-3.1-4.2-3.1-4.2l-0.9,0.5l4.4,11.4l1-0.2c0-2.2,1.1-3.5,2.4-4.9c1.8-2.1,3.8-4.5,2.3-9.7l-0.5-1.7L925,47.8z"/> | |
| 467 | + | <path fill="#004C00" d="M929.5,37c0,0-0.5,4.9-1.9,7.8l-0.3,0.7l0.7,0.1c1.8,0.1,3.5-3.7,4.1-5.4c0.4,0,1.4,0.1,2,0.1c-0.3,1.2-0.7,2.8-0.7,3.8c0,0.4,0,0.7,0.2,0.9l0.5,0.3c0.8,0.1,5.2-0.2,6.1-0.3l0-1c-1.5-0.2-3.3-0.7-3.5-1.2c-0.2-0.6,0.6-3,1.5-5l0.3-0.7l-9-0.6L929.5,37z"/> | |
| 468 | + | <path fill="#004C00" d="M942,46.6L927.3,47c0,0,4.9,6.3,5.4,6.9c-2.6,2.4-4.9,4.2-7,5.4l-0.6,0.4l0.6,0.5c1.4,1.1,4.2-0.2,8.6-4.2c2.3,3.2,3.8,5.5,4.3,6.9l0.2,0.7l2.7-2.2l0-0.2l0-0.3c0-0.5,0-2.1-4.7-7.3c3-3.1,5.6-6,5.6-6.1l0.7-0.9L942,46.6z M937.3,49.4c-0.8,0.9-1.7,1.8-2.1,2.3c-0.4-0.4-1.2-1.2-1.9-1.9C934.4,49.6,936,49.5,937.3,49.4z"/> | |
| 469 | + | <path fill="#004C00" d="M931.1,25.2l-1.7,1.7l-4.5-5.2l-0.2,0c-0.7,0-4.3,0.1-5.2,1.4c-0.1,0.1-0.1,0.3-0.1,0.4c0,0.1,0,0.1,0,0.2c0.1,0.5,0.7,0.7,1.8,1.1c0.5,0.2,1.2,0.5,1.5,0.7c-1.1,2.3-2.3,4.4-3.7,6.4c-0.7-0.4-4.1-2.5-4.1-2.5l-0.6,0.8c0,0,2.8,3.1,3.3,3.6c-2.8,3.5-6.2,6.6-10.4,9.5l0.4,0.9c12.8-3.3,21.9-16.2,22.1-16.5l2.3,2.2c-3.2,2-6.2,3.8-6.2,3.8l0.5,0.9c2-0.8,4.8-1.9,7.6-3L945,42.4l10.8-3.8l-0.1-1c-5.1-0.2-12-4.4-17.1-7.9c2.9-1,5.3-1.7,6.4-1.6c0.7,0,0.9-0.3,1-0.5c0.4-1.1-3.5-4.4-11.4-9.7l-0.5-0.3l-0.2,0.6 M934.9,27c0.7-1.2,1.6-2.9,1.9-3.3c0.5,0.3,1.8,1.1,2.5,1.6c-0.4,0.2-1.9,1.1-3.7,2.3C935.4,27.4,935.2,27.2,934.9,27z"/> | |
| 470 | + | <path fill="#004C00" d="M926.9,35.4l-6.2,3.5l0.3,0.9c0,0,1.1-0.1,1.8-0.1c-0.2,0.7-0.6,1.8-1.1,3.2c-0.6,0-5-0.2-5-0.2l0.8,0.9c0.3,0.3,1.6,1.7,2.7,1.7c0,0,0.3-0.1,0.6-0.1c-0.8,2-1.7,3.7-2.6,5l0.7,0.7c1.5-1.2,3.2-3.4,4.8-6.4c0.3-0.1,1.2-0.4,1.2-0.4l-0.1-1c0,0-0.2,0-0.4,0c1.8-3.6,3.1-7.1,3.1-7.1l0.5-1.3L926.9,35.4z"/> | |
| 471 | + | <path fill="#004C00" d="M925,135.8c0,0.1-1.4,5.1-5.1,7.1c-0.5-0.6-3.1-4.2-3.1-4.2l-0.9,0.5l4.4,11.4l1-0.2c0-2.2,1.1-3.5,2.4-4.9c1.8-2.1,3.8-4.5,2.3-9.7l-0.5-1.7L925,135.8z"/> | |
| 472 | + | <path fill="#004C00" d="M929.5,125c0,0-0.5,4.9-1.9,7.8l-0.3,0.7l0.7,0.1c1.8,0.1,3.5-3.7,4.1-5.4c0.4,0,1.4,0.1,2,0.1c-0.3,1.2-0.7,2.8-0.7,3.8c0,0.4,0,0.7,0.2,0.9l0.5,0.3c0.8,0.1,5.2-0.2,6.1-0.3l0-1c-1.5-0.2-3.3-0.7-3.5-1.2c-0.2-0.6,0.6-3,1.5-5l0.3-0.7l-9-0.6L929.5,125z"/> | |
| 473 | + | <path fill="#004C00" d="M942,134.6l-14.7,0.4c0,0,4.9,6.3,5.4,6.9c-2.6,2.4-4.9,4.2-7,5.4l-0.6,0.4l0.6,0.5c1.4,1.1,4.2-0.2,8.6-4.2c2.3,3.2,3.8,5.5,4.3,6.9l0.2,0.7l2.7-2.3l0-0.2l0-0.3c0-0.5,0-2.1-4.7-7.3c3-3.1,5.6-6,5.6-6.1l0.7-0.9L942,134.6z M937.3,137.4c-0.8,0.9-1.7,1.8-2.1,2.3c-0.4-0.4-1.2-1.2-1.9-1.9C934.4,137.6,936,137.5,937.3,137.4z"/> | |
| 474 | + | <path fill="#004C00" d="M931.1,113.2l-1.7,1.7l-4.5-5.2l-0.2,0c-0.7,0-4.3,0.1-5.2,1.4c-0.1,0.1-0.1,0.3-0.1,0.4c0,0.1,0,0.1,0,0.2c0.1,0.5,0.7,0.7,1.8,1.1c0.5,0.2,1.2,0.5,1.5,0.7c-1.1,2.3-2.3,4.4-3.7,6.4c-0.7-0.4-4.1-2.5-4.1-2.5l-0.6,0.8c0,0,2.8,3.1,3.3,3.6c-2.8,3.5-6.2,6.6-10.4,9.5l0.4,0.9c12.8-3.3,21.9-16.2,22.1-16.5l2.3,2.2c-3.2,2-6.2,3.8-6.2,3.8l0.5,0.9c2-0.8,4.8-1.9,7.6-3l11.4,10.9l10.8-3.8l-0.1-1c-5.1-0.2-12-4.4-17.1-7.9c2.9-1,5.3-1.7,6.4-1.6c0.7,0,0.9-0.3,1-0.5c0.4-1.1-3.5-4.4-11.4-9.7l-0.5-0.3l-0.2,0.6 M934.9,115c0.7-1.2,1.6-2.9,1.9-3.3c0.5,0.3,1.8,1.1,2.5,1.6c-0.4,0.2-1.9,1.1-3.7,2.3C935.4,115.4,935.2,115.2,934.9,115z"/> | |
| 475 | + | <path fill="#004C00" d="M926.9,123.4l-6.2,3.5l0.3,0.9c0,0,1.1-0.1,1.8-0.1c-0.2,0.7-0.6,1.8-1.1,3.2c-0.6,0-5-0.2-5-0.2l0.8,0.9c0.3,0.3,1.6,1.7,2.7,1.7c0,0,0.3-0.1,0.6-0.1c-0.8,2-1.7,3.7-2.6,5l0.7,0.7c1.5-1.2,3.2-3.4,4.8-6.4c0.3-0.1,1.2-0.4,1.2-0.4l-0.1-1c0,0-0.2,0-0.4,0c1.8-3.6,3.1-7.1,3.1-7.1l0.5-1.3L926.9,123.4z"/> | |
| 476 | + | <rect x="2386" y="12" rx="5" fill="#294D8E" width="35" height="57"/> | |
| 477 | + | <rect x="2387" y="13" rx="4" fill="#F4F4F4" width="33" height="55"/> | |
| 478 | + | <rect x="2390" y="16" rx="3" fill="#294D8E" width="27" height="49"/> | |
| 479 | + | <rect x="2393" y="19" rx="7" fill="#F4F4F4" width="21" height="43"/> | |
| 480 | + | <rect x="2386" y="100" rx="5" fill="#294D8E" width="35" height="57"/> | |
| 481 | + | <rect x="2387" y="101" rx="4" fill="#FFFFFF" width="33" height="55"/> | |
| 482 | + | <rect x="2390" y="104" rx="3" fill="#294D8E" width="27" height="49"/> | |
| 483 | + | <rect x="2393" y="107" rx="7" fill="#FFFFFF" width="21" height="43"/> | |
| 484 | + | <path fill="#99B9FF" stroke="#678ED3" d="M2323.8,13.9l-0.1,1.3c0,0,8.9-2.4,9.4-0.6c0,0-0.4,4.1-1.4,4.9c0-0.5-0.7-0.6-1.7-0.5c0.1-1.1,0.3-3.7,1-4l-1.6-0.7l-0.2,4.8c-0.5,0.1-1,0.2-1.5,0.3l-0.9-4.8l-1,0.4l0.9,4.7c-1.1,0.3-2,0.5-2,0.5l-1.8-7l-2.2-0.5l3.1,8.9c0,0,7.7-2.7,7.5-0.7c3.2-2.5,4.3-6.5,4.3-6.5C2333.4,11,2323.8,13.9,2323.8,13.9z"/> | |
| 485 | + | <path fill="#99B9FF" stroke="#678ED3" d="M2267.6,19.9c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2259.5,20.7,2267.6,19.9,2267.6,19.9z"/> | |
| 486 | + | <path fill="#99B9FF" stroke="#678ED3" d="M2139.6,16.1c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2131.5,16.9,2139.6,16.1,2139.6,16.1z"/> | |
| 487 | + | <polygon fill="#99B9FF" stroke="#678ED3" points="2264.9,16.3 2265.7,17.8 2260.9,18.3 2260.4,16.8"/> | |
| 488 | + | <path fill="#99B9FF" stroke="#678ED3" d="M2267.1,12.5c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2267.1,12.5z"/> | |
| 489 | + | <path fill="#99B9FF" stroke="#678ED3" d="M2203.6,18.9c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2195.5,19.7,2203.6,18.9,2203.6,18.9z"/> | |
| 490 | + | <path fill="#99B9FF" stroke="#678ED3" d="M2203.1,14.5c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2203.1,14.5z"/> | |
| 491 | + | <path fill="#99B9FF" stroke="#678ED3" d="M2323.8,101.9l-0.1,1.3c0,0,8.9-2.4,9.4-0.6c0,0-0.4,4.1-1.4,4.9c0-0.5-0.7-0.6-1.7-0.5c0.1-1.1,0.3-3.7,1-4l-1.6-0.7l-0.2,4.8c-0.5,0.1-1,0.2-1.5,0.3l-0.9-4.8l-1,0.4l0.9,4.7c-1.1,0.3-2,0.5-2,0.5l-1.8-7l-2.2-0.5l3.1,8.9c0,0,7.7-2.7,7.5-0.7c3.2-2.5,4.3-6.5,4.3-6.5C2333.4,99,2323.8,101.9,2323.8,101.9z"/> | |
| 492 | + | <path fill="#99B9FF" stroke="#678ED3" d="M2267.6,107.9c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2259.5,108.7,2267.6,107.9,2267.6,107.9z"/> | |
| 493 | + | <path fill="#99B9FF" stroke="#678ED3" d="M2139.6,104.1c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2131.5,104.9,2139.6,104.1,2139.6,104.1z"/> | |
| 494 | + | <polygon fill="#99B9FF" stroke="#678ED3" points="2264.9,104.3 2265.7,105.8 2260.9,106.3 2260.4,104.8 "/> | |
| 495 | + | <path fill="#99B9FF" stroke="#678ED3" d="M2267.1,100.5c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2267.1,100.5z"/> | |
| 496 | + | <path fill="#99B9FF" stroke="#678ED3" d="M2203.6,106.9c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2195.5,107.7,2203.6,106.9,2203.6,106.9z"/> | |
| 497 | + | <path fill="#99B9FF" stroke="#678ED3" d="M2203.1,102.5c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2203.1,102.5z"/> | |
| 498 | + | <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2643.1,20.3l-0.1,1.3c0,0,8.9-2.4,9.4-0.6c0,0-0.4,4.1-1.4,4.9c0-0.5-0.7-0.6-1.7-0.5c0.1-1.1,0.3-3.7,1-4l-1.6-0.7l-0.2,4.8c-0.5,0.1-1,0.2-1.5,0.3l-0.9-4.8l-1,0.4l0.9,4.7c-1.1,0.3-2,0.5-2,0.5l-1.8-7L2640,19l3.1,8.9c0,0,7.7-2.7,7.5-0.7c3.2-2.5,4.3-6.5,4.3-6.5C2652.7,17.4,2643.1,20.3,2643.1,20.3z"/> | |
| 499 | + | <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2586.9,26.3c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2578.9,27.1,2586.9,26.3,2586.9,26.3z"/> | |
| 500 | + | <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2458.9,22.5c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2450.9,23.3,2458.9,22.5,2458.9,22.5z"/> | |
| 501 | + | <polygon fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" points="2584.3,22.6 2585,24.1 2580.3,24.7 2579.7,23.2 "/> | |
| 502 | + | <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2586.5,18.8c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2586.5,18.8z"/> | |
| 503 | + | <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2522.9,25.3c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2514.9,26.1,2522.9,25.3,2522.9,25.3z"/> | |
| 504 | + | <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2522.5,20.8c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2522.5,20.8z"/> | |
| 505 | + | <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2643.1,108.3l-0.1,1.3c0,0,8.9-2.4,9.4-0.6c0,0-0.4,4.1-1.4,4.9c0-0.5-0.7-0.6-1.7-0.5c0.1-1.1,0.3-3.7,1-4l-1.6-0.7l-0.2,4.8c-0.5,0.1-1,0.2-1.5,0.3l-0.9-4.8l-1,0.4l0.9,4.7c-1.1,0.3-2,0.5-2,0.5l-1.8-7l-2.2-0.5l3.1,8.9c0,0,7.7-2.7,7.5-0.7c3.2-2.5,4.3-6.5,4.3-6.5C2652.7,105.4,2643.1,108.3,2643.1,108.3z"/> | |
| 506 | + | <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2586.9,114.3c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2578.9,115.1,2586.9,114.3,2586.9,114.3z"/> | |
| 507 | + | <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2458.9,110.5c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2450.9,111.3,2458.9,110.5,2458.9,110.5z"/> | |
| 508 | + | <polygon fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" points="2584.3,110.6 2585,112.1 2580.3,112.7 2579.7,111.2 "/> | |
| 509 | + | <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2586.5,106.8c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2586.5,106.8z"/> | |
| 510 | + | <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2522.9,113.3c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2514.9,114.1,2522.9,113.3,2522.9,113.3z"/> | |
| 511 | + | <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2522.5,108.8c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2522.5,108.8z"/> | |
| 512 | + | <path fill="#101040" d="M632.3,54.7c-2-0.9-11.8,3.3-11.8,3.3l0.5-20.7c0,0,5.3-4.3,9.6-1.7l-1.2-5c-1.4-0.2-8.4,5.5-8.4,5.5s-0.6-18.8,4.8-19.9c0,0-8.9-2.6-9.7-2.2c-0.4,0.2-0.4,13.8-0.2,27.1c-3.2,3.1-5.9,6.2-7.1,7.3l0.4-28.5c0,0-4.7-2.6-5.8-1.4l0.2,14.8c-0.3-0.5-0.7-0.9-1-1.3c-1-1.3-6.1-4.2-6.1-4.2s1.7,5.6,2.5,6.6c0.7,0.9,3,2.9,4.6,2.2l0.2,16.5c-3.4,3-7.9,6.8-8.5,5.4c-1.5-3.2-4.7-3-5.2-1.5c-0.5,1.6,4.2,10.2,5.8,10.1c0.8-0.1,4.4-4.9,8-10l0.1,5.3c1.6,2.1,3.8,0.5,4.6-0.7l0.3-11c1.1-1.6,4-4.5,7.1-7.9c0.2,12.1,0.3,22.8,0.3,22.8c4.9-5.2,16.7-4,16.7-4L632.3,54.7z"/> | |
| 513 | + | </svg> |
addedsrc/App.tsx+131 −0
| 1 | + | import { useEffect, useState, useSyncExternalStore } from 'react'; | |
| 2 | + | import { Game } from './game/engine'; | |
| 3 | + | import { loadPrefs, savePrefs, type Prefs } from './game/prefs'; | |
| 4 | + | import { loadSave } from './game/save'; | |
| 5 | + | import { play, setSoundEnabled, setVolume } from './game/sound'; | |
| 6 | + | import { WIND_EN, WIND_NAMES } from './game/tiles'; | |
| 7 | + | import type { SeatId } from './game/types'; | |
| 8 | + | import { Center } from './ui/Center'; | |
| 9 | + | import { Help } from './ui/Help'; | |
| 10 | + | import { Seat } from './ui/Seat'; | |
| 11 | + | import { Settings } from './ui/Settings'; | |
| 12 | + | import { SoundToggle } from './ui/SoundToggle'; | |
| 13 | + | import './styles.css'; | |
| 14 | + | ||
| 15 | + | const SEATS: SeatId[] = [0, 1, 2, 3]; | |
| 16 | + | ||
| 17 | + | export default function App() { | |
| 18 | + | const [prefs, setPrefs] = useState<Prefs>(loadPrefs); | |
| 19 | + | // Built once, from the preferences as they stood at startup; after that the | |
| 20 | + | // settings screen edits the live game rather than replacing it. | |
| 21 | + | const [game] = useState(() => new Game(prefs.names, prefs.rules)); | |
| 22 | + | useSyncExternalStore(game.subscribe, game.getSnapshot); | |
| 23 | + | const [helpSeat, setHelpSeat] = useState<SeatId | null>(null); | |
| 24 | + | const [settingsOpen, setSettingsOpen] = useState(false); | |
| 25 | + | // Read once at startup; after that the live game is the source of truth. | |
| 26 | + | const [saved] = useState(() => loadSave()); | |
| 27 | + | const phase = game.state.phase; | |
| 28 | + | ||
| 29 | + | // Names and stakes live in the game state as well as in the preferences — | |
| 30 | + | // saved games keep the settings they were played under. | |
| 31 | + | const commitSettings = (p: Prefs) => { | |
| 32 | + | setPrefs(p); | |
| 33 | + | game.applySettings(p.names, p.rules); | |
| 34 | + | setSettingsOpen(false); | |
| 35 | + | }; | |
| 36 | + | // The panel auditions sound changes as you make them, so cancelling has to | |
| 37 | + | // put the audio back the way it was. | |
| 38 | + | const cancelSettings = () => { | |
| 39 | + | setSoundEnabled(prefs.sound); | |
| 40 | + | setVolume(prefs.volume); | |
| 41 | + | setSettingsOpen(false); | |
| 42 | + | }; | |
| 43 | + | const settingsPanel = settingsOpen && ( | |
| 44 | + | <Settings | |
| 45 | + | prefs={prefs} | |
| 46 | + | onSave={commitSettings} | |
| 47 | + | onClose={cancelSettings} | |
| 48 | + | stakesLocked={phase !== 'lobby' && phase !== 'gameEnd'} | |
| 49 | + | /> | |
| 50 | + | ); | |
| 51 | + | ||
| 52 | + | // Preferences are written straight through, so a mute survives a refresh. | |
| 53 | + | useEffect(() => { | |
| 54 | + | savePrefs(prefs); | |
| 55 | + | setSoundEnabled(prefs.sound); | |
| 56 | + | setVolume(prefs.volume); | |
| 57 | + | }, [prefs]); | |
| 58 | + | useEffect(() => game.onSound(play), [game]); | |
| 59 | + | ||
| 60 | + | if (phase === 'lobby') { | |
| 61 | + | const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0; | |
| 62 | + | return ( | |
| 63 | + | <div className="lobby"> | |
| 64 | + | <h1>台灣麻將</h1> | |
| 65 | + | <p className="sub">Taiwanese Mahjong · 16 tiles · four players, one screen</p> | |
| 66 | + | <p className="hint"> | |
| 67 | + | 把擋板放在螢幕四邊,各自遮住自己的牌。 | |
| 68 | + | <br /> | |
| 69 | + | Stand a card over your edge of the screen to hide your hand. | |
| 70 | + | </p> | |
| 71 | + | ||
| 72 | + | {saved && ( | |
| 73 | + | <div className="resume"> | |
| 74 | + | <div className="resume-line"> | |
| 75 | + | {WIND_NAMES[round]}圈 · 第 {saved.state.handNumber} 局 · {WIND_EN[round]} round, hand{' '} | |
| 76 | + | {saved.state.handNumber} | |
| 77 | + | </div> | |
| 78 | + | <div className="resume-scores"> | |
| 79 | + | {saved.state.players.map((p, i) => ( | |
| 80 | + | <span key={i}> | |
| 81 | + | {p.name} <b>{p.score}</b> | |
| 82 | + | </span> | |
| 83 | + | ))} | |
| 84 | + | </div> | |
| 85 | + | <div className="resume-when">存於 saved {relativeTime(saved.savedAt)}</div> | |
| 86 | + | </div> | |
| 87 | + | )} | |
| 88 | + | ||
| 89 | + | <div className="lobby-buttons"> | |
| 90 | + | {saved && ( | |
| 91 | + | <button className="btn big-btn" onClick={() => game.restore(saved.state)}> | |
| 92 | + | 繼續對局 Resume | |
| 93 | + | </button> | |
| 94 | + | )} | |
| 95 | + | <button className={`btn big-btn ${saved ? 'ghost' : ''}`} onClick={() => game.newGame()}> | |
| 96 | + | {saved ? '開新局 New game' : '開始 Start'} | |
| 97 | + | </button> | |
| 98 | + | </div> | |
| 99 | + | ||
| 100 | + | <div className="lobby-extras"> | |
| 101 | + | <button className="btn ghost sound-toggle" onClick={() => setSettingsOpen(true)}> | |
| 102 | + | ⚙ 設定 Settings | |
| 103 | + | </button> | |
| 104 | + | <SoundToggle prefs={prefs} onPrefs={setPrefs} /> | |
| 105 | + | </div> | |
| 106 | + | {settingsPanel} | |
| 107 | + | </div> | |
| 108 | + | ); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | return ( | |
| 112 | + | <div className="table"> | |
| 113 | + | {SEATS.map((s) => ( | |
| 114 | + | <Seat key={s} game={game} seat={s} onHelp={() => setHelpSeat(s)} /> | |
| 115 | + | ))} | |
| 116 | + | <Center game={game} prefs={prefs} onPrefs={setPrefs} onSettings={() => setSettingsOpen(true)} /> | |
| 117 | + | {helpSeat !== null && <Help seat={helpSeat} onClose={() => setHelpSeat(null)} />} | |
| 118 | + | {settingsPanel} | |
| 119 | + | </div> | |
| 120 | + | ); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | function relativeTime(ts: number): string { | |
| 124 | + | const mins = Math.round((Date.now() - ts) / 60000); | |
| 125 | + | if (mins < 1) return '剛剛 just now'; | |
| 126 | + | if (mins < 60) return `${mins} 分鐘前 · ${mins} min ago`; | |
| 127 | + | const hours = Math.round(mins / 60); | |
| 128 | + | if (hours < 24) return `${hours} 小時前 · ${hours} h ago`; | |
| 129 | + | const days = Math.round(hours / 24); | |
| 130 | + | return `${days} 天前 · ${days} d ago`; | |
| 131 | + | } |
addedsrc/assets/hero.pngbinary
Binary file
addedsrc/assets/vite.svg+1 −0
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="29.961" y="-17.13" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-13.43" y="-22.082" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="34.321" y="-37.644" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="38.847" y="-10.552" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="22.45" y="-1.645" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="32.919" y="11.36" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter></defs></svg> |
addedsrc/game/engine.test.ts+520 −0
| 1 | + | import { describe, expect, it } from 'vitest'; | |
| 2 | + | import { Game } from './engine'; | |
| 3 | + | import { decompose, isWinningHand, waits } from './hu'; | |
| 4 | + | import { scoreHand } from './tai'; | |
| 5 | + | import { buildWall, NUM_BASIC, type Tile } from './tiles'; | |
| 6 | + | import { DEFAULT_RULES, type Meld, type SeatId } from './types'; | |
| 7 | + | ||
| 8 | + | const SEATS: SeatId[] = [0, 1, 2, 3]; | |
| 9 | + | ||
| 10 | + | /** | |
| 11 | + | * Force a seat one tile from home: four pungs down and `hand` concealed, so | |
| 12 | + | * the hand turns on a single tile. Tile conservation goes out of the window, | |
| 13 | + | * which is fine — the tests that care about it deal properly. | |
| 14 | + | */ | |
| 15 | + | function setUpReadyHand(game: Game, seat: SeatId, hand: Tile[]) { | |
| 16 | + | const p = game.state.players[seat]; | |
| 17 | + | const from = ((seat + 1) % 4) as SeatId; | |
| 18 | + | p.melds = [9, 12, 15, 18].map( | |
| 19 | + | (t): Meld => ({ kind: 'pung', tiles: [t, t, t], from }), | |
| 20 | + | ); | |
| 21 | + | p.hand = [...hand]; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | describe('wall', () => { | |
| 25 | + | it('has 144 tiles with the right multiplicities', () => { | |
| 26 | + | const w = buildWall(); | |
| 27 | + | expect(w.length).toBe(144); | |
| 28 | + | for (let t = 0; t < NUM_BASIC; t++) expect(w.filter((x) => x === t).length).toBe(4); | |
| 29 | + | for (let t = NUM_BASIC; t < 42; t++) expect(w.filter((x) => x === t).length).toBe(1); | |
| 30 | + | }); | |
| 31 | + | }); | |
| 32 | + | ||
| 33 | + | describe('hu detection', () => { | |
| 34 | + | it('accepts a 17-tile hand of five sets and a pair', () => { | |
| 35 | + | // 123m 456m 789m 111s 東東東 + 白白 → 5 sets + pair | |
| 36 | + | const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33, 33]; | |
| 37 | + | expect(isWinningHand(h, 0)).toBe(true); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | it('rejects a hand one tile short of a set', () => { | |
| 41 | + | const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 28, 33, 33]; | |
| 42 | + | expect(isWinningHand(h, 0)).toBe(false); | |
| 43 | + | }); | |
| 44 | + | ||
| 45 | + | it('works with melds already exposed', () => { | |
| 46 | + | const h: Tile[] = [0, 1, 2, 3, 4, 5, 33, 33]; // 2 sets + pair, 3 melds down | |
| 47 | + | expect(isWinningHand(h, 3)).toBe(true); | |
| 48 | + | }); | |
| 49 | + | ||
| 50 | + | it('finds both readings of 111222333', () => { | |
| 51 | + | const d = decompose([0, 0, 0, 1, 1, 1, 2, 2, 2, 33, 33], 3); | |
| 52 | + | const kinds = d.map((x) => x.sets.map((s) => s.type).sort().join('')); | |
| 53 | + | expect(kinds).toContain('pungpungpung'); | |
| 54 | + | expect(kinds).toContain('chowchowchow'); | |
| 55 | + | }); | |
| 56 | + | ||
| 57 | + | it('computes waits', () => { | |
| 58 | + | const h: Tile[] = [0, 1, 2, 3, 4, 5, 33]; // 2 melds down, waiting to pair 白 | |
| 59 | + | expect(waits(h, 3)).toEqual([33]); | |
| 60 | + | }); | |
| 61 | + | }); | |
| 62 | + | ||
| 63 | + | describe('scoring', () => { | |
| 64 | + | const baseCtx = { | |
| 65 | + | winner: 0 as SeatId, | |
| 66 | + | dealer: 1 as SeatId, | |
| 67 | + | dealerStreak: 0, | |
| 68 | + | roundWind: 0, | |
| 69 | + | melds: [], | |
| 70 | + | concealed: [] as Tile[], | |
| 71 | + | winTile: 0, | |
| 72 | + | selfDraw: false, | |
| 73 | + | loser: 1 as SeatId, | |
| 74 | + | flowers: [] as Tile[], | |
| 75 | + | robKong: false, | |
| 76 | + | afterKong: false, | |
| 77 | + | lastTile: false, | |
| 78 | + | lastDiscard: false, | |
| 79 | + | heavenly: false, | |
| 80 | + | earthly: false, | |
| 81 | + | humanly: false, | |
| 82 | + | }; | |
| 83 | + | ||
| 84 | + | it('scores 清一色 + 碰碰胡', () => { | |
| 85 | + | // 111m 222m 333m 444m 555m + 66m, all concealed, won on a discard | |
| 86 | + | const concealed: Tile[] = [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 5, 5]; | |
| 87 | + | const r = scoreHand({ ...baseCtx, concealed, winTile: 4 }); | |
| 88 | + | const names = r.items.map((i) => i.name); | |
| 89 | + | expect(names).toContain('清一色'); | |
| 90 | + | expect(names).toContain('碰碰胡'); | |
| 91 | + | expect(names).toContain('門清'); | |
| 92 | + | }); | |
| 93 | + | ||
| 94 | + | it('scores 大三元', () => { | |
| 95 | + | const concealed: Tile[] = [31, 31, 31, 32, 32, 32, 33, 33, 0, 1, 2, 3, 4, 5, 9, 9]; | |
| 96 | + | const r = scoreHand({ ...baseCtx, concealed, winTile: 33 }); | |
| 97 | + | expect(r.items.map((i) => i.name)).toContain('大三元'); | |
| 98 | + | }); | |
| 99 | + | ||
| 100 | + | it('counts seat flowers', () => { | |
| 101 | + | const concealed: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33]; | |
| 102 | + | // winner is seat 0, dealer is seat 1 → seat wind index 3 → 冬(37) and 竹(41) | |
| 103 | + | const r = scoreHand({ ...baseCtx, concealed, winTile: 33, flowers: [37, 41] }); | |
| 104 | + | expect(r.items.find((i) => i.name.startsWith('正花'))?.tai).toBe(2); | |
| 105 | + | }); | |
| 106 | + | }); | |
| 107 | + | ||
| 108 | + | /** | |
| 109 | + | * Plays whole hands with a simple bot to shake out state-machine bugs. | |
| 110 | + | * `hurry` exercises the non-blocking path: at most one seat answers a discard | |
| 111 | + | * before the next player draws and closes the window on everyone else. | |
| 112 | + | */ | |
| 113 | + | function playRandomHand(game: Game, rng: () => number, maxSteps = 4000, hurry = false) { | |
| 114 | + | let steps = 0; | |
| 115 | + | while (game.state.phase !== 'handEnd' && steps++ < maxSteps) { | |
| 116 | + | const s = game.state; | |
| 117 | + | if (s.phase === 'turn') { | |
| 118 | + | const seat = s.turn; | |
| 119 | + | if (game.canSelfDrawWin(seat)) { | |
| 120 | + | game.declareSelfDraw(seat); | |
| 121 | + | continue; | |
| 122 | + | } | |
| 123 | + | const ak = game.concealedKongs(seat); | |
| 124 | + | if (ak.length && rng() < 0.5) { | |
| 125 | + | game.declareConcealedKong(seat, ak[0]); | |
| 126 | + | continue; | |
| 127 | + | } | |
| 128 | + | const gk = game.addedKongs(seat); | |
| 129 | + | if (gk.length && rng() < 0.5) { | |
| 130 | + | game.declareAddedKong(seat, gk[0]); | |
| 131 | + | continue; | |
| 132 | + | } | |
| 133 | + | const hand = s.players[seat].hand; | |
| 134 | + | game.discard(seat, hand[Math.floor(rng() * hand.length)]); | |
| 135 | + | } else if (s.phase === 'claims' || s.phase === 'robkong') { | |
| 136 | + | const seat = (Object.keys(s.options) as unknown as number[]) | |
| 137 | + | .map(Number) | |
| 138 | + | .find((k) => s.responses[k as SeatId] === undefined) as SeatId | undefined; | |
| 139 | + | if (seat === undefined) throw new Error('claims phase with no pending seat'); | |
| 140 | + | const opts = s.options[seat]!; | |
| 141 | + | const hu = opts.find((o) => o.type === 'hu'); | |
| 142 | + | if (hu) game.respond(seat, { type: 'hu' }); | |
| 143 | + | else if (rng() < 0.35) game.respond(seat, { type: opts[0].type, with: opts[0].with }); | |
| 144 | + | else if (hurry) game.resolveNow(); | |
| 145 | + | else game.respond(seat, 'pass'); | |
| 146 | + | } else { | |
| 147 | + | break; | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | if (steps >= maxSteps) throw new Error('hand did not terminate'); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | describe('saving', () => { | |
| 154 | + | it('round-trips the whole game through JSON', () => { | |
| 155 | + | let seed = 555; | |
| 156 | + | const rng = () => { | |
| 157 | + | seed = (seed * 1103515245 + 12345) & 0x7fffffff; | |
| 158 | + | return seed / 0x7fffffff; | |
| 159 | + | }; | |
| 160 | + | const game = new Game(); | |
| 161 | + | game.startHand(99); | |
| 162 | + | // Play part of a hand so melds, flowers and discards are all populated. | |
| 163 | + | for (let i = 0; i < 40 && game.state.phase !== 'handEnd'; i++) { | |
| 164 | + | const s = game.state; | |
| 165 | + | if (s.phase === 'turn') { | |
| 166 | + | const hand = s.players[s.turn].hand; | |
| 167 | + | game.discard(s.turn, hand[Math.floor(rng() * hand.length)]); | |
| 168 | + | } else { | |
| 169 | + | game.resolveNow(); | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | const clone = JSON.parse(JSON.stringify(game.state)); | |
| 174 | + | expect(clone).toEqual(game.state); | |
| 175 | + | ||
| 176 | + | // A restored game keeps playing from exactly where it left off. | |
| 177 | + | const resumed = new Game(); | |
| 178 | + | resumed.restore(clone); | |
| 179 | + | expect(resumed.state.turn).toBe(game.state.turn); | |
| 180 | + | expect(resumed.state.players[0].hand).toEqual(game.state.players[0].hand); | |
| 181 | + | if (resumed.state.phase === 'turn') { | |
| 182 | + | const hand = resumed.state.players[resumed.state.turn].hand; | |
| 183 | + | const before = hand.length; | |
| 184 | + | resumed.discard(resumed.state.turn, hand[0]); | |
| 185 | + | expect(resumed.state.players.some((p) => p.discards.length > 0)).toBe(true); | |
| 186 | + | expect(before).toBeGreaterThan(0); | |
| 187 | + | } | |
| 188 | + | }); | |
| 189 | + | }); | |
| 190 | + | ||
| 191 | + | describe('full hands', () => { | |
| 192 | + | it('lets the next player close the claim window without waiting', () => { | |
| 193 | + | let seed = 4242; | |
| 194 | + | const rng = () => { | |
| 195 | + | seed = (seed * 1103515245 + 12345) & 0x7fffffff; | |
| 196 | + | return seed / 0x7fffffff; | |
| 197 | + | }; | |
| 198 | + | const game = new Game(); | |
| 199 | + | for (let h = 0; h < 60; h++) { | |
| 200 | + | game.startHand(h * 104729 + 11); | |
| 201 | + | playRandomHand(game, rng, 4000, true); | |
| 202 | + | const s = game.state; | |
| 203 | + | expect(s.phase).toBe('handEnd'); | |
| 204 | + | expect(s.drawnFront + s.drawnBack + s.wall.length).toBe(144); | |
| 205 | + | expect(s.players.reduce((a, p) => a + p.score, 0)).toBe(400); | |
| 206 | + | game.nextHand(); | |
| 207 | + | if (game.state.phase === 'gameEnd') break; | |
| 208 | + | } | |
| 209 | + | }); | |
| 210 | + | ||
| 211 | + | it('honours a claim already declared when the window is closed', () => { | |
| 212 | + | const game = new Game(); | |
| 213 | + | game.startHand(7); | |
| 214 | + | const s = game.state; | |
| 215 | + | // Give the seat to the dealer's right a pair to pung with. | |
| 216 | + | const victim = ((s.dealer + 2) % 4) as SeatId; | |
| 217 | + | const tile = s.players[s.dealer].hand[0]; | |
| 218 | + | s.players[victim].hand = [tile, tile, ...s.players[victim].hand.slice(2)]; | |
| 219 | + | game.discard(s.dealer, tile); | |
| 220 | + | expect(s.phase).toBe('claims'); | |
| 221 | + | game.respond(victim, { type: 'pung' }); | |
| 222 | + | // The other seats never answer; the next player draws anyway. | |
| 223 | + | game.resolveNow(); | |
| 224 | + | expect(s.players[victim].melds.some((m) => m.kind === 'pung')).toBe(true); | |
| 225 | + | expect(s.turn).toBe(victim); | |
| 226 | + | }); | |
| 227 | + | ||
| 228 | + | it('takes back a mis-tapped discard', () => { | |
| 229 | + | const game = new Game(); | |
| 230 | + | game.startHand(11); | |
| 231 | + | const s = game.state; | |
| 232 | + | const before = JSON.parse(JSON.stringify(s)); | |
| 233 | + | expect(game.undoable()).toBeNull(); | |
| 234 | + | ||
| 235 | + | game.discard(s.dealer, s.players[s.dealer].hand[0]); | |
| 236 | + | expect(game.undoable()).not.toBeNull(); | |
| 237 | + | game.undo(); | |
| 238 | + | ||
| 239 | + | expect(game.state).toEqual(before); | |
| 240 | + | expect(game.undoable()).toBeNull(); | |
| 241 | + | }); | |
| 242 | + | ||
| 243 | + | it('takes back a pass, handing the claim buttons back', () => { | |
| 244 | + | const game = new Game(); | |
| 245 | + | game.startHand(7); | |
| 246 | + | const s = game.state; | |
| 247 | + | const victim = ((s.dealer + 2) % 4) as SeatId; | |
| 248 | + | const tile = s.players[s.dealer].hand[0]; | |
| 249 | + | s.players[victim].hand = [tile, tile, ...s.players[victim].hand.slice(2)]; | |
| 250 | + | game.discard(s.dealer, tile); | |
| 251 | + | ||
| 252 | + | game.respond(victim, 'pass'); | |
| 253 | + | expect(game.state.responses[victim]).toBe('pass'); | |
| 254 | + | game.undo(); | |
| 255 | + | expect(game.state.responses[victim]).toBeUndefined(); | |
| 256 | + | expect(game.state.options[victim]).toBeTruthy(); | |
| 257 | + | ||
| 258 | + | // ...and the claim can then be made for real. | |
| 259 | + | game.respond(victim, { type: 'pung' }); | |
| 260 | + | game.resolveNow(); | |
| 261 | + | expect(game.state.players[victim].melds.some((m) => m.kind === 'pung')).toBe(true); | |
| 262 | + | }); | |
| 263 | + | ||
| 264 | + | it('caps the undo stack and forgets the previous hand', () => { | |
| 265 | + | const game = new Game(); | |
| 266 | + | game.startHand(3); | |
| 267 | + | for (let i = 0; i < 30; i++) { | |
| 268 | + | const s = game.state; | |
| 269 | + | if (s.phase === 'turn') game.discard(s.turn, s.players[s.turn].hand[0]); | |
| 270 | + | else game.resolveNow(); | |
| 271 | + | } | |
| 272 | + | let depth = 0; | |
| 273 | + | while (game.undoable()) { | |
| 274 | + | game.undo(); | |
| 275 | + | depth++; | |
| 276 | + | } | |
| 277 | + | expect(depth).toBe(20); | |
| 278 | + | ||
| 279 | + | game.startHand(4); | |
| 280 | + | expect(game.undoable()).toBeNull(); | |
| 281 | + | }); | |
| 282 | + | ||
| 283 | + | it('過水 locks a declined win out until the next draw', () => { | |
| 284 | + | const game = new Game(); | |
| 285 | + | game.startHand(21); | |
| 286 | + | const s = game.state; | |
| 287 | + | // Put a seat one tile from home: four melds down, 123m + 白白 in hand, | |
| 288 | + | // so it wins on 一萬 or 四萬. | |
| 289 | + | const victim = ((s.dealer + 2) % 4) as SeatId; | |
| 290 | + | setUpReadyHand(game, victim, [1, 2, 33, 33]); // 聽 一萬 / 四萬 | |
| 291 | + | const tile = 3; // 四萬 | |
| 292 | + | ||
| 293 | + | game.state.players[s.dealer].hand = [tile, ...game.state.players[s.dealer].hand.slice(1)]; | |
| 294 | + | game.discard(s.dealer, tile); | |
| 295 | + | expect(s.options[victim]?.some((o) => o.type === 'hu')).toBe(true); | |
| 296 | + | game.respond(victim, 'pass'); | |
| 297 | + | game.resolveNow(); | |
| 298 | + | expect(game.sacredTiles(victim)).toContain(tile); | |
| 299 | + | ||
| 300 | + | // Same tile again from the next seat round: no 胡 on offer this time. | |
| 301 | + | const other = s.turn; | |
| 302 | + | expect(other).not.toBe(victim); | |
| 303 | + | game.state.players[other].hand = [tile, ...game.state.players[other].hand.slice(1)]; | |
| 304 | + | game.discard(other, tile); | |
| 305 | + | expect(s.options[victim]?.some((o) => o.type === 'hu')).toBeFalsy(); | |
| 306 | + | }); | |
| 307 | + | ||
| 308 | + | it('過水 lifts once the locked seat draws again', () => { | |
| 309 | + | const game = new Game(); | |
| 310 | + | game.startHand(21); | |
| 311 | + | const s = game.state; | |
| 312 | + | const victim = ((s.dealer + 2) % 4) as SeatId; | |
| 313 | + | setUpReadyHand(game, victim, [1, 2, 33, 33]); // 聽 一萬 / 四萬 | |
| 314 | + | s.sacred[victim] = [3]; | |
| 315 | + | ||
| 316 | + | // Walk the table round until the locked seat draws. | |
| 317 | + | for (let i = 0; i < 40 && !(s.phase === 'turn' && s.turn === victim); i++) { | |
| 318 | + | if (s.phase === 'turn') game.discard(s.turn, s.players[s.turn].hand[0]); | |
| 319 | + | else game.resolveNow(); | |
| 320 | + | } | |
| 321 | + | expect(s.turn).toBe(victim); | |
| 322 | + | expect(game.sacredTiles(victim)).toEqual([]); | |
| 323 | + | }); | |
| 324 | + | ||
| 325 | + | it('過水 can be switched off, or lifted by a claim', () => { | |
| 326 | + | for (const clearedByClaim of [false, true]) { | |
| 327 | + | const game = new Game(undefined, { ...DEFAULT_RULES, sacredClearedByClaim: clearedByClaim }); | |
| 328 | + | game.startHand(21); | |
| 329 | + | const s = game.state; | |
| 330 | + | const victim = ((s.dealer + 1) % 4) as SeatId; // to the discarder's left, so it can chow | |
| 331 | + | setUpReadyHand(game, victim, [1, 2, 33, 33]); // 聽 一萬 / 四萬 | |
| 332 | + | s.sacred[victim] = [3]; | |
| 333 | + | game.state.players[s.dealer].hand = [3, ...game.state.players[s.dealer].hand.slice(1)]; | |
| 334 | + | game.discard(s.dealer, 3); | |
| 335 | + | game.respond(victim, { type: 'chow', with: [1, 2] }); | |
| 336 | + | game.resolveNow(); | |
| 337 | + | expect(game.sacredTiles(victim)).toEqual(clearedByClaim ? [] : [3]); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | const off = new Game(undefined, { ...DEFAULT_RULES, sacredDiscard: false }); | |
| 341 | + | off.startHand(21); | |
| 342 | + | const seat = ((off.state.dealer + 2) % 4) as SeatId; | |
| 343 | + | off.state.sacred[seat] = [3]; | |
| 344 | + | expect(off.sacredTiles(seat)).toEqual([]); | |
| 345 | + | }); | |
| 346 | + | ||
| 347 | + | it('一炮多響 pays every seat that calls on the tile', () => { | |
| 348 | + | const game = new Game(undefined, { ...DEFAULT_RULES, multipleWinners: true }); | |
| 349 | + | game.startHand(41); | |
| 350 | + | const s = game.state; | |
| 351 | + | const a = ((s.dealer + 1) % 4) as SeatId; | |
| 352 | + | const b = ((s.dealer + 2) % 4) as SeatId; | |
| 353 | + | setUpReadyHand(game, a, [1, 2, 33, 33]); | |
| 354 | + | setUpReadyHand(game, b, [1, 2, 32, 32]); | |
| 355 | + | s.players[s.dealer].hand = [3, ...s.players[s.dealer].hand.slice(1)]; | |
| 356 | + | ||
| 357 | + | game.discard(s.dealer, 3); | |
| 358 | + | game.respond(a, { type: 'hu' }); | |
| 359 | + | // The nearer seat does not get to close the door on the further one. | |
| 360 | + | expect(s.phase).toBe('claims'); | |
| 361 | + | game.respond(b, { type: 'hu' }); | |
| 362 | + | ||
| 363 | + | const r = s.result!; | |
| 364 | + | expect(r.winners.map((w) => w.seat).sort()).toEqual([a, b].sort()); | |
| 365 | + | expect(r.loser).toBe(s.dealer); | |
| 366 | + | expect(r.deltas[a]).toBeGreaterThan(0); | |
| 367 | + | expect(r.deltas[b]).toBeGreaterThan(0); | |
| 368 | + | expect(r.deltas[s.dealer]).toBe(-(r.deltas[a] + r.deltas[b])); | |
| 369 | + | expect(r.deltas.reduce((x, y) => x + y, 0)).toBe(0); | |
| 370 | + | }); | |
| 371 | + | ||
| 372 | + | it('awards the discard to the nearest caller when 一炮多響 is off', () => { | |
| 373 | + | const game = new Game(); | |
| 374 | + | game.startHand(41); | |
| 375 | + | const s = game.state; | |
| 376 | + | const a = ((s.dealer + 1) % 4) as SeatId; | |
| 377 | + | const b = ((s.dealer + 2) % 4) as SeatId; | |
| 378 | + | setUpReadyHand(game, a, [1, 2, 33, 33]); | |
| 379 | + | setUpReadyHand(game, b, [1, 2, 32, 32]); | |
| 380 | + | s.players[s.dealer].hand = [3, ...s.players[s.dealer].hand.slice(1)]; | |
| 381 | + | ||
| 382 | + | game.discard(s.dealer, 3); | |
| 383 | + | game.respond(b, { type: 'hu' }); | |
| 384 | + | game.respond(a, { type: 'hu' }); | |
| 385 | + | expect(s.result!.winners.map((w) => w.seat)).toEqual([a]); | |
| 386 | + | }); | |
| 387 | + | ||
| 388 | + | it('包牌 — whoever fed the third dragon answers for the whole hand', () => { | |
| 389 | + | for (const liability of [true, false]) { | |
| 390 | + | const game = new Game(undefined, { ...DEFAULT_RULES, liability }); | |
| 391 | + | game.startHand(31); | |
| 392 | + | const s = game.state; | |
| 393 | + | const winner = ((s.dealer + 1) % 4) as SeatId; | |
| 394 | + | const feeder = ((s.dealer + 2) % 4) as SeatId; | |
| 395 | + | const other = ((s.dealer + 3) % 4) as SeatId; | |
| 396 | + | ||
| 397 | + | const p = s.players[winner]; | |
| 398 | + | p.melds = [ | |
| 399 | + | { kind: 'pung', tiles: [31, 31, 31], from: other }, // 中 | |
| 400 | + | { kind: 'pung', tiles: [9, 9, 9], from: other }, | |
| 401 | + | { kind: 'pung', tiles: [32, 32, 32], from: other }, // 發 | |
| 402 | + | { kind: 'pung', tiles: [33, 33, 33], from: feeder }, // 白 — the third dragon | |
| 403 | + | ]; | |
| 404 | + | p.hand = [1, 2, 5, 5, 3]; | |
| 405 | + | s.turn = winner; | |
| 406 | + | s.phase = 'turn'; | |
| 407 | + | s.drawnTile = 3; | |
| 408 | + | ||
| 409 | + | game.declareSelfDraw(winner); | |
| 410 | + | const r = s.result!; | |
| 411 | + | expect(r.winners[0].items.map((i) => i.name)).toContain('大三元'); | |
| 412 | + | expect(r.winners[0].liable).toBe(liability ? feeder : null); | |
| 413 | + | if (liability) { | |
| 414 | + | // Everyone else is out of it; the feeder covers all three shares. | |
| 415 | + | expect(r.deltas[other]).toBe(0); | |
| 416 | + | expect(r.deltas[s.dealer]).toBe(0); | |
| 417 | + | expect(r.deltas[feeder]).toBe(-r.deltas[winner]); | |
| 418 | + | } else { | |
| 419 | + | expect(r.deltas[other]).toBeLessThan(0); | |
| 420 | + | expect(r.deltas[feeder]).toBeLessThan(0); | |
| 421 | + | } | |
| 422 | + | expect(r.deltas.reduce((x, y) => x + y, 0)).toBe(0); | |
| 423 | + | } | |
| 424 | + | }); | |
| 425 | + | ||
| 426 | + | it('lets 包牌 go when the third dragon came from the winner’s own hand', () => { | |
| 427 | + | const game = new Game(); | |
| 428 | + | game.startHand(31); | |
| 429 | + | const s = game.state; | |
| 430 | + | const winner = ((s.dealer + 1) % 4) as SeatId; | |
| 431 | + | const other = ((s.dealer + 3) % 4) as SeatId; | |
| 432 | + | const p = s.players[winner]; | |
| 433 | + | p.melds = [ | |
| 434 | + | { kind: 'pung', tiles: [31, 31, 31], from: other }, | |
| 435 | + | { kind: 'pung', tiles: [9, 9, 9], from: other }, | |
| 436 | + | { kind: 'pung', tiles: [32, 32, 32], from: other }, | |
| 437 | + | { kind: 'ankong', tiles: [33, 33, 33, 33] }, // 暗槓, nobody handed it over | |
| 438 | + | ]; | |
| 439 | + | p.hand = [1, 2, 5, 5, 3]; | |
| 440 | + | s.turn = winner; | |
| 441 | + | s.phase = 'turn'; | |
| 442 | + | s.drawnTile = 3; | |
| 443 | + | ||
| 444 | + | game.declareSelfDraw(winner); | |
| 445 | + | expect(s.result!.winners[0].items.map((i) => i.name)).toContain('大三元'); | |
| 446 | + | expect(s.result!.winners[0].liable).toBeNull(); | |
| 447 | + | }); | |
| 448 | + | ||
| 449 | + | it('keeps the chips zero-sum under every house rule', () => { | |
| 450 | + | const variants = [ | |
| 451 | + | { multipleWinners: true }, | |
| 452 | + | { multipleWinners: true, sacredDiscard: false }, | |
| 453 | + | { liability: false, sacredClearedByClaim: true }, | |
| 454 | + | ]; | |
| 455 | + | for (const [v, patch] of variants.entries()) { | |
| 456 | + | let seed = 900 + v; | |
| 457 | + | const rng = () => { | |
| 458 | + | seed = (seed * 1103515245 + 12345) & 0x7fffffff; | |
| 459 | + | return seed / 0x7fffffff; | |
| 460 | + | }; | |
| 461 | + | const game = new Game(undefined, { ...DEFAULT_RULES, ...patch }); | |
| 462 | + | for (let h = 0; h < 30; h++) { | |
| 463 | + | game.startHand(h * 7919 + v); | |
| 464 | + | playRandomHand(game, rng); | |
| 465 | + | const s = game.state; | |
| 466 | + | expect(s.phase).toBe('handEnd'); | |
| 467 | + | expect(s.players.reduce((a, p) => a + p.score, 0)).toBe(400); | |
| 468 | + | expect(s.drawnFront + s.drawnBack + s.wall.length).toBe(144); | |
| 469 | + | game.nextHand(); | |
| 470 | + | if (game.state.phase === 'gameEnd') break; | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | }); | |
| 474 | + | ||
| 475 | + | it('plays 200 random hands without corrupting state', () => { | |
| 476 | + | let seed = 12345; | |
| 477 | + | const rng = () => { | |
| 478 | + | seed = (seed * 1103515245 + 12345) & 0x7fffffff; | |
| 479 | + | return seed / 0x7fffffff; | |
| 480 | + | }; | |
| 481 | + | const game = new Game(); | |
| 482 | + | let wins = 0; | |
| 483 | + | let draws = 0; | |
| 484 | + | ||
| 485 | + | for (let h = 0; h < 200; h++) { | |
| 486 | + | game.startHand(h * 7919 + 3); | |
| 487 | + | playRandomHand(game, rng); | |
| 488 | + | const s = game.state; | |
| 489 | + | expect(s.phase).toBe('handEnd'); | |
| 490 | + | if (s.result!.drawGame) draws++; | |
| 491 | + | else wins++; | |
| 492 | + | ||
| 493 | + | // the wall square's bookkeeping matches the array it draws from | |
| 494 | + | expect(s.drawnFront + s.drawnBack + s.wall.length).toBe(144); | |
| 495 | + | expect(s.drawnFront).toBeGreaterThanOrEqual(65); // the deal | |
| 496 | + | ||
| 497 | + | // tile conservation: every tile is accounted for exactly once | |
| 498 | + | const all: Tile[] = [...s.wall]; | |
| 499 | + | for (const p of s.players) all.push(...p.hand, ...p.flowers, ...p.discards, ...p.melds.flatMap((m) => m.tiles)); | |
| 500 | + | expect(all.length).toBe(144); | |
| 501 | + | for (let t = 0; t < NUM_BASIC; t++) expect(all.filter((x) => x === t).length).toBe(4); | |
| 502 | + | ||
| 503 | + | // hand sizes are legal | |
| 504 | + | for (const seat of SEATS) { | |
| 505 | + | const p = s.players[seat]; | |
| 506 | + | const expected = 16 - p.melds.length * 3; | |
| 507 | + | expect(p.hand.length).toBeGreaterThanOrEqual(expected); | |
| 508 | + | expect(p.hand.length).toBeLessThanOrEqual(expected + 1); | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | // chips are zero-sum | |
| 512 | + | const total = s.players.reduce((a, p) => a + p.score, 0); | |
| 513 | + | expect(total).toBe(400); | |
| 514 | + | ||
| 515 | + | game.nextHand(); | |
| 516 | + | if (game.state.phase === 'gameEnd') break; | |
| 517 | + | } | |
| 518 | + | expect(wins + draws).toBeGreaterThan(0); | |
| 519 | + | }); | |
| 520 | + | }); |
addedsrc/game/engine.ts+928 −0
| 1 | + | import { isWinningHand, waits } from './hu'; | |
| 2 | + | import { dealerTai, scoreHand, type TaiItem } from './tai'; | |
| 3 | + | import { | |
| 4 | + | buildWall, | |
| 5 | + | EAST, | |
| 6 | + | isFlower, | |
| 7 | + | isSuited, | |
| 8 | + | labelOf, | |
| 9 | + | makeRng, | |
| 10 | + | RED, | |
| 11 | + | shuffle, | |
| 12 | + | sortTiles, | |
| 13 | + | WHITE, | |
| 14 | + | WIND_NAMES, | |
| 15 | + | type Tile, | |
| 16 | + | } from './tiles'; | |
| 17 | + | import { clearSave, saveGame } from './save'; | |
| 18 | + | import { DEFAULT_NAMES, DEFAULT_RULES, type Meld, type Player, type Rules, type SeatId } from './types'; | |
| 19 | + | ||
| 20 | + | export type Phase = 'lobby' | 'turn' | 'claims' | 'robkong' | 'handEnd' | 'gameEnd'; | |
| 21 | + | ||
| 22 | + | /** | |
| 23 | + | * Things worth hearing. The engine only names them; what they sound like, and | |
| 24 | + | * whether anything is audible at all, is the UI's business (`sound.ts`). | |
| 25 | + | */ | |
| 26 | + | export type SoundEvent = | |
| 27 | + | | 'deal' | |
| 28 | + | | 'discard' | |
| 29 | + | | 'claimWindow' | |
| 30 | + | | 'claim' | |
| 31 | + | | 'kong' | |
| 32 | + | | 'flower' | |
| 33 | + | | 'win' | |
| 34 | + | | 'drawGame' | |
| 35 | + | | 'undo'; | |
| 36 | + | ||
| 37 | + | export type ClaimType = 'hu' | 'kong' | 'pung' | 'chow'; | |
| 38 | + | ||
| 39 | + | export interface ClaimOption { | |
| 40 | + | type: ClaimType; | |
| 41 | + | /** For a chow: the two tiles from hand that complete it. */ | |
| 42 | + | with?: Tile[]; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | export interface Claim { | |
| 46 | + | type: ClaimType; | |
| 47 | + | with?: Tile[]; | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /** One seat's win. A hand has several of these only under 一炮多響. */ | |
| 51 | + | export interface WinnerResult { | |
| 52 | + | seat: SeatId; | |
| 53 | + | selfDraw: boolean; | |
| 54 | + | tai: number; | |
| 55 | + | items: TaiItem[]; | |
| 56 | + | winTile: Tile; | |
| 57 | + | /** 包牌 — the seat answering for this hand on its own, if any. */ | |
| 58 | + | liable: SeatId | null; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | export interface HandResult { | |
| 62 | + | drawGame: boolean; | |
| 63 | + | /** Empty on a 流局. */ | |
| 64 | + | winners: WinnerResult[]; | |
| 65 | + | /** The discarder, when the hand was won off a discard. */ | |
| 66 | + | loser: SeatId | null; | |
| 67 | + | deltas: number[]; | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | export interface GameState { | |
| 71 | + | rules: Rules; | |
| 72 | + | players: Player[]; | |
| 73 | + | wall: Tile[]; | |
| 74 | + | dealer: SeatId; | |
| 75 | + | dealerStreak: number; | |
| 76 | + | rotations: number; // completed dealer passes; roundWind = floor(rotations / 4) | |
| 77 | + | turn: SeatId; | |
| 78 | + | phase: Phase; | |
| 79 | + | drawnTile: Tile | null; | |
| 80 | + | /** True while the drawn tile is still held apart at the end of the hand. */ | |
| 81 | + | drawnSeparate: boolean; | |
| 82 | + | lastDiscard: { tile: Tile; from: SeatId } | null; | |
| 83 | + | /** Seats that may respond to the tile on the table, and with what. */ | |
| 84 | + | options: Partial<Record<SeatId, ClaimOption[]>>; | |
| 85 | + | responses: Partial<Record<SeatId, Claim | 'pass'>>; | |
| 86 | + | /** | |
| 87 | + | * 過水 — per seat, the tiles they have already declined to win on since | |
| 88 | + | * their last draw. Those tiles are dead to them until the lock lifts. | |
| 89 | + | */ | |
| 90 | + | sacred: Tile[][]; | |
| 91 | + | afterKong: boolean; | |
| 92 | + | robKongTile: Tile | null; | |
| 93 | + | /** How many of the original 144 wall positions have been taken off the front. */ | |
| 94 | + | drawnFront: number; | |
| 95 | + | /** ...and off the back (kong / flower replacements). */ | |
| 96 | + | drawnBack: number; | |
| 97 | + | result: HandResult | null; | |
| 98 | + | log: string[]; | |
| 99 | + | handNumber: number; | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | const SEATS: SeatId[] = [0, 1, 2, 3]; | |
| 103 | + | const next = (s: SeatId): SeatId => (((s + 1) % 4) as SeatId); | |
| 104 | + | ||
| 105 | + | /** How many actions back you can walk. Deeper than anyone will remember. */ | |
| 106 | + | const UNDO_DEPTH = 20; | |
| 107 | + | ||
| 108 | + | interface Snapshot { | |
| 109 | + | state: GameState; | |
| 110 | + | /** What pressing undo will take back, in the players' own words. */ | |
| 111 | + | zh: string; | |
| 112 | + | en: string; | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | export class Game { | |
| 116 | + | state: GameState; | |
| 117 | + | private listeners = new Set<() => void>(); | |
| 118 | + | private version = 0; | |
| 119 | + | ||
| 120 | + | constructor(names: string[] = DEFAULT_NAMES, rules: Rules = DEFAULT_RULES) { | |
| 121 | + | this.state = { | |
| 122 | + | rules, | |
| 123 | + | players: names.map((name) => ({ | |
| 124 | + | name, | |
| 125 | + | hand: [], | |
| 126 | + | melds: [], | |
| 127 | + | flowers: [], | |
| 128 | + | discards: [], | |
| 129 | + | score: rules.startingScore, | |
| 130 | + | })), | |
| 131 | + | wall: [], | |
| 132 | + | dealer: 0, | |
| 133 | + | dealerStreak: 0, | |
| 134 | + | rotations: 0, | |
| 135 | + | turn: 0, | |
| 136 | + | phase: 'lobby', | |
| 137 | + | drawnTile: null, | |
| 138 | + | drawnSeparate: false, | |
| 139 | + | lastDiscard: null, | |
| 140 | + | options: {}, | |
| 141 | + | responses: {}, | |
| 142 | + | sacred: [[], [], [], []], | |
| 143 | + | afterKong: false, | |
| 144 | + | robKongTile: null, | |
| 145 | + | drawnFront: 0, | |
| 146 | + | drawnBack: 0, | |
| 147 | + | result: null, | |
| 148 | + | log: [], | |
| 149 | + | handNumber: 0, | |
| 150 | + | }; | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // ---- store plumbing ---------------------------------------------------- | |
| 154 | + | subscribe = (fn: () => void) => { | |
| 155 | + | this.listeners.add(fn); | |
| 156 | + | return () => this.listeners.delete(fn); | |
| 157 | + | }; | |
| 158 | + | getSnapshot = () => this.version; | |
| 159 | + | private emit() { | |
| 160 | + | this.version++; | |
| 161 | + | saveGame(this.state); | |
| 162 | + | for (const l of this.listeners) l(); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // ---- undo -------------------------------------------------------------- | |
| 166 | + | /** | |
| 167 | + | * Four people share one touchscreen, so a mis-tap is the likeliest complaint | |
| 168 | + | * at the table. The whole state is plain JSON, so taking one back is just a | |
| 169 | + | * matter of having kept a copy. | |
| 170 | + | * | |
| 171 | + | * Snapshots are pushed by the methods a *button* calls — discard, respond, | |
| 172 | + | * the kong and win declarations — not by the internal steps they run, so one | |
| 173 | + | * press is one undo. They live outside the state, which is why an undo does | |
| 174 | + | * not survive a reload: what you can take back is what happened while | |
| 175 | + | * everyone was still sitting there watching it happen. | |
| 176 | + | */ | |
| 177 | + | private undoStack: Snapshot[] = []; | |
| 178 | + | ||
| 179 | + | private push(zh: string, en: string) { | |
| 180 | + | this.undoStack.push({ state: JSON.parse(JSON.stringify(this.state)) as GameState, zh, en }); | |
| 181 | + | if (this.undoStack.length > UNDO_DEPTH) this.undoStack.shift(); | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /** What undo would take back right now, or null if there is nothing. */ | |
| 185 | + | undoable(): { zh: string; en: string } | null { | |
| 186 | + | const top = this.undoStack[this.undoStack.length - 1]; | |
| 187 | + | return top ? { zh: top.zh, en: top.en } : null; | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | undo() { | |
| 191 | + | const snap = this.undoStack.pop(); | |
| 192 | + | if (!snap) return; | |
| 193 | + | this.state = snap.state; | |
| 194 | + | this.sfx('undo'); | |
| 195 | + | this.emit(); | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | private soundListeners = new Set<(e: SoundEvent) => void>(); | |
| 199 | + | /** Sound is a side channel, not state: it is never saved and never replayed. */ | |
| 200 | + | onSound = (fn: (e: SoundEvent) => void) => { | |
| 201 | + | this.soundListeners.add(fn); | |
| 202 | + | return () => { | |
| 203 | + | this.soundListeners.delete(fn); | |
| 204 | + | }; | |
| 205 | + | }; | |
| 206 | + | private sfx(e: SoundEvent) { | |
| 207 | + | for (const l of this.soundListeners) l(e); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /** | |
| 211 | + | * Take the settings screen's word for the names and the stakes. Chips are | |
| 212 | + | * only re-dealt before the first hand — changing 底 mid-game shouldn't | |
| 213 | + | * rewrite what everyone has already won. | |
| 214 | + | */ | |
| 215 | + | applySettings(names: string[], rules: Rules) { | |
| 216 | + | const s = this.state; | |
| 217 | + | s.rules = rules; | |
| 218 | + | names.forEach((name, i) => { | |
| 219 | + | s.players[i].name = name; | |
| 220 | + | }); | |
| 221 | + | if (s.phase === 'lobby') for (const p of s.players) p.score = rules.startingScore; | |
| 222 | + | this.emit(); | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /** Pick up a game saved earlier. */ | |
| 226 | + | restore(state: GameState) { | |
| 227 | + | this.state = state; | |
| 228 | + | this.emit(); | |
| 229 | + | } | |
| 230 | + | private say(msg: string) { | |
| 231 | + | this.state.log.unshift(msg); | |
| 232 | + | if (this.state.log.length > 40) this.state.log.pop(); | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | get roundWind() { | |
| 236 | + | return Math.floor(this.state.rotations / 4) % 4; | |
| 237 | + | } | |
| 238 | + | get wallLeft() { | |
| 239 | + | return this.state.wall.length - this.state.rules.wallReserve; | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // ---- hand setup -------------------------------------------------------- | |
| 243 | + | startHand(seed = Math.floor(Math.random() * 2 ** 31)) { | |
| 244 | + | const s = this.state; | |
| 245 | + | // Once the next hand is dealt the last one is settled and paid; there is | |
| 246 | + | // nothing left to take back, and the tiles are already shuffled away. | |
| 247 | + | this.undoStack = []; | |
| 248 | + | s.wall = shuffle(buildWall(), makeRng(seed)); | |
| 249 | + | s.handNumber++; | |
| 250 | + | for (const p of s.players) { | |
| 251 | + | p.hand = []; | |
| 252 | + | p.melds = []; | |
| 253 | + | p.flowers = []; | |
| 254 | + | p.discards = []; | |
| 255 | + | } | |
| 256 | + | s.result = null; | |
| 257 | + | s.lastDiscard = null; | |
| 258 | + | s.options = {}; | |
| 259 | + | s.responses = {}; | |
| 260 | + | s.sacred = [[], [], [], []]; | |
| 261 | + | s.afterKong = false; | |
| 262 | + | s.robKongTile = null; | |
| 263 | + | s.drawnFront = 0; | |
| 264 | + | s.drawnBack = 0; | |
| 265 | + | s.log = []; | |
| 266 | + | ||
| 267 | + | for (let round = 0; round < 4; round++) { | |
| 268 | + | for (let i = 0; i < 4; i++) { | |
| 269 | + | const seat = ((s.dealer + i) % 4) as SeatId; | |
| 270 | + | for (let k = 0; k < 4; k++) s.players[seat].hand.push(this.takeFront()); | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | // The dealer's 17th tile is the opening draw. | |
| 274 | + | const opener = this.takeFront(); | |
| 275 | + | s.players[s.dealer].hand.push(opener); | |
| 276 | + | ||
| 277 | + | // 補花 — replace flowers in seat order, repeating until nobody holds one. | |
| 278 | + | let dealerLast: Tile | null = null; | |
| 279 | + | let more = true; | |
| 280 | + | while (more) { | |
| 281 | + | more = false; | |
| 282 | + | for (let i = 0; i < 4; i++) { | |
| 283 | + | const seat = ((s.dealer + i) % 4) as SeatId; | |
| 284 | + | const r = this.replaceFlowers(seat); | |
| 285 | + | if (r.moved) more = true; | |
| 286 | + | if (seat === s.dealer && r.last !== null) dealerLast = r.last; | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | for (const p of s.players) p.hand = sortTiles(p.hand); | |
| 290 | + | ||
| 291 | + | s.turn = s.dealer; | |
| 292 | + | this.setDrawn(s.dealer, isFlower(opener) ? dealerLast : opener); | |
| 293 | + | s.phase = 'turn'; | |
| 294 | + | this.say(`${WIND_NAMES[this.roundWind]}圈 — 莊家 dealer: ${s.players[s.dealer].name}`); | |
| 295 | + | this.sfx('deal'); | |
| 296 | + | this.emit(); | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /** | |
| 300 | + | * Moves flowers out of a hand, drawing replacements from the back of the wall. | |
| 301 | + | * Returns the last replacement tile so the caller can keep marking it as the | |
| 302 | + | * tile the player just drew. | |
| 303 | + | */ | |
| 304 | + | private replaceFlowers(seat: SeatId): { moved: boolean; last: Tile | null } { | |
| 305 | + | const s = this.state; | |
| 306 | + | const p = s.players[seat]; | |
| 307 | + | let moved = false; | |
| 308 | + | let last: Tile | null = null; | |
| 309 | + | for (;;) { | |
| 310 | + | const i = p.hand.findIndex(isFlower); | |
| 311 | + | if (i < 0) break; | |
| 312 | + | const f = p.hand.splice(i, 1)[0]; | |
| 313 | + | p.flowers.push(f); | |
| 314 | + | p.flowers.sort((a, b) => a - b); | |
| 315 | + | moved = true; | |
| 316 | + | last = null; | |
| 317 | + | if (s.wall.length === 0) break; | |
| 318 | + | last = this.takeBack(); | |
| 319 | + | p.hand.push(last); | |
| 320 | + | } | |
| 321 | + | return { moved, last }; | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | /** | |
| 325 | + | * Park the tile a player just drew at the end of their hand, held apart from | |
| 326 | + | * the rest. Hands are never auto-sorted after the deal — the arrangement is | |
| 327 | + | * the player's, so we only ever append. | |
| 328 | + | */ | |
| 329 | + | private setDrawn(seat: SeatId, tile: Tile | null) { | |
| 330 | + | const s = this.state; | |
| 331 | + | s.drawnTile = tile; | |
| 332 | + | s.drawnSeparate = tile !== null; | |
| 333 | + | if (tile === null) return; | |
| 334 | + | const hand = s.players[seat].hand; | |
| 335 | + | const i = hand.lastIndexOf(tile); | |
| 336 | + | if (i >= 0) hand.splice(i, 1); | |
| 337 | + | hand.push(tile); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | /** Move a tile within a hand. Players arrange their own tiles however they like. */ | |
| 341 | + | reorderHand(seat: SeatId, from: number, to: number) { | |
| 342 | + | const s = this.state; | |
| 343 | + | const hand = s.players[seat].hand; | |
| 344 | + | if (from === to || from < 0 || to < 0 || from >= hand.length || to >= hand.length) return; | |
| 345 | + | const last = hand.length - 1; | |
| 346 | + | // Sliding the drawn tile into the hand (or something past it) merges it in. | |
| 347 | + | if (s.drawnSeparate && s.turn === seat && (from === last || to === last)) s.drawnSeparate = false; | |
| 348 | + | const [t] = hand.splice(from, 1); | |
| 349 | + | hand.splice(to, 0, t); | |
| 350 | + | this.emit(); | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | /** 理牌 — sort a hand back into suit order. */ | |
| 354 | + | sortHand(seat: SeatId) { | |
| 355 | + | const s = this.state; | |
| 356 | + | s.players[seat].hand = sortTiles(s.players[seat].hand); | |
| 357 | + | if (s.turn === seat) s.drawnSeparate = false; | |
| 358 | + | this.emit(); | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | /** Next tile off the front of the wall (the normal draw). */ | |
| 362 | + | private takeFront(): Tile { | |
| 363 | + | this.state.drawnFront++; | |
| 364 | + | return this.state.wall.shift()!; | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | /** Next tile off the back of the wall (kong and flower replacements). */ | |
| 368 | + | private takeBack(): Tile { | |
| 369 | + | this.state.drawnBack++; | |
| 370 | + | return this.state.wall.pop()!; | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // ---- turn flow --------------------------------------------------------- | |
| 374 | + | private beginTurn(seat: SeatId) { | |
| 375 | + | const s = this.state; | |
| 376 | + | if (this.wallLeft <= 0) return this.endInDraw(); | |
| 377 | + | const t = this.takeFront(); | |
| 378 | + | s.players[seat].hand.push(t); | |
| 379 | + | let drawn: Tile | null = t; | |
| 380 | + | if (isFlower(t)) { | |
| 381 | + | drawn = this.replaceFlowers(seat).last; | |
| 382 | + | this.say(`${s.players[seat].name} 補花 flower`); | |
| 383 | + | this.sfx('flower'); | |
| 384 | + | } | |
| 385 | + | s.turn = seat; | |
| 386 | + | this.setDrawn(seat, drawn); | |
| 387 | + | s.phase = 'turn'; | |
| 388 | + | s.afterKong = false; | |
| 389 | + | s.lastDiscard = null; | |
| 390 | + | s.options = {}; | |
| 391 | + | s.responses = {}; | |
| 392 | + | // Your own draw always lifts 過水, whatever the table thinks about claims. | |
| 393 | + | s.sacred[seat] = []; | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | // ---- 過水 --------------------------------------------------------------- | |
| 397 | + | /** Has this seat already passed on winning with this tile? */ | |
| 398 | + | private isSacred(seat: SeatId, tile: Tile): boolean { | |
| 399 | + | const s = this.state; | |
| 400 | + | return s.rules.sacredDiscard && s.sacred[seat].includes(tile); | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | /** | |
| 404 | + | * Everyone who was offered 胡 on the tile now on the table and did not take | |
| 405 | + | * it has declined it — including anyone who took a 碰 or 吃 instead. Called | |
| 406 | + | * once the claims are settled, so the seat that actually won is exempt. | |
| 407 | + | */ | |
| 408 | + | private recordDeclinedWins(winners: SeatId[]) { | |
| 409 | + | const s = this.state; | |
| 410 | + | if (!s.rules.sacredDiscard) return; | |
| 411 | + | const tile = s.phase === 'robkong' ? s.robKongTile : s.lastDiscard?.tile; | |
| 412 | + | if (tile === undefined || tile === null) return; | |
| 413 | + | for (const seat of SEATS) { | |
| 414 | + | if (winners.includes(seat)) continue; | |
| 415 | + | if (!s.options[seat]?.some((o) => o.type === 'hu')) continue; | |
| 416 | + | if (!s.sacred[seat].includes(tile)) s.sacred[seat].push(tile); | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | /** Replacement tile after a kong, taken from the back of the wall. */ | |
| 421 | + | private kongReplacement(seat: SeatId) { | |
| 422 | + | const s = this.state; | |
| 423 | + | if (s.wall.length === 0) return this.endInDraw(); | |
| 424 | + | const t = this.takeBack(); | |
| 425 | + | s.players[seat].hand.push(t); | |
| 426 | + | const drawn = isFlower(t) ? this.replaceFlowers(seat).last : t; | |
| 427 | + | this.setDrawn(seat, drawn); | |
| 428 | + | s.turn = seat; | |
| 429 | + | s.phase = 'turn'; | |
| 430 | + | s.afterKong = true; | |
| 431 | + | s.sacred[seat] = []; // a replacement off the back is still a draw | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | /** 過水 — tiles this seat has passed on winning with, for the badge. */ | |
| 435 | + | sacredTiles(seat: SeatId): Tile[] { | |
| 436 | + | return this.state.rules.sacredDiscard ? this.state.sacred[seat] : []; | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | discard(seat: SeatId, tile: Tile) { | |
| 440 | + | const s = this.state; | |
| 441 | + | if (s.phase !== 'turn' || s.turn !== seat) return; | |
| 442 | + | const p = s.players[seat]; | |
| 443 | + | const i = p.hand.indexOf(tile); | |
| 444 | + | if (i < 0) return; | |
| 445 | + | this.push(`${p.name} 打 ${labelOf(tile)}`, `${p.name} discarded ${labelOf(tile)}`); | |
| 446 | + | p.hand.splice(i, 1); | |
| 447 | + | p.discards.push(tile); | |
| 448 | + | s.drawnTile = null; | |
| 449 | + | s.drawnSeparate = false; | |
| 450 | + | s.lastDiscard = { tile, from: seat }; | |
| 451 | + | s.responses = {}; | |
| 452 | + | s.options = this.claimOptionsFor(tile, seat); | |
| 453 | + | this.sfx('discard'); | |
| 454 | + | ||
| 455 | + | if (Object.keys(s.options).length === 0) { | |
| 456 | + | this.afterDiscardResolved(); | |
| 457 | + | } else { | |
| 458 | + | s.phase = 'claims'; | |
| 459 | + | // The point of the chime: claims don't block, so somebody has to be told | |
| 460 | + | // their window is open before the next player draws it shut. | |
| 461 | + | this.sfx('claimWindow'); | |
| 462 | + | } | |
| 463 | + | this.emit(); | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | /** Called once nobody wants the discarded tile. */ | |
| 467 | + | private afterDiscardResolved() { | |
| 468 | + | const s = this.state; | |
| 469 | + | const from = s.lastDiscard!.from; | |
| 470 | + | if (this.wallLeft <= 0) return this.endInDraw(); | |
| 471 | + | this.beginTurn(next(from)); | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | private claimOptionsFor(tile: Tile, from: SeatId): Partial<Record<SeatId, ClaimOption[]>> { | |
| 475 | + | const s = this.state; | |
| 476 | + | const out: Partial<Record<SeatId, ClaimOption[]>> = {}; | |
| 477 | + | for (const seat of SEATS) { | |
| 478 | + | if (seat === from) continue; | |
| 479 | + | const p = s.players[seat]; | |
| 480 | + | const opts: ClaimOption[] = []; | |
| 481 | + | if (isWinningHand([...p.hand, tile], p.melds.length) && !this.isSacred(seat, tile)) { | |
| 482 | + | opts.push({ type: 'hu' }); | |
| 483 | + | } | |
| 484 | + | const same = p.hand.filter((t) => t === tile).length; | |
| 485 | + | if (same >= 3 && this.wallLeft > 0) opts.push({ type: 'kong' }); | |
| 486 | + | if (same >= 2) opts.push({ type: 'pung' }); | |
| 487 | + | if (seat === next(from) && isSuited(tile)) { | |
| 488 | + | for (const pair of chowPartners(p.hand, tile)) opts.push({ type: 'chow', with: pair }); | |
| 489 | + | } | |
| 490 | + | if (opts.length) out[seat] = opts; | |
| 491 | + | } | |
| 492 | + | return out; | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | /** | |
| 496 | + | * The seat that draws next if nobody takes the tile on the table — they are | |
| 497 | + | * the ones who can close the claim window. | |
| 498 | + | */ | |
| 499 | + | nextDrawer(): SeatId | null { | |
| 500 | + | const s = this.state; | |
| 501 | + | if (s.phase === 'claims' && s.lastDiscard) return next(s.lastDiscard.from); | |
| 502 | + | if (s.phase === 'robkong') return s.turn; | |
| 503 | + | return null; | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | /** | |
| 507 | + | * Carry on without waiting for anyone still thinking: everyone who hasn't | |
| 508 | + | * answered passes. Claims already declared still stand, so a player who | |
| 509 | + | * called in time keeps the tile. This is what stops one distracted player | |
| 510 | + | * from halting the table. | |
| 511 | + | */ | |
| 512 | + | resolveNow() { | |
| 513 | + | const s = this.state; | |
| 514 | + | if (s.phase !== 'claims' && s.phase !== 'robkong') return; | |
| 515 | + | this.push('關閉叫牌', 'Closed the claim window'); | |
| 516 | + | for (const key of Object.keys(s.options)) { | |
| 517 | + | const seat = Number(key) as SeatId; | |
| 518 | + | if (s.responses[seat] === undefined) s.responses[seat] = 'pass'; | |
| 519 | + | } | |
| 520 | + | this.tryResolveClaims(); | |
| 521 | + | this.emit(); | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | respond(seat: SeatId, claim: Claim | 'pass') { | |
| 525 | + | const s = this.state; | |
| 526 | + | if (s.phase !== 'claims' && s.phase !== 'robkong') return; | |
| 527 | + | if (!s.options[seat]) return; | |
| 528 | + | const name = s.players[seat].name; | |
| 529 | + | if (claim === 'pass') this.push(`${name} 過`, `${name} passed`); | |
| 530 | + | else this.push(`${name} ${CLAIM_ZH[claim.type]}`, `${name} called ${CLAIM_EN[claim.type].toLowerCase()}`); | |
| 531 | + | s.responses[seat] = claim; | |
| 532 | + | this.tryResolveClaims(); | |
| 533 | + | this.emit(); | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | private tryResolveClaims() { | |
| 537 | + | const s = this.state; | |
| 538 | + | const rank = (c: ClaimType) => (c === 'hu' ? 3 : c === 'chow' ? 1 : 2); | |
| 539 | + | const pending = (Object.keys(s.options) as unknown as SeatId[]) | |
| 540 | + | .map(Number) | |
| 541 | + | .filter((seat) => s.responses[seat as SeatId] === undefined) as SeatId[]; | |
| 542 | + | ||
| 543 | + | const declared = (Object.entries(s.responses) as [string, Claim | 'pass'][]) | |
| 544 | + | .filter(([, c]) => c !== 'pass') | |
| 545 | + | .map(([seat, c]) => ({ seat: Number(seat) as SeatId, claim: c as Claim })); | |
| 546 | + | ||
| 547 | + | if (declared.length === 0) { | |
| 548 | + | if (pending.length > 0) return; | |
| 549 | + | // everyone passed | |
| 550 | + | this.recordDeclinedWins([]); | |
| 551 | + | if (s.phase === 'robkong') return this.finishAddKong(); | |
| 552 | + | return this.afterDiscardResolved(); | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | const bestPending = Math.max( | |
| 556 | + | 0, | |
| 557 | + | ...pending.map((seat) => Math.max(...s.options[seat]!.map((o) => rank(o.type)))), | |
| 558 | + | ); | |
| 559 | + | const best = Math.max(...declared.map((d) => rank(d.claim.type))); | |
| 560 | + | if (best < bestPending && pending.length > 0) return; // someone might outrank | |
| 561 | + | ||
| 562 | + | const from = s.lastDiscard?.from ?? s.turn; | |
| 563 | + | const winners = declared.filter((d) => rank(d.claim.type) === best); | |
| 564 | + | // Closest player after the discarder wins a tie. | |
| 565 | + | winners.sort( | |
| 566 | + | (a, b) => ((a.seat - from + 4) % 4) - ((b.seat - from + 4) % 4), | |
| 567 | + | ); | |
| 568 | + | ||
| 569 | + | if (best === rank('hu')) { | |
| 570 | + | // Anyone else who can win on this tile is worth waiting for: under | |
| 571 | + | // 一炮多響 they get paid too, and without it the tile still belongs to | |
| 572 | + | // the seat nearest the discarder rather than to the quickest hand. The | |
| 573 | + | // Draw button closes the window either way, so nobody is held up. | |
| 574 | + | if (pending.some((seat) => s.options[seat]!.some((o) => o.type === 'hu'))) return; | |
| 575 | + | const seats = s.rules.multipleWinners ? winners.map((w) => w.seat) : [winners[0].seat]; | |
| 576 | + | this.recordDeclinedWins(seats); | |
| 577 | + | return this.winOnTile(seats); | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | this.recordDeclinedWins([winners[0].seat]); | |
| 581 | + | this.executeClaim(winners[0].seat, winners[0].claim); | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | /** Everyone who called 胡 on the tile currently on the table takes it. */ | |
| 585 | + | private winOnTile(seats: SeatId[]) { | |
| 586 | + | const s = this.state; | |
| 587 | + | const robKong = s.phase === 'robkong'; | |
| 588 | + | const tile = robKong ? s.robKongTile! : s.lastDiscard!.tile; | |
| 589 | + | const from = robKong ? s.turn : s.lastDiscard!.from; | |
| 590 | + | this.settle( | |
| 591 | + | seats.map((seat) => ({ seat, winTile: tile, selfDraw: false, robKong })), | |
| 592 | + | from, | |
| 593 | + | ); | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | /** Melds the tile on the table into a hand. 胡 goes through `winOnTile`. */ | |
| 597 | + | private executeClaim(seat: SeatId, claim: Claim) { | |
| 598 | + | const s = this.state; | |
| 599 | + | if (claim.type === 'hu') return this.winOnTile([seat]); | |
| 600 | + | const tile = s.phase === 'robkong' ? s.robKongTile! : s.lastDiscard!.tile; | |
| 601 | + | const from = s.phase === 'robkong' ? s.turn : s.lastDiscard!.from; | |
| 602 | + | const p = s.players[seat]; | |
| 603 | + | ||
| 604 | + | // Take the tile off the discard pile. | |
| 605 | + | if (s.lastDiscard) { | |
| 606 | + | const dp = s.players[from].discards; | |
| 607 | + | if (dp[dp.length - 1] === tile) dp.pop(); | |
| 608 | + | } | |
| 609 | + | const take = (t: Tile, n: number) => { | |
| 610 | + | for (let k = 0; k < n; k++) p.hand.splice(p.hand.indexOf(t), 1); | |
| 611 | + | }; | |
| 612 | + | ||
| 613 | + | let meld: Meld; | |
| 614 | + | if (claim.type === 'kong') { | |
| 615 | + | take(tile, 3); | |
| 616 | + | meld = { kind: 'kong', tiles: [tile, tile, tile, tile], from }; | |
| 617 | + | } else if (claim.type === 'pung') { | |
| 618 | + | take(tile, 2); | |
| 619 | + | meld = { kind: 'pung', tiles: [tile, tile, tile], from }; | |
| 620 | + | } else { | |
| 621 | + | for (const t of claim.with!) take(t, 1); | |
| 622 | + | meld = { kind: 'chow', tiles: sortTiles([tile, ...claim.with!]), from }; | |
| 623 | + | } | |
| 624 | + | p.melds.push(meld); | |
| 625 | + | // Some tables treat taking a tile as enough of a fresh start to lift 過水; | |
| 626 | + | // stricter ones make you wait for your own draw. | |
| 627 | + | if (s.rules.sacredClearedByClaim) s.sacred[seat] = []; | |
| 628 | + | this.say(`${p.name} ${claimName(claim.type)} ${labelOf(tile)}`); | |
| 629 | + | this.sfx(claim.type === 'kong' ? 'kong' : 'claim'); | |
| 630 | + | ||
| 631 | + | s.lastDiscard = null; | |
| 632 | + | s.options = {}; | |
| 633 | + | s.responses = {}; | |
| 634 | + | if (claim.type === 'kong') { | |
| 635 | + | this.kongReplacement(seat); | |
| 636 | + | } else { | |
| 637 | + | s.turn = seat; | |
| 638 | + | s.drawnTile = null; | |
| 639 | + | s.drawnSeparate = false; | |
| 640 | + | s.phase = 'turn'; | |
| 641 | + | s.afterKong = false; | |
| 642 | + | } | |
| 643 | + | } | |
| 644 | + | ||
| 645 | + | // ---- own-turn declarations -------------------------------------------- | |
| 646 | + | /** Tiles the seat may declare a concealed kong on. */ | |
| 647 | + | concealedKongs(seat: SeatId): Tile[] { | |
| 648 | + | const p = this.state.players[seat]; | |
| 649 | + | const counts = new Map<Tile, number>(); | |
| 650 | + | for (const t of p.hand) counts.set(t, (counts.get(t) ?? 0) + 1); | |
| 651 | + | return [...counts.entries()].filter(([, n]) => n === 4).map(([t]) => t); | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | /** Tiles that can be added to an existing exposed pung. */ | |
| 655 | + | addedKongs(seat: SeatId): Tile[] { | |
| 656 | + | const p = this.state.players[seat]; | |
| 657 | + | return p.melds | |
| 658 | + | .filter((m) => m.kind === 'pung') | |
| 659 | + | .map((m) => m.tiles[0]) | |
| 660 | + | .filter((t) => p.hand.includes(t)); | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | declareConcealedKong(seat: SeatId, tile: Tile) { | |
| 664 | + | const s = this.state; | |
| 665 | + | if (s.phase !== 'turn' || s.turn !== seat || this.wallLeft <= 0) return; | |
| 666 | + | const p = s.players[seat]; | |
| 667 | + | this.push(`${p.name} 暗槓 ${labelOf(tile)}`, `${p.name} declared a concealed kong`); | |
| 668 | + | for (let k = 0; k < 4; k++) p.hand.splice(p.hand.indexOf(tile), 1); | |
| 669 | + | p.melds.push({ kind: 'ankong', tiles: [tile, tile, tile, tile] }); | |
| 670 | + | this.say(`${p.name} 暗槓 concealed kong`); | |
| 671 | + | this.sfx('kong'); | |
| 672 | + | this.kongReplacement(seat); | |
| 673 | + | this.emit(); | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | declareAddedKong(seat: SeatId, tile: Tile) { | |
| 677 | + | const s = this.state; | |
| 678 | + | if (s.phase !== 'turn' || s.turn !== seat || this.wallLeft <= 0) return; | |
| 679 | + | const p = s.players[seat]; | |
| 680 | + | const meld = p.melds.find((m) => m.kind === 'pung' && m.tiles[0] === tile); | |
| 681 | + | if (!meld) return; | |
| 682 | + | this.push(`${p.name} 加槓 ${labelOf(tile)}`, `${p.name} declared an added kong`); | |
| 683 | + | p.hand.splice(p.hand.indexOf(tile), 1); | |
| 684 | + | meld.kind = 'addkong'; | |
| 685 | + | meld.tiles = [tile, tile, tile, tile]; | |
| 686 | + | s.robKongTile = tile; | |
| 687 | + | ||
| 688 | + | // 搶槓 — anyone who can win on this tile may take it. | |
| 689 | + | const opts: Partial<Record<SeatId, ClaimOption[]>> = {}; | |
| 690 | + | for (const other of SEATS) { | |
| 691 | + | if (other === seat) continue; | |
| 692 | + | const op = s.players[other]; | |
| 693 | + | if (isWinningHand([...op.hand, tile], op.melds.length) && !this.isSacred(other, tile)) { | |
| 694 | + | opts[other] = [{ type: 'hu' }]; | |
| 695 | + | } | |
| 696 | + | } | |
| 697 | + | if (Object.keys(opts).length) { | |
| 698 | + | s.options = opts; | |
| 699 | + | s.responses = {}; | |
| 700 | + | s.phase = 'robkong'; | |
| 701 | + | this.say(`${p.name} 加槓 ${labelOf(tile)} — 可搶槓 robbable`); | |
| 702 | + | this.sfx('kong'); | |
| 703 | + | this.sfx('claimWindow'); | |
| 704 | + | } else { | |
| 705 | + | this.say(`${p.name} 加槓 added kong`); | |
| 706 | + | this.sfx('kong'); | |
| 707 | + | this.finishAddKong(); | |
| 708 | + | } | |
| 709 | + | this.emit(); | |
| 710 | + | } | |
| 711 | + | ||
| 712 | + | private finishAddKong() { | |
| 713 | + | const s = this.state; | |
| 714 | + | s.options = {}; | |
| 715 | + | s.responses = {}; | |
| 716 | + | s.robKongTile = null; | |
| 717 | + | this.kongReplacement(s.turn); | |
| 718 | + | } | |
| 719 | + | ||
| 720 | + | canSelfDrawWin(seat: SeatId): boolean { | |
| 721 | + | const s = this.state; | |
| 722 | + | if (s.phase !== 'turn' || s.turn !== seat) return false; | |
| 723 | + | const p = s.players[seat]; | |
| 724 | + | return p.hand.length === (5 - p.melds.length) * 3 + 2 && isWinningHand(p.hand, p.melds.length); | |
| 725 | + | } | |
| 726 | + | ||
| 727 | + | declareSelfDraw(seat: SeatId) { | |
| 728 | + | if (!this.canSelfDrawWin(seat)) return; | |
| 729 | + | const s = this.state; | |
| 730 | + | const p = s.players[seat]; | |
| 731 | + | const winTile = s.drawnTile ?? p.hand[p.hand.length - 1]; | |
| 732 | + | this.push(`${p.name} 自摸`, `${p.name} declared a self-draw`); | |
| 733 | + | this.settle([{ seat, winTile, selfDraw: true, robKong: false }], null); | |
| 734 | + | } | |
| 735 | + | ||
| 736 | + | /** Tiles a seat is waiting on right now (for the 聽 badge). */ | |
| 737 | + | tenpai(seat: SeatId): Tile[] { | |
| 738 | + | const p = this.state.players[seat]; | |
| 739 | + | if (p.hand.length !== (5 - p.melds.length) * 3 + 1) return []; | |
| 740 | + | return waits(p.hand, p.melds.length); | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | // ---- settlement -------------------------------------------------------- | |
| 744 | + | /** | |
| 745 | + | * 包牌 — feeding the pung that visibly completes a 大三元 or 大四喜 makes | |
| 746 | + | * the feeder answer for the whole hand, on the reasoning that by the third | |
| 747 | + | * dragon nobody could claim not to have seen it coming. | |
| 748 | + | * | |
| 749 | + | * Only the seat that fed the *last* of those sets is on the hook, and only | |
| 750 | + | * if it came off a discard: a hand that assembled them itself has nobody to | |
| 751 | + | * blame. | |
| 752 | + | */ | |
| 753 | + | private liableFor(winner: SeatId, items: TaiItem[]): SeatId | null { | |
| 754 | + | const s = this.state; | |
| 755 | + | if (!s.rules.liability) return null; | |
| 756 | + | const dragons = items.some((i) => i.name === '大三元'); | |
| 757 | + | const winds = items.some((i) => i.name === '大四喜'); | |
| 758 | + | if (!dragons && !winds) return null; | |
| 759 | + | ||
| 760 | + | const inSet = dragons | |
| 761 | + | ? (t: Tile) => t >= RED && t <= WHITE | |
| 762 | + | : (t: Tile) => t >= EAST && t < RED; | |
| 763 | + | const melds = s.players[winner].melds.filter((m) => m.kind !== 'chow' && inSet(m.tiles[0])); | |
| 764 | + | // One of the sets was still in hand, so it was not handed over. | |
| 765 | + | if (melds.length !== (dragons ? 3 : 4)) return null; | |
| 766 | + | // `from` is absent on a 暗槓 — that one they made themselves. | |
| 767 | + | return melds[melds.length - 1].from ?? null; | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | /** A win waiting to be paid out. */ | |
| 771 | + | private settle( | |
| 772 | + | wins: { seat: SeatId; winTile: Tile; selfDraw: boolean; robKong: boolean }[], | |
| 773 | + | loser: SeatId | null, | |
| 774 | + | ) { | |
| 775 | + | const s = this.state; | |
| 776 | + | const { base, taiValue } = s.rules; | |
| 777 | + | const extra = dealerTai(s.dealerStreak) * taiValue; // 拉莊, billed to the dealer alone | |
| 778 | + | const totalDiscards = s.players.reduce((a, q) => a + q.discards.length, 0); | |
| 779 | + | const anyMeld = s.players.some((q) => q.melds.length > 0); | |
| 780 | + | const firstGoAround = totalDiscards <= 4 && !anyMeld; | |
| 781 | + | const deltas = [0, 0, 0, 0]; | |
| 782 | + | const results: WinnerResult[] = []; | |
| 783 | + | ||
| 784 | + | for (const w of wins) { | |
| 785 | + | const p = s.players[w.seat]; | |
| 786 | + | // For a self-draw the winning tile is already in hand; drop exactly one copy. | |
| 787 | + | const conc = w.selfDraw ? removeOne(p.hand, w.winTile) : p.hand.slice(); | |
| 788 | + | ||
| 789 | + | const score = scoreHand({ | |
| 790 | + | winner: w.seat, | |
| 791 | + | dealer: s.dealer, | |
| 792 | + | dealerStreak: s.dealerStreak, | |
| 793 | + | roundWind: this.roundWind, | |
| 794 | + | melds: p.melds, | |
| 795 | + | concealed: conc, | |
| 796 | + | winTile: w.winTile, | |
| 797 | + | selfDraw: w.selfDraw, | |
| 798 | + | loser, | |
| 799 | + | flowers: p.flowers, | |
| 800 | + | robKong: w.robKong, | |
| 801 | + | afterKong: s.afterKong && w.selfDraw, | |
| 802 | + | lastTile: w.selfDraw && this.wallLeft <= 0, | |
| 803 | + | lastDiscard: !w.selfDraw && this.wallLeft <= 0, | |
| 804 | + | heavenly: w.selfDraw && w.seat === s.dealer && totalDiscards === 0, | |
| 805 | + | earthly: w.selfDraw && w.seat !== s.dealer && firstGoAround, | |
| 806 | + | humanly: !w.selfDraw && w.seat !== s.dealer && firstGoAround, | |
| 807 | + | }); | |
| 808 | + | ||
| 809 | + | const unit = base + score.tai * taiValue; | |
| 810 | + | // Who would normally be settling with this winner, and for how much. | |
| 811 | + | const bills: [SeatId, number][] = []; | |
| 812 | + | if (w.selfDraw) { | |
| 813 | + | for (const seat of SEATS) { | |
| 814 | + | if (seat === w.seat) continue; | |
| 815 | + | bills.push([seat, unit + (seat === s.dealer ? extra : 0)]); | |
| 816 | + | } | |
| 817 | + | } else if (loser !== null) { | |
| 818 | + | bills.push([loser, unit + (loser === s.dealer && w.seat !== s.dealer ? extra : 0)]); | |
| 819 | + | } | |
| 820 | + | ||
| 821 | + | const liable = this.liableFor(w.seat, score.items); | |
| 822 | + | if (liable !== null && liable !== w.seat) { | |
| 823 | + | // 包家 settles the whole hand alone, in everyone else's place. | |
| 824 | + | const total = bills.reduce((a, [, n]) => a + n, 0); | |
| 825 | + | deltas[liable] -= total; | |
| 826 | + | deltas[w.seat] += total; | |
| 827 | + | } else { | |
| 828 | + | for (const [seat, n] of bills) { | |
| 829 | + | deltas[seat] -= n; | |
| 830 | + | deltas[w.seat] += n; | |
| 831 | + | } | |
| 832 | + | } | |
| 833 | + | ||
| 834 | + | results.push({ | |
| 835 | + | seat: w.seat, | |
| 836 | + | selfDraw: w.selfDraw, | |
| 837 | + | tai: score.tai, | |
| 838 | + | items: score.items, | |
| 839 | + | winTile: w.winTile, | |
| 840 | + | liable: liable !== null && liable !== w.seat ? liable : null, | |
| 841 | + | }); | |
| 842 | + | } | |
| 843 | + | ||
| 844 | + | for (const seat of SEATS) s.players[seat].score += deltas[seat]; | |
| 845 | + | ||
| 846 | + | s.result = { drawGame: false, winners: results, loser, deltas }; | |
| 847 | + | s.phase = 'handEnd'; | |
| 848 | + | for (const r of results) { | |
| 849 | + | this.say(`${s.players[r.seat].name} 胡牌 wins ${r.tai} 台`); | |
| 850 | + | if (r.liable !== null) this.say(`${s.players[r.liable].name} 包牌 pays it all`); | |
| 851 | + | } | |
| 852 | + | this.sfx('win'); | |
| 853 | + | this.emit(); | |
| 854 | + | } | |
| 855 | + | ||
| 856 | + | private endInDraw() { | |
| 857 | + | const s = this.state; | |
| 858 | + | s.result = { | |
| 859 | + | drawGame: true, | |
| 860 | + | winners: [], | |
| 861 | + | loser: null, | |
| 862 | + | deltas: [0, 0, 0, 0], | |
| 863 | + | }; | |
| 864 | + | s.phase = 'handEnd'; | |
| 865 | + | this.say('流局 draw'); | |
| 866 | + | this.sfx('drawGame'); | |
| 867 | + | this.emit(); | |
| 868 | + | } | |
| 869 | + | ||
| 870 | + | /** Reset chips, dealer and round, then deal a fresh game. */ | |
| 871 | + | newGame() { | |
| 872 | + | const s = this.state; | |
| 873 | + | clearSave(); | |
| 874 | + | for (const p of s.players) p.score = s.rules.startingScore; | |
| 875 | + | s.dealer = 0; | |
| 876 | + | s.dealerStreak = 0; | |
| 877 | + | s.rotations = 0; | |
| 878 | + | s.handNumber = 0; | |
| 879 | + | s.result = null; | |
| 880 | + | this.startHand(); | |
| 881 | + | } | |
| 882 | + | ||
| 883 | + | /** Advance the dealer / round and deal the next hand. */ | |
| 884 | + | nextHand() { | |
| 885 | + | const s = this.state; | |
| 886 | + | const r = s.result; | |
| 887 | + | if (!r) return; | |
| 888 | + | // 連莊 — a draw, or the dealer being among the winners, keeps the deal. | |
| 889 | + | const dealerKeeps = r.drawGame || r.winners.some((w) => w.seat === s.dealer); | |
| 890 | + | if (dealerKeeps) { | |
| 891 | + | s.dealerStreak++; | |
| 892 | + | } else { | |
| 893 | + | s.dealer = next(s.dealer); | |
| 894 | + | s.dealerStreak = 0; | |
| 895 | + | s.rotations++; | |
| 896 | + | } | |
| 897 | + | if (s.rotations >= 16) { | |
| 898 | + | s.phase = 'gameEnd'; | |
| 899 | + | this.emit(); | |
| 900 | + | return; | |
| 901 | + | } | |
| 902 | + | this.startHand(); | |
| 903 | + | } | |
| 904 | + | } | |
| 905 | + | ||
| 906 | + | function removeOne(tiles: Tile[], t: Tile): Tile[] { | |
| 907 | + | const out = tiles.slice(); | |
| 908 | + | const i = out.indexOf(t); | |
| 909 | + | if (i >= 0) out.splice(i, 1); | |
| 910 | + | return out; | |
| 911 | + | } | |
| 912 | + | ||
| 913 | + | function chowPartners(hand: Tile[], tile: Tile): Tile[][] { | |
| 914 | + | const has = (t: Tile) => hand.includes(t); | |
| 915 | + | const r = tile % 9; | |
| 916 | + | const out: Tile[][] = []; | |
| 917 | + | if (r >= 2 && has(tile - 2) && has(tile - 1)) out.push([tile - 2, tile - 1]); | |
| 918 | + | if (r >= 1 && r <= 7 && has(tile - 1) && has(tile + 1)) out.push([tile - 1, tile + 1]); | |
| 919 | + | if (r <= 6 && has(tile + 1) && has(tile + 2)) out.push([tile + 1, tile + 2]); | |
| 920 | + | return out; | |
| 921 | + | } | |
| 922 | + | ||
| 923 | + | const CLAIM_ZH: Record<ClaimType, string> = { hu: '胡', kong: '槓', pung: '碰', chow: '吃' }; | |
| 924 | + | const CLAIM_EN: Record<ClaimType, string> = { hu: 'Win', kong: 'Kong', pung: 'Pung', chow: 'Chow' }; | |
| 925 | + | ||
| 926 | + | export function claimName(c: ClaimType): string { | |
| 927 | + | return `${CLAIM_ZH[c]} ${CLAIM_EN[c]}`; | |
| 928 | + | } |
addedsrc/game/hu.ts+118 −0
| 1 | + | import { NUM_BASIC, NUM_SUITED, toCounts, type Tile } from './tiles'; | |
| 2 | + | ||
| 3 | + | export type SetType = 'chow' | 'pung'; | |
| 4 | + | ||
| 5 | + | /** A completed set. `tile` is the pung tile, or the *lowest* tile of a chow. */ | |
| 6 | + | export interface TileSet { | |
| 7 | + | type: SetType; | |
| 8 | + | tile: Tile; | |
| 9 | + | concealed: boolean; | |
| 10 | + | kong?: boolean; | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | export interface Decomposition { | |
| 14 | + | sets: TileSet[]; | |
| 15 | + | pair: Tile; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | /** Every way the concealed tiles split into `needSets` sets + one pair. */ | |
| 19 | + | export function decompose(tiles: Tile[], needSets: number): Decomposition[] { | |
| 20 | + | const counts = toCounts(tiles); | |
| 21 | + | const total = counts.reduce((a, b) => a + b, 0); | |
| 22 | + | if (total !== needSets * 3 + 2) return []; | |
| 23 | + | ||
| 24 | + | const out: Decomposition[] = []; | |
| 25 | + | const seen = new Set<string>(); | |
| 26 | + | ||
| 27 | + | for (let p = 0; p < NUM_BASIC; p++) { | |
| 28 | + | if (counts[p] < 2) continue; | |
| 29 | + | counts[p] -= 2; | |
| 30 | + | const found: TileSet[][] = []; | |
| 31 | + | splitSets(counts, needSets, [], found); | |
| 32 | + | counts[p] += 2; | |
| 33 | + | for (const sets of found) { | |
| 34 | + | const key = p + '|' + sets.map((s) => s.type + s.tile).sort().join(','); | |
| 35 | + | if (seen.has(key)) continue; | |
| 36 | + | seen.add(key); | |
| 37 | + | out.push({ sets, pair: p }); | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | return out; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | function splitSets(counts: number[], need: number, acc: TileSet[], out: TileSet[][]) { | |
| 44 | + | if (need === 0) { | |
| 45 | + | for (const c of counts) if (c !== 0) return; | |
| 46 | + | out.push(acc.slice()); | |
| 47 | + | return; | |
| 48 | + | } | |
| 49 | + | let i = 0; | |
| 50 | + | while (i < NUM_BASIC && counts[i] === 0) i++; | |
| 51 | + | if (i === NUM_BASIC) return; | |
| 52 | + | ||
| 53 | + | if (counts[i] >= 3) { | |
| 54 | + | counts[i] -= 3; | |
| 55 | + | acc.push({ type: 'pung', tile: i, concealed: true }); | |
| 56 | + | splitSets(counts, need - 1, acc, out); | |
| 57 | + | acc.pop(); | |
| 58 | + | counts[i] += 3; | |
| 59 | + | } | |
| 60 | + | if (i < NUM_SUITED && i % 9 <= 6 && counts[i + 1] > 0 && counts[i + 2] > 0) { | |
| 61 | + | counts[i]--; | |
| 62 | + | counts[i + 1]--; | |
| 63 | + | counts[i + 2]--; | |
| 64 | + | acc.push({ type: 'chow', tile: i, concealed: true }); | |
| 65 | + | splitSets(counts, need - 1, acc, out); | |
| 66 | + | acc.pop(); | |
| 67 | + | counts[i]++; | |
| 68 | + | counts[i + 1]++; | |
| 69 | + | counts[i + 2]++; | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | export function isWinningHand(tiles: Tile[], meldCount: number): boolean { | |
| 74 | + | return decompose(tiles, 5 - meldCount).length > 0; | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /** Tiles that would complete the hand if drawn/claimed. `tiles` is the concealed hand. */ | |
| 78 | + | export function waits(tiles: Tile[], meldCount: number): Tile[] { | |
| 79 | + | const need = 5 - meldCount; | |
| 80 | + | if (tiles.length !== need * 3 + 1) return []; | |
| 81 | + | const out: Tile[] = []; | |
| 82 | + | for (let t = 0; t < NUM_BASIC; t++) { | |
| 83 | + | if (decompose([...tiles, t], need).length > 0) out.push(t); | |
| 84 | + | } | |
| 85 | + | return out; | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | export type WaitShape = 'pair' | 'edge' | 'closed' | 'open'; | |
| 89 | + | ||
| 90 | + | /** | |
| 91 | + | * How the winning tile was waited on — used for 獨聽 / 單釣. | |
| 92 | + | * Only meaningful when the hand has a single wait. | |
| 93 | + | */ | |
| 94 | + | export function waitShape(concealedBefore: Tile[], winTile: Tile, meldCount: number): WaitShape { | |
| 95 | + | const need = 5 - meldCount; | |
| 96 | + | const decs = decompose([...concealedBefore, winTile], need); | |
| 97 | + | let best: WaitShape = 'open'; | |
| 98 | + | const rank = { pair: 3, edge: 2, closed: 2, open: 1 } as const; | |
| 99 | + | for (const d of decs) { | |
| 100 | + | let shape: WaitShape | null = null; | |
| 101 | + | if (d.pair === winTile) { | |
| 102 | + | // could be 單釣 only if the pair really needed this tile | |
| 103 | + | const c = toCounts(concealedBefore); | |
| 104 | + | if (c[winTile] === 1) shape = 'pair'; | |
| 105 | + | } | |
| 106 | + | if (!shape) { | |
| 107 | + | for (const s of d.sets) { | |
| 108 | + | if (s.type === 'chow') { | |
| 109 | + | if (s.tile === winTile && s.tile % 9 === 6) shape = 'edge'; // 7-8 waiting 9? (789 low=7) | |
| 110 | + | else if (s.tile + 2 === winTile && s.tile % 9 === 0) shape = 'edge'; // 1-2 waiting 3 | |
| 111 | + | else if (s.tile + 1 === winTile) shape = 'closed'; | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | if (shape && rank[shape] > rank[best]) best = shape; | |
| 116 | + | } | |
| 117 | + | return best; | |
| 118 | + | } |
addedsrc/game/prefs.ts+52 −0
| 1 | + | import { DEFAULT_NAMES, DEFAULT_RULES, type Rules } from './types'; | |
| 2 | + | ||
| 3 | + | const KEY = 'taiwanese-mahjong/prefs'; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * Preferences belong to the table, not to a hand: they outlive a save, survive | |
| 7 | + | * 開新局, and are what a fresh `Game` is built from. | |
| 8 | + | */ | |
| 9 | + | export interface Prefs { | |
| 10 | + | /** Master mute. Off by default — a shared screen should not surprise a room. */ | |
| 11 | + | sound: boolean; | |
| 12 | + | /** 0..1 */ | |
| 13 | + | volume: number; | |
| 14 | + | names: string[]; | |
| 15 | + | rules: Rules; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | export const DEFAULT_PREFS: Prefs = { | |
| 19 | + | sound: false, | |
| 20 | + | volume: 0.7, | |
| 21 | + | names: DEFAULT_NAMES, | |
| 22 | + | rules: DEFAULT_RULES, | |
| 23 | + | }; | |
| 24 | + | ||
| 25 | + | export function loadPrefs(): Prefs { | |
| 26 | + | try { | |
| 27 | + | const raw = localStorage.getItem(KEY); | |
| 28 | + | if (!raw) return DEFAULT_PREFS; | |
| 29 | + | const p = JSON.parse(raw) as Partial<Prefs>; | |
| 30 | + | return { | |
| 31 | + | sound: typeof p.sound === 'boolean' ? p.sound : DEFAULT_PREFS.sound, | |
| 32 | + | volume: clamp(typeof p.volume === 'number' ? p.volume : DEFAULT_PREFS.volume, 0, 1), | |
| 33 | + | names: | |
| 34 | + | Array.isArray(p.names) && p.names.length === 4 && p.names.every((n) => typeof n === 'string') | |
| 35 | + | ? p.names | |
| 36 | + | : DEFAULT_NAMES, | |
| 37 | + | rules: { ...DEFAULT_RULES, ...(p.rules ?? {}) }, | |
| 38 | + | }; | |
| 39 | + | } catch { | |
| 40 | + | return DEFAULT_PREFS; | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | export function savePrefs(p: Prefs) { | |
| 45 | + | try { | |
| 46 | + | localStorage.setItem(KEY, JSON.stringify(p)); | |
| 47 | + | } catch { | |
| 48 | + | // Storage unavailable — the settings just don't outlive the tab. | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | export const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n)); |
addedsrc/game/save.ts+65 −0
| 1 | + | import type { GameState } from './engine'; | |
| 2 | + | import { NUM_BASIC, WALL_SIZE } from './tiles'; | |
| 3 | + | ||
| 4 | + | const KEY = 'taiwanese-mahjong/save'; | |
| 5 | + | /** Bump when GameState changes shape; older saves are then ignored rather than crashing. */ | |
| 6 | + | const VERSION = 2; | |
| 7 | + | ||
| 8 | + | export interface SavedGame { | |
| 9 | + | version: number; | |
| 10 | + | savedAt: number; | |
| 11 | + | state: GameState; | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | /** | |
| 15 | + | * The game state is plain data, so a save is just its JSON. Written after every | |
| 16 | + | * move, so a closed lid or a stray refresh costs nothing. | |
| 17 | + | */ | |
| 18 | + | export function saveGame(state: GameState) { | |
| 19 | + | if (state.phase === 'lobby') return; | |
| 20 | + | try { | |
| 21 | + | const payload: SavedGame = { version: VERSION, savedAt: Date.now(), state }; | |
| 22 | + | localStorage.setItem(KEY, JSON.stringify(payload)); | |
| 23 | + | } catch { | |
| 24 | + | // Storage unavailable or full — play carries on unsaved rather than dying. | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | export function loadSave(): SavedGame | null { | |
| 29 | + | try { | |
| 30 | + | const raw = localStorage.getItem(KEY); | |
| 31 | + | if (!raw) return null; | |
| 32 | + | const parsed = JSON.parse(raw) as SavedGame; | |
| 33 | + | if (parsed?.version !== VERSION) return null; | |
| 34 | + | if (!isPlausible(parsed.state)) return null; | |
| 35 | + | return parsed; | |
| 36 | + | } catch { | |
| 37 | + | return null; | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | export function clearSave() { | |
| 42 | + | try { | |
| 43 | + | localStorage.removeItem(KEY); | |
| 44 | + | } catch { | |
| 45 | + | // nothing to do | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /** Guards against a truncated or hand-edited save putting the table in a bad state. */ | |
| 50 | + | function isPlausible(s: GameState | undefined): boolean { | |
| 51 | + | if (!s || !Array.isArray(s.players) || s.players.length !== 4) return false; | |
| 52 | + | if (!Array.isArray(s.wall) || typeof s.phase !== 'string') return false; | |
| 53 | + | if (s.phase === 'lobby') return false; | |
| 54 | + | if (!Array.isArray(s.sacred) || s.sacred.length !== 4) return false; | |
| 55 | + | if (!s.rules || typeof s.rules.base !== 'number') return false; | |
| 56 | + | ||
| 57 | + | let tiles = s.wall.length; | |
| 58 | + | for (const p of s.players) { | |
| 59 | + | if (!p || !Array.isArray(p.hand) || !Array.isArray(p.melds)) return false; | |
| 60 | + | tiles += p.hand.length + p.flowers.length + p.discards.length; | |
| 61 | + | for (const m of p.melds) tiles += m.tiles.length; | |
| 62 | + | } | |
| 63 | + | if (tiles !== WALL_SIZE) return false; | |
| 64 | + | return s.players.every((p) => p.hand.every((t) => t >= 0 && t < NUM_BASIC + 8)); | |
| 65 | + | } |
addedsrc/game/sound.ts+168 −0
| 1 | + | import type { SoundEvent } from './engine'; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * Table noise, synthesised rather than sampled — a handful of oscillators is | |
| 5 | + | * smaller than one .wav and needs no loading, which matters when the whole | |
| 6 | + | * point is that the cue lands the instant the tile hits the table. | |
| 7 | + | * | |
| 8 | + | * Nothing here touches the audio hardware until `play` is first called, so the | |
| 9 | + | * module is safe to import in tests and safe to load before the user has | |
| 10 | + | * gestured at the page (browsers refuse to start audio before that). | |
| 11 | + | */ | |
| 12 | + | ||
| 13 | + | let ctx: AudioContext | null = null; | |
| 14 | + | let master: GainNode | null = null; | |
| 15 | + | let noise: AudioBuffer | null = null; | |
| 16 | + | let enabled = false; | |
| 17 | + | let volume = 0.7; | |
| 18 | + | ||
| 19 | + | function audio(): AudioContext | null { | |
| 20 | + | if (typeof window === 'undefined') return null; | |
| 21 | + | if (!ctx) { | |
| 22 | + | const Ctor = window.AudioContext ?? (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; | |
| 23 | + | if (!Ctor) return null; | |
| 24 | + | try { | |
| 25 | + | ctx = new Ctor(); | |
| 26 | + | } catch { | |
| 27 | + | return null; | |
| 28 | + | } | |
| 29 | + | master = ctx.createGain(); | |
| 30 | + | master.gain.value = volume; | |
| 31 | + | master.connect(ctx.destination); | |
| 32 | + | } | |
| 33 | + | // Chrome parks the context until a gesture, and again when the tab sleeps. | |
| 34 | + | if (ctx.state === 'suspended') void ctx.resume(); | |
| 35 | + | return ctx; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** One second of white noise, reused by every clack. */ | |
| 39 | + | function noiseBuffer(c: AudioContext): AudioBuffer { | |
| 40 | + | if (!noise) { | |
| 41 | + | noise = c.createBuffer(1, c.sampleRate, c.sampleRate); | |
| 42 | + | const d = noise.getChannelData(0); | |
| 43 | + | for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; | |
| 44 | + | } | |
| 45 | + | return noise; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | export function setSoundEnabled(on: boolean) { | |
| 49 | + | enabled = on; | |
| 50 | + | if (on) audio(); // unlock now, while we are still inside the click that toggled it | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | export function setVolume(v: number) { | |
| 54 | + | volume = v; | |
| 55 | + | if (master && ctx) master.gain.setTargetAtTime(v, ctx.currentTime, 0.01); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /** Envelope helper: a tone that starts at `gain` and decays away over `dur`. */ | |
| 59 | + | function tone( | |
| 60 | + | c: AudioContext, | |
| 61 | + | opts: { | |
| 62 | + | freq: number; | |
| 63 | + | at: number; | |
| 64 | + | dur: number; | |
| 65 | + | gain: number; | |
| 66 | + | type?: OscillatorType; | |
| 67 | + | /** Slide to this frequency over the life of the note. */ | |
| 68 | + | to?: number; | |
| 69 | + | }, | |
| 70 | + | ) { | |
| 71 | + | const o = c.createOscillator(); | |
| 72 | + | const g = c.createGain(); | |
| 73 | + | o.type = opts.type ?? 'sine'; | |
| 74 | + | const t = c.currentTime + opts.at; | |
| 75 | + | o.frequency.setValueAtTime(opts.freq, t); | |
| 76 | + | if (opts.to !== undefined) o.frequency.exponentialRampToValueAtTime(opts.to, t + opts.dur); | |
| 77 | + | g.gain.setValueAtTime(0.0001, t); | |
| 78 | + | g.gain.exponentialRampToValueAtTime(opts.gain, t + 0.004); | |
| 79 | + | g.gain.exponentialRampToValueAtTime(0.0001, t + opts.dur); | |
| 80 | + | o.connect(g).connect(master!); | |
| 81 | + | o.start(t); | |
| 82 | + | o.stop(t + opts.dur + 0.02); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /** Bamboo-on-bamboo: a filtered noise burst with a short woody body under it. */ | |
| 86 | + | function clack(c: AudioContext, at: number, gain = 1) { | |
| 87 | + | const t = c.currentTime + at; | |
| 88 | + | const src = c.createBufferSource(); | |
| 89 | + | src.buffer = noiseBuffer(c); | |
| 90 | + | src.playbackRate.value = 1; | |
| 91 | + | const bp = c.createBiquadFilter(); | |
| 92 | + | bp.type = 'bandpass'; | |
| 93 | + | bp.frequency.value = 2100; | |
| 94 | + | bp.Q.value = 1.1; | |
| 95 | + | const g = c.createGain(); | |
| 96 | + | g.gain.setValueAtTime(0.55 * gain, t); | |
| 97 | + | g.gain.exponentialRampToValueAtTime(0.0001, t + 0.075); | |
| 98 | + | src.connect(bp).connect(g).connect(master!); | |
| 99 | + | src.start(t, Math.random() * 0.5); | |
| 100 | + | src.stop(t + 0.1); | |
| 101 | + | tone(c, { freq: 320, to: 160, at, dur: 0.09, gain: 0.28 * gain, type: 'triangle' }); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /** Struck-metal chime: a fundamental with two inharmonic partials over it. */ | |
| 105 | + | function bell(c: AudioContext, freq: number, at: number, gain = 1, dur = 0.55) { | |
| 106 | + | tone(c, { freq, at, dur, gain: 0.3 * gain, type: 'sine' }); | |
| 107 | + | tone(c, { freq: freq * 2.02, at, dur: dur * 0.7, gain: 0.13 * gain, type: 'sine' }); | |
| 108 | + | tone(c, { freq: freq * 3.01, at, dur: dur * 0.4, gain: 0.06 * gain, type: 'sine' }); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /** | |
| 112 | + | * Each cue has its own shape so it is identifiable without looking up — the | |
| 113 | + | * whole reason for the feature is a player noticing across the table. | |
| 114 | + | */ | |
| 115 | + | export function play(e: SoundEvent) { | |
| 116 | + | if (!enabled) return; | |
| 117 | + | const c = audio(); | |
| 118 | + | if (!c || !master) return; | |
| 119 | + | ||
| 120 | + | switch (e) { | |
| 121 | + | // A tile laid down. Dry and short so it never masks the chime after it. | |
| 122 | + | case 'discard': | |
| 123 | + | clack(c, 0); | |
| 124 | + | break; | |
| 125 | + | // Somebody's claim window is open. Rising two-note bell, easy to hear over | |
| 126 | + | // talk, and deliberately unlike the clack that precedes it. | |
| 127 | + | case 'claimWindow': | |
| 128 | + | bell(c, 784, 0.06, 1, 0.4); // G5 | |
| 129 | + | bell(c, 1047, 0.19, 0.9, 0.55); // C6 | |
| 130 | + | break; | |
| 131 | + | case 'claim': | |
| 132 | + | clack(c, 0, 0.8); | |
| 133 | + | clack(c, 0.055, 0.9); | |
| 134 | + | bell(c, 660, 0.02, 0.5, 0.3); | |
| 135 | + | break; | |
| 136 | + | // Four tiles going down, plus a replacement off the back of the wall. | |
| 137 | + | case 'kong': | |
| 138 | + | clack(c, 0, 0.7); | |
| 139 | + | clack(c, 0.05, 0.8); | |
| 140 | + | clack(c, 0.1, 0.9); | |
| 141 | + | clack(c, 0.15, 1); | |
| 142 | + | bell(c, 523, 0.16, 0.55, 0.5); | |
| 143 | + | break; | |
| 144 | + | case 'flower': | |
| 145 | + | bell(c, 1319, 0, 0.45, 0.35); | |
| 146 | + | break; | |
| 147 | + | // 胡牌 — a rising figure, the only cue that takes its time. | |
| 148 | + | case 'win': | |
| 149 | + | bell(c, 523, 0, 1, 0.5); | |
| 150 | + | bell(c, 659, 0.11, 1, 0.5); | |
| 151 | + | bell(c, 784, 0.22, 1, 0.6); | |
| 152 | + | bell(c, 1047, 0.33, 1.1, 1.1); | |
| 153 | + | break; | |
| 154 | + | // 流局 — the same figure, falling and dulled. | |
| 155 | + | case 'drawGame': | |
| 156 | + | bell(c, 523, 0, 0.7, 0.5); | |
| 157 | + | bell(c, 415, 0.13, 0.7, 0.6); | |
| 158 | + | bell(c, 330, 0.26, 0.7, 0.9); | |
| 159 | + | break; | |
| 160 | + | // Sixteen tiles apiece, so a scatter rather than a beat. | |
| 161 | + | case 'deal': | |
| 162 | + | for (let i = 0; i < 9; i++) clack(c, i * 0.045 + Math.random() * 0.02, 0.4); | |
| 163 | + | break; | |
| 164 | + | case 'undo': | |
| 165 | + | tone(c, { freq: 700, to: 330, at: 0, dur: 0.22, gain: 0.18, type: 'triangle' }); | |
| 166 | + | break; | |
| 167 | + | } | |
| 168 | + | } |
addedsrc/game/tai.ts+209 −0
| 1 | + | import { decompose, waits, waitShape, type Decomposition, type TileSet } from './hu'; | |
| 2 | + | import { | |
| 3 | + | EAST, | |
| 4 | + | NUM_SUITED, | |
| 5 | + | RED, | |
| 6 | + | WHITE, | |
| 7 | + | flowerSeat, | |
| 8 | + | flowerSet, | |
| 9 | + | isHonor, | |
| 10 | + | isSuited, | |
| 11 | + | suitOf, | |
| 12 | + | type Tile, | |
| 13 | + | } from './tiles'; | |
| 14 | + | import { meldIsConcealed, meldIsKong, type Meld, type SeatId } from './types'; | |
| 15 | + | ||
| 16 | + | export interface WinContext { | |
| 17 | + | winner: SeatId; | |
| 18 | + | dealer: SeatId; | |
| 19 | + | /** 連莊 count: 0 on a fresh dealer, 1 after one repeat, ... */ | |
| 20 | + | dealerStreak: number; | |
| 21 | + | /** 圈風 0=東 1=南 2=西 3=北 */ | |
| 22 | + | roundWind: number; | |
| 23 | + | melds: Meld[]; | |
| 24 | + | /** Concealed tiles, NOT including the winning tile. */ | |
| 25 | + | concealed: Tile[]; | |
| 26 | + | winTile: Tile; | |
| 27 | + | selfDraw: boolean; | |
| 28 | + | /** Discarder, when won off a discard. */ | |
| 29 | + | loser: SeatId | null; | |
| 30 | + | flowers: Tile[]; | |
| 31 | + | robKong: boolean; | |
| 32 | + | afterKong: boolean; | |
| 33 | + | lastTile: boolean; | |
| 34 | + | lastDiscard: boolean; | |
| 35 | + | heavenly: boolean; | |
| 36 | + | earthly: boolean; | |
| 37 | + | humanly: boolean; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | export interface TaiItem { | |
| 41 | + | /** The term as it is called at the table. */ | |
| 42 | + | name: string; | |
| 43 | + | /** Plain-English gloss for players still learning the terms. */ | |
| 44 | + | en: string; | |
| 45 | + | tai: number; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | export interface TaiResult { | |
| 49 | + | tai: number; | |
| 50 | + | items: TaiItem[]; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /** Score the hand, taking the highest-value interpretation of ambiguous hands. */ | |
| 54 | + | export function scoreHand(ctx: WinContext): TaiResult { | |
| 55 | + | const need = 5 - ctx.melds.length; | |
| 56 | + | const decs = decompose([...ctx.concealed, ctx.winTile], need); | |
| 57 | + | if (decs.length === 0) return { tai: 0, items: [] }; | |
| 58 | + | ||
| 59 | + | let best: TaiResult = { tai: -1, items: [] }; | |
| 60 | + | for (const d of decs) { | |
| 61 | + | const r = scoreDecomposition(ctx, d); | |
| 62 | + | if (r.tai > best.tai) best = r; | |
| 63 | + | } | |
| 64 | + | return best; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | function seatWindOf(seat: SeatId, dealer: SeatId): number { | |
| 68 | + | return (seat - dealer + 4) % 4; | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | function scoreDecomposition(ctx: WinContext, d: Decomposition): TaiResult { | |
| 72 | + | const items: TaiItem[] = []; | |
| 73 | + | const add = (name: string, en: string, tai: number) => { | |
| 74 | + | if (tai !== 0) items.push({ name, en, tai }); | |
| 75 | + | }; | |
| 76 | + | ||
| 77 | + | // ---- assemble the five sets ------------------------------------------- | |
| 78 | + | const sets: TileSet[] = ctx.melds.map((m) => ({ | |
| 79 | + | type: m.kind === 'chow' ? 'chow' : 'pung', | |
| 80 | + | tile: m.kind === 'chow' ? Math.min(...m.tiles) : m.tiles[0], | |
| 81 | + | concealed: meldIsConcealed(m), | |
| 82 | + | kong: meldIsKong(m), | |
| 83 | + | })); | |
| 84 | + | for (const s of d.sets) sets.push({ ...s }); | |
| 85 | + | ||
| 86 | + | // A set completed by someone else's discard is not concealed. | |
| 87 | + | if (!ctx.selfDraw) { | |
| 88 | + | const i = sets.findIndex( | |
| 89 | + | (s) => | |
| 90 | + | s.concealed && | |
| 91 | + | !s.kong && | |
| 92 | + | (s.type === 'pung' | |
| 93 | + | ? s.tile === ctx.winTile | |
| 94 | + | : ctx.winTile >= s.tile && ctx.winTile <= s.tile + 2), | |
| 95 | + | ); | |
| 96 | + | if (i >= 0) sets[i].concealed = false; | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | const exposedCount = ctx.melds.filter((m) => !meldIsConcealed(m)).length; | |
| 100 | + | const fullyConcealed = exposedCount === 0; // 門清 (暗槓 still counts) | |
| 101 | + | const seatWind = seatWindOf(ctx.winner, ctx.dealer); | |
| 102 | + | ||
| 103 | + | const allTiles: Tile[] = []; | |
| 104 | + | for (const m of ctx.melds) allTiles.push(...m.tiles); | |
| 105 | + | allTiles.push(...ctx.concealed, ctx.winTile); | |
| 106 | + | ||
| 107 | + | // ---- flowers ---------------------------------------------------------- | |
| 108 | + | if (ctx.flowers.length === 8) { | |
| 109 | + | add('八仙過海', 'All eight flowers', 8); | |
| 110 | + | } else { | |
| 111 | + | const own = ctx.flowers.filter((f) => flowerSeat(f) === seatWind).length; | |
| 112 | + | if (own) add(`正花 ×${own}`, `Seat flowers ×${own}`, own); | |
| 113 | + | for (const set of [0, 1]) { | |
| 114 | + | if (ctx.flowers.filter((f) => flowerSet(f) === set).length === 4) { | |
| 115 | + | add(set === 0 ? '花槓 (四季)' : '花槓 (四花)', set === 0 ? 'Four seasons' : 'Four flowers', 2); | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // ---- honor sets ------------------------------------------------------- | |
| 121 | + | const pungs = sets.filter((s) => s.type === 'pung'); | |
| 122 | + | const dragonPungs = pungs.filter((s) => s.tile >= RED && s.tile <= WHITE).length; | |
| 123 | + | const windPungs = pungs.filter((s) => s.tile >= EAST && s.tile < RED); | |
| 124 | + | const dragonPair = d.pair >= RED && d.pair <= WHITE; | |
| 125 | + | const windPair = d.pair >= EAST && d.pair < RED; | |
| 126 | + | ||
| 127 | + | if (dragonPungs === 3) add('大三元', 'Big three dragons', 8); | |
| 128 | + | else if (dragonPungs === 2 && dragonPair) add('小三元', 'Little three dragons', 4); | |
| 129 | + | else if (dragonPungs > 0) add(`三元牌 ×${dragonPungs}`, `Dragon pung ×${dragonPungs}`, dragonPungs); | |
| 130 | + | ||
| 131 | + | const windPungCount = windPungs.length; | |
| 132 | + | if (windPungCount === 4) add('大四喜', 'Big four winds', 16); | |
| 133 | + | else if (windPungCount === 3 && windPair) add('小四喜', 'Little four winds', 8); | |
| 134 | + | else { | |
| 135 | + | if (windPungs.some((s) => s.tile - EAST === ctx.roundWind)) add('圈風', 'Round wind', 1); | |
| 136 | + | if (windPungs.some((s) => s.tile - EAST === seatWind)) add('門風', 'Seat wind', 1); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // ---- shape ------------------------------------------------------------ | |
| 140 | + | const allPungs = sets.every((s) => s.type === 'pung'); | |
| 141 | + | const allChows = sets.every((s) => s.type === 'chow'); | |
| 142 | + | if (allPungs) add('碰碰胡', 'All pungs', 4); | |
| 143 | + | ||
| 144 | + | const suits = new Set(allTiles.filter(isSuited).map(suitOf)); | |
| 145 | + | const hasHonor = allTiles.some(isHonor); | |
| 146 | + | if (!allTiles.some(isSuited)) add('字一色', 'All honors', 16); | |
| 147 | + | else if (suits.size === 1) | |
| 148 | + | add(hasHonor ? '混一色' : '清一色', hasHonor ? 'Half flush' : 'Full flush', hasHonor ? 4 : 8); | |
| 149 | + | ||
| 150 | + | const kinds = new Set(allTiles.map(suitOf)); | |
| 151 | + | if (kinds.has('m') && kinds.has('s') && kinds.has('p') && kinds.has('wind') && kinds.has('dragon')) { | |
| 152 | + | add('五門齊', 'All five types', 2); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | const concealedPungs = sets.filter((s) => s.type === 'pung' && s.concealed).length; | |
| 156 | + | if (concealedPungs === 5) add('五暗刻', 'Five concealed pungs', 8); | |
| 157 | + | else if (concealedPungs === 4) add('四暗刻', 'Four concealed pungs', 5); | |
| 158 | + | else if (concealedPungs === 3) add('三暗刻', 'Three concealed pungs', 2); | |
| 159 | + | ||
| 160 | + | const pairIsValuable = | |
| 161 | + | dragonPair || (windPair && (d.pair - EAST === ctx.roundWind || d.pair - EAST === seatWind)); | |
| 162 | + | if (allChows && !pairIsValuable && !ctx.selfDraw && !hasHonorSet(sets)) add('平胡', 'All chows', 2); | |
| 163 | + | ||
| 164 | + | // ---- how it was won --------------------------------------------------- | |
| 165 | + | if (ctx.heavenly) add('天胡', 'Blessing of heaven', 16); | |
| 166 | + | else if (ctx.earthly) add('地胡', 'Blessing of earth', 16); | |
| 167 | + | else if (ctx.humanly) add('人胡', 'Blessing of man', 8); | |
| 168 | + | ||
| 169 | + | if (ctx.selfDraw) add('自摸', 'Self-draw', 1); | |
| 170 | + | if (fullyConcealed) add('門清', 'Fully concealed', 1); | |
| 171 | + | if (fullyConcealed && ctx.selfDraw) add('門清自摸', 'Concealed self-draw', 1); | |
| 172 | + | if (!ctx.selfDraw && exposedCount === 5) add('全求人', 'All melded', 2); | |
| 173 | + | ||
| 174 | + | if (ctx.robKong) add('搶槓', 'Robbing the kong', 1); | |
| 175 | + | if (ctx.afterKong) add('槓上開花', 'Win off a kong', 1); | |
| 176 | + | if (ctx.lastTile) add('海底撈月', 'Last tile of the wall', 1); | |
| 177 | + | if (ctx.lastDiscard) add('河底撈魚', 'Last discard', 1); | |
| 178 | + | ||
| 179 | + | const w = waits(ctx.concealed, ctx.melds.length); | |
| 180 | + | if (w.length === 1) { | |
| 181 | + | const shape = waitShape(ctx.concealed, ctx.winTile, ctx.melds.length); | |
| 182 | + | add(shape === 'pair' ? '單釣' : '獨聽', shape === 'pair' ? 'Single pair wait' : 'Sole wait', 1); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // ---- dealer ----------------------------------------------------------- | |
| 186 | + | // 莊家 tai only applies when the dealer is the winner; when the dealer is a | |
| 187 | + | // payer the same amount is billed to the dealer alone (see settleHand). | |
| 188 | + | if (ctx.winner === ctx.dealer) | |
| 189 | + | add(dealerLabel(ctx.dealerStreak), dealerLabelEn(ctx.dealerStreak), dealerTai(ctx.dealerStreak)); | |
| 190 | + | ||
| 191 | + | return { tai: items.reduce((a, i) => a + i.tai, 0), items }; | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | function hasHonorSet(sets: TileSet[]): boolean { | |
| 195 | + | return sets.some((s) => s.tile >= NUM_SUITED); | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /** 莊 1台, 連N拉N → 2N+1台. */ | |
| 199 | + | export function dealerTai(streak: number): number { | |
| 200 | + | return 1 + 2 * streak; | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | export function dealerLabel(streak: number): string { | |
| 204 | + | return streak > 0 ? `莊家 連${streak}拉${streak}` : '莊家'; | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | export function dealerLabelEn(streak: number): string { | |
| 208 | + | return streak > 0 ? `Dealer, ${streak} in a row` : 'Dealer'; | |
| 209 | + | } |
addedsrc/game/tiles.ts+130 −0
| 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 | + | } |
addedsrc/game/types.ts+72 −0
| 1 | + | import type { Tile } from './tiles'; | |
| 2 | + | ||
| 3 | + | export type SeatId = 0 | 1 | 2 | 3; | |
| 4 | + | ||
| 5 | + | /** Placeholder names, until somebody types their own on the settings screen. */ | |
| 6 | + | export const DEFAULT_NAMES = ['玩家 1', '玩家 2', '玩家 3', '玩家 4']; | |
| 7 | + | ||
| 8 | + | export type MeldKind = | |
| 9 | + | | 'chow' // 吃 | |
| 10 | + | | 'pung' // 碰 | |
| 11 | + | | 'kong' // 明槓 (claimed from a discard) | |
| 12 | + | | 'ankong' // 暗槓 (four from hand) | |
| 13 | + | | 'addkong'; // 加槓 (fourth tile added to an exposed pung) | |
| 14 | + | ||
| 15 | + | export interface Meld { | |
| 16 | + | kind: MeldKind; | |
| 17 | + | /** Sorted tiles making up the meld. */ | |
| 18 | + | tiles: Tile[]; | |
| 19 | + | /** Whose discard it came from, if any. */ | |
| 20 | + | from?: SeatId; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | export const meldIsConcealed = (m: Meld) => m.kind === 'ankong'; | |
| 24 | + | export const meldIsKong = (m: Meld) => m.kind === 'kong' || m.kind === 'ankong' || m.kind === 'addkong'; | |
| 25 | + | ||
| 26 | + | export interface Player { | |
| 27 | + | name: string; | |
| 28 | + | hand: Tile[]; // concealed, sorted | |
| 29 | + | melds: Meld[]; | |
| 30 | + | flowers: Tile[]; | |
| 31 | + | discards: Tile[]; | |
| 32 | + | score: number; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | export interface Rules { | |
| 36 | + | /** 底 — flat amount every payer contributes. */ | |
| 37 | + | base: number; | |
| 38 | + | /** 台 — value of one tai. */ | |
| 39 | + | taiValue: number; | |
| 40 | + | /** Tiles left in the wall when the hand is declared a draw (流局). */ | |
| 41 | + | wallReserve: number; | |
| 42 | + | /** Starting chips per player. */ | |
| 43 | + | startingScore: number; | |
| 44 | + | /** | |
| 45 | + | * 過水 — pass on a tile you could have won on and it is dead to you until | |
| 46 | + | * your own next draw. | |
| 47 | + | */ | |
| 48 | + | sacredDiscard: boolean; | |
| 49 | + | /** | |
| 50 | + | * Whether taking a 碰/吃/槓 also lifts a 過水 lock, or only drawing does. | |
| 51 | + | * Tables genuinely differ, hence the flag. | |
| 52 | + | */ | |
| 53 | + | sacredClearedByClaim: boolean; | |
| 54 | + | /** 一炮多響 — one discard pays out to every seat that declares on it. */ | |
| 55 | + | multipleWinners: boolean; | |
| 56 | + | /** | |
| 57 | + | * 包牌 — feed the third dragon or wind pung to an obvious 大三元 / 大四喜 | |
| 58 | + | * and you answer for the whole hand yourself. | |
| 59 | + | */ | |
| 60 | + | liability: boolean; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | export const DEFAULT_RULES: Rules = { | |
| 64 | + | base: 3, | |
| 65 | + | taiValue: 1, | |
| 66 | + | wallReserve: 16, | |
| 67 | + | startingScore: 100, | |
| 68 | + | sacredDiscard: true, | |
| 69 | + | sacredClearedByClaim: false, | |
| 70 | + | multipleWinners: false, | |
| 71 | + | liability: true, | |
| 72 | + | }; |
addedsrc/main.tsx+9 −0
| 1 | + | import { StrictMode } from 'react' | |
| 2 | + | import { createRoot } from 'react-dom/client' | |
| 3 | + | import App from './App.tsx' | |
| 4 | + | ||
| 5 | + | createRoot(document.getElementById('root')!).render( | |
| 6 | + | <StrictMode> | |
| 7 | + | <App /> | |
| 8 | + | </StrictMode>, | |
| 9 | + | ) |
addedsrc/styles.css+1164 −0
| 1 | + | @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;600;900&display=swap'); | |
| 2 | + | ||
| 3 | + | :root { | |
| 4 | + | --depth: clamp(232px, 30vh, 330px); | |
| 5 | + | --h-len: calc(100vw - 2 * var(--depth)); | |
| 6 | + | --v-len: 100vh; | |
| 7 | + | ||
| 8 | + | --felt: #0f4534; | |
| 9 | + | --felt-2: #0a3126; | |
| 10 | + | --ivory: #f6f0e2; | |
| 11 | + | --ivory-edge: #d9cfb6; | |
| 12 | + | --ink: #21262b; | |
| 13 | + | --gold: #d9b45c; | |
| 14 | + | --red: #b3261e; | |
| 15 | + | --green: #1c7a4b; | |
| 16 | + | --blue: #1d5aa8; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | * { | |
| 20 | + | box-sizing: border-box; | |
| 21 | + | -webkit-tap-highlight-color: transparent; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | html, | |
| 25 | + | body, | |
| 26 | + | #root { | |
| 27 | + | height: 100%; | |
| 28 | + | margin: 0; | |
| 29 | + | overflow: hidden; | |
| 30 | + | overscroll-behavior: none; | |
| 31 | + | touch-action: manipulation; | |
| 32 | + | user-select: none; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | body { | |
| 36 | + | font-family: 'Noto Sans TC', system-ui, sans-serif; | |
| 37 | + | color: var(--ivory); | |
| 38 | + | background: var(--felt-2); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /* ---------- lobby ---------- */ | |
| 42 | + | .lobby { | |
| 43 | + | height: 100%; | |
| 44 | + | display: flex; | |
| 45 | + | flex-direction: column; | |
| 46 | + | align-items: center; | |
| 47 | + | justify-content: center; | |
| 48 | + | gap: 0.5rem; | |
| 49 | + | background: radial-gradient(ellipse at center, var(--felt) 0%, var(--felt-2) 75%); | |
| 50 | + | } | |
| 51 | + | .lobby h1 { | |
| 52 | + | font-size: clamp(3rem, 9vw, 7rem); | |
| 53 | + | font-weight: 900; | |
| 54 | + | letter-spacing: 0.1em; | |
| 55 | + | margin: 0 0 0.5rem; | |
| 56 | + | color: var(--gold); | |
| 57 | + | } | |
| 58 | + | .lobby p { | |
| 59 | + | margin: 0; | |
| 60 | + | opacity: 0.75; | |
| 61 | + | letter-spacing: 0.15em; | |
| 62 | + | } | |
| 63 | + | .lobby .hint { | |
| 64 | + | opacity: 0.45; | |
| 65 | + | font-size: 0.9rem; | |
| 66 | + | margin-bottom: 1.5rem; | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /* ---------- table ---------- */ | |
| 70 | + | .table { | |
| 71 | + | position: relative; | |
| 72 | + | height: 100%; | |
| 73 | + | width: 100%; | |
| 74 | + | background: radial-gradient(ellipse at center, var(--felt) 0%, var(--felt-2) 80%); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | .slot { | |
| 78 | + | position: absolute; | |
| 79 | + | overflow: hidden; | |
| 80 | + | } | |
| 81 | + | .slot-bottom { | |
| 82 | + | bottom: 0; | |
| 83 | + | left: var(--depth); | |
| 84 | + | right: var(--depth); | |
| 85 | + | height: var(--depth); | |
| 86 | + | } | |
| 87 | + | .slot-top { | |
| 88 | + | top: 0; | |
| 89 | + | left: var(--depth); | |
| 90 | + | right: var(--depth); | |
| 91 | + | height: var(--depth); | |
| 92 | + | } | |
| 93 | + | .slot-left { | |
| 94 | + | left: 0; | |
| 95 | + | top: 0; | |
| 96 | + | width: var(--depth); | |
| 97 | + | height: 100%; | |
| 98 | + | } | |
| 99 | + | .slot-right { | |
| 100 | + | right: 0; | |
| 101 | + | top: 0; | |
| 102 | + | width: var(--depth); | |
| 103 | + | height: 100%; | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | .seat { | |
| 107 | + | position: absolute; | |
| 108 | + | top: 50%; | |
| 109 | + | left: 50%; | |
| 110 | + | height: var(--depth); | |
| 111 | + | display: flex; | |
| 112 | + | flex-direction: column; | |
| 113 | + | justify-content: flex-end; | |
| 114 | + | align-items: center; | |
| 115 | + | gap: 2px; | |
| 116 | + | padding: 2px 8px 4px; | |
| 117 | + | border-radius: 10px; | |
| 118 | + | overflow: hidden; | |
| 119 | + | transition: background 0.2s; | |
| 120 | + | } | |
| 121 | + | .slot-bottom .seat, | |
| 122 | + | .slot-top .seat { | |
| 123 | + | width: var(--h-len); | |
| 124 | + | --tile-w: min(calc((var(--h-len) - 40px) / 18), 72px); | |
| 125 | + | } | |
| 126 | + | .slot-left .seat, | |
| 127 | + | .slot-right .seat { | |
| 128 | + | width: var(--v-len); | |
| 129 | + | --tile-w: min(calc((var(--v-len) - 40px) / 18), 72px); | |
| 130 | + | } | |
| 131 | + | .seat.active { | |
| 132 | + | background: linear-gradient(to top, rgba(217, 180, 92, 0.22), transparent 70%); | |
| 133 | + | } | |
| 134 | + | .seat.claiming { | |
| 135 | + | background: linear-gradient(to top, rgba(179, 38, 30, 0.25), transparent 70%); | |
| 136 | + | animation: pulse 1.1s ease-in-out infinite; | |
| 137 | + | } | |
| 138 | + | @keyframes pulse { | |
| 139 | + | 50% { | |
| 140 | + | background: linear-gradient(to top, rgba(179, 38, 30, 0.45), transparent 70%); | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | /* ---------- tiles ---------- */ | |
| 145 | + | /* Faces come from a 43x2 sprite sheet; the second row is the highlighted | |
| 146 | + | variant, which we use for the tile a player has lifted to discard. */ | |
| 147 | + | .tile { | |
| 148 | + | position: relative; | |
| 149 | + | flex: none; | |
| 150 | + | --tw: var(--tile-w); | |
| 151 | + | width: var(--tw); | |
| 152 | + | height: calc(var(--tw) * 1.375); | |
| 153 | + | border-radius: calc(var(--tw) * 0.1); | |
| 154 | + | transition: transform 0.12s ease, filter 0.12s ease; | |
| 155 | + | } | |
| 156 | + | .tile.t-meld { | |
| 157 | + | --tw: calc(var(--tile-w) * 0.62); | |
| 158 | + | } | |
| 159 | + | .tile.t-small { | |
| 160 | + | --tw: calc(var(--tile-w) * 0.42); | |
| 161 | + | } | |
| 162 | + | .tile.face { | |
| 163 | + | background-image: url('/tiles/tiles.svg'); | |
| 164 | + | background-repeat: no-repeat; | |
| 165 | + | background-size: calc(var(--tw) * 43) calc(var(--tw) * 2.75); | |
| 166 | + | background-position: calc(var(--col) * var(--tw) * -1) 0; | |
| 167 | + | filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.45)); | |
| 168 | + | } | |
| 169 | + | .tile.back { | |
| 170 | + | background-image: url('/tiles/back.png'); | |
| 171 | + | background-size: 100% 100%; | |
| 172 | + | filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.45)); | |
| 173 | + | } | |
| 174 | + | .tile.tappable { | |
| 175 | + | cursor: pointer; | |
| 176 | + | } | |
| 177 | + | .tile.raised { | |
| 178 | + | transform: translateY(calc(var(--tw) * -0.28)); | |
| 179 | + | filter: drop-shadow(0 10px 12px rgba(0, 0, 0, 0.55)); | |
| 180 | + | } | |
| 181 | + | /* Lifted tiles switch to the sheet's highlighted row. */ | |
| 182 | + | .tile.face.raised { | |
| 183 | + | background-position: calc(var(--col) * var(--tw) * -1) calc(var(--tw) * -1.375); | |
| 184 | + | } | |
| 185 | + | .tile.hl { | |
| 186 | + | outline: 2px solid var(--gold); | |
| 187 | + | outline-offset: 1px; | |
| 188 | + | border-radius: calc(var(--tw) * 0.1); | |
| 189 | + | } | |
| 190 | + | .tile.dim { | |
| 191 | + | opacity: 0.45; | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /* ---------- seat sections ---------- */ | |
| 195 | + | .hand { | |
| 196 | + | display: flex; | |
| 197 | + | gap: 2px; | |
| 198 | + | /* the extra top room is where the 摸 tag sits */ | |
| 199 | + | height: calc(var(--tile-w) * 1.71 + 6px); | |
| 200 | + | padding-top: calc(var(--tile-w) * 0.34); | |
| 201 | + | align-items: flex-end; | |
| 202 | + | flex: none; | |
| 203 | + | } | |
| 204 | + | /* Every hand tile sits in a draggable cell. */ | |
| 205 | + | .hand-cell { | |
| 206 | + | position: relative; | |
| 207 | + | display: flex; | |
| 208 | + | align-items: flex-end; | |
| 209 | + | touch-action: none; | |
| 210 | + | cursor: grab; | |
| 211 | + | } | |
| 212 | + | .hand-cell.dragging { | |
| 213 | + | z-index: 5; | |
| 214 | + | cursor: grabbing; | |
| 215 | + | } | |
| 216 | + | .hand-cell.dragging .tile { | |
| 217 | + | transform: translateY(calc(var(--tile-w) * -0.2)) scale(1.06); | |
| 218 | + | filter: drop-shadow(0 14px 18px rgba(0, 0, 0, 0.65)); | |
| 219 | + | } | |
| 220 | + | .drawn-slot { | |
| 221 | + | margin-left: calc(var(--tile-w) * 0.45); | |
| 222 | + | } | |
| 223 | + | .drawn-slot .tile { | |
| 224 | + | outline: 2px solid var(--gold); | |
| 225 | + | outline-offset: 1px; | |
| 226 | + | border-radius: calc(var(--tw) * 0.1); | |
| 227 | + | } | |
| 228 | + | .drawn-tag { | |
| 229 | + | position: absolute; | |
| 230 | + | top: calc(var(--tile-w) * -0.3); | |
| 231 | + | left: 0; | |
| 232 | + | right: 0; | |
| 233 | + | text-align: center; | |
| 234 | + | font-size: calc(var(--tile-w) * 0.24); | |
| 235 | + | font-weight: 600; | |
| 236 | + | letter-spacing: 0.1em; | |
| 237 | + | color: var(--gold); | |
| 238 | + | white-space: nowrap; | |
| 239 | + | pointer-events: none; | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | .melds { | |
| 243 | + | display: flex; | |
| 244 | + | gap: 10px; | |
| 245 | + | flex-wrap: wrap; | |
| 246 | + | justify-content: center; | |
| 247 | + | min-height: calc(var(--tile-w) * 0.86); | |
| 248 | + | flex: none; | |
| 249 | + | } | |
| 250 | + | .meld { | |
| 251 | + | display: flex; | |
| 252 | + | gap: 1px; | |
| 253 | + | } | |
| 254 | + | .meld.flowers { | |
| 255 | + | margin-left: 12px; | |
| 256 | + | padding-left: 12px; | |
| 257 | + | border-left: 1px dashed rgba(246, 240, 226, 0.3); | |
| 258 | + | } | |
| 259 | + | .discards { | |
| 260 | + | display: flex; | |
| 261 | + | flex-wrap: wrap; | |
| 262 | + | gap: 2px; | |
| 263 | + | justify-content: center; | |
| 264 | + | align-content: flex-start; | |
| 265 | + | max-width: 94%; | |
| 266 | + | min-height: 0; | |
| 267 | + | margin-bottom: 2px; | |
| 268 | + | flex: 1 1 0; | |
| 269 | + | overflow-y: auto; | |
| 270 | + | scrollbar-width: none; | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | .nameplate { | |
| 274 | + | display: flex; | |
| 275 | + | align-items: center; | |
| 276 | + | gap: 8px; | |
| 277 | + | font-size: 13px; | |
| 278 | + | letter-spacing: 0.08em; | |
| 279 | + | opacity: 0.9; | |
| 280 | + | padding-top: 2px; | |
| 281 | + | flex: none; | |
| 282 | + | } | |
| 283 | + | .nameplate .wind { | |
| 284 | + | display: flex; | |
| 285 | + | flex-direction: column; | |
| 286 | + | align-items: center; | |
| 287 | + | line-height: 1; | |
| 288 | + | font-size: 18px; | |
| 289 | + | font-weight: 900; | |
| 290 | + | color: var(--gold); | |
| 291 | + | } | |
| 292 | + | .nameplate .wind em { | |
| 293 | + | font-style: normal; | |
| 294 | + | font-size: 8px; | |
| 295 | + | font-weight: 600; | |
| 296 | + | letter-spacing: 0.1em; | |
| 297 | + | text-transform: uppercase; | |
| 298 | + | opacity: 0.6; | |
| 299 | + | } | |
| 300 | + | .nameplate .wind.dealer { | |
| 301 | + | color: #ff7a68; | |
| 302 | + | } | |
| 303 | + | .nameplate .tag { | |
| 304 | + | font-size: 11px; | |
| 305 | + | padding: 1px 6px; | |
| 306 | + | border-radius: 999px; | |
| 307 | + | background: rgba(217, 180, 92, 0.2); | |
| 308 | + | border: 1px solid rgba(217, 180, 92, 0.5); | |
| 309 | + | } | |
| 310 | + | .nameplate .tag.ting { | |
| 311 | + | background: rgba(46, 170, 120, 0.2); | |
| 312 | + | border-color: rgba(46, 170, 120, 0.6); | |
| 313 | + | } | |
| 314 | + | .nameplate .tag.sacred { | |
| 315 | + | background: rgba(179, 38, 30, 0.22); | |
| 316 | + | border-color: rgba(224, 108, 96, 0.6); | |
| 317 | + | color: #ffc9c2; | |
| 318 | + | } | |
| 319 | + | .nameplate .chips { | |
| 320 | + | margin-left: 4px; | |
| 321 | + | font-weight: 900; | |
| 322 | + | font-size: 16px; | |
| 323 | + | color: var(--gold); | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | .actionbar { | |
| 327 | + | display: flex; | |
| 328 | + | gap: 8px; | |
| 329 | + | align-items: center; | |
| 330 | + | justify-content: flex-end; | |
| 331 | + | padding-right: calc(var(--tile-w) * 0.4); | |
| 332 | + | align-self: stretch; | |
| 333 | + | min-height: 40px; | |
| 334 | + | flex-wrap: wrap; | |
| 335 | + | flex: none; | |
| 336 | + | } | |
| 337 | + | .waiting { | |
| 338 | + | font-size: 12px; | |
| 339 | + | opacity: 0.5; | |
| 340 | + | } | |
| 341 | + | .delta { | |
| 342 | + | font-size: 20px; | |
| 343 | + | font-weight: 900; | |
| 344 | + | } | |
| 345 | + | .delta.up { | |
| 346 | + | color: #5fd39a; | |
| 347 | + | } | |
| 348 | + | .delta.down { | |
| 349 | + | color: #ff8a80; | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | /* ---------- buttons ---------- */ | |
| 353 | + | .btn { | |
| 354 | + | font-family: inherit; | |
| 355 | + | font-size: 17px; | |
| 356 | + | font-weight: 400; | |
| 357 | + | color: #1a1a1a; | |
| 358 | + | background: linear-gradient(180deg, #f3e6c4, #d9b45c); | |
| 359 | + | border: none; | |
| 360 | + | border-radius: 8px; | |
| 361 | + | padding: 6px 15px; | |
| 362 | + | min-height: 46px; | |
| 363 | + | box-shadow: 0 2px 0 #9a7d38; | |
| 364 | + | cursor: pointer; | |
| 365 | + | display: inline-flex; | |
| 366 | + | align-items: center; | |
| 367 | + | gap: 6px; | |
| 368 | + | } | |
| 369 | + | .btn:active { | |
| 370 | + | transform: translateY(2px); | |
| 371 | + | box-shadow: none; | |
| 372 | + | } | |
| 373 | + | .btn-sub { | |
| 374 | + | font-size: 12px; | |
| 375 | + | opacity: 0.7; | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | /* Bilingual label: the called term above, English cue below. */ | |
| 379 | + | .bi { | |
| 380 | + | display: flex; | |
| 381 | + | flex-direction: column; | |
| 382 | + | align-items: center; | |
| 383 | + | justify-content: center; | |
| 384 | + | line-height: 1.05; | |
| 385 | + | gap: 1px; | |
| 386 | + | } | |
| 387 | + | .bi .zh { | |
| 388 | + | font-size: 19px; | |
| 389 | + | font-weight: 400; | |
| 390 | + | letter-spacing: 0.06em; | |
| 391 | + | } | |
| 392 | + | .bi .en { | |
| 393 | + | font-size: 9.5px; | |
| 394 | + | font-weight: 600; | |
| 395 | + | letter-spacing: 0.11em; | |
| 396 | + | text-transform: uppercase; | |
| 397 | + | opacity: 0.68; | |
| 398 | + | white-space: nowrap; | |
| 399 | + | } | |
| 400 | + | .bi .bi-sub { | |
| 401 | + | font-size: 11px; | |
| 402 | + | opacity: 0.72; | |
| 403 | + | white-space: nowrap; | |
| 404 | + | } | |
| 405 | + | .big-btn .bi .zh { | |
| 406 | + | font-size: 24px; | |
| 407 | + | } | |
| 408 | + | .big-btn .bi .en { | |
| 409 | + | font-size: 11px; | |
| 410 | + | } | |
| 411 | + | .btn.claim.hu { | |
| 412 | + | background: linear-gradient(180deg, #ff8a7a, #c0392b); | |
| 413 | + | color: #fff; | |
| 414 | + | box-shadow: 0 2px 0 #7c231a; | |
| 415 | + | } | |
| 416 | + | .btn.claim.kong, | |
| 417 | + | .btn.claim.pung { | |
| 418 | + | background: linear-gradient(180deg, #8fd3ff, #2b7ec0); | |
| 419 | + | color: #fff; | |
| 420 | + | box-shadow: 0 2px 0 #1c527d; | |
| 421 | + | } | |
| 422 | + | .btn.claim.chow { | |
| 423 | + | background: linear-gradient(180deg, #9be3b7, #2e9e63); | |
| 424 | + | color: #fff; | |
| 425 | + | box-shadow: 0 2px 0 #1d6640; | |
| 426 | + | } | |
| 427 | + | .btn.sort { | |
| 428 | + | min-height: 20px; | |
| 429 | + | padding: 1px 8px; | |
| 430 | + | font-size: 11px; | |
| 431 | + | letter-spacing: 0.06em; | |
| 432 | + | border-radius: 999px; | |
| 433 | + | } | |
| 434 | + | .btn.proceed { | |
| 435 | + | background: linear-gradient(180deg, #f3e6c4, #d9b45c); | |
| 436 | + | } | |
| 437 | + | .btn.pass, | |
| 438 | + | .btn.ghost { | |
| 439 | + | background: transparent; | |
| 440 | + | color: rgba(246, 240, 226, 0.75); | |
| 441 | + | border: 1px solid rgba(246, 240, 226, 0.35); | |
| 442 | + | box-shadow: none; | |
| 443 | + | } | |
| 444 | + | .btn.discard { | |
| 445 | + | background: linear-gradient(180deg, #fff2cf, #e0c070); | |
| 446 | + | } | |
| 447 | + | .big-btn { | |
| 448 | + | font-size: 22px; | |
| 449 | + | padding: 12px 34px; | |
| 450 | + | min-height: 56px; | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | /* ---------- center ---------- */ | |
| 454 | + | .center { | |
| 455 | + | position: absolute; | |
| 456 | + | inset: var(--depth); | |
| 457 | + | display: flex; | |
| 458 | + | flex-direction: column; | |
| 459 | + | align-items: center; | |
| 460 | + | justify-content: center; | |
| 461 | + | gap: 10px; | |
| 462 | + | pointer-events: none; | |
| 463 | + | } | |
| 464 | + | .center > * { | |
| 465 | + | pointer-events: auto; | |
| 466 | + | } | |
| 467 | + | /* Everything in the centre reads on top of the wall square. */ | |
| 468 | + | .info, | |
| 469 | + | .last-discard, | |
| 470 | + | .prompt, | |
| 471 | + | .result, | |
| 472 | + | .log, | |
| 473 | + | .log, | |
| 474 | + | .info { | |
| 475 | + | display: flex; | |
| 476 | + | flex-direction: column; | |
| 477 | + | align-items: center; | |
| 478 | + | gap: 2px; | |
| 479 | + | opacity: 0.9; | |
| 480 | + | text-align: center; | |
| 481 | + | } | |
| 482 | + | .info .round, | |
| 483 | + | .info .wall { | |
| 484 | + | display: flex; | |
| 485 | + | flex-direction: column; | |
| 486 | + | align-items: center; | |
| 487 | + | line-height: 1.05; | |
| 488 | + | } | |
| 489 | + | .info .wall { | |
| 490 | + | margin-top: 10px; | |
| 491 | + | padding-top: 10px; | |
| 492 | + | border-top: 1px solid rgba(246, 240, 226, 0.14); | |
| 493 | + | } | |
| 494 | + | .info .big { | |
| 495 | + | font-size: 40px; | |
| 496 | + | font-weight: 400; | |
| 497 | + | color: var(--gold); | |
| 498 | + | } | |
| 499 | + | .info .lbl { | |
| 500 | + | font-size: 11px; | |
| 501 | + | letter-spacing: 0.06em; | |
| 502 | + | opacity: 0.6; | |
| 503 | + | } | |
| 504 | + | .info .hand-no { | |
| 505 | + | margin-top: 8px; | |
| 506 | + | font-size: 11px; | |
| 507 | + | opacity: 0.5; | |
| 508 | + | letter-spacing: 0.1em; | |
| 509 | + | } | |
| 510 | + | /* The wall: a square of four staggered sides, 18 stacks of two each, drawn | |
| 511 | + | with the tileset's own blank tile. Sits behind the centre readout. */ | |
| 512 | + | .wall-ring { | |
| 513 | + | position: absolute; | |
| 514 | + | top: 50%; | |
| 515 | + | left: 50%; | |
| 516 | + | --ring: min(calc(100vh - 2 * var(--depth) - 16px), calc(100vw - 2 * var(--depth) - 16px), 560px); | |
| 517 | + | --ws: calc(var(--ring) / 19.375); | |
| 518 | + | --wd: calc(var(--ws) * 1.375); | |
| 519 | + | width: var(--ring); | |
| 520 | + | height: var(--ring); | |
| 521 | + | transform: translate(-50%, -50%); | |
| 522 | + | pointer-events: none; | |
| 523 | + | z-index: 0; | |
| 524 | + | } | |
| 525 | + | .wall-side { | |
| 526 | + | position: absolute; | |
| 527 | + | display: flex; | |
| 528 | + | } | |
| 529 | + | /* Pinwheel: each side starts where the previous one ended. */ | |
| 530 | + | .wall-side.top { | |
| 531 | + | top: 0; | |
| 532 | + | left: var(--wd); | |
| 533 | + | right: 0; | |
| 534 | + | height: var(--wd); | |
| 535 | + | } | |
| 536 | + | .wall-side.right { | |
| 537 | + | top: var(--wd); | |
| 538 | + | right: 0; | |
| 539 | + | bottom: 0; | |
| 540 | + | width: var(--wd); | |
| 541 | + | flex-direction: column; | |
| 542 | + | } | |
| 543 | + | .wall-side.bottom { | |
| 544 | + | bottom: 0; | |
| 545 | + | left: 0; | |
| 546 | + | right: var(--wd); | |
| 547 | + | height: var(--wd); | |
| 548 | + | flex-direction: row-reverse; | |
| 549 | + | } | |
| 550 | + | .wall-side.left { | |
| 551 | + | top: 0; | |
| 552 | + | left: 0; | |
| 553 | + | bottom: var(--wd); | |
| 554 | + | width: var(--wd); | |
| 555 | + | flex-direction: column-reverse; | |
| 556 | + | } | |
| 557 | + | .wall-stack { | |
| 558 | + | position: relative; | |
| 559 | + | flex: none; | |
| 560 | + | width: var(--ws); | |
| 561 | + | height: var(--wd); | |
| 562 | + | } | |
| 563 | + | .wall-side.left .wall-stack, | |
| 564 | + | .wall-side.right .wall-stack { | |
| 565 | + | width: var(--wd); | |
| 566 | + | height: var(--ws); | |
| 567 | + | } | |
| 568 | + | .wall-stack i { | |
| 569 | + | position: absolute; | |
| 570 | + | top: 50%; | |
| 571 | + | left: 50%; | |
| 572 | + | width: var(--ws); | |
| 573 | + | height: var(--wd); | |
| 574 | + | margin: calc(var(--wd) / -2) 0 0 calc(var(--ws) / -2); | |
| 575 | + | background-image: url('/tiles/back.png'); | |
| 576 | + | background-size: 100% 100%; | |
| 577 | + | filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.45)); | |
| 578 | + | } | |
| 579 | + | .wall-side.left .wall-stack i, | |
| 580 | + | .wall-side.right .wall-stack i { | |
| 581 | + | transform: rotate(90deg); | |
| 582 | + | } | |
| 583 | + | /* One tile left in the stack. */ | |
| 584 | + | .wall-stack.s1 i { | |
| 585 | + | opacity: 0.8; | |
| 586 | + | transform: scale(0.86); | |
| 587 | + | } | |
| 588 | + | .wall-side.left .wall-stack.s1 i, | |
| 589 | + | .wall-side.right .wall-stack.s1 i { | |
| 590 | + | transform: rotate(90deg) scale(0.86); | |
| 591 | + | } | |
| 592 | + | /* Spent stacks stay as ghosts so the square keeps its shape. */ | |
| 593 | + | .wall-stack.s0 i { | |
| 594 | + | background: none; | |
| 595 | + | filter: none; | |
| 596 | + | border: 1px dashed rgba(246, 240, 226, 0.11); | |
| 597 | + | border-radius: 2px; | |
| 598 | + | } | |
| 599 | + | .wall-stack.dead i { | |
| 600 | + | opacity: 0.4; | |
| 601 | + | filter: grayscale(0.6); | |
| 602 | + | } | |
| 603 | + | .wall-stack.next::after { | |
| 604 | + | content: ''; | |
| 605 | + | position: absolute; | |
| 606 | + | inset: -2px; | |
| 607 | + | border: 2px solid var(--gold); | |
| 608 | + | border-radius: 3px; | |
| 609 | + | box-shadow: 0 0 12px rgba(217, 180, 92, 0.7); | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | .facing { | |
| 613 | + | position: relative; | |
| 614 | + | z-index: 1; | |
| 615 | + | display: flex; | |
| 616 | + | flex-direction: column; | |
| 617 | + | align-items: center; | |
| 618 | + | gap: 8px; | |
| 619 | + | transition: transform 0.35s ease; | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | .last-discard { | |
| 623 | + | display: flex; | |
| 624 | + | flex-direction: column; | |
| 625 | + | align-items: center; | |
| 626 | + | gap: 6px; | |
| 627 | + | --tile-w: 64px; | |
| 628 | + | } | |
| 629 | + | .ld-label { | |
| 630 | + | font-size: 12px; | |
| 631 | + | opacity: 0.6; | |
| 632 | + | } | |
| 633 | + | .prompt { | |
| 634 | + | font-size: 15px; | |
| 635 | + | letter-spacing: 0.1em; | |
| 636 | + | opacity: 0.8; | |
| 637 | + | } | |
| 638 | + | .prompt.warn { | |
| 639 | + | color: #ffb4a2; | |
| 640 | + | font-weight: 600; | |
| 641 | + | } | |
| 642 | + | .log { | |
| 643 | + | position: absolute; | |
| 644 | + | bottom: 8px; | |
| 645 | + | left: 12px; | |
| 646 | + | font-size: 12px; | |
| 647 | + | line-height: 1.5; | |
| 648 | + | opacity: 0.55; | |
| 649 | + | pointer-events: none; | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | .result { | |
| 653 | + | background: rgba(10, 30, 24, 0.94); | |
| 654 | + | border: 1px solid rgba(217, 180, 92, 0.45); | |
| 655 | + | border-radius: 14px; | |
| 656 | + | padding: 18px 26px; | |
| 657 | + | display: flex; | |
| 658 | + | flex-direction: column; | |
| 659 | + | align-items: center; | |
| 660 | + | gap: 10px; | |
| 661 | + | max-height: 90%; | |
| 662 | + | overflow: auto; | |
| 663 | + | } | |
| 664 | + | .result h2 { | |
| 665 | + | margin: 0; | |
| 666 | + | font-size: 24px; | |
| 667 | + | color: var(--gold); | |
| 668 | + | } | |
| 669 | + | .result .wt { | |
| 670 | + | color: var(--ivory); | |
| 671 | + | opacity: 0.8; | |
| 672 | + | } | |
| 673 | + | .tai-list { | |
| 674 | + | display: grid; | |
| 675 | + | grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); | |
| 676 | + | gap: 2px 18px; | |
| 677 | + | font-size: 14px; | |
| 678 | + | width: 100%; | |
| 679 | + | } | |
| 680 | + | .tai-item { | |
| 681 | + | display: flex; | |
| 682 | + | align-items: baseline; | |
| 683 | + | justify-content: space-between; | |
| 684 | + | gap: 12px; | |
| 685 | + | padding: 2px 0; | |
| 686 | + | border-bottom: 1px dotted rgba(246, 240, 226, 0.2); | |
| 687 | + | } | |
| 688 | + | .tai-name { | |
| 689 | + | display: flex; | |
| 690 | + | flex-direction: column; | |
| 691 | + | line-height: 1.15; | |
| 692 | + | } | |
| 693 | + | .tai-name em { | |
| 694 | + | font-style: normal; | |
| 695 | + | font-size: 10px; | |
| 696 | + | letter-spacing: 0.08em; | |
| 697 | + | text-transform: uppercase; | |
| 698 | + | opacity: 0.55; | |
| 699 | + | } | |
| 700 | + | .tai-val { | |
| 701 | + | font-weight: 700; | |
| 702 | + | color: var(--gold); | |
| 703 | + | } | |
| 704 | + | .deltas { | |
| 705 | + | display: flex; | |
| 706 | + | gap: 16px; | |
| 707 | + | font-size: 14px; | |
| 708 | + | opacity: 0.9; | |
| 709 | + | } | |
| 710 | + | .deltas .up { | |
| 711 | + | color: #5fd39a; | |
| 712 | + | } | |
| 713 | + | .deltas .down { | |
| 714 | + | color: #ff8a80; | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | /* ---------- help panel ---------- */ | |
| 718 | + | .helpbtn { | |
| 719 | + | margin-left: 2px; | |
| 720 | + | width: 22px; | |
| 721 | + | height: 22px; | |
| 722 | + | border-radius: 50%; | |
| 723 | + | border: 1px solid rgba(246, 240, 226, 0.35); | |
| 724 | + | background: transparent; | |
| 725 | + | color: rgba(246, 240, 226, 0.75); | |
| 726 | + | font-family: inherit; | |
| 727 | + | font-size: 12px; | |
| 728 | + | line-height: 1; | |
| 729 | + | cursor: pointer; | |
| 730 | + | } | |
| 731 | + | /* The rules panel gets its own deeper slot so it is comfortable to read. */ | |
| 732 | + | .help-slot { | |
| 733 | + | position: absolute; | |
| 734 | + | inset: 0; | |
| 735 | + | z-index: 30; | |
| 736 | + | background: rgba(4, 18, 14, 0.55); | |
| 737 | + | } | |
| 738 | + | .help-slot.slot-bottom, | |
| 739 | + | .help-slot.slot-top, | |
| 740 | + | .help-slot.slot-left, | |
| 741 | + | .help-slot.slot-right { | |
| 742 | + | inset: 0; | |
| 743 | + | width: auto; | |
| 744 | + | height: auto; | |
| 745 | + | } | |
| 746 | + | .help { | |
| 747 | + | position: absolute; | |
| 748 | + | top: 50%; | |
| 749 | + | left: 50%; | |
| 750 | + | height: var(--hd); | |
| 751 | + | z-index: 31; | |
| 752 | + | display: flex; | |
| 753 | + | flex-direction: column; | |
| 754 | + | background: rgba(8, 32, 25, 0.985); | |
| 755 | + | border: 1px solid rgba(217, 180, 92, 0.4); | |
| 756 | + | border-radius: 12px; | |
| 757 | + | padding: 10px 18px 14px; | |
| 758 | + | box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6); | |
| 759 | + | } | |
| 760 | + | /* Anchored to the asking player's own edge, and rotated to face them. The | |
| 761 | + | leading translate is applied last, so it shifts the box in screen space. */ | |
| 762 | + | .help-slot.slot-bottom .help, | |
| 763 | + | .help-slot.slot-top .help { | |
| 764 | + | width: calc(100vw - 60px); | |
| 765 | + | --hd: min(52vh, 560px); | |
| 766 | + | --off: calc(50vh - var(--hd) / 2 - 14px); | |
| 767 | + | } | |
| 768 | + | .help-slot.slot-left .help, | |
| 769 | + | .help-slot.slot-right .help { | |
| 770 | + | width: calc(100vh - 60px); | |
| 771 | + | --hd: min(52vw, 560px); | |
| 772 | + | --off: calc(50vw - var(--hd) / 2 - 14px); | |
| 773 | + | } | |
| 774 | + | .help-slot.slot-bottom .help { | |
| 775 | + | translate: 0 var(--off); | |
| 776 | + | } | |
| 777 | + | .help-slot.slot-top .help { | |
| 778 | + | translate: 0 calc(var(--off) * -1); | |
| 779 | + | } | |
| 780 | + | .help-slot.slot-left .help { | |
| 781 | + | translate: calc(var(--off) * -1) 0; | |
| 782 | + | } | |
| 783 | + | .help-slot.slot-right .help { | |
| 784 | + | translate: var(--off) 0; | |
| 785 | + | } | |
| 786 | + | .help-head { | |
| 787 | + | display: flex; | |
| 788 | + | align-items: center; | |
| 789 | + | justify-content: space-between; | |
| 790 | + | gap: 16px; | |
| 791 | + | flex: none; | |
| 792 | + | } | |
| 793 | + | .help-head h3 { | |
| 794 | + | margin: 0; | |
| 795 | + | font-size: 15px; | |
| 796 | + | font-weight: 400; | |
| 797 | + | letter-spacing: 0.08em; | |
| 798 | + | color: var(--gold); | |
| 799 | + | } | |
| 800 | + | .help-head .btn { | |
| 801 | + | min-height: 30px; | |
| 802 | + | padding: 2px 12px; | |
| 803 | + | font-size: 13px; | |
| 804 | + | } | |
| 805 | + | .help-body { | |
| 806 | + | flex: 1 1 0; | |
| 807 | + | min-height: 0; | |
| 808 | + | overflow-y: auto; | |
| 809 | + | columns: 4; | |
| 810 | + | column-gap: 26px; | |
| 811 | + | column-fill: auto; | |
| 812 | + | padding-top: 8px; | |
| 813 | + | overflow-x: auto; | |
| 814 | + | scrollbar-width: thin; | |
| 815 | + | } | |
| 816 | + | .help-body section { | |
| 817 | + | break-inside: avoid; | |
| 818 | + | margin-bottom: 10px; | |
| 819 | + | } | |
| 820 | + | .help-body h4 { | |
| 821 | + | margin: 0 0 2px; | |
| 822 | + | font-size: 13px; | |
| 823 | + | font-weight: 400; | |
| 824 | + | color: var(--gold); | |
| 825 | + | letter-spacing: 0.06em; | |
| 826 | + | } | |
| 827 | + | .help-body h4 em { | |
| 828 | + | font-style: normal; | |
| 829 | + | font-size: 9px; | |
| 830 | + | letter-spacing: 0.12em; | |
| 831 | + | text-transform: uppercase; | |
| 832 | + | opacity: 0.55; | |
| 833 | + | margin-left: 4px; | |
| 834 | + | } | |
| 835 | + | .help-body p { | |
| 836 | + | margin: 0 0 4px; | |
| 837 | + | display: flex; | |
| 838 | + | flex-direction: column; | |
| 839 | + | line-height: 1.35; | |
| 840 | + | font-size: 11.5px; | |
| 841 | + | } | |
| 842 | + | .help-body p em { | |
| 843 | + | font-style: normal; | |
| 844 | + | font-size: 10.5px; | |
| 845 | + | opacity: 0.6; | |
| 846 | + | } | |
| 847 | + | .help-body .credit { | |
| 848 | + | opacity: 0.6; | |
| 849 | + | } | |
| 850 | + | ||
| 851 | + | /* ---------- lobby resume ---------- */ | |
| 852 | + | .lobby .sub { | |
| 853 | + | font-size: 0.95rem; | |
| 854 | + | } | |
| 855 | + | .resume { | |
| 856 | + | margin-top: 1.4rem; | |
| 857 | + | padding: 12px 26px; | |
| 858 | + | border: 1px solid rgba(217, 180, 92, 0.35); | |
| 859 | + | border-radius: 12px; | |
| 860 | + | background: rgba(0, 0, 0, 0.2); | |
| 861 | + | text-align: center; | |
| 862 | + | display: flex; | |
| 863 | + | flex-direction: column; | |
| 864 | + | gap: 6px; | |
| 865 | + | } | |
| 866 | + | .resume-line { | |
| 867 | + | font-size: 14px; | |
| 868 | + | color: var(--gold); | |
| 869 | + | letter-spacing: 0.06em; | |
| 870 | + | } | |
| 871 | + | .resume-scores { | |
| 872 | + | display: flex; | |
| 873 | + | gap: 18px; | |
| 874 | + | justify-content: center; | |
| 875 | + | font-size: 13px; | |
| 876 | + | opacity: 0.8; | |
| 877 | + | } | |
| 878 | + | .resume-scores b { | |
| 879 | + | color: var(--gold); | |
| 880 | + | font-weight: 600; | |
| 881 | + | } | |
| 882 | + | .resume-when { | |
| 883 | + | font-size: 11px; | |
| 884 | + | opacity: 0.45; | |
| 885 | + | letter-spacing: 0.08em; | |
| 886 | + | } | |
| 887 | + | .lobby-buttons { | |
| 888 | + | display: flex; | |
| 889 | + | gap: 14px; | |
| 890 | + | margin-top: 1.6rem; | |
| 891 | + | align-items: center; | |
| 892 | + | } | |
| 893 | + | ||
| 894 | + | /* ---------- sound ---------- */ | |
| 895 | + | .lobby-extras { | |
| 896 | + | margin-top: 1.4rem; | |
| 897 | + | display: flex; | |
| 898 | + | gap: 12px; | |
| 899 | + | align-items: center; | |
| 900 | + | } | |
| 901 | + | .sound-toggle { | |
| 902 | + | min-height: 34px; | |
| 903 | + | padding: 2px 14px; | |
| 904 | + | font-size: 13px; | |
| 905 | + | letter-spacing: 0.06em; | |
| 906 | + | border-radius: 999px; | |
| 907 | + | gap: 8px; | |
| 908 | + | } | |
| 909 | + | .sound-toggle.on { | |
| 910 | + | border-color: rgba(217, 180, 92, 0.7); | |
| 911 | + | color: var(--gold); | |
| 912 | + | } | |
| 913 | + | .sound-toggle.compact { | |
| 914 | + | min-height: 28px; | |
| 915 | + | padding: 2px 9px; | |
| 916 | + | font-size: 15px; | |
| 917 | + | } | |
| 918 | + | .sound-toggle .spk { | |
| 919 | + | font-size: 15px; | |
| 920 | + | line-height: 1; | |
| 921 | + | } | |
| 922 | + | ||
| 923 | + | /* ---------- shared table controls ---------- */ | |
| 924 | + | /* Bottom-right of the centre square, opposite the log. Unrotated on purpose: | |
| 925 | + | it belongs to the table, not to a seat. */ | |
| 926 | + | .table-controls { | |
| 927 | + | position: absolute; | |
| 928 | + | right: 10px; | |
| 929 | + | bottom: 8px; | |
| 930 | + | display: flex; | |
| 931 | + | align-items: center; | |
| 932 | + | gap: 8px; | |
| 933 | + | } | |
| 934 | + | .btn.undo { | |
| 935 | + | min-height: 34px; | |
| 936 | + | padding: 3px 14px; | |
| 937 | + | border-radius: 999px; | |
| 938 | + | gap: 9px; | |
| 939 | + | max-width: 40vh; | |
| 940 | + | } | |
| 941 | + | .undo-arrow { | |
| 942 | + | font-size: 17px; | |
| 943 | + | line-height: 1; | |
| 944 | + | color: var(--gold); | |
| 945 | + | } | |
| 946 | + | .undo-what { | |
| 947 | + | display: flex; | |
| 948 | + | flex-direction: column; | |
| 949 | + | align-items: flex-start; | |
| 950 | + | line-height: 1.15; | |
| 951 | + | min-width: 0; | |
| 952 | + | } | |
| 953 | + | .undo-what .zh { | |
| 954 | + | font-size: 13px; | |
| 955 | + | letter-spacing: 0.05em; | |
| 956 | + | } | |
| 957 | + | .undo-what .en { | |
| 958 | + | font-size: 9px; | |
| 959 | + | letter-spacing: 0.09em; | |
| 960 | + | text-transform: uppercase; | |
| 961 | + | opacity: 0.55; | |
| 962 | + | white-space: nowrap; | |
| 963 | + | overflow: hidden; | |
| 964 | + | text-overflow: ellipsis; | |
| 965 | + | max-width: 100%; | |
| 966 | + | } | |
| 967 | + | ||
| 968 | + | /* ---------- result: one block per winner ---------- */ | |
| 969 | + | .result .win { | |
| 970 | + | display: flex; | |
| 971 | + | flex-direction: column; | |
| 972 | + | align-items: center; | |
| 973 | + | gap: 8px; | |
| 974 | + | width: 100%; | |
| 975 | + | } | |
| 976 | + | .result .win + .win { | |
| 977 | + | padding-top: 10px; | |
| 978 | + | border-top: 1px solid rgba(217, 180, 92, 0.3); | |
| 979 | + | } | |
| 980 | + | .result .liable { | |
| 981 | + | font-size: 12px; | |
| 982 | + | letter-spacing: 0.08em; | |
| 983 | + | color: #ffb4a2; | |
| 984 | + | } | |
| 985 | + | .result .multi { | |
| 986 | + | font-size: 12px; | |
| 987 | + | letter-spacing: 0.1em; | |
| 988 | + | color: var(--gold); | |
| 989 | + | opacity: 0.85; | |
| 990 | + | } | |
| 991 | + | ||
| 992 | + | /* ---------- settings ---------- */ | |
| 993 | + | .over-buttons { | |
| 994 | + | display: flex; | |
| 995 | + | gap: 12px; | |
| 996 | + | align-items: center; | |
| 997 | + | } | |
| 998 | + | .settings-scrim { | |
| 999 | + | position: absolute; | |
| 1000 | + | inset: 0; | |
| 1001 | + | z-index: 40; | |
| 1002 | + | background: rgba(4, 18, 14, 0.72); | |
| 1003 | + | display: flex; | |
| 1004 | + | align-items: center; | |
| 1005 | + | justify-content: center; | |
| 1006 | + | padding: 20px; | |
| 1007 | + | } | |
| 1008 | + | .settings { | |
| 1009 | + | width: min(880px, 100%); | |
| 1010 | + | max-height: 100%; | |
| 1011 | + | display: flex; | |
| 1012 | + | flex-direction: column; | |
| 1013 | + | background: rgba(8, 32, 25, 0.99); | |
| 1014 | + | border: 1px solid rgba(217, 180, 92, 0.4); | |
| 1015 | + | border-radius: 14px; | |
| 1016 | + | box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6); | |
| 1017 | + | padding: 12px 20px 16px; | |
| 1018 | + | } | |
| 1019 | + | .settings-head { | |
| 1020 | + | display: flex; | |
| 1021 | + | align-items: center; | |
| 1022 | + | justify-content: space-between; | |
| 1023 | + | gap: 16px; | |
| 1024 | + | flex: none; | |
| 1025 | + | padding-bottom: 8px; | |
| 1026 | + | border-bottom: 1px solid rgba(246, 240, 226, 0.12); | |
| 1027 | + | } | |
| 1028 | + | .settings-head h3 { | |
| 1029 | + | margin: 0; | |
| 1030 | + | font-size: 17px; | |
| 1031 | + | font-weight: 400; | |
| 1032 | + | letter-spacing: 0.08em; | |
| 1033 | + | color: var(--gold); | |
| 1034 | + | } | |
| 1035 | + | .settings-head-btns { | |
| 1036 | + | display: flex; | |
| 1037 | + | gap: 10px; | |
| 1038 | + | } | |
| 1039 | + | .settings-head .btn { | |
| 1040 | + | min-height: 36px; | |
| 1041 | + | padding: 2px 16px; | |
| 1042 | + | font-size: 14px; | |
| 1043 | + | } | |
| 1044 | + | .settings-body { | |
| 1045 | + | flex: 1 1 0; | |
| 1046 | + | min-height: 0; | |
| 1047 | + | overflow-y: auto; | |
| 1048 | + | scrollbar-width: thin; | |
| 1049 | + | padding-top: 10px; | |
| 1050 | + | display: grid; | |
| 1051 | + | grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); | |
| 1052 | + | gap: 4px 28px; | |
| 1053 | + | align-content: start; | |
| 1054 | + | } | |
| 1055 | + | .settings-body section { | |
| 1056 | + | margin-bottom: 12px; | |
| 1057 | + | } | |
| 1058 | + | .settings-body h4 { | |
| 1059 | + | margin: 0 0 6px; | |
| 1060 | + | font-size: 13px; | |
| 1061 | + | font-weight: 400; | |
| 1062 | + | color: var(--gold); | |
| 1063 | + | letter-spacing: 0.06em; | |
| 1064 | + | } | |
| 1065 | + | .settings-body h4 em { | |
| 1066 | + | font-style: normal; | |
| 1067 | + | font-size: 9px; | |
| 1068 | + | letter-spacing: 0.12em; | |
| 1069 | + | text-transform: uppercase; | |
| 1070 | + | opacity: 0.55; | |
| 1071 | + | margin-left: 5px; | |
| 1072 | + | } | |
| 1073 | + | .settings .field { | |
| 1074 | + | display: flex; | |
| 1075 | + | align-items: center; | |
| 1076 | + | gap: 10px; | |
| 1077 | + | padding: 3px 0; | |
| 1078 | + | } | |
| 1079 | + | .settings .f-label { | |
| 1080 | + | display: flex; | |
| 1081 | + | align-items: baseline; | |
| 1082 | + | gap: 5px; | |
| 1083 | + | font-size: 13px; | |
| 1084 | + | min-width: 92px; | |
| 1085 | + | opacity: 0.85; | |
| 1086 | + | } | |
| 1087 | + | .settings .f-label em { | |
| 1088 | + | font-style: normal; | |
| 1089 | + | font-size: 9px; | |
| 1090 | + | letter-spacing: 0.1em; | |
| 1091 | + | text-transform: uppercase; | |
| 1092 | + | opacity: 0.55; | |
| 1093 | + | } | |
| 1094 | + | .settings input[type='text'], | |
| 1095 | + | .settings input:not([type]), | |
| 1096 | + | .settings input[type='number'] { | |
| 1097 | + | flex: 1 1 0; | |
| 1098 | + | min-width: 0; | |
| 1099 | + | font-family: inherit; | |
| 1100 | + | font-size: 15px; | |
| 1101 | + | color: var(--ivory); | |
| 1102 | + | background: rgba(0, 0, 0, 0.28); | |
| 1103 | + | border: 1px solid rgba(246, 240, 226, 0.25); | |
| 1104 | + | border-radius: 7px; | |
| 1105 | + | padding: 7px 10px; | |
| 1106 | + | user-select: text; | |
| 1107 | + | } | |
| 1108 | + | .settings input:focus { | |
| 1109 | + | outline: none; | |
| 1110 | + | border-color: rgba(217, 180, 92, 0.8); | |
| 1111 | + | } | |
| 1112 | + | .settings input[type='number'] { | |
| 1113 | + | max-width: 110px; | |
| 1114 | + | } | |
| 1115 | + | .settings input[type='range'] { | |
| 1116 | + | flex: 1 1 0; | |
| 1117 | + | accent-color: var(--gold); | |
| 1118 | + | } | |
| 1119 | + | .settings input:disabled { | |
| 1120 | + | opacity: 0.4; | |
| 1121 | + | } | |
| 1122 | + | .settings .switch { | |
| 1123 | + | display: grid; | |
| 1124 | + | grid-template-columns: auto 1fr; | |
| 1125 | + | gap: 2px 10px; | |
| 1126 | + | align-items: center; | |
| 1127 | + | padding: 5px 0; | |
| 1128 | + | cursor: pointer; | |
| 1129 | + | } | |
| 1130 | + | .settings .switch input { | |
| 1131 | + | width: 20px; | |
| 1132 | + | height: 20px; | |
| 1133 | + | accent-color: var(--gold); | |
| 1134 | + | cursor: pointer; | |
| 1135 | + | } | |
| 1136 | + | .settings .sw-name { | |
| 1137 | + | display: flex; | |
| 1138 | + | align-items: baseline; | |
| 1139 | + | gap: 6px; | |
| 1140 | + | font-size: 14px; | |
| 1141 | + | } | |
| 1142 | + | .settings .sw-name em { | |
| 1143 | + | font-style: normal; | |
| 1144 | + | font-size: 9px; | |
| 1145 | + | letter-spacing: 0.1em; | |
| 1146 | + | text-transform: uppercase; | |
| 1147 | + | opacity: 0.55; | |
| 1148 | + | } | |
| 1149 | + | .settings .note { | |
| 1150 | + | grid-column: 2; | |
| 1151 | + | font-size: 11px; | |
| 1152 | + | line-height: 1.35; | |
| 1153 | + | opacity: 0.55; | |
| 1154 | + | } | |
| 1155 | + | .settings .switch.dead { | |
| 1156 | + | opacity: 0.4; | |
| 1157 | + | cursor: default; | |
| 1158 | + | } | |
| 1159 | + | .settings .note.lock { | |
| 1160 | + | grid-column: auto; | |
| 1161 | + | margin: 4px 0 0; | |
| 1162 | + | color: #ffb4a2; | |
| 1163 | + | opacity: 0.75; | |
| 1164 | + | } |
addedsrc/ui/Bi.tsx+23 −0
| 1 | + | import type { ClaimType } from '../game/engine'; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * Bilingual label: the Chinese term players call at the table, with the English | |
| 5 | + | * underneath for anyone still learning it. Tile faces stay pure Chinese — those | |
| 6 | + | * are the tiles themselves, not UI chrome. | |
| 7 | + | */ | |
| 8 | + | export function Bi({ zh, en, sub }: { zh: string; en: string; sub?: string }) { | |
| 9 | + | return ( | |
| 10 | + | <span className="bi"> | |
| 11 | + | <span className="zh">{zh}</span> | |
| 12 | + | <span className="en">{en}</span> | |
| 13 | + | {sub && <span className="bi-sub">{sub}</span>} | |
| 14 | + | </span> | |
| 15 | + | ); | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | export const CLAIM_LABEL: Record<ClaimType, [string, string]> = { | |
| 19 | + | hu: ['胡', 'Win'], | |
| 20 | + | kong: ['槓', 'Kong'], | |
| 21 | + | pung: ['碰', 'Pung'], | |
| 22 | + | chow: ['吃', 'Chow'], | |
| 23 | + | }; |
addedsrc/ui/Center.tsx+148 −0
| 1 | + | import type { Game } from '../game/engine'; | |
| 2 | + | import type { Prefs } from '../game/prefs'; | |
| 3 | + | import type { SeatId } from '../game/types'; | |
| 4 | + | import { labelOf } from '../game/tiles'; | |
| 5 | + | import { Bi } from './Bi'; | |
| 6 | + | import { SoundToggle } from './SoundToggle'; | |
| 7 | + | import { TileView } from './TileView'; | |
| 8 | + | import { SEAT_ROT } from './rotation'; | |
| 9 | + | import { WallRing } from './WallRing'; | |
| 10 | + | ||
| 11 | + | interface Props { | |
| 12 | + | game: Game; | |
| 13 | + | prefs: Prefs; | |
| 14 | + | onPrefs: (p: Prefs) => void; | |
| 15 | + | onSettings: () => void; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | export function Center({ game, prefs, onPrefs, onSettings }: Props) { | |
| 19 | + | const s = game.state; | |
| 20 | + | const r = s.result; | |
| 21 | + | // Point the live readout at whoever can move the game along. | |
| 22 | + | const pending = (Object.keys(s.options) as unknown as number[]) | |
| 23 | + | .map(Number) | |
| 24 | + | .filter((k) => s.responses[k as SeatId] === undefined) as SeatId[]; | |
| 25 | + | const facing = game.nextDrawer() ?? s.turn; | |
| 26 | + | // Deliberately not per-seat: whoever spots the mis-tap should be able to | |
| 27 | + | // reach it, not just the player who made it. | |
| 28 | + | const undoable = s.phase === 'gameEnd' ? null : game.undoable(); | |
| 29 | + | ||
| 30 | + | return ( | |
| 31 | + | <div className="center"> | |
| 32 | + | {s.phase !== 'gameEnd' && <WallRing state={s} />} | |
| 33 | + | ||
| 34 | + | <div className="facing" style={{ transform: `rotate(${SEAT_ROT[facing]}deg)` }}> | |
| 35 | + | {s.lastDiscard && s.phase !== 'handEnd' && ( | |
| 36 | + | <div className="last-discard"> | |
| 37 | + | <div className="ld-label">{s.players[s.lastDiscard.from].name} 打 discarded</div> | |
| 38 | + | <TileView tile={s.lastDiscard.tile} size="hand" highlight /> | |
| 39 | + | </div> | |
| 40 | + | )} | |
| 41 | + | ||
| 42 | + | {s.phase === 'claims' && ( | |
| 43 | + | <div className="prompt"> | |
| 44 | + | {pending.length} 家可吃碰 · {pending.length} may claim | |
| 45 | + | </div> | |
| 46 | + | )} | |
| 47 | + | {s.phase === 'robkong' && <div className="prompt warn">搶槓機會 · Rob the kong?</div>} | |
| 48 | + | </div> | |
| 49 | + | ||
| 50 | + | {r && s.phase !== 'gameEnd' && ( | |
| 51 | + | <div className="result"> | |
| 52 | + | {r.drawGame ? ( | |
| 53 | + | <h2>流局 · Draw</h2> | |
| 54 | + | ) : ( | |
| 55 | + | <> | |
| 56 | + | {/* One block per winner — more than one only under 一炮多響. */} | |
| 57 | + | {r.winners.map((w) => ( | |
| 58 | + | <div className="win" key={w.seat}> | |
| 59 | + | <h2> | |
| 60 | + | {s.players[w.seat].name} {w.selfDraw ? '自摸' : '胡牌'} · {w.tai} 台 | |
| 61 | + | <span className="wt"> {labelOf(w.winTile)}</span> | |
| 62 | + | </h2> | |
| 63 | + | {w.liable !== null && ( | |
| 64 | + | <div className="liable"> | |
| 65 | + | 包牌 · {s.players[w.liable].name} pays the whole hand | |
| 66 | + | </div> | |
| 67 | + | )} | |
| 68 | + | <div className="tai-list"> | |
| 69 | + | {w.items.map((it, i) => ( | |
| 70 | + | <div key={i} className="tai-item"> | |
| 71 | + | <span className="tai-name"> | |
| 72 | + | {it.name} | |
| 73 | + | <em>{it.en}</em> | |
| 74 | + | </span> | |
| 75 | + | <span className="tai-val">{it.tai}</span> | |
| 76 | + | </div> | |
| 77 | + | ))} | |
| 78 | + | </div> | |
| 79 | + | </div> | |
| 80 | + | ))} | |
| 81 | + | {r.winners.length > 1 && ( | |
| 82 | + | <div className="multi">一炮多響 · one discard, {r.winners.length} winners</div> | |
| 83 | + | )} | |
| 84 | + | <div className="deltas"> | |
| 85 | + | {r.deltas.map((d, i) => ( | |
| 86 | + | <span key={i} className={d > 0 ? 'up' : d < 0 ? 'down' : ''}> | |
| 87 | + | {s.players[i].name} {d > 0 ? `+${d}` : d} | |
| 88 | + | </span> | |
| 89 | + | ))} | |
| 90 | + | </div> | |
| 91 | + | </> | |
| 92 | + | )} | |
| 93 | + | <button className="btn big-btn" onClick={() => game.nextHand()}> | |
| 94 | + | <Bi zh="下一局" en="Next hand" /> | |
| 95 | + | </button> | |
| 96 | + | </div> | |
| 97 | + | )} | |
| 98 | + | ||
| 99 | + | {s.phase === 'gameEnd' && ( | |
| 100 | + | <div className="result"> | |
| 101 | + | <h2>一將終了 · Game over</h2> | |
| 102 | + | <div className="deltas"> | |
| 103 | + | {[...s.players] | |
| 104 | + | .map((p, i) => ({ p, i })) | |
| 105 | + | .sort((a, b) => b.p.score - a.p.score) | |
| 106 | + | .map(({ p, i }) => ( | |
| 107 | + | <span key={i} className={p.score > s.rules.startingScore ? 'up' : p.score < s.rules.startingScore ? 'down' : ''}> | |
| 108 | + | {p.name} {p.score} | |
| 109 | + | </span> | |
| 110 | + | ))} | |
| 111 | + | </div> | |
| 112 | + | <div className="over-buttons"> | |
| 113 | + | <button className="btn big-btn" onClick={() => game.newGame()}> | |
| 114 | + | <Bi zh="再來一將" en="New game" /> | |
| 115 | + | </button> | |
| 116 | + | {/* The moment anyone is going to want the names or the stakes changed. */} | |
| 117 | + | <button className="btn ghost big-btn" onClick={onSettings}> | |
| 118 | + | <Bi zh="設定" en="Settings" /> | |
| 119 | + | </button> | |
| 120 | + | </div> | |
| 121 | + | </div> | |
| 122 | + | )} | |
| 123 | + | ||
| 124 | + | <div className="table-controls"> | |
| 125 | + | {undoable && ( | |
| 126 | + | <button className="btn ghost undo" onClick={() => game.undo()}> | |
| 127 | + | <span className="undo-arrow" aria-hidden> | |
| 128 | + | ↺ | |
| 129 | + | </span> | |
| 130 | + | <span className="undo-what"> | |
| 131 | + | <span className="zh">復原 {undoable.zh}</span> | |
| 132 | + | <span className="en">Undo · {undoable.en}</span> | |
| 133 | + | </span> | |
| 134 | + | </button> | |
| 135 | + | )} | |
| 136 | + | <SoundToggle prefs={prefs} onPrefs={onPrefs} compact /> | |
| 137 | + | </div> | |
| 138 | + | ||
| 139 | + | <div className="log"> | |
| 140 | + | {s.log.slice(0, 5).map((l, i) => ( | |
| 141 | + | <div key={i} style={{ opacity: 1 - i * 0.18 }}> | |
| 142 | + | {l} | |
| 143 | + | </div> | |
| 144 | + | ))} | |
| 145 | + | </div> | |
| 146 | + | </div> | |
| 147 | + | ); | |
| 148 | + | } |
addedsrc/ui/Hand.tsx+109 −0
| 1 | + | import { useRef, useState } from 'react'; | |
| 2 | + | import type { Tile } from '../game/tiles'; | |
| 3 | + | import { TileView } from './TileView'; | |
| 4 | + | ||
| 5 | + | interface Props { | |
| 6 | + | tiles: Tile[]; | |
| 7 | + | /** Index of the tile held apart from the rest, if any. */ | |
| 8 | + | drawnIndex: number | null; | |
| 9 | + | selected: number | null; | |
| 10 | + | canDiscard: boolean; | |
| 11 | + | onSelect: (i: number) => void; | |
| 12 | + | onDiscard: (i: number) => void; | |
| 13 | + | onMove: (from: number, to: number) => void; | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /** How far a pointer must travel before a tap becomes a drag. */ | |
| 17 | + | const DRAG_SLOP = 12; | |
| 18 | + | ||
| 19 | + | /** | |
| 20 | + | * Finds the tile nearest the pointer. Works whatever the seat's rotation: | |
| 21 | + | * bounding rects are always screen-axis-aligned, so we read the row's direction | |
| 22 | + | * off the first two tiles rather than assuming it runs left-to-right. | |
| 23 | + | */ | |
| 24 | + | function nearestIndex(container: HTMLElement, x: number, y: number): number | null { | |
| 25 | + | const els = Array.from(container.querySelectorAll<HTMLElement>('[data-idx]')); | |
| 26 | + | if (els.length < 2) return null; | |
| 27 | + | const centers = els.map((el) => { | |
| 28 | + | const r = el.getBoundingClientRect(); | |
| 29 | + | return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; | |
| 30 | + | }); | |
| 31 | + | const horizontal = Math.abs(centers[1].x - centers[0].x) >= Math.abs(centers[1].y - centers[0].y); | |
| 32 | + | const along = (p: { x: number; y: number }) => (horizontal ? p.x : p.y); | |
| 33 | + | const p = along({ x, y }); | |
| 34 | + | ||
| 35 | + | let best = 0; | |
| 36 | + | let bestD = Infinity; | |
| 37 | + | centers.forEach((c, i) => { | |
| 38 | + | const d = Math.abs(along(c) - p); | |
| 39 | + | if (d < bestD) { | |
| 40 | + | bestD = d; | |
| 41 | + | best = i; | |
| 42 | + | } | |
| 43 | + | }); | |
| 44 | + | return best; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | export function Hand({ | |
| 48 | + | tiles, | |
| 49 | + | drawnIndex, | |
| 50 | + | selected, | |
| 51 | + | canDiscard, | |
| 52 | + | onSelect, | |
| 53 | + | onDiscard, | |
| 54 | + | onMove, | |
| 55 | + | }: Props) { | |
| 56 | + | const ref = useRef<HTMLDivElement>(null); | |
| 57 | + | const drag = useRef<{ from: number; cur: number; x: number; y: number; moved: boolean } | null>(null); | |
| 58 | + | const [dragging, setDragging] = useState<number | null>(null); | |
| 59 | + | ||
| 60 | + | const down = (e: React.PointerEvent, i: number) => { | |
| 61 | + | (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); | |
| 62 | + | drag.current = { from: i, cur: i, x: e.clientX, y: e.clientY, moved: false }; | |
| 63 | + | }; | |
| 64 | + | ||
| 65 | + | const move = (e: React.PointerEvent) => { | |
| 66 | + | const d = drag.current; | |
| 67 | + | if (!d || !ref.current) return; | |
| 68 | + | if (!d.moved && Math.hypot(e.clientX - d.x, e.clientY - d.y) < DRAG_SLOP) return; | |
| 69 | + | if (!d.moved) { | |
| 70 | + | d.moved = true; | |
| 71 | + | setDragging(d.cur); | |
| 72 | + | } | |
| 73 | + | const to = nearestIndex(ref.current, e.clientX, e.clientY); | |
| 74 | + | if (to !== null && to !== d.cur) { | |
| 75 | + | onMove(d.cur, to); | |
| 76 | + | d.cur = to; | |
| 77 | + | setDragging(to); | |
| 78 | + | } | |
| 79 | + | }; | |
| 80 | + | ||
| 81 | + | const up = () => { | |
| 82 | + | const d = drag.current; | |
| 83 | + | drag.current = null; | |
| 84 | + | setDragging(null); | |
| 85 | + | if (!d || d.moved || !canDiscard) return; | |
| 86 | + | if (selected === d.from) onDiscard(d.from); | |
| 87 | + | else onSelect(d.from); | |
| 88 | + | }; | |
| 89 | + | ||
| 90 | + | return ( | |
| 91 | + | <div className="hand" ref={ref} onPointerMove={move} onPointerUp={up} onPointerCancel={up}> | |
| 92 | + | {tiles.map((t, i) => ( | |
| 93 | + | <div | |
| 94 | + | key={i} | |
| 95 | + | data-idx={i} | |
| 96 | + | className={ | |
| 97 | + | 'hand-cell' + | |
| 98 | + | (i === drawnIndex ? ' drawn-slot' : '') + | |
| 99 | + | (i === dragging ? ' dragging' : '') | |
| 100 | + | } | |
| 101 | + | onPointerDown={(e) => down(e, i)} | |
| 102 | + | > | |
| 103 | + | {i === drawnIndex && <span className="drawn-tag">摸 Drew</span>} | |
| 104 | + | <TileView tile={t} raised={selected === i} /> | |
| 105 | + | </div> | |
| 106 | + | ))} | |
| 107 | + | </div> | |
| 108 | + | ); | |
| 109 | + | } |
addedsrc/ui/Help.tsx+166 −0
| 1 | + | import type { SeatId } from '../game/types'; | |
| 2 | + | import { SEAT_ROT, SEAT_SLOT } from './rotation'; | |
| 3 | + | ||
| 4 | + | interface Section { | |
| 5 | + | zh: string; | |
| 6 | + | en: string; | |
| 7 | + | body: [string, string][]; | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | /** Rules reference, shown inside one player's own strip so only they read it. */ | |
| 11 | + | const SECTIONS: Section[] = [ | |
| 12 | + | { | |
| 13 | + | zh: '目標', | |
| 14 | + | en: 'The goal', | |
| 15 | + | body: [ | |
| 16 | + | ['十六張牌,湊成五組加一對眼。', 'Build five sets plus one pair from 16 tiles.'], | |
| 17 | + | ['一組=順子(三張同花色連號)或刻子(三張相同)。', 'A set is a run of three in one suit, or three of a kind.'], | |
| 18 | + | ['摸進第十七張後打出一張。', 'You draw a 17th tile each turn, then discard one.'], | |
| 19 | + | ], | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | zh: '牌', | |
| 23 | + | en: 'The tiles', | |
| 24 | + | body: [ | |
| 25 | + | ['萬 · 條 · 筒 一到九,各四張。', 'Characters, bamboo and dots, 1–9, four of each.'], | |
| 26 | + | ['字牌:東南西北 中發白,各四張。', 'Honours: four winds and three dragons, four of each.'], | |
| 27 | + | ['花牌八張,摸到自動補牌,還能算台。', 'Eight flowers: drawn ones replace themselves and score.'], | |
| 28 | + | ], | |
| 29 | + | }, | |
| 30 | + | { | |
| 31 | + | zh: '輪到你', | |
| 32 | + | en: 'Your turn', | |
| 33 | + | body: [ | |
| 34 | + | ['點一張牌抬起,再點一次打出。', 'Tap a tile to lift it, tap again to discard it.'], | |
| 35 | + | ['剛摸的牌會分開放在最右邊,標著「摸」。', 'The tile you just drew sits apart on the right, marked 摸.'], | |
| 36 | + | ['牌可以拖曳重排,或按「理牌」自動排序。', 'Drag tiles to rearrange, or press 理牌 to sort.'], | |
| 37 | + | ], | |
| 38 | + | }, | |
| 39 | + | { | |
| 40 | + | zh: '吃碰槓', | |
| 41 | + | en: 'Claiming a discard', | |
| 42 | + | body: [ | |
| 43 | + | ['碰:手上兩張相同,可碰任何人打的牌。', 'Pung: two matching tiles, from anyone’s discard.'], | |
| 44 | + | ['吃:只能吃上家(左手邊)打的牌。', 'Chow: a run, and only from the player to your left.'], | |
| 45 | + | ['優先序:胡 > 碰/槓 > 吃。', 'Priority: win beats pung/kong, which beat chow.'], | |
| 46 | + | ['吃碰之後不用摸牌,直接打一張。', 'After claiming you do not draw — just discard.'], | |
| 47 | + | [ | |
| 48 | + | '要碰要快:下家一摸牌就來不及了,桌子不會等你。', | |
| 49 | + | 'Call fast — the window shuts when the next player draws. Nobody waits.', | |
| 50 | + | ], | |
| 51 | + | ], | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | zh: '槓', | |
| 55 | + | en: 'Kongs', | |
| 56 | + | body: [ | |
| 57 | + | ['四張相同:明槓、暗槓、或碰後加槓。', 'Four of a kind, claimed, concealed, or added to a pung.'], | |
| 58 | + | ['槓完從牌尾補一張,胡了算「槓上開花」。', 'You draw from the tail; winning on it scores 槓上開花.'], | |
| 59 | + | ['別人加槓時你剛好能胡,可以搶槓。', 'You may rob an added kong if that tile completes your hand.'], | |
| 60 | + | ], | |
| 61 | + | }, | |
| 62 | + | { | |
| 63 | + | zh: '胡牌', | |
| 64 | + | en: 'Winning', | |
| 65 | + | body: [ | |
| 66 | + | ['自摸:自己摸到,三家各付一份。', 'Self-draw: all three opponents pay you.'], | |
| 67 | + | ['放槍:胡別人打的牌,放槍者一家付。', 'Off a discard: the discarder alone pays.'], | |
| 68 | + | ['一份 = 底 + 台數 × 台值。', 'One share is the base plus your tai count.'], | |
| 69 | + | ['牌牆剩十六張還沒人胡就流局。', 'If 16 tiles remain undrawn, the hand is a draw.'], | |
| 70 | + | ], | |
| 71 | + | }, | |
| 72 | + | { | |
| 73 | + | zh: '常見台數', | |
| 74 | + | en: 'Common tai', | |
| 75 | + | body: [ | |
| 76 | + | ['自摸 1 · 門清 1 · 門清自摸 +1', 'Self-draw 1 · concealed 1 · both together +1'], | |
| 77 | + | ['正花每張 1 · 圈風 1 · 門風 1 · 三元牌每組 1', 'Own flower 1 · round wind 1 · seat wind 1 · dragon pung 1'], | |
| 78 | + | ['碰碰胡 4 · 混一色 4 · 清一色 8 · 字一色 16', 'All pungs 4 · half flush 4 · full flush 8 · all honours 16'], | |
| 79 | + | ['大三元 8 · 小四喜 8 · 大四喜 16', 'Big three dragons 8 · little four winds 8 · big four winds 16'], | |
| 80 | + | ['莊家 1 台,連莊每次再加 2 台。', 'Dealer scores 1, plus 2 more for each repeat deal.'], | |
| 81 | + | ], | |
| 82 | + | }, | |
| 83 | + | { | |
| 84 | + | zh: '房規', | |
| 85 | + | en: 'House rules', | |
| 86 | + | body: [ | |
| 87 | + | [ | |
| 88 | + | '過水:不胡的牌,到自己再摸牌前都不能胡。', | |
| 89 | + | 'Sacred discard: pass on a winning tile and it is dead to you until your next draw.', | |
| 90 | + | ], | |
| 91 | + | [ | |
| 92 | + | '一炮多響:一張放槍牌,幾家胡就付幾家。', | |
| 93 | + | 'Multiple winners: one discard can pay out to every seat that calls on it.', | |
| 94 | + | ], | |
| 95 | + | [ | |
| 96 | + | '包牌:放第三張三元/四喜刻子的人,一人付全部。', | |
| 97 | + | 'Liability: feed the third dragon or wind pung to a big hand and you pay for it alone.', | |
| 98 | + | ], | |
| 99 | + | ['以上都可以在設定裡開關。', 'All three are switches on the settings screen.'], | |
| 100 | + | ], | |
| 101 | + | }, | |
| 102 | + | { | |
| 103 | + | zh: '拿錯了', | |
| 104 | + | en: 'Mis-taps', | |
| 105 | + | body: [ | |
| 106 | + | ['桌子中央有「復原」,可以退回上一步。', 'The 復原 button in the middle takes back the last action.'], | |
| 107 | + | ['誰看到誰按,不限於按錯的那個人。', 'Anyone can press it — not just whoever made the mistake.'], | |
| 108 | + | ], | |
| 109 | + | }, | |
| 110 | + | { | |
| 111 | + | zh: '莊家', | |
| 112 | + | en: 'The dealer', | |
| 113 | + | body: [ | |
| 114 | + | ['莊家胡牌或流局就連莊。', 'The dealer keeps the deal on a win or a draw.'], | |
| 115 | + | ['否則換下家做莊,四家輪完換一圈風。', 'Otherwise it passes on; the round wind turns every four passes.'], | |
| 116 | + | ], | |
| 117 | + | }, | |
| 118 | + | ]; | |
| 119 | + | ||
| 120 | + | /** | |
| 121 | + | * Rules panel for one seat. Rendered at table level rather than inside the | |
| 122 | + | * seat strip so it can be deep enough to read, but still rotated to face only | |
| 123 | + | * that player. | |
| 124 | + | */ | |
| 125 | + | export function Help({ seat, onClose }: { seat: SeatId; onClose: () => void }) { | |
| 126 | + | return ( | |
| 127 | + | <div className={`help-slot ${SEAT_SLOT[seat]}`} onClick={onClose}> | |
| 128 | + | <div | |
| 129 | + | className="help" | |
| 130 | + | style={{ transform: `translate(-50%, -50%) rotate(${SEAT_ROT[seat]}deg)` }} | |
| 131 | + | onClick={(e) => e.stopPropagation()} | |
| 132 | + | > | |
| 133 | + | <div className="help-head"> | |
| 134 | + | <h3>台灣麻將規則 · How to play</h3> | |
| 135 | + | <button className="btn ghost" onClick={onClose}> | |
| 136 | + | 關閉 Close | |
| 137 | + | </button> | |
| 138 | + | </div> | |
| 139 | + | <div className="help-body"> | |
| 140 | + | {SECTIONS.map((sec) => ( | |
| 141 | + | <section key={sec.zh}> | |
| 142 | + | <h4> | |
| 143 | + | {sec.zh} <em>{sec.en}</em> | |
| 144 | + | </h4> | |
| 145 | + | {sec.body.map(([zh, en], i) => ( | |
| 146 | + | <p key={i}> | |
| 147 | + | <span>{zh}</span> | |
| 148 | + | <em>{en}</em> | |
| 149 | + | </p> | |
| 150 | + | ))} | |
| 151 | + | </section> | |
| 152 | + | ))} | |
| 153 | + | <section className="credit"> | |
| 154 | + | <h4> | |
| 155 | + | 牌面 <em>Tile art</em> | |
| 156 | + | </h4> | |
| 157 | + | <p> | |
| 158 | + | <span>gnome-mahjongg「postmodern」牌組</span> | |
| 159 | + | <em>From gnome-mahjongg’s postmodern theme, GPL-2.0-or-later.</em> | |
| 160 | + | </p> | |
| 161 | + | </section> | |
| 162 | + | </div> | |
| 163 | + | </div> | |
| 164 | + | </div> | |
| 165 | + | ); | |
| 166 | + | } |
addedsrc/ui/Seat.tsx+192 −0
| 1 | + | import { useEffect, useState } from 'react'; | |
| 2 | + | import type { Game } from '../game/engine'; | |
| 3 | + | import { labelOf, WIND_EN, WIND_NAMES } from '../game/tiles'; | |
| 4 | + | import type { SeatId } from '../game/types'; | |
| 5 | + | import { Bi, CLAIM_LABEL } from './Bi'; | |
| 6 | + | import { Hand } from './Hand'; | |
| 7 | + | import { TileView } from './TileView'; | |
| 8 | + | ||
| 9 | + | const ROT: Record<SeatId, number> = { 0: 0, 1: -90, 2: 180, 3: 90 }; | |
| 10 | + | // seat 0 = bottom (near edge of the laptop), then counter-clockwise on screen: | |
| 11 | + | // 1 = right, 2 = top, 3 = left — which is the natural 東南西北 seating order. | |
| 12 | + | const SLOT: Record<SeatId, string> = { 0: 'slot-bottom', 1: 'slot-right', 2: 'slot-top', 3: 'slot-left' }; | |
| 13 | + | ||
| 14 | + | interface Props { | |
| 15 | + | game: Game; | |
| 16 | + | seat: SeatId; | |
| 17 | + | onHelp: () => void; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | export function Seat({ game, seat, onHelp }: Props) { | |
| 21 | + | const s = game.state; | |
| 22 | + | const p = s.players[seat]; | |
| 23 | + | const [sel, setSel] = useState<number | null>(null); | |
| 24 | + | const myTurn = s.phase === 'turn' && s.turn === seat; | |
| 25 | + | const opts = s.options[seat]; | |
| 26 | + | const responded = s.responses[seat] !== undefined; | |
| 27 | + | const claiming = (s.phase === 'claims' || s.phase === 'robkong') && !!opts && !responded; | |
| 28 | + | // Whoever draws next closes the claim window — nobody has to wait on a | |
| 29 | + | // player who hasn't noticed their buttons. | |
| 30 | + | const canProceed = game.nextDrawer() === seat; | |
| 31 | + | ||
| 32 | + | useEffect(() => { | |
| 33 | + | setSel(null); | |
| 34 | + | }, [myTurn, s.handNumber, p.hand.length, s.drawnTile]); | |
| 35 | + | ||
| 36 | + | const seatWind = (seat - s.dealer + 4) % 4; | |
| 37 | + | const isDealer = seat === s.dealer; | |
| 38 | + | const live = s.phase !== 'handEnd' && s.phase !== 'lobby'; | |
| 39 | + | const tenpai = live ? game.tenpai(seat) : []; | |
| 40 | + | const sacred = live ? game.sacredTiles(seat) : []; | |
| 41 | + | const concealedKongs = myTurn ? game.concealedKongs(seat) : []; | |
| 42 | + | const addedKongs = myTurn ? game.addedKongs(seat) : []; | |
| 43 | + | const canHu = myTurn && game.canSelfDrawWin(seat); | |
| 44 | + | const delta = s.phase === 'handEnd' ? s.result?.deltas[seat] ?? 0 : 0; | |
| 45 | + | ||
| 46 | + | const doDiscard = (i: number) => { | |
| 47 | + | game.discard(seat, p.hand[i]); | |
| 48 | + | setSel(null); | |
| 49 | + | }; | |
| 50 | + | ||
| 51 | + | // The tile just drawn is held apart at the end of the hand until it is played | |
| 52 | + | // or the player slides it in, so it stays obvious which tile just arrived. | |
| 53 | + | const drawnIndex = myTurn && s.drawnSeparate && s.drawnTile !== null ? p.hand.length - 1 : null; | |
| 54 | + | const selTile = sel === null ? null : p.hand[sel] ?? null; | |
| 55 | + | ||
| 56 | + | return ( | |
| 57 | + | <div className={`slot ${SLOT[seat]}`}> | |
| 58 | + | <div | |
| 59 | + | className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''}`} | |
| 60 | + | style={{ transform: `translate(-50%, -50%) rotate(${ROT[seat]}deg)` }} | |
| 61 | + | > | |
| 62 | + | <div className="discards"> | |
| 63 | + | {p.discards.map((t, i) => ( | |
| 64 | + | <TileView | |
| 65 | + | key={i} | |
| 66 | + | tile={t} | |
| 67 | + | size="small" | |
| 68 | + | highlight={s.lastDiscard?.from === seat && i === p.discards.length - 1} | |
| 69 | + | /> | |
| 70 | + | ))} | |
| 71 | + | </div> | |
| 72 | + | ||
| 73 | + | <div className="melds"> | |
| 74 | + | {p.melds.map((m, i) => ( | |
| 75 | + | <div className="meld" key={i}> | |
| 76 | + | {m.tiles.map((t, j) => ( | |
| 77 | + | <TileView key={j} tile={t} size="meld" faceDown={m.kind === 'ankong' && j > 0 && j < 3} /> | |
| 78 | + | ))} | |
| 79 | + | </div> | |
| 80 | + | ))} | |
| 81 | + | {p.flowers.length > 0 && ( | |
| 82 | + | <div className="meld flowers"> | |
| 83 | + | {p.flowers.map((t, i) => ( | |
| 84 | + | <TileView key={i} tile={t} size="meld" /> | |
| 85 | + | ))} | |
| 86 | + | </div> | |
| 87 | + | )} | |
| 88 | + | </div> | |
| 89 | + | ||
| 90 | + | <div className="actionbar"> | |
| 91 | + | {claiming && ( | |
| 92 | + | <> | |
| 93 | + | {opts!.map((o, i) => ( | |
| 94 | + | <button | |
| 95 | + | key={i} | |
| 96 | + | className={`btn claim ${o.type}`} | |
| 97 | + | onClick={() => game.respond(seat, { type: o.type, with: o.with })} | |
| 98 | + | > | |
| 99 | + | <Bi | |
| 100 | + | zh={CLAIM_LABEL[o.type][0]} | |
| 101 | + | en={CLAIM_LABEL[o.type][1]} | |
| 102 | + | sub={o.with && o.with.map(labelOf).join(' ')} | |
| 103 | + | /> | |
| 104 | + | </button> | |
| 105 | + | ))} | |
| 106 | + | <button className="btn pass" onClick={() => game.respond(seat, 'pass')}> | |
| 107 | + | <Bi zh="過" en="Pass" /> | |
| 108 | + | </button> | |
| 109 | + | </> | |
| 110 | + | )} | |
| 111 | + | {canProceed && ( | |
| 112 | + | <button className="btn proceed" onClick={() => game.resolveNow()}> | |
| 113 | + | <Bi | |
| 114 | + | zh={s.phase === 'robkong' ? '繼續' : '摸牌'} | |
| 115 | + | en={s.phase === 'robkong' ? 'Continue' : 'Draw'} | |
| 116 | + | /> | |
| 117 | + | </button> | |
| 118 | + | )} | |
| 119 | + | {!claiming && !canProceed && responded && s.phase !== 'turn' && ( | |
| 120 | + | <div className="waiting">已回應 Responded</div> | |
| 121 | + | )} | |
| 122 | + | {myTurn && ( | |
| 123 | + | <> | |
| 124 | + | {canHu && ( | |
| 125 | + | <button className="btn claim hu" onClick={() => game.declareSelfDraw(seat)}> | |
| 126 | + | <Bi zh="自摸" en="Self-draw" /> | |
| 127 | + | </button> | |
| 128 | + | )} | |
| 129 | + | {concealedKongs.map((t) => ( | |
| 130 | + | <button key={`ak${t}`} className="btn claim kong" onClick={() => game.declareConcealedKong(seat, t)}> | |
| 131 | + | <Bi zh="暗槓" en="Concealed kong" sub={labelOf(t)} /> | |
| 132 | + | </button> | |
| 133 | + | ))} | |
| 134 | + | {addedKongs.map((t) => ( | |
| 135 | + | <button key={`gk${t}`} className="btn claim kong" onClick={() => game.declareAddedKong(seat, t)}> | |
| 136 | + | <Bi zh="加槓" en="Added kong" sub={labelOf(t)} /> | |
| 137 | + | </button> | |
| 138 | + | ))} | |
| 139 | + | {selTile !== null && sel !== null && ( | |
| 140 | + | <button className="btn discard" onClick={() => doDiscard(sel)}> | |
| 141 | + | <Bi zh="打出" en="Discard" sub={labelOf(selTile)} /> | |
| 142 | + | </button> | |
| 143 | + | )} | |
| 144 | + | </> | |
| 145 | + | )} | |
| 146 | + | {delta !== 0 && <div className={`delta ${delta > 0 ? 'up' : 'down'}`}>{delta > 0 ? `+${delta}` : delta}</div>} | |
| 147 | + | </div> | |
| 148 | + | ||
| 149 | + | <Hand | |
| 150 | + | tiles={p.hand} | |
| 151 | + | drawnIndex={drawnIndex} | |
| 152 | + | selected={sel} | |
| 153 | + | canDiscard={myTurn} | |
| 154 | + | onSelect={setSel} | |
| 155 | + | onDiscard={doDiscard} | |
| 156 | + | onMove={(from, to) => game.reorderHand(seat, from, to)} | |
| 157 | + | /> | |
| 158 | + | ||
| 159 | + | <div className="nameplate"> | |
| 160 | + | <span className={`wind ${isDealer ? 'dealer' : ''}`}> | |
| 161 | + | {WIND_NAMES[seatWind]} | |
| 162 | + | <em>{WIND_EN[seatWind]}</em> | |
| 163 | + | </span> | |
| 164 | + | <span className="pname">{p.name}</span> | |
| 165 | + | {isDealer && ( | |
| 166 | + | <span className="tag"> | |
| 167 | + | 莊 Dealer{s.dealerStreak > 0 ? ` 連${s.dealerStreak}` : ''} | |
| 168 | + | </span> | |
| 169 | + | )} | |
| 170 | + | {tenpai.length > 0 && ( | |
| 171 | + | <span className="tag ting">聽 Ready {tenpai.map(labelOf).join('·')}</span> | |
| 172 | + | )} | |
| 173 | + | {/* Without this the 胡 button just quietly fails to appear. */} | |
| 174 | + | {sacred.length > 0 && ( | |
| 175 | + | <span className="tag sacred" title="過水 — passed on winning with these until your next draw"> | |
| 176 | + | 過水 {sacred.map(labelOf).join('·')} | |
| 177 | + | </span> | |
| 178 | + | )} | |
| 179 | + | <span className="chips">{p.score}</span> | |
| 180 | + | {p.hand.length > 0 && s.phase !== 'handEnd' && ( | |
| 181 | + | <button className="btn ghost sort" onClick={() => game.sortHand(seat)}> | |
| 182 | + | 理牌 Sort | |
| 183 | + | </button> | |
| 184 | + | )} | |
| 185 | + | <button className="helpbtn" onClick={onHelp} aria-label="規則 rules"> | |
| 186 | + | ? | |
| 187 | + | </button> | |
| 188 | + | </div> | |
| 189 | + | </div> | |
| 190 | + | </div> | |
| 191 | + | ); | |
| 192 | + | } |
addedsrc/ui/Settings.tsx+209 −0
| 1 | + | import { useState } from 'react'; | |
| 2 | + | import { clamp, type Prefs } from '../game/prefs'; | |
| 3 | + | import { play, setSoundEnabled, setVolume } from '../game/sound'; | |
| 4 | + | import { DEFAULT_NAMES, type Rules } from '../game/types'; | |
| 5 | + | ||
| 6 | + | interface Props { | |
| 7 | + | prefs: Prefs; | |
| 8 | + | onSave: (p: Prefs) => void; | |
| 9 | + | onClose: () => void; | |
| 10 | + | /** Stakes are only editable before the first hand of a game is dealt. */ | |
| 11 | + | stakesLocked: boolean; | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | interface Toggle { | |
| 15 | + | key: keyof Rules; | |
| 16 | + | zh: string; | |
| 17 | + | en: string; | |
| 18 | + | note: string; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /** The house rules tables actually argue about before they start. */ | |
| 22 | + | const TOGGLES: Toggle[] = [ | |
| 23 | + | { | |
| 24 | + | key: 'sacredDiscard', | |
| 25 | + | zh: '過水', | |
| 26 | + | en: 'Sacred discard', | |
| 27 | + | note: 'Pass on a tile you could have won on and it is dead to you until your own next draw.', | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | key: 'sacredClearedByClaim', | |
| 31 | + | zh: '吃碰解過水', | |
| 32 | + | en: 'A claim lifts it', | |
| 33 | + | note: 'Taking a 吃 / 碰 / 槓 counts as a fresh start, rather than making you wait to draw.', | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | key: 'multipleWinners', | |
| 37 | + | zh: '一炮多響', | |
| 38 | + | en: 'Multiple winners', | |
| 39 | + | note: 'One discard pays out to every seat that calls on it, not just the nearest.', | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | key: 'liability', | |
| 43 | + | zh: '包牌', | |
| 44 | + | en: 'Liability', | |
| 45 | + | note: 'Feed the third dragon or wind pung to a 大三元 / 大四喜 and you pay for the whole hand.', | |
| 46 | + | }, | |
| 47 | + | ]; | |
| 48 | + | ||
| 49 | + | export function Settings({ prefs, onSave, onClose, stakesLocked }: Props) { | |
| 50 | + | // Edited locally and committed on 完成, so a half-typed name never reaches | |
| 51 | + | // the table and 取消 really does mean cancel. | |
| 52 | + | const [draft, setDraft] = useState<Prefs>(prefs); | |
| 53 | + | const rules = draft.rules; | |
| 54 | + | const setRules = (patch: Partial<Rules>) => setDraft({ ...draft, rules: { ...rules, ...patch } }); | |
| 55 | + | ||
| 56 | + | const num = ( | |
| 57 | + | key: 'base' | 'taiValue' | 'startingScore', | |
| 58 | + | zh: string, | |
| 59 | + | en: string, | |
| 60 | + | min: number, | |
| 61 | + | max: number, | |
| 62 | + | ) => ( | |
| 63 | + | <label className="field"> | |
| 64 | + | <span className="f-label"> | |
| 65 | + | {zh} | |
| 66 | + | <em>{en}</em> | |
| 67 | + | </span> | |
| 68 | + | <input | |
| 69 | + | type="number" | |
| 70 | + | inputMode="numeric" | |
| 71 | + | min={min} | |
| 72 | + | max={max} | |
| 73 | + | disabled={stakesLocked} | |
| 74 | + | value={rules[key]} | |
| 75 | + | onChange={(e) => setRules({ [key]: clamp(Math.round(+e.target.value || 0), min, max) })} | |
| 76 | + | /> | |
| 77 | + | </label> | |
| 78 | + | ); | |
| 79 | + | ||
| 80 | + | return ( | |
| 81 | + | <div className="settings-scrim" onClick={onClose}> | |
| 82 | + | <div className="settings" onClick={(e) => e.stopPropagation()}> | |
| 83 | + | <div className="settings-head"> | |
| 84 | + | <h3>設定 · Settings</h3> | |
| 85 | + | <div className="settings-head-btns"> | |
| 86 | + | <button className="btn ghost" onClick={onClose}> | |
| 87 | + | 取消 Cancel | |
| 88 | + | </button> | |
| 89 | + | <button className="btn" onClick={() => onSave(draft)}> | |
| 90 | + | 完成 Done | |
| 91 | + | </button> | |
| 92 | + | </div> | |
| 93 | + | </div> | |
| 94 | + | ||
| 95 | + | <div className="settings-body"> | |
| 96 | + | <section> | |
| 97 | + | <h4> | |
| 98 | + | 玩家 <em>Players</em> | |
| 99 | + | </h4> | |
| 100 | + | <div className="names"> | |
| 101 | + | {draft.names.map((n, i) => ( | |
| 102 | + | <label className="field" key={i}> | |
| 103 | + | <span className="f-label">{i + 1}</span> | |
| 104 | + | <input | |
| 105 | + | value={n} | |
| 106 | + | maxLength={12} | |
| 107 | + | placeholder={DEFAULT_NAMES[i]} | |
| 108 | + | onChange={(e) => { | |
| 109 | + | const names = draft.names.slice(); | |
| 110 | + | names[i] = e.target.value; | |
| 111 | + | setDraft({ ...draft, names }); | |
| 112 | + | }} | |
| 113 | + | /> | |
| 114 | + | </label> | |
| 115 | + | ))} | |
| 116 | + | </div> | |
| 117 | + | </section> | |
| 118 | + | ||
| 119 | + | <section> | |
| 120 | + | <h4> | |
| 121 | + | 籌碼 <em>Stakes</em> | |
| 122 | + | </h4> | |
| 123 | + | {num('base', '底', 'Base', 0, 999)} | |
| 124 | + | {num('taiValue', '台', 'Per tai', 0, 999)} | |
| 125 | + | {num('startingScore', '起始籌碼', 'Starting chips', 0, 99999)} | |
| 126 | + | {stakesLocked && ( | |
| 127 | + | <p className="note lock"> | |
| 128 | + | 對局進行中不能改動籌碼。 | |
| 129 | + | <br /> | |
| 130 | + | Stakes are fixed once a game is under way — finish it, or start a new one. | |
| 131 | + | </p> | |
| 132 | + | )} | |
| 133 | + | </section> | |
| 134 | + | ||
| 135 | + | <section> | |
| 136 | + | <h4> | |
| 137 | + | 規則 <em>House rules</em> | |
| 138 | + | </h4> | |
| 139 | + | {TOGGLES.map((t) => { | |
| 140 | + | const on = rules[t.key] as boolean; | |
| 141 | + | // 過水 has to be on for the rule about lifting it to mean anything. | |
| 142 | + | const dead = t.key === 'sacredClearedByClaim' && !rules.sacredDiscard; | |
| 143 | + | return ( | |
| 144 | + | <label className={`switch ${dead ? 'dead' : ''}`} key={t.key}> | |
| 145 | + | <input | |
| 146 | + | type="checkbox" | |
| 147 | + | checked={on} | |
| 148 | + | disabled={dead} | |
| 149 | + | onChange={(e) => setRules({ [t.key]: e.target.checked })} | |
| 150 | + | /> | |
| 151 | + | <span className="sw-name"> | |
| 152 | + | {t.zh} | |
| 153 | + | <em>{t.en}</em> | |
| 154 | + | </span> | |
| 155 | + | <span className="note">{t.note}</span> | |
| 156 | + | </label> | |
| 157 | + | ); | |
| 158 | + | })} | |
| 159 | + | </section> | |
| 160 | + | ||
| 161 | + | <section> | |
| 162 | + | <h4> | |
| 163 | + | 音效 <em>Sound</em> | |
| 164 | + | </h4> | |
| 165 | + | <label className="switch"> | |
| 166 | + | <input | |
| 167 | + | type="checkbox" | |
| 168 | + | checked={draft.sound} | |
| 169 | + | onChange={(e) => { | |
| 170 | + | // Start the audio hardware inside the click, not after it. | |
| 171 | + | setSoundEnabled(e.target.checked); | |
| 172 | + | setDraft({ ...draft, sound: e.target.checked }); | |
| 173 | + | if (e.target.checked) play('claimWindow'); | |
| 174 | + | }} | |
| 175 | + | /> | |
| 176 | + | <span className="sw-name"> | |
| 177 | + | 開啟音效 | |
| 178 | + | <em>Sound on</em> | |
| 179 | + | </span> | |
| 180 | + | <span className="note"> | |
| 181 | + | A click as a tile goes down, and a chime when someone's claim window opens. | |
| 182 | + | </span> | |
| 183 | + | </label> | |
| 184 | + | <label className="field"> | |
| 185 | + | <span className="f-label"> | |
| 186 | + | 音量 | |
| 187 | + | <em>Volume</em> | |
| 188 | + | </span> | |
| 189 | + | <input | |
| 190 | + | type="range" | |
| 191 | + | min={0} | |
| 192 | + | max={100} | |
| 193 | + | value={Math.round(draft.volume * 100)} | |
| 194 | + | disabled={!draft.sound} | |
| 195 | + | onChange={(e) => { | |
| 196 | + | const v = +e.target.value / 100; | |
| 197 | + | setVolume(v); | |
| 198 | + | setDraft({ ...draft, volume: v }); | |
| 199 | + | }} | |
| 200 | + | onMouseUp={() => draft.sound && play('discard')} | |
| 201 | + | onTouchEnd={() => draft.sound && play('discard')} | |
| 202 | + | /> | |
| 203 | + | </label> | |
| 204 | + | </section> | |
| 205 | + | </div> | |
| 206 | + | </div> | |
| 207 | + | </div> | |
| 208 | + | ); | |
| 209 | + | } |
addedsrc/ui/SoundToggle.tsx+36 −0
| 1 | + | import type { Prefs } from '../game/prefs'; | |
| 2 | + | import { play, setSoundEnabled } from '../game/sound'; | |
| 3 | + | ||
| 4 | + | interface Props { | |
| 5 | + | prefs: Prefs; | |
| 6 | + | onPrefs: (p: Prefs) => void; | |
| 7 | + | /** Icon only, for the cramped row of controls in the middle of the table. */ | |
| 8 | + | compact?: boolean; | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | /** | |
| 12 | + | * Mute. Turning sound *on* plays the chime straight away — partly so the room | |
| 13 | + | * hears how loud it is before a hand starts, partly because the browser will | |
| 14 | + | * only start the audio hardware from inside a click like this one. | |
| 15 | + | */ | |
| 16 | + | export function SoundToggle({ prefs, onPrefs, compact }: Props) { | |
| 17 | + | const on = prefs.sound; | |
| 18 | + | return ( | |
| 19 | + | <button | |
| 20 | + | className={`btn ghost sound-toggle ${compact ? 'compact' : ''} ${on ? 'on' : ''}`} | |
| 21 | + | aria-pressed={on} | |
| 22 | + | onClick={() => { | |
| 23 | + | // Unlock the audio hardware here, inside the click, rather than | |
| 24 | + | // waiting for the effect that persists the preference. | |
| 25 | + | setSoundEnabled(!on); | |
| 26 | + | onPrefs({ ...prefs, sound: !on }); | |
| 27 | + | if (!on) play('claimWindow'); | |
| 28 | + | }} | |
| 29 | + | > | |
| 30 | + | <span className="spk" aria-hidden> | |
| 31 | + | {on ? '🔊' : '🔇'} | |
| 32 | + | </span> | |
| 33 | + | {!compact && <span>{on ? '音效開 Sound on' : '靜音 Muted'}</span>} | |
| 34 | + | </button> | |
| 35 | + | ); | |
| 36 | + | } |
addedsrc/ui/TileView.tsx+35 −0
| 1 | + | import { labelOf, SPRITE_COL, type Tile } from '../game/tiles'; | |
| 2 | + | ||
| 3 | + | interface Props { | |
| 4 | + | tile?: Tile; | |
| 5 | + | faceDown?: boolean; | |
| 6 | + | size?: 'hand' | 'meld' | 'small'; | |
| 7 | + | raised?: boolean; | |
| 8 | + | dim?: boolean; | |
| 9 | + | highlight?: boolean; | |
| 10 | + | onClick?: () => void; | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | export function TileView({ tile, faceDown, size = 'hand', raised, dim, highlight, onClick }: Props) { | |
| 14 | + | const down = faceDown || tile === undefined; | |
| 15 | + | const cls = [ | |
| 16 | + | 'tile', | |
| 17 | + | `t-${size}`, | |
| 18 | + | down ? 'back' : 'face', | |
| 19 | + | raised ? 'raised' : '', | |
| 20 | + | dim ? 'dim' : '', | |
| 21 | + | highlight ? 'hl' : '', | |
| 22 | + | onClick ? 'tappable' : '', | |
| 23 | + | ] | |
| 24 | + | .filter(Boolean) | |
| 25 | + | .join(' '); | |
| 26 | + | ||
| 27 | + | return ( | |
| 28 | + | <div | |
| 29 | + | className={cls} | |
| 30 | + | onClick={onClick} | |
| 31 | + | aria-label={down ? undefined : labelOf(tile!)} | |
| 32 | + | style={down ? undefined : ({ '--col': SPRITE_COL[tile!] } as React.CSSProperties)} | |
| 33 | + | /> | |
| 34 | + | ); | |
| 35 | + | } |
addedsrc/ui/WallRing.tsx+45 −0
| 1 | + | import { STACKS_PER_SIDE, WALL_SIZE, WALL_STACKS } from '../game/tiles'; | |
| 2 | + | import type { GameState } from '../game/engine'; | |
| 3 | + | ||
| 4 | + | /** | |
| 5 | + | * The wall as it sits on a real table: a square of four staggered sides, 18 | |
| 6 | + | * stacks of two to a side. Position 0 is the break point — draws eat the square | |
| 7 | + | * from there, while kong and flower replacements come off the far end (the 底牌 | |
| 8 | + | * tail), so it is consumed from both directions at once. Spent stacks stay as | |
| 9 | + | * faint ghosts so the square keeps its shape all game. | |
| 10 | + | */ | |
| 11 | + | export function WallRing({ state }: { state: GameState }) { | |
| 12 | + | const front = state.drawnFront; | |
| 13 | + | const back = WALL_SIZE - state.drawnBack; | |
| 14 | + | const deadFrom = back - state.rules.wallReserve; | |
| 15 | + | ||
| 16 | + | const stacks = Array.from({ length: WALL_STACKS }, (_, i) => { | |
| 17 | + | const a = i * 2; | |
| 18 | + | const b = a + 1; | |
| 19 | + | const live = (p: number) => p >= front && p < back; | |
| 20 | + | const count = (live(a) ? 1 : 0) + (live(b) ? 1 : 0); | |
| 21 | + | return { count, dead: b >= deadFrom, next: front === a || front === b }; | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | const side = (from: number, cls: string) => ( | |
| 25 | + | <div className={`wall-side ${cls}`} key={cls}> | |
| 26 | + | {stacks.slice(from, from + STACKS_PER_SIDE).map((st, i) => ( | |
| 27 | + | <div | |
| 28 | + | key={i} | |
| 29 | + | className={`wall-stack s${st.count}${st.dead ? ' dead' : ''}${st.next ? ' next' : ''}`} | |
| 30 | + | > | |
| 31 | + | <i /> | |
| 32 | + | </div> | |
| 33 | + | ))} | |
| 34 | + | </div> | |
| 35 | + | ); | |
| 36 | + | ||
| 37 | + | return ( | |
| 38 | + | <div className="wall-ring" aria-label="牌牆"> | |
| 39 | + | {side(0, 'top')} | |
| 40 | + | {side(STACKS_PER_SIDE, 'right')} | |
| 41 | + | {side(STACKS_PER_SIDE * 2, 'bottom')} | |
| 42 | + | {side(STACKS_PER_SIDE * 3, 'left')} | |
| 43 | + | </div> | |
| 44 | + | ); | |
| 45 | + | } |
addedsrc/ui/rotation.ts+15 −0
| 1 | + | import type { SeatId } from '../game/types'; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * Screen rotation that makes a seat's content face that player. | |
| 5 | + | * Seat 0 sits at the bottom edge and play runs counter-clockwise on screen: | |
| 6 | + | * 1 = right, 2 = top, 3 = left. | |
| 7 | + | */ | |
| 8 | + | export const SEAT_ROT: Record<SeatId, number> = { 0: 0, 1: -90, 2: 180, 3: 90 }; | |
| 9 | + | ||
| 10 | + | export const SEAT_SLOT: Record<SeatId, string> = { | |
| 11 | + | 0: 'slot-bottom', | |
| 12 | + | 1: 'slot-right', | |
| 13 | + | 2: 'slot-top', | |
| 14 | + | 3: 'slot-left', | |
| 15 | + | }; |
addedtsconfig.app.json+26 −0
| 1 | + | { | |
| 2 | + | "compilerOptions": { | |
| 3 | + | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", | |
| 4 | + | "target": "es2023", | |
| 5 | + | "lib": ["ES2023", "DOM"], | |
| 6 | + | "module": "esnext", | |
| 7 | + | "types": ["vite/client"], | |
| 8 | + | "allowArbitraryExtensions": true, | |
| 9 | + | "skipLibCheck": true, | |
| 10 | + | ||
| 11 | + | /* Bundler mode */ | |
| 12 | + | "moduleResolution": "bundler", | |
| 13 | + | "allowImportingTsExtensions": true, | |
| 14 | + | "verbatimModuleSyntax": true, | |
| 15 | + | "moduleDetection": "force", | |
| 16 | + | "noEmit": true, | |
| 17 | + | "jsx": "react-jsx", | |
| 18 | + | ||
| 19 | + | /* Linting */ | |
| 20 | + | "noUnusedLocals": true, | |
| 21 | + | "noUnusedParameters": true, | |
| 22 | + | "erasableSyntaxOnly": true, | |
| 23 | + | "noFallthroughCasesInSwitch": true | |
| 24 | + | }, | |
| 25 | + | "include": ["src"] | |
| 26 | + | } |
addedtsconfig.json+7 −0
| 1 | + | { | |
| 2 | + | "files": [], | |
| 3 | + | "references": [ | |
| 4 | + | { "path": "./tsconfig.app.json" }, | |
| 5 | + | { "path": "./tsconfig.node.json" } | |
| 6 | + | ] | |
| 7 | + | } |
addedtsconfig.node.json+23 −0
| 1 | + | { | |
| 2 | + | "compilerOptions": { | |
| 3 | + | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", | |
| 4 | + | "target": "es2023", | |
| 5 | + | "lib": ["ES2023"], | |
| 6 | + | "types": ["node"], | |
| 7 | + | "skipLibCheck": true, | |
| 8 | + | ||
| 9 | + | /* Bundler mode */ | |
| 10 | + | "module": "nodenext", | |
| 11 | + | "allowImportingTsExtensions": true, | |
| 12 | + | "verbatimModuleSyntax": true, | |
| 13 | + | "moduleDetection": "force", | |
| 14 | + | "noEmit": true, | |
| 15 | + | ||
| 16 | + | /* Linting */ | |
| 17 | + | "noUnusedLocals": true, | |
| 18 | + | "noUnusedParameters": true, | |
| 19 | + | "erasableSyntaxOnly": true, | |
| 20 | + | "noFallthroughCasesInSwitch": true | |
| 21 | + | }, | |
| 22 | + | "include": ["vite.config.ts"] | |
| 23 | + | } |
addedvite.config.ts+7 −0
| 1 | + | import { defineConfig } from 'vite' | |
| 2 | + | import react from '@vitejs/plugin-react' | |
| 3 | + | ||
| 4 | + | // https://vite.dev/config/ | |
| 5 | + | export default defineConfig({ | |
| 6 | + | plugins: [react()], | |
| 7 | + | }) |