├── .husky └── pre-commit ├── public ├── robots.txt ├── ogp.png ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── app.webmanifest └── safari-pinned-tab.svg ├── .gitignore ├── wasm └── decomposer │ ├── .vscode │ └── settings.json │ ├── .gitignore │ ├── src │ ├── lib.rs │ ├── data.rs │ └── counts.rs │ └── Cargo.toml ├── src ├── components │ ├── tile │ │ ├── images │ │ │ ├── dark │ │ │ │ ├── 5z.svg │ │ │ │ ├── bg.svg │ │ │ │ ├── 7z.svg │ │ │ │ ├── 3z.svg │ │ │ │ ├── 4z.svg │ │ │ │ ├── 1z.svg │ │ │ │ ├── 2z.svg │ │ │ │ ├── 2p.svg │ │ │ │ ├── index.ts │ │ │ │ ├── 1m.svg │ │ │ │ ├── 2m.svg │ │ │ │ ├── 8m.svg │ │ │ │ ├── 3m.svg │ │ │ │ └── 7m.svg │ │ │ ├── light │ │ │ │ ├── 5z.svg │ │ │ │ ├── bg.svg │ │ │ │ ├── 7z.svg │ │ │ │ ├── 3z.svg │ │ │ │ ├── 4z.svg │ │ │ │ ├── 1z.svg │ │ │ │ ├── 2z.svg │ │ │ │ ├── 2p.svg │ │ │ │ ├── index.ts │ │ │ │ ├── 1m.svg │ │ │ │ ├── 2m.svg │ │ │ │ ├── 8m.svg │ │ │ │ ├── 3m.svg │ │ │ │ └── 7m.svg │ │ │ └── back.svg │ │ └── index.tsx │ ├── ui │ │ ├── TileInput.tsx │ │ ├── ConfigItem.tsx │ │ ├── Checkbox.tsx │ │ ├── Segment.tsx │ │ ├── Dropdown.tsx │ │ ├── MenuTab.tsx │ │ ├── Button.tsx │ │ ├── SimpleStepper.tsx │ │ ├── ThemeSwitcher.tsx │ │ ├── Stepper.tsx │ │ ├── TileColorSwitcher.tsx │ │ ├── TileButton.tsx │ │ ├── ScoringTableHeader.tsx │ │ └── TileKeyboard.tsx │ ├── AppContentLoading.tsx │ ├── AppContent.tsx │ ├── LimitBadge.tsx │ ├── App.tsx │ ├── HoraItem.tsx │ ├── Footer.tsx │ ├── Tempai.tsx │ ├── Settings.tsx │ ├── Shanten.tsx │ ├── About.tsx │ ├── YakuList.tsx │ ├── PointDiff.tsx │ ├── KeyboardHelp.tsx │ ├── Calculator.tsx │ ├── TableSettings.tsx │ ├── AppearanceSettings.tsx │ ├── Header.tsx │ ├── Result.tsx │ ├── ResultGlance.tsx │ └── HandOptions.tsx ├── lib │ ├── config.ts │ ├── table.ts │ ├── os.ts │ ├── rule.ts │ ├── score.ts │ ├── yaku.ts │ ├── store │ │ ├── action.ts │ │ └── state.ts │ ├── tile │ │ ├── block.ts │ │ └── index.test.ts │ ├── i18n.ts │ ├── input.ts │ └── util.ts ├── style.css ├── images │ ├── point-stick │ │ ├── 1000.svg │ │ └── 100.svg │ ├── theme │ │ ├── dark.svg │ │ ├── light.svg │ │ └── auto.svg │ └── tile-color │ │ ├── dark.svg │ │ ├── light.svg │ │ ├── auto.svg │ │ └── auto-inverted.svg ├── index.tsx ├── contexts │ └── store.tsx └── hooks │ └── dom.ts ├── .vscode ├── extensions.json └── settings.json ├── tsconfig.json ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .editorconfig ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── plugins ├── commit-hash.ts └── yaml.ts ├── tsconfig.node.json ├── tsconfig.app.json ├── vite.config.ts ├── biome.json ├── LICENSE ├── index.html ├── README.md ├── CONTRIBUTING.md └── package.json /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint && npm test 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | -------------------------------------------------------------------------------- /wasm/decomposer/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /public/ogp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livewing/mahjong-calc/HEAD/public/ogp.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livewing/mahjong-calc/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/components/tile/images/dark/5z.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/light/5z.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome", "EditorConfig.EditorConfig"] 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livewing/mahjong-calc/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livewing/mahjong-calc/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /wasm/decomposer/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | bin/ 5 | pkg/ 6 | wasm-pack.log 7 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livewing/mahjong-calc/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livewing/mahjong-calc/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livewing/mahjong-calc/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/components/tile/images/back.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | export interface AppConfig { 2 | theme: 'auto' | 'light' | 'dark'; 3 | tileColor: 'auto' | 'auto-inverted' | 'light' | 'dark'; 4 | showBazoro: boolean; 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/table.ts: -------------------------------------------------------------------------------- 1 | export type Wind = 'east' | 'south' | 'west' | 'north'; 2 | 3 | export interface Table { 4 | round: Wind; 5 | seat: Wind; 6 | continue: number; 7 | deposit: number; 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /src/components/tile/images/dark/bg.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/tile/images/light/bg.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | USER node 3 | WORKDIR /home/node 4 | ENV PATH $PATH:/home/node/.cargo/bin 5 | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s - -y \ 6 | && cargo install wasm-pack 7 | CMD [ "/bin/bash" ] 8 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "@tailwindcss/forms"; 3 | 4 | @custom-variant dark (&:where(.dark, .dark *)); 5 | 6 | @layer base { 7 | button:not(:disabled), 8 | [role="button"]:not(:disabled) { 9 | cursor: pointer; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/images/point-stick/1000.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/lib/os.ts: -------------------------------------------------------------------------------- 1 | export const isApple = /Macintosh|iPhone|iPad/i.test(navigator.userAgent); 2 | export const formatKeys = (keys: string, appleKeys = keys) => 3 | isApple 4 | ? appleKeys 5 | .replace('Shift', '⇧') 6 | .replace('Cmd', '⌘') 7 | .replace('Backspace', '⌫') 8 | .replace('+', '') 9 | : keys; 10 | -------------------------------------------------------------------------------- /src/components/ui/TileInput.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { TileInputArea } from './TileInputArea'; 3 | import { TileKeyboard } from './TileKeyboard'; 4 | 5 | export const TileInput: FC = () => { 6 | return ( 7 |
8 | 9 | 10 |
11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /wasm/decomposer/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod counts; 2 | mod data; 3 | mod decomposer; 4 | mod shanten; 5 | 6 | use shanten::decompose_min_shanten; 7 | use wasm_bindgen::prelude::*; 8 | 9 | #[wasm_bindgen] 10 | pub fn decompose_from_counts(counts: &[u8], meld: i32) -> JsValue { 11 | let results = decompose_min_shanten(counts, meld); 12 | serde_wasm_bindgen::to_value(&results).unwrap_or(JsValue::NULL) 13 | } 14 | -------------------------------------------------------------------------------- /src/components/AppContentLoading.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import Stick1000 from '../images/point-stick/1000.svg?react'; 3 | 4 | export const AppContentLoading: FC = () => ( 5 |
6 | 7 |
8 | ); 9 | -------------------------------------------------------------------------------- /plugins/commit-hash.ts: -------------------------------------------------------------------------------- 1 | import { execFileSync } from 'node:child_process'; 2 | import type { Plugin } from 'vite'; 3 | 4 | const plugin = (): Plugin => ({ 5 | name: 'commit-hash', 6 | config: () => ({ 7 | define: { 8 | COMMIT_HASH: JSON.stringify( 9 | execFileSync('git', ['rev-parse', 'HEAD'], { 10 | encoding: 'utf-8' 11 | }).trim() 12 | ) 13 | } 14 | }) 15 | }); 16 | export default plugin; 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "css.validate": false, 3 | "editor.defaultFormatter": "biomejs.biome", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "quickfix.biome": "explicit", 7 | "source.organizeImports.biome": "explicit" 8 | }, 9 | "typescript.tsdk": "node_modules/typescript/lib", 10 | "[json]": { 11 | "editor.defaultFormatter": "biomejs.biome" 12 | }, 13 | "[jsonc]": { 14 | "editor.defaultFormatter": "biomejs.biome" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/strictest/tsconfig.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "target": "esnext", 6 | "lib": ["esnext", "dom"], 7 | "module": "esnext", 8 | "moduleResolution": "bundler", 9 | "moduleDetection": "force", 10 | "allowImportingTsExtensions": true, 11 | "noEmit": true, 12 | "noUncheckedSideEffectImports": true 13 | }, 14 | "files": ["./vite.config.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /src/components/ui/ConfigItem.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import type { FC } from 'react'; 3 | 4 | interface ConfigItemProps { 5 | label?: React.ReactNode; 6 | labelFor?: string; 7 | children?: React.ReactNode; 8 | } 9 | 10 | export const ConfigItem: FC = ({ 11 | label, 12 | labelFor, 13 | children 14 | }) => ( 15 |
16 | 19 | {children} 20 |
21 | ); 22 | -------------------------------------------------------------------------------- /plugins/yaml.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'js-yaml'; 2 | import type { Plugin } from 'vite'; 3 | 4 | const yamlFileRegex = /\.ya?ml$/; 5 | 6 | const plugin = (): Plugin => ({ 7 | name: 'yaml', 8 | transform: async (src, id) => { 9 | if (yamlFileRegex.test(id)) { 10 | const yaml = load(src, { onWarning: e => console.warn(e.toString()) }); 11 | return { 12 | code: `export default ${JSON.stringify(yaml)};`, 13 | map: null 14 | }; 15 | } 16 | return null; 17 | } 18 | }); 19 | export default plugin; 20 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Node.js", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | "customizations": { 7 | "settings": { 8 | "terminal.integrated.defaultProfile.linux": "bash", 9 | "terminal.integrated.profiles.linux": { 10 | "bash": { 11 | "path": "bash" 12 | } 13 | } 14 | }, 15 | "extensions": ["biomejs.biome", "EditorConfig.EditorConfig"] 16 | }, 17 | "forwardPorts": [5173], 18 | "postCreateCommand": "npm i && npm run build:wasm", 19 | "remoteUser": "node" 20 | } 21 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { App } from './components/App'; 4 | import { StoreProvider } from './contexts/store'; 5 | import { initI18n } from './lib/i18n'; 6 | import './style.css'; 7 | 8 | initI18n().then(() => { 9 | const el = document.getElementById('app'); 10 | if (el === null) throw new Error('#app is not found'); 11 | createRoot(el).render( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /wasm/decomposer/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "decomposer" 3 | version = "0.1.0" 4 | authors = ["livewing.net "] 5 | edition = "2021" 6 | description = "Mahjong hand decomposer" 7 | repository = "https://github.com/livewing/mahjong-calc" 8 | license = "MIT" 9 | 10 | [lib] 11 | crate-type = ["cdylib", "rlib"] 12 | 13 | [dependencies] 14 | itertools = "0.10.5" 15 | serde = { version = "1.0.217", features = ["derive"] } 16 | serde-wasm-bindgen = "0.4.5" 17 | wasm-bindgen = { version = "0.2.100", features = ["serde-serialize"] } 18 | 19 | [profile.release] 20 | opt-level = "s" 21 | -------------------------------------------------------------------------------- /public/app.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "麻雀得点計算機", 3 | "short_name": "得点計算", 4 | "categories": ["utilities"], 5 | "description": "麻雀の手牌を入力して、待ち牌と得点を計算します。", 6 | "dir": "ltr", 7 | "lang": "ja-JP", 8 | "start_url": ".", 9 | "theme_color": "#2563eb", 10 | "background_color": "black", 11 | "orientation": "portrait", 12 | "display": "standalone", 13 | "icons": [ 14 | { 15 | "src": "/android-chrome-192x192.png", 16 | "sizes": "192x192", 17 | "type": "image/png" 18 | }, 19 | { 20 | "src": "/android-chrome-512x512.png", 21 | "sizes": "512x512", 22 | "type": "image/png" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/images/point-stick/100.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/AppContent.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { useStore } from '../contexts/store'; 3 | import { Calculator } from './Calculator'; 4 | import { ScoringTable } from './ScoringTable'; 5 | import { Settings } from './Settings'; 6 | 7 | const AppContent: FC = () => { 8 | const [{ currentScreen }] = useStore(); 9 | return ( 10 |
11 | {currentScreen === 'main' && } 12 | {currentScreen === 'scoring-table' && } 13 | {currentScreen === 'settings' && } 14 |
15 | ); 16 | }; 17 | export default AppContent; 18 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/strictest/tsconfig.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "esnext", 6 | "module": "esnext", 7 | "lib": ["dom", "dom.iterable", "esnext"], 8 | "types": ["vite/client", "vite-plugin-svgr/client"], 9 | "jsx": "react-jsx", 10 | "sourceMap": true, 11 | "moduleResolution": "bundler", 12 | "moduleDetection": "force", 13 | "resolveJsonModule": true, 14 | "allowImportingTsExtensions": true, 15 | "noPropertyAccessFromIndexSignature": false, 16 | "noUncheckedSideEffectImports": true, 17 | "noEmit": true 18 | }, 19 | "include": ["./src"] 20 | } 21 | -------------------------------------------------------------------------------- /src/contexts/store.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type Dispatch, 3 | type FC, 4 | createContext, 5 | useContext, 6 | useReducer 7 | } from 'react'; 8 | import type React from 'react'; 9 | import { reducer } from '../lib/store'; 10 | import type { Action } from '../lib/store/action'; 11 | import { type AppState, defaultState } from '../lib/store/state'; 12 | 13 | export const StoreContext = createContext<[AppState, Dispatch]>([ 14 | defaultState(), 15 | s => s 16 | ]); 17 | 18 | export const StoreProvider: FC<{ children?: React.ReactNode }> = ({ 19 | children 20 | }) => { 21 | const v = useReducer(reducer, defaultState()); 22 | return {children}; 23 | }; 24 | 25 | export const useStore = () => useContext(StoreContext); 26 | -------------------------------------------------------------------------------- /src/images/theme/dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/images/theme/light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/images/tile-color/dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/images/tile-color/light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/ui/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import type { FC } from 'react'; 3 | 4 | interface CheckboxProps { 5 | id: string; 6 | checked?: boolean; 7 | children?: React.ReactNode; 8 | onChange?: (checked: boolean) => void; 9 | } 10 | 11 | export const Checkbox: FC = ({ 12 | id, 13 | checked = false, 14 | children, 15 | onChange = () => void 0 16 | }) => ( 17 |
18 | onChange(e.target.checked)} 24 | /> 25 | 26 |
27 | ); 28 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 16 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite'; 2 | import react from '@vitejs/plugin-react-swc'; 3 | import type { UserConfig } from 'vite'; 4 | import { VitePWA } from 'vite-plugin-pwa'; 5 | import svgr from 'vite-plugin-svgr'; 6 | import wasm from 'vite-plugin-wasm'; 7 | import commitHash from './plugins/commit-hash'; 8 | import yaml from './plugins/yaml'; 9 | 10 | export default { 11 | plugins: [ 12 | tailwindcss(), 13 | react(), 14 | wasm(), 15 | yaml(), 16 | svgr({ 17 | svgrOptions: { plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'] } 18 | }), 19 | commitHash(), 20 | VitePWA({ 21 | registerType: 'autoUpdate', 22 | workbox: { 23 | skipWaiting: true, 24 | clientsClaim: true, 25 | cleanupOutdatedCaches: true 26 | } 27 | }) 28 | ], 29 | build: { 30 | // top-level await 31 | target: ['chrome89', 'edge89', 'firefox89', 'safari15', 'es2022'] 32 | } 33 | } satisfies UserConfig; 34 | -------------------------------------------------------------------------------- /src/components/ui/Segment.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import type { FC } from 'react'; 3 | 4 | interface SegmentProps { 5 | items?: React.ReactNode[]; 6 | index?: number; 7 | onChange?: (index: number) => void; 8 | } 9 | 10 | export const Segment: FC = ({ 11 | items = [], 12 | index = 0, 13 | onChange = () => void 0 14 | }) => ( 15 |
16 | {items.map((item, i) => ( 17 | 26 | ))} 27 |
28 | ); 29 | -------------------------------------------------------------------------------- /src/images/tile-color/auto.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/images/tile-color/auto-inverted.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "files": { 4 | "ignore": ["wasm", "package.json"] 5 | }, 6 | "vcs": { 7 | "enabled": true, 8 | "clientKind": "git", 9 | "useIgnoreFile": true 10 | }, 11 | "linter": { 12 | "enabled": true, 13 | "rules": { 14 | "recommended": true, 15 | "correctness": { 16 | "noUnusedFunctionParameters": "warn", 17 | "noUnusedImports": "warn", 18 | "noUnusedPrivateClassMembers": "warn", 19 | "noUnusedVariables": "warn" 20 | }, 21 | "suspicious": { 22 | "noArrayIndexKey": "off" 23 | }, 24 | "performance": { 25 | "noAccumulatingSpread": "off" 26 | } 27 | } 28 | }, 29 | "formatter": { 30 | "enabled": true, 31 | "useEditorconfig": true 32 | }, 33 | "organizeImports": { 34 | "enabled": true 35 | }, 36 | "javascript": { 37 | "formatter": { 38 | "quoteStyle": "single", 39 | "trailingCommas": "none", 40 | "arrowParentheses": "asNeeded" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/rule.ts: -------------------------------------------------------------------------------- 1 | export interface Rule { 2 | red: { 3 | m: 0 | 1 | 2 | 3 | 4; 4 | p: 0 | 1 | 2 | 3 | 4; 5 | s: 0 | 1 | 2 | 3 | 4; 6 | }; 7 | honbaBonus: 100 | 500; 8 | roundedMangan: boolean; 9 | doubleWindFu: 2 | 4; 10 | accumlatedYakuman: boolean; 11 | multipleYakuman: boolean; 12 | kokushi13DoubleYakuman: boolean; 13 | suankoTankiDoubleYakuman: boolean; 14 | daisushiDoubleYakuman: boolean; 15 | pureChurenDoubleYakuman: boolean; 16 | } 17 | 18 | export const compareRules = (a: Rule, b: Rule) => 19 | a.red.m === b.red.m && 20 | a.red.p === b.red.p && 21 | a.red.s === b.red.s && 22 | a.honbaBonus === b.honbaBonus && 23 | a.roundedMangan === b.roundedMangan && 24 | a.doubleWindFu === b.doubleWindFu && 25 | a.accumlatedYakuman === b.accumlatedYakuman && 26 | a.multipleYakuman === b.multipleYakuman && 27 | a.kokushi13DoubleYakuman === b.kokushi13DoubleYakuman && 28 | a.suankoTankiDoubleYakuman === b.suankoTankiDoubleYakuman && 29 | a.daisushiDoubleYakuman === b.daisushiDoubleYakuman && 30 | a.pureChurenDoubleYakuman === b.pureChurenDoubleYakuman; 31 | -------------------------------------------------------------------------------- /src/lib/score.ts: -------------------------------------------------------------------------------- 1 | const calculateRawBasePoint = (fu: number, han: number) => fu * 2 ** (2 + han); 2 | 3 | export const calculateBasePoint = ( 4 | fu: number, 5 | han: number, 6 | roundedMangan: boolean, 7 | accumlatedYakuman: boolean 8 | ) => { 9 | const bp = calculateRawBasePoint(fu === 25 ? 25 : ceil10(fu), han); 10 | return accumlatedYakuman && han >= 13 11 | ? 8000 12 | : han >= 11 13 | ? 6000 14 | : han >= 8 15 | ? 4000 16 | : han >= 6 17 | ? 3000 18 | : han >= 5 || bp >= (roundedMangan ? 1920 : 2000) 19 | ? 2000 20 | : bp; 21 | }; 22 | 23 | const ceil = (r: number) => (n: number) => Math.ceil(n / r) * r; 24 | 25 | export const ceil10 = ceil(10); 26 | export const ceil100 = ceil(100); 27 | 28 | const tuples = [ 29 | '', 30 | 'double-', 31 | 'triple-', 32 | 'quadruple-', 33 | 'quintuple-', 34 | 'sextuple-', 35 | 'septuple-', 36 | 'octuple-', 37 | 'nonuple-', 38 | 'decuple-' 39 | ]; 40 | export const yakumanTupleKey = (n: number): string => 41 | n <= 10 ? `result.${tuples[n - 1]}yakuman` : 'result.more-yakuman'; 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2021 livewing.net 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/lib/yaku.ts: -------------------------------------------------------------------------------- 1 | export const yakuNames = [ 2 | 'riichi', 3 | 'ippatsu', 4 | 'tsumo', 5 | 'tanyao', 6 | 'pinfu', 7 | 'iipeko', 8 | 'field-wind', 9 | 'seat-wind', 10 | 'white', 11 | 'green', 12 | 'red', 13 | 'rinshan', 14 | 'chankan', 15 | 'haitei', 16 | 'hotei', 17 | 'sanshoku-dojun', 18 | 'sanshoku-doko', 19 | 'ittsu', 20 | 'chanta', 21 | 'chitoitsu', 22 | 'toitoi', 23 | 'sananko', 24 | 'honroto', 25 | 'sankantsu', 26 | 'shosangen', 27 | 'double-riichi', 28 | 'honitsu', 29 | 'junchan', 30 | 'ryampeko', 31 | 'chinitsu', 32 | 'dora', 33 | 'red-dora' 34 | ] as const; 35 | 36 | export interface NormalYaku { 37 | type: 'yaku'; 38 | name: (typeof yakuNames)[number]; 39 | han: number; 40 | } 41 | 42 | export const yakumanNames = [ 43 | 'kokushi', 44 | 'kokushi-13', 45 | 'suanko', 46 | 'suanko-tanki', 47 | 'daisangen', 48 | 'tsuiso', 49 | 'shosushi', 50 | 'daisushi', 51 | 'ryuiso', 52 | 'chinroto', 53 | 'sukantsu', 54 | 'churen', 55 | 'pure-churen', 56 | 'tenho', 57 | 'chiho' 58 | ] as const; 59 | 60 | export interface Yakuman { 61 | type: 'yakuman'; 62 | name: (typeof yakumanNames)[number]; 63 | point: number; 64 | } 65 | 66 | export type Yaku = NormalYaku | Yakuman; 67 | -------------------------------------------------------------------------------- /src/components/ui/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import type { FC } from 'react'; 3 | import { MdArrowDropDown } from 'react-icons/md'; 4 | import { Button } from './Button'; 5 | 6 | interface DropdownProps { 7 | id?: string; 8 | label?: React.ReactNode; 9 | open?: boolean; 10 | children?: React.ReactNode; 11 | onSetOpen?: (open: boolean) => void; 12 | } 13 | 14 | export const Dropdown: FC = ({ 15 | id, 16 | label, 17 | open = false, 18 | children, 19 | onSetOpen = () => void 0 20 | }) => ( 21 | <> 22 | {open && ( 23 | 34 |
35 |
36 | {children} 37 |
38 |
39 | 40 | 41 | ); 42 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 麻雀得点計算機 20 | 21 | 22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/lib/store/action.ts: -------------------------------------------------------------------------------- 1 | import type { AppConfig } from '../config'; 2 | import type { HandOptions, Meld } from '../input'; 3 | import type { Rule } from '../rule'; 4 | import type { Table } from '../table'; 5 | import type { Tile } from '../tile'; 6 | import type { AppState } from './state'; 7 | 8 | interface A { 9 | type: T; 10 | payload: P; 11 | } 12 | 13 | export type Action = 14 | | A<'set-current-screen', AppState['currentScreen']> 15 | | A<'set-current-scoring-table-tab', AppState['currentScoringTableTab']> 16 | | A<'set-current-settings-tab', AppState['currentSettingsTab']> 17 | | A<'set-app-config', AppConfig> 18 | | A<'set-current-rule', Rule> 19 | | A<'set-table', Table> 20 | | A<'set-input', AppState['input']> 21 | | A<'set-input-random', 5 | 8 | 11 | 14 | 'chinitsu'> 22 | | A<'set-input-focus', AppState['inputFocus']> 23 | | A<'remove-hand-tile', number> 24 | | A<'remove-dora-tile', number> 25 | | A<'add-meld', Meld> 26 | | A<'update-meld', { i: number; meld: Meld }> 27 | | A<'remove-meld', number> 28 | | A<'toggle-current-meld-red', null> 29 | | A<'clear-input', null> 30 | | A<'click-tile-keyboard', Tile> 31 | | A<'set-hand-options', HandOptions> 32 | | A<'delete-saved-rule', string> 33 | | A<'save-current-rule', string>; 34 | -------------------------------------------------------------------------------- /src/components/LimitBadge.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { yakumanTupleKey } from '../lib/score'; 4 | 5 | interface LimitBadgeProps { 6 | base: number; 7 | } 8 | 9 | export const LimitBadge: FC = ({ base }) => { 10 | const { t } = useTranslation(); 11 | 12 | if (base < 2000) return null; 13 | if (base < 3000) 14 | return ( 15 |
16 | {t('result.mangan')} 17 |
18 | ); 19 | if (base < 4000) 20 | return ( 21 |
22 | {t('result.haneman')} 23 |
24 | ); 25 | if (base < 6000) 26 | return ( 27 |
28 | {t('result.baiman')} 29 |
30 | ); 31 | if (base < 8000) 32 | return ( 33 |
34 | {t('result.sambaiman')} 35 |
36 | ); 37 | 38 | const yakuman = Math.floor(base / 8000); 39 | return ( 40 |
41 | {t(yakumanTupleKey(yakuman))} 42 |
43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/lib/tile/block.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CharacterTileCountsIndex, 3 | NumberTileCountsIndex, 4 | TileCounts, 5 | TileCountsIndex 6 | } from '.'; 7 | 8 | export interface Block { 9 | type: 'kotsu' | 'shuntsu' | 'ryammen' | 'penchan' | 'kanchan' | 'toitsu'; 10 | tile: TileCountsIndex; 11 | } 12 | 13 | export interface NumberBlock { 14 | type: 'kotsu' | 'shuntsu' | 'ryammen' | 'penchan' | 'kanchan' | 'toitsu'; 15 | tile: NumberTileCountsIndex; 16 | } 17 | 18 | export interface CharacterBlock { 19 | type: 'kotsu' | 'toitsu'; 20 | tile: CharacterTileCountsIndex; 21 | } 22 | 23 | export const blockToTileCounts = (b: Block): TileCounts => { 24 | const empty = [...Array(34)]; 25 | switch (b.type) { 26 | case 'kotsu': 27 | return empty.map((_, i) => (i === b.tile ? 3 : 0)) as TileCounts; 28 | case 'shuntsu': 29 | return empty.map((_, i) => 30 | b.tile <= i && i < b.tile + 3 ? 1 : 0 31 | ) as TileCounts; 32 | case 'ryammen': 33 | case 'penchan': 34 | return empty.map((_, i) => 35 | b.tile <= i && i < b.tile + 2 ? 1 : 0 36 | ) as TileCounts; 37 | case 'kanchan': 38 | return empty.map((_, i) => 39 | b.tile === i || b.tile + 2 === i ? 1 : 0 40 | ) as TileCounts; 41 | case 'toitsu': 42 | return empty.map((_, i) => (i === b.tile ? 2 : 0)) as TileCounts; 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/components/ui/MenuTab.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import type { FC } from 'react'; 3 | 4 | interface MenuTabProps { 5 | items?: React.ReactNode[]; 6 | index?: number; 7 | row?: boolean; 8 | onSetIndex?: (index: number) => void; 9 | } 10 | 11 | export const MenuTab: FC = ({ 12 | items = [], 13 | index = 0, 14 | row = false, 15 | onSetIndex = () => void 0 16 | }) => ( 17 | 40 | ); 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mahjong-calc 2 | 3 | [![CI](https://github.com/livewing/mahjong-calc/workflows/CI/badge.svg)](https://github.com/livewing/mahjong-calc/actions?query=workflow%3ACI) 4 | [![LICENSE](https://img.shields.io/github/license/livewing/mahjong-calc)](./LICENSE) 5 | 6 | ![Screenshot](https://user-images.githubusercontent.com/7447366/167593547-c88f910a-65f5-48ec-853b-668efe03c900.png) 7 | 8 | 麻雀の手牌を入力すると、待ち牌・得点や牌効率の計算をすることができる Web アプリケーション (PWA) です。スマートフォンと PC の Web ブラウザ上で動作します。 9 | 10 | Riichi-Mahjong score calculator app in the web browser. 11 | 12 | ## 実行 - Run 13 | 14 | [麻雀得点計算機](https://mahjong-calc.livewing.net/) 15 | 16 | This app is available in Japanese, English, Simplified Chinese, and Korean. To translate the app to a new language, see [CONTRIBUTING.md](./CONTRIBUTING.md). 17 | 18 | QR Code 19 | 20 | ## 使用方法 - How to use 21 | 22 | [使用方法](./doc/how-to-use.md) (Japanese) 23 | 24 | ## 開発 - Contributing 25 | 26 | See [CONTRIBUTING.md](./CONTRIBUTING.md). 27 | 28 | ## ライセンス - License 29 | 30 | [The MIT License](./LICENSE) 31 | 32 | ## クレジット - Credits 33 | 34 | 麻雀牌の画像は [FluffyStuff/riichi-mahjong-tiles](https://github.com/FluffyStuff/riichi-mahjong-tiles) のものを使用しています ([CC BY](https://github.com/FluffyStuff/riichi-mahjong-tiles/blob/master/LICENSE.md)) 。 35 | -------------------------------------------------------------------------------- /src/components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import type { FC } from 'react'; 3 | 4 | const buttonClasses = { 5 | none: 'inline-flex items-center gap-1 px-2 py-1 border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-black hover:bg-neutral-200 dark:hover:bg-neutral-800 disabled:hover:bg-white dark:disabled:hover:bg-black disabled:opacity-30 disabled:cursor-not-allowed rounded-md shadow select-none focus:outline-none focus:ring-2 transition', 6 | danger: 7 | 'inline-flex items-center gap-1 px-2 py-1 border border-red-300 dark:border-red-700 bg-red-500 hover:bg-red-600 disabled:hover:bg-red-500 disabled:opacity-30 disabled:cursor-not-allowed text-white rounded-md shadow select-none focus:outline-none focus:ring-2 transition' 8 | } as const; 9 | 10 | interface ButtonProps { 11 | id?: string | undefined; 12 | children?: React.ReactNode; 13 | color?: 'none' | 'danger' | undefined; 14 | disabled?: boolean | undefined; 15 | title?: string | undefined; 16 | onClick?: (() => void) | undefined; 17 | } 18 | export const Button: FC = ({ 19 | id, 20 | children, 21 | color = 'none', 22 | disabled, 23 | title, 24 | onClick = () => void 0 25 | }) => ( 26 | 36 | ); 37 | -------------------------------------------------------------------------------- /src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, Suspense, lazy, useLayoutEffect } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { useStore } from '../contexts/store'; 4 | import { usePrefersColorScheme } from '../hooks/dom'; 5 | import { AppContentLoading } from './AppContentLoading'; 6 | import { Footer } from './Footer'; 7 | import { Header } from './Header'; 8 | 9 | const wrapperClasses = { 10 | light: 'flex flex-col min-h-screen touch-manipulation', 11 | dark: 'flex flex-col min-h-screen touch-manipulation dark' 12 | } as const; 13 | 14 | const AppContent = lazy(() => import('./AppContent')); 15 | 16 | export const App: FC = () => { 17 | const [ 18 | { 19 | appConfig: { theme } 20 | } 21 | ] = useStore(); 22 | const systemColor = usePrefersColorScheme(); 23 | const isDark = 24 | theme === 'dark' || (theme === 'auto' && systemColor === 'dark'); 25 | const { 26 | i18n: { resolvedLanguage } 27 | } = useTranslation(); 28 | useLayoutEffect(() => { 29 | const html = document.querySelector('html'); 30 | if (html === null) return; 31 | html.lang = resolvedLanguage ?? 'ja'; 32 | }, [resolvedLanguage]); 33 | return ( 34 |
35 |
36 | }> 37 | 38 | 39 |
40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/HoraItem.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useState } from 'react'; 2 | import type { Hora } from '../lib/hora'; 3 | import { FuDetail } from './FuDetail'; 4 | import { HoraItemSummary } from './HoraItemSummary'; 5 | import { PointDiff } from './PointDiff'; 6 | import { Score } from './Score'; 7 | import { ScorePlusList } from './ScorePlusList'; 8 | import { YakuList } from './YakuList'; 9 | 10 | interface HoraItemProps { 11 | info: Hora; 12 | } 13 | 14 | export const HoraItem: FC = ({ info }) => { 15 | const [open, setOpen] = useState(false); 16 | 17 | return ( 18 |
19 | 26 | {open && ( 27 | <> 28 |
29 | 38 | 39 | 40 | 41 | 42 | 43 | )} 44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/components/tile/images/dark/7z.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/light/7z.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import type { FC } from 'react'; 3 | import { Trans, useTranslation } from 'react-i18next'; 4 | 5 | const Link: FC<{ href: string; children?: React.ReactNode }> = ({ 6 | href, 7 | children 8 | }) => ( 9 | 15 | {children} 16 | 17 | ); 18 | 19 | export const Footer: FC = () => { 20 | const { i18n } = useTranslation(); 21 | return ( 22 |
23 |

24 | © livewing.net 25 |

26 |

27 | 28 | {''} 29 | 30 | 31 |

32 |

33 | 34 | {''} 35 | 36 | 37 | 38 |

39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/ui/SimpleStepper.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import type { FC } from 'react'; 3 | import { MdAdd, MdRemove } from 'react-icons/md'; 4 | 5 | interface StepperButtonProps { 6 | children?: React.ReactNode; 7 | disabled?: boolean; 8 | onClick?: () => void; 9 | } 10 | const StepperButton: FC = ({ 11 | children, 12 | disabled = false, 13 | onClick = () => void 0 14 | }) => ( 15 | 23 | ); 24 | 25 | interface SimpleStepperProps { 26 | canDecrement?: boolean; 27 | canIncrement?: boolean; 28 | onChange?: (delta: 1 | -1) => void; 29 | } 30 | export const SimpleStepper: FC = ({ 31 | canDecrement = true, 32 | canIncrement = true, 33 | onChange = () => void 0 34 | }) => ( 35 |
36 | onChange(-1)}> 37 | 38 | 39 | onChange(1)}> 40 | 41 | 42 |
43 | ); 44 | -------------------------------------------------------------------------------- /src/components/Tempai.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { useStore } from '../contexts/store'; 4 | import type { TileAvailability } from '../lib/tile'; 5 | import { TileButton } from './ui/TileButton'; 6 | 7 | interface TempaiProps { 8 | tileAvailabilities: TileAvailability[]; 9 | } 10 | 11 | export const Tempai: FC = ({ tileAvailabilities }) => { 12 | const [{ inputFocus }, dispatch] = useStore(); 13 | const { t } = useTranslation(); 14 | 15 | const ta = tileAvailabilities.filter(a => a.count > 0); 16 | 17 | return ( 18 |
19 |
{t('result.tempai')}
20 |
21 | {ta.map((a, i) => ( 22 |
23 |
24 | { 27 | const focus = inputFocus.type !== 'hand' ? inputFocus : null; 28 | if (focus !== null) 29 | dispatch({ 30 | type: 'set-input-focus', 31 | payload: { type: 'hand' } 32 | }); 33 | dispatch({ type: 'click-tile-keyboard', payload: a.tile }); 34 | if (focus !== null) 35 | dispatch({ type: 'set-input-focus', payload: focus }); 36 | }} 37 | /> 38 |
39 |
× {a.count}
40 |
41 | ))} 42 |
43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/lib/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18next, { 2 | type ResourceLanguage, 3 | use, 4 | type Resource, 5 | type TFunction 6 | } from 'i18next'; 7 | import LanguageDetector from 'i18next-browser-languagedetector'; 8 | import { initReactI18next } from 'react-i18next'; 9 | 10 | export const getResources = (): Resource => { 11 | const yamls = import.meta.glob( 12 | ['../../locales/*.yml', '../../locales/*.yaml'], 13 | { 14 | import: 'default', 15 | eager: true 16 | } 17 | ); 18 | const ret: Resource = {}; 19 | for (const [key, value] of Object.entries(yamls)) { 20 | const m = key.match(/([^/]*)(?:\.([^.]+$))/); 21 | if (m === null) throw new Error(`Invalid import path: ${key}`); 22 | ret[m[1] as RegExpMatchArray[number]] = value; 23 | } 24 | return ret; 25 | }; 26 | 27 | type InitI18nParams = Partial<{ 28 | useDetector: boolean; 29 | lng: string; 30 | }>; 31 | export const initI18n = (params?: InitI18nParams): Promise => 32 | ((params?.useDetector ?? true) ? use(LanguageDetector) : i18next) 33 | .use(initReactI18next) 34 | .init( 35 | (() => { 36 | const lng = params?.lng; 37 | return typeof lng === 'undefined' 38 | ? { 39 | resources: getResources(), 40 | fallbackLng: 'en', 41 | interpolation: { escapeValue: false }, 42 | debug: import.meta.env.DEV 43 | } 44 | : { 45 | resources: getResources(), 46 | lng, 47 | fallbackLng: 'en', 48 | interpolation: { escapeValue: false }, 49 | debug: import.meta.env.DEV 50 | }; 51 | })() 52 | ); 53 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to mahjong-calc 2 | 3 | ## Environment 4 | 5 | - Node.js (latest) 6 | - Rust (latest, stable) 7 | - wasm-pack (latest) 8 | 9 | Dev Container is available. 10 | 11 | ## Clone and run 12 | 13 | ``` 14 | $ git clone https://github.com/livewing/mahjong-calc.git 15 | $ cd mahjong-calc 16 | $ npm i 17 | $ npm run build:wasm 18 | $ npm run dev 19 | ``` 20 | 21 | Vite dev server runs on `PORT=5173`. If you are using Dev Container, run `npm run dev -- --host` instead of `npm run dev`. 22 | 23 | ## Lint 24 | 25 | ``` 26 | $ npm run lint 27 | ``` 28 | 29 | ## Build 30 | 31 | ``` 32 | $ npm run build:wasm 33 | $ npm run build 34 | ``` 35 | 36 | Bundle output directory is `dist/`. 37 | 38 | ## Translating 39 | 40 | See [#153](https://github.com/livewing/mahjong-calc/issues/153) for discussion on localization. 41 | 42 | ### New languages 43 | 44 | To translate mahjong-calc to a new language, copy the `locales/ja.yml` file to the locale you are translating to. For example, to translate mahjong-calc to Esperanto you would do: 45 | 46 | ``` 47 | $ cp locales/ja.yml locales/eo.yml 48 | ``` 49 | 50 | Then edit the file with your translation. 51 | 52 | ```yaml 53 | # locales/eo.yml 54 | 55 | locale: 56 | name: Esperanto # Language name 57 | translation: 58 | header: 59 | title: Mahjong Poentaro Kalkulilo 60 | update: Ĝisdatigo 61 | # --snip-- 62 | ``` 63 | 64 | mahjong-calc uses i18next, so please refer to the [i18next documentation](https://www.i18next.com/) for translation. In the language with plurals, you will see [Plurals - i18next documentation](https://www.i18next.com/translation-function/plurals). 65 | 66 | ### Updating existing translations 67 | 68 | To update existing translations, just update the yml file with new strings. 69 | -------------------------------------------------------------------------------- /src/components/tile/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { useStore } from '../../contexts/store'; 3 | import { usePrefersColorScheme } from '../../hooks/dom'; 4 | import type { TileOrBack } from '../../lib/tile'; 5 | import Back from './images/back.svg?react'; 6 | import { tileImage as darkTileImage } from './images/dark'; 7 | import DarkBG from './images/dark/bg.svg?react'; 8 | import { tileImage as lightTileImage } from './images/light'; 9 | import LightBG from './images/light/bg.svg?react'; 10 | 11 | interface TileProps { 12 | tile: TileOrBack; 13 | color?: 'light' | 'dark' | undefined; 14 | dim?: boolean | undefined; 15 | } 16 | 17 | export const Tile: FC = ({ tile, color, dim = false }) => { 18 | const [ 19 | { 20 | appConfig: { theme, tileColor: tileConfigColor } 21 | } 22 | ] = useStore(); 23 | const systemColor = usePrefersColorScheme(); 24 | const appColor = theme === 'auto' ? systemColor : theme; 25 | const tileColor = 26 | color ?? 27 | (tileConfigColor === 'light' || tileConfigColor === 'dark' 28 | ? tileConfigColor 29 | : tileConfigColor === 'auto' 30 | ? appColor 31 | : appColor === 'light' 32 | ? 'dark' 33 | : 'light'); 34 | 35 | const Background = 36 | tile.type === 'back' ? Back : tileColor === 'light' ? LightBG : DarkBG; 37 | const TileImage = 38 | tile.type === 'back' 39 | ? null 40 | : tileColor === 'light' 41 | ? lightTileImage(tile) 42 | : darkTileImage(tile); 43 | return ( 44 |
51 | 52 | {TileImage !== null && ( 53 | 54 | )} 55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/images/theme/auto.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mahjong-calc", 3 | "version": "2.1.4", 4 | "description": "Mahjong score calculator", 5 | "type": "module", 6 | "scripts": { 7 | "preview": "vite preview", 8 | "build": "tsc -b && vite build", 9 | "build:wasm": "cd wasm/decomposer && wasm-pack build", 10 | "dev": "vite", 11 | "lint": "biome check", 12 | "lint:fix": "biome check --write", 13 | "prepare": "husky", 14 | "test": "vitest run" 15 | }, 16 | "keywords": [], 17 | "author": "livewing.net (https://livewing.net/)", 18 | "license": "MIT", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/livewing/mahjong-calc.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/livewing/mahjong-calc/issues" 25 | }, 26 | "private": true, 27 | "dependencies": { 28 | "copy-to-clipboard": "^3.3.3", 29 | "decomposer": "file:./wasm/decomposer/pkg", 30 | "i18next": "^24.2.2", 31 | "i18next-browser-languagedetector": "^8.0.3", 32 | "react": "^19.0.0", 33 | "react-dom": "^19.0.0", 34 | "react-hotkeys-hook": "^4.6.1", 35 | "react-i18next": "^15.4.1", 36 | "react-icons": "^5.4.0" 37 | }, 38 | "devDependencies": { 39 | "@biomejs/biome": "1.9.4", 40 | "@svgr/plugin-svgo": "^8.1.0", 41 | "@tailwindcss/forms": "^0.5.10", 42 | "@tailwindcss/vite": "^4.0.6", 43 | "@tsconfig/strictest": "^2.0.5", 44 | "@types/js-yaml": "^4.0.9", 45 | "@types/node": "^22.13.4", 46 | "@types/react": "^19.0.10", 47 | "@types/react-dom": "^19.0.4", 48 | "@vitejs/plugin-react-swc": "^3.8.0", 49 | "husky": "^9.1.7", 50 | "js-yaml": "^4.1.0", 51 | "tailwindcss": "^4.0.6", 52 | "typescript": "^5.7.3", 53 | "vite": "^6.1.0", 54 | "vite-plugin-pwa": "^0.21.1", 55 | "vite-plugin-svgr": "^4.3.0", 56 | "vite-plugin-wasm": "^3.4.1", 57 | "vitest": "^3.0.5", 58 | "workbox-sw": "^7.3.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/hooks/dom.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | const mediaQuery = '(prefers-color-scheme: dark)'; 4 | 5 | export const usePrefersColorScheme = () => { 6 | const [colorScheme, setColorScheme] = useState<'light' | 'dark'>( 7 | window.matchMedia(mediaQuery).matches ? 'dark' : 'light' 8 | ); 9 | 10 | useEffect(() => { 11 | const media = window.matchMedia(mediaQuery); 12 | const f = ({ matches }: MediaQueryListEvent) => 13 | setColorScheme(matches ? 'dark' : 'light'); 14 | media.addEventListener('change', f); 15 | return () => media.removeEventListener('change', f); 16 | }); 17 | 18 | return colorScheme; 19 | }; 20 | 21 | export const useBoundingClientRect = () => { 22 | const ref = useRef(null); 23 | const [boundingClientRect, setBoundingClientRect] = useState( 24 | ref.current?.getBoundingClientRect() 25 | ); 26 | 27 | useEffect(() => { 28 | const observer = new ResizeObserver(() => 29 | setBoundingClientRect(ref.current?.getBoundingClientRect()) 30 | ); 31 | if (typeof ref.current !== 'undefined' && ref.current !== null) 32 | observer.observe(ref.current); 33 | 34 | const f = () => setBoundingClientRect(ref.current?.getBoundingClientRect()); 35 | window.addEventListener('scroll', f); 36 | 37 | return () => { 38 | window.removeEventListener('scroll', f); 39 | observer.disconnect(); 40 | }; 41 | }, []); 42 | 43 | return [ref, boundingClientRect] as const; 44 | }; 45 | 46 | export const useWindowSize = () => { 47 | const [windowSize, setWindowSize] = useState({ 48 | width: window.innerWidth, 49 | height: window.innerHeight 50 | }); 51 | 52 | useEffect(() => { 53 | const f = () => 54 | setWindowSize({ 55 | width: window.innerWidth, 56 | height: window.innerHeight 57 | }); 58 | window.addEventListener('resize', f); 59 | return () => window.removeEventListener('resize', f); 60 | }, []); 61 | 62 | return windowSize; 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/ui/ThemeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import type { FC } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { MdRadioButtonChecked, MdRadioButtonUnchecked } from 'react-icons/md'; 5 | import { useStore } from '../../contexts/store'; 6 | import Auto from '../../images/theme/auto.svg?react'; 7 | import Dark from '../../images/theme/dark.svg?react'; 8 | import Light from '../../images/theme/light.svg?react'; 9 | import type { AppConfig } from '../../lib/config'; 10 | 11 | const themeItems: { 12 | id: AppConfig['theme']; 13 | image: FC>; 14 | }[] = [ 15 | { id: 'auto', image: Auto }, 16 | { id: 'light', image: Light }, 17 | { id: 'dark', image: Dark } 18 | ]; 19 | 20 | export const ThemeSwitcher: FC = () => { 21 | const [{ appConfig }, dispatch] = useStore(); 22 | const { t } = useTranslation(); 23 | 24 | return ( 25 |
26 | {themeItems.map(item => ( 27 | 47 | ))} 48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/Settings.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import type { IconType } from 'react-icons'; 4 | import { IoMdDesktop } from 'react-icons/io'; 5 | import { MdChecklist, MdInfo } from 'react-icons/md'; 6 | import { useStore } from '../contexts/store'; 7 | import type { AppState } from '../lib/store/state'; 8 | import { About } from './About'; 9 | import { AppearanceSettings } from './AppearanceSettings'; 10 | import { RuleSettings } from './RuleSettings'; 11 | import { MenuTab } from './ui/MenuTab'; 12 | 13 | const tabItems: { 14 | id: AppState['currentSettingsTab']; 15 | icon: IconType; 16 | }[] = [ 17 | { id: 'rule', icon: MdChecklist }, 18 | { id: 'appearance', icon: IoMdDesktop }, 19 | { id: 'about', icon: MdInfo } 20 | ]; 21 | 22 | export const Settings: FC = () => { 23 | const [{ currentSettingsTab }, dispatch] = useStore(); 24 | const { t } = useTranslation(); 25 | return ( 26 |
27 |
28 | ( 30 |
31 |
32 | 33 |
34 |
{t(`settings.${item.id}`)}
35 |
36 | ))} 37 | index={tabItems.findIndex(item => item.id === currentSettingsTab)} 38 | onSetIndex={i => 39 | dispatch({ 40 | type: 'set-current-settings-tab', 41 | payload: (tabItems[i] as (typeof tabItems)[number]).id 42 | }) 43 | } 44 | /> 45 |
46 |
47 | {currentSettingsTab === 'rule' && } 48 | {currentSettingsTab === 'appearance' && } 49 | {currentSettingsTab === 'about' && } 50 |
51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/Shanten.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { useStore } from '../contexts/store'; 4 | import { type TileAvailability, tilesToCounts } from '../lib/tile'; 5 | import { countBy, sumBy } from '../lib/util'; 6 | import { TileButton } from './ui/TileButton'; 7 | 8 | interface ShantenProps { 9 | shanten: number; 10 | tileAvailabilities: TileAvailability[]; 11 | } 12 | 13 | export const Shanten: FC = ({ shanten, tileAvailabilities }) => { 14 | const [{ inputFocus }, dispatch] = useStore(); 15 | const { t } = useTranslation(); 16 | 17 | const ta = tileAvailabilities.filter(a => a.count > 0); 18 | const ty = countBy(tilesToCounts(ta.map(a => a.tile)), c => c > 0); 19 | const n = sumBy(tileAvailabilities, a => a.count); 20 | 21 | return ( 22 |
23 |
24 |
25 | {t('result.shanten', { count: shanten })} 26 |
27 |
28 | {t('result.type', { count: ty })} {t('result.tile', { count: n })} 29 |
30 |
31 |
32 | {ta.map((a, i) => ( 33 |
34 |
35 | { 38 | const focus = inputFocus.type !== 'hand' ? inputFocus : null; 39 | if (focus !== null) 40 | dispatch({ 41 | type: 'set-input-focus', 42 | payload: { type: 'hand' } 43 | }); 44 | dispatch({ type: 'click-tile-keyboard', payload: a.tile }); 45 | if (focus !== null) 46 | dispatch({ type: 'set-input-focus', payload: focus }); 47 | }} 48 | /> 49 |
50 |
× {a.count}
51 |
52 | ))} 53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/About.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import type React from 'react'; 3 | import { Trans, useTranslation } from 'react-i18next'; 4 | import { FaGithub } from 'react-icons/fa'; 5 | import packageJSON from '../../package.json'; 6 | 7 | declare global { 8 | const COMMIT_HASH: string; 9 | } 10 | 11 | const Link: FC<{ href: string; children?: React.ReactNode }> = ({ 12 | href, 13 | children 14 | }) => ( 15 | 21 | {children} 22 | 23 | ); 24 | 25 | export const About: FC = () => { 26 | const { t, i18n } = useTranslation(); 27 | return ( 28 |
29 |
30 |

{t('header.title')}

31 |
32 |

33 | {packageJSON.version} 34 |

35 |

36 | {COMMIT_HASH} 37 |

38 |
39 |
40 |
41 | 42 |
43 | 44 | GitHub 45 |
46 | 47 |
48 |

49 | © livewing.net 50 |

51 |

52 | 53 | The MIT License 54 | 55 |

56 |

57 | 58 | {''} 59 | 60 | 61 | 62 |

63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /src/components/tile/images/dark/3z.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/dark/4z.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/light/3z.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/light/4z.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ui/Stepper.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import type { FC } from 'react'; 3 | import { MdAdd, MdRefresh, MdRemove } from 'react-icons/md'; 4 | 5 | interface StepperButtonProps { 6 | children?: React.ReactNode; 7 | disabled?: boolean; 8 | onClick?: () => void; 9 | } 10 | const StepperButton: FC = ({ 11 | children, 12 | disabled = false, 13 | onClick = () => void 0 14 | }) => ( 15 | 23 | ); 24 | 25 | interface StepperProps { 26 | value?: number; 27 | defaultValue?: number; 28 | canDecrement?: boolean; 29 | canIncrement?: boolean; 30 | onChange?: (value: number) => void; 31 | } 32 | export const Stepper: FC = ({ 33 | value = 0, 34 | defaultValue = 0, 35 | canDecrement = true, 36 | canIncrement = true, 37 | onChange = () => void 0 38 | }) => ( 39 |
40 | onChange(value - 1)}> 41 | 42 | 43 |
44 | {value !== defaultValue && ( 45 | 52 | )} 53 | {value} 54 |
55 | onChange(value + 1)}> 56 | 57 | 58 |
59 | ); 60 | -------------------------------------------------------------------------------- /src/components/ui/TileColorSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import type { FC } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { MdRadioButtonChecked, MdRadioButtonUnchecked } from 'react-icons/md'; 5 | import { useStore } from '../../contexts/store'; 6 | import AutoInverted from '../../images/tile-color/auto-inverted.svg?react'; 7 | import Auto from '../../images/tile-color/auto.svg?react'; 8 | import Dark from '../../images/tile-color/dark.svg?react'; 9 | import Light from '../../images/tile-color/light.svg?react'; 10 | import type { AppConfig } from '../../lib/config'; 11 | 12 | const themeItems: { 13 | id: AppConfig['tileColor']; 14 | image: FC>; 15 | }[] = [ 16 | { id: 'auto', image: Auto }, 17 | { id: 'auto-inverted', image: AutoInverted }, 18 | { id: 'light', image: Light }, 19 | { id: 'dark', image: Dark } 20 | ]; 21 | 22 | export const TileColorSwitcher: FC = () => { 23 | const [{ appConfig }, dispatch] = useStore(); 24 | const { t } = useTranslation(); 25 | 26 | return ( 27 |
28 | {themeItems.map(item => ( 29 | 51 | ))} 52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/ui/TileButton.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { useStore } from '../../contexts/store'; 3 | import { usePrefersColorScheme } from '../../hooks/dom'; 4 | import type { TileOrBack } from '../../lib/tile'; 5 | import { Tile as TileImage } from '../tile'; 6 | 7 | interface TileButtonProps { 8 | tile?: TileOrBack | undefined; 9 | dim?: boolean | undefined; 10 | disabled?: boolean | undefined; 11 | focusIndicator?: boolean | undefined; 12 | tsumoIndicator?: boolean | undefined; 13 | overlayText?: string | undefined; 14 | onClick?: (() => void) | undefined; 15 | } 16 | 17 | export const TileButton: FC = ({ 18 | tile, 19 | dim = false, 20 | disabled = false, 21 | focusIndicator = false, 22 | tsumoIndicator = false, 23 | overlayText = '', 24 | onClick 25 | }) => { 26 | const [ 27 | { 28 | appConfig: { theme } 29 | } 30 | ] = useStore(); 31 | const systemColor = usePrefersColorScheme(); 32 | const appColor = theme === 'auto' ? systemColor : theme; 33 | 34 | return ( 35 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/lib/input.ts: -------------------------------------------------------------------------------- 1 | import type { Rule } from './rule'; 2 | import type { NumberTile, Tile } from './tile'; 3 | 4 | export interface Pon { 5 | type: 'pon'; 6 | tile: Tile | null; 7 | } 8 | 9 | export interface Chii { 10 | type: 'chii'; 11 | tile: NumberTile | null; 12 | includeRed: boolean; 13 | } 14 | 15 | export interface Kan { 16 | type: 'kan'; 17 | tile: Tile | null; 18 | closed: boolean; 19 | } 20 | 21 | export type Meld = Pon | Chii | Kan; 22 | 23 | export interface Input { 24 | hand: Tile[]; 25 | melds: Meld[]; 26 | dora: Tile[]; 27 | } 28 | 29 | export const instantiateMeld = (meld: Meld, red: Rule['red']): Tile[] => { 30 | const { type, tile } = meld; 31 | if (tile === null) return []; 32 | 33 | switch (type) { 34 | case 'pon': 35 | if (tile.type !== 'z' && tile.n === 5) { 36 | const r = red[tile.type]; 37 | return [ 38 | ...[ 39 | ...Array(tile.red ? Math.max(0, 4 - r - 1) : Math.min(3, 4 - r)) 40 | ].map(() => ({ ...tile, red: false })), 41 | ...[...Array(tile.red ? Math.min(3, r) : Math.max(0, r - 1))].map( 42 | () => ({ ...tile, red: true }) 43 | ) 44 | ]; 45 | } 46 | return [...Array(3)].map(() => tile); 47 | case 'chii': { 48 | if (tile.n >= 8) throw new Error(); 49 | const r = 50 | (meld.includeRed && red[tile.type] >= 1) || red[tile.type] === 4; 51 | return [ 52 | tile, 53 | tile.n + 1 === 5 54 | ? { ...tile, n: 5, red: r } 55 | : ({ ...tile, n: tile.n + 1 } as NumberTile), 56 | (tile.n + 2 === 5 57 | ? { ...tile, n: 5, red: r } 58 | : { ...tile, n: tile.n + 2 }) as NumberTile 59 | ]; 60 | } 61 | case 'kan': 62 | if (tile.type !== 'z' && tile.n === 5) { 63 | const r = red[tile.type]; 64 | return [ 65 | ...[...Array(4 - r)].map(() => ({ ...tile, red: false })), 66 | ...[...Array(r)].map(() => ({ ...tile, red: true })) 67 | ]; 68 | } 69 | return [...Array(4)].map(() => tile); 70 | } 71 | }; 72 | 73 | export type InputFocus = 74 | | { type: 'hand' } 75 | | { type: 'dora' } 76 | | { type: 'meld'; i: number }; 77 | 78 | export interface HandOptions { 79 | riichi: 'none' | 'riichi' | 'double-riichi'; 80 | ippatsu: boolean; 81 | rinshan: boolean; 82 | chankan: boolean; 83 | haitei: boolean; 84 | tenho: boolean; 85 | } 86 | -------------------------------------------------------------------------------- /src/components/tile/images/dark/1z.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/light/1z.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/YakuList.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import type { FC } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { useStore } from '../contexts/store'; 5 | import { yakumanTupleKey } from '../lib/score'; 6 | import { sumBy } from '../lib/util'; 7 | import type { Yaku } from '../lib/yaku'; 8 | 9 | interface YakuBadgeProps { 10 | l?: React.ReactNode; 11 | r?: React.ReactNode; 12 | yakuman?: boolean; 13 | } 14 | const YakuBadge: FC = ({ l, r, yakuman = false }) => ( 15 |
16 |
17 | {l} 18 |
19 |
26 | {r} 27 |
28 |
29 | ); 30 | 31 | interface YakuListProps { 32 | yaku: Yaku[]; 33 | } 34 | export const YakuList: FC = ({ yaku }) => { 35 | const [ 36 | { 37 | appConfig: { showBazoro } 38 | } 39 | ] = useStore(); 40 | const { t } = useTranslation(); 41 | 42 | const isYakuman = yaku.some(y => y.type === 'yakuman'); 43 | 44 | return ( 45 |
46 | {isYakuman &&
{t('yaku.yaku')}
} 47 | {!isYakuman && ( 48 |
49 | {t('yaku.yaku')} ·{' '} 50 | {t('result.han', { 51 | count: 52 | sumBy(yaku, y => (y.type === 'yaku' ? y.han : 0)) + 53 | (showBazoro ? 2 : 0) 54 | })} 55 |
56 | )} 57 | {(showBazoro || yaku.length > 0) && ( 58 |
59 | {yaku.map(y => ( 60 | 70 | ))} 71 | {showBazoro && !isYakuman && ( 72 | 73 | )} 74 |
75 | )} 76 | {!showBazoro && yaku.length === 0 && ( 77 |
{t('result.no-yaku')}
78 | )} 79 |
80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /src/lib/util.ts: -------------------------------------------------------------------------------- 1 | export function assertNonNullable(x: T | undefined | null): asserts x is T { 2 | if (typeof x === 'undefined' || x === null) 3 | throw new Error('NonNullable assertion failed!'); 4 | } 5 | 6 | export const product2 = (a: T[], b: U[]): [T, U][] => { 7 | return a 8 | .map(a => b.map(b => [a, b])) 9 | .reduce((acc, cur) => [...acc, ...cur], []) as [T, U][]; 10 | }; 11 | 12 | export const memoize =

( 13 | f: (...params: P) => R, 14 | serialize: (...params: P) => string 15 | ): ((...params: P) => R) => { 16 | const memo: { [key: string]: R } = {}; 17 | return (...params: P) => { 18 | const serialized = serialize(...params); 19 | if (typeof memo[serialized] === 'undefined') 20 | memo[serialized] = f(...params); 21 | return memo[serialized] as R; 22 | }; 23 | }; 24 | 25 | export const uniqueSorted = ( 26 | a: T[], 27 | compare: (a: T, b: T) => boolean 28 | ): T[] => { 29 | if (a.length === 0) return []; 30 | 31 | const [first, ...rest] = a; 32 | assertNonNullable(first); 33 | return rest.reduce( 34 | (acc, cur) => 35 | compare(acc[acc.length - 1] as T, cur) ? acc : [...acc, cur], 36 | [first] 37 | ); 38 | }; 39 | 40 | export const groupBy = ( 41 | a: T[], 42 | f: (e: T, i: number) => U | null 43 | ) => 44 | a.reduce( 45 | (acc, cur, i) => { 46 | const key = f(cur, i); 47 | if (key === null) return acc; 48 | return { ...acc, [key]: [...(acc[key] ?? []), cur] }; 49 | }, 50 | {} as { [_ in U]?: T[] } 51 | ); 52 | 53 | export const countBy = (a: T[], f: (e: T, i: number) => boolean) => 54 | a.reduce((acc, cur, i) => acc + (f(cur, i) ? 1 : 0), 0); 55 | 56 | export const countGroupBy = ( 57 | a: T[], 58 | f: (e: T, i: number) => U | null 59 | ) => 60 | a.reduce( 61 | (acc, cur, i) => { 62 | const key = f(cur, i); 63 | if (key === null) return acc; 64 | return { ...acc, [key]: (acc[key] ?? 0) + 1 }; 65 | }, 66 | {} as { [_ in U]?: number } 67 | ); 68 | 69 | export const sumBy = ( 70 | a: T[], 71 | f: (e: T, i: number) => number, 72 | initialValue = 0 73 | ) => a.reduce((acc, cur, i) => acc + f(cur, i), initialValue); 74 | 75 | export const minsBy = (a: T[], f: (e: T, i: number) => number) => 76 | a.reduce( 77 | (acc, cur, i) => { 78 | const c = f(cur, i); 79 | if (c < acc[0]) return [c, [cur]] as [number, T[]]; 80 | if (c === acc[0]) return [acc[0], [...acc[1], cur]] as [number, T[]]; 81 | return acc; 82 | }, 83 | [Number.POSITIVE_INFINITY, []] as [number, T[]] 84 | ); 85 | 86 | export const shuffle = (a: T[]) => { 87 | const ret = [...a]; 88 | for (let i = ret.length - 1; i > 0; i--) { 89 | const j = Math.floor(Math.random() * (i + 1)); 90 | [ret[i], ret[j]] = [ret[j] as T, ret[i] as T]; 91 | } 92 | return ret; 93 | }; 94 | 95 | export const unreachable = (message?: string) => { 96 | throw new Error(message ?? 'Entered unreachable code'); 97 | }; 98 | -------------------------------------------------------------------------------- /src/components/PointDiff.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { useStore } from '../contexts/store'; 4 | import { sumOfFu } from '../lib/fu'; 5 | import type { Hora } from '../lib/hora'; 6 | import { calculateBasePoint, ceil100 } from '../lib/score'; 7 | import { sumBy } from '../lib/util'; 8 | 9 | interface PointDiffProps { 10 | info: Hora; 11 | } 12 | 13 | export const PointDiff: FC = ({ info }) => { 14 | const [ 15 | { 16 | currentRule: { 17 | roundedMangan, 18 | accumlatedYakuman, 19 | multipleYakuman, 20 | honbaBonus 21 | }, 22 | table 23 | } 24 | ] = useStore(); 25 | const { t } = useTranslation(); 26 | 27 | if (info.yaku.every(y => y.name === 'dora' || y.name === 'red-dora')) 28 | return null; 29 | 30 | const base = 31 | info.type === 'kokushi' || info.yaku.some(y => y.type === 'yakuman') 32 | ? info.yaku.reduce((acc, cur) => { 33 | const p = cur.type === 'yakuman' ? cur.point : 0; 34 | if (multipleYakuman) return acc + p; 35 | return Math.max(acc, p); 36 | }, 0) * 8000 37 | : info.yaku.every(y => y.name === 'dora' || y.name === 'red-dora') 38 | ? 0 39 | : calculateBasePoint( 40 | info.type === 'mentsu' ? sumOfFu(info.fu) : 25, 41 | sumBy(info.yaku, y => (y.type === 'yaku' ? y.han : 0)), 42 | roundedMangan, 43 | accumlatedYakuman 44 | ); 45 | const isDealer = table.seat === 'east'; 46 | 47 | return ( 48 |

49 |
{t('result.point-diff')}
50 | {info.by === 'ron' && ( 51 |
52 | {t('result.point', { 53 | count: 54 | (ceil100(base * (isDealer ? 6 : 4)) + 55 | table.continue * 3 * honbaBonus) * 56 | 2 + 57 | 1000 * table.deposit 58 | })} 59 |
60 | )} 61 | {info.by === 'tsumo' && isDealer && ( 62 |
63 | {t('result.point', { 64 | count: 65 | (ceil100(base * 2) + table.continue * honbaBonus) * 4 + 66 | 1000 * table.deposit 67 | })} 68 |
69 | )} 70 | {info.by === 'tsumo' && !isDealer && ( 71 | <> 72 |
73 | {t('result.non-dealer-diff', { 74 | count: 75 | ceil100(base) * 3 + 76 | ceil100(base * 2) + 77 | table.continue * honbaBonus * 4 + 78 | 1000 * table.deposit 79 | })} 80 |
81 |
82 | {t('result.dealer-diff', { 83 | count: 84 | ceil100(base) * 2 + 85 | ceil100(base * 2) * 2 + 86 | table.continue * honbaBonus * 4 + 87 | 1000 * table.deposit 88 | })} 89 |
90 | 91 | )} 92 |
93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /wasm/decomposer/src/data.rs: -------------------------------------------------------------------------------- 1 | use crate::counts::{Counts, PackedNumberCounts}; 2 | use serde::Serialize; 3 | 4 | #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] 5 | pub enum BlockType { 6 | #[serde(rename = "kotsu")] 7 | Kotsu, 8 | #[serde(rename = "shuntsu")] 9 | Shuntsu, 10 | #[serde(rename = "ryammen")] 11 | Ryammen, 12 | #[serde(rename = "penchan")] 13 | Penchan, 14 | #[serde(rename = "kanchan")] 15 | Kanchan, 16 | #[serde(rename = "toitsu")] 17 | Toitsu, 18 | } 19 | 20 | #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] 21 | pub struct Block { 22 | #[serde(rename = "type")] 23 | pub block_type: BlockType, 24 | pub tile: u8, 25 | } 26 | 27 | #[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] 28 | pub struct NumberDecomposeResult { 29 | pub rest: PackedNumberCounts, 30 | pub blocks: Vec, 31 | } 32 | 33 | impl NumberDecomposeResult { 34 | pub fn into_decompose_result(self, t: u8) -> DecomposeResult { 35 | DecomposeResult { 36 | rest: { 37 | let mut counts = Counts::new(); 38 | match t { 39 | 0 => counts.m = self.rest, 40 | 1 => counts.p = self.rest, 41 | 2 => counts.s = self.rest, 42 | _ => unreachable!(), 43 | } 44 | counts 45 | }, 46 | blocks: self 47 | .blocks 48 | .into_iter() 49 | .map(|Block { block_type, tile }| Block { 50 | block_type, 51 | tile: tile + 9 * t, 52 | }) 53 | .collect(), 54 | } 55 | } 56 | } 57 | 58 | #[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] 59 | pub struct DecomposeResult { 60 | pub rest: Counts, 61 | pub blocks: Vec, 62 | } 63 | 64 | impl DecomposeResult { 65 | pub fn shanten(&self, meld: i32) -> i32 { 66 | let mut kotsu = 0; 67 | let mut shuntsu = 0; 68 | let mut ryammen = 0; 69 | let mut penchan = 0; 70 | let mut kanchan = 0; 71 | let mut toitsu = 0; 72 | for b in &self.blocks { 73 | match b.block_type { 74 | BlockType::Kotsu => kotsu += 1, 75 | BlockType::Shuntsu => shuntsu += 1, 76 | BlockType::Ryammen => ryammen += 1, 77 | BlockType::Penchan => penchan += 1, 78 | BlockType::Kanchan => kanchan += 1, 79 | BlockType::Toitsu => toitsu += 1, 80 | } 81 | } 82 | 83 | let mentsu = kotsu + shuntsu + meld; 84 | let tatsu_blocks = ryammen + penchan + kanchan + toitsu; 85 | let tatsu = if mentsu + tatsu_blocks > 4 { 86 | 4 - mentsu 87 | } else { 88 | tatsu_blocks 89 | }; 90 | let has_toitsu = mentsu + tatsu_blocks > 4 && toitsu > 0; 91 | 92 | 8 - mentsu * 2 - tatsu - if has_toitsu { 1 } else { 0 } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/lib/tile/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { 3 | type Tile, 4 | type TileCounts, 5 | countsIndexToTile, 6 | tileToCountsIndex, 7 | tilesToCounts 8 | } from '.'; 9 | 10 | describe('tileToCountsIndex', () => { 11 | test('1m', () => { 12 | expect(tileToCountsIndex({ type: 'm', n: 1 })).toBe(0); 13 | }); 14 | test('2p', () => { 15 | expect(tileToCountsIndex({ type: 'p', n: 2 })).toBe(10); 16 | }); 17 | test('3s', () => { 18 | expect(tileToCountsIndex({ type: 's', n: 3 })).toBe(20); 19 | }); 20 | test('4z', () => { 21 | expect(tileToCountsIndex({ type: 'z', n: 4 })).toBe(30); 22 | }); 23 | }); 24 | 25 | describe('countsIndexToTile', () => { 26 | test('0', () => { 27 | expect(countsIndexToTile(0)).toEqual({ type: 'm', n: 1 }); 28 | }); 29 | test('4', () => { 30 | expect(countsIndexToTile(4)).toEqual({ type: 'm', n: 5, red: false }); 31 | }); 32 | test('10', () => { 33 | expect(countsIndexToTile(10)).toEqual({ type: 'p', n: 2 }); 34 | }); 35 | test('20', () => { 36 | expect(countsIndexToTile(20)).toEqual({ type: 's', n: 3 }); 37 | }); 38 | test('30', () => { 39 | expect(countsIndexToTile(30)).toEqual({ type: 'z', n: 4 }); 40 | }); 41 | test('33', () => { 42 | expect(countsIndexToTile(33)).toEqual({ type: 'z', n: 7 }); 43 | }); 44 | }); 45 | 46 | describe('tilesToCounts', () => { 47 | test('empty', () => { 48 | expect(tilesToCounts([])).toEqual( 49 | // biome-ignore format: 50 | [ 51 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 52 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 53 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 54 | 0, 0, 0, 0, 0, 0, 0 55 | ] 56 | ); 57 | }); 58 | test('1m0p9s17z', () => { 59 | expect( 60 | tilesToCounts([ 61 | { type: 'm', n: 1 }, 62 | { type: 'p', n: 5, red: true }, 63 | { type: 's', n: 9 }, 64 | { type: 'z', n: 1 }, 65 | { type: 'z', n: 7 } 66 | ]) 67 | ).toEqual( 68 | // biome-ignore format: 69 | [ 70 | 1, 0, 0, 0, 0, 0, 0, 0, 0, 71 | 0, 0, 0, 0, 1, 0, 0, 0, 0, 72 | 0, 0, 0, 0, 0, 0, 0, 0, 1, 73 | 1, 0, 0, 0, 0, 0, 1 74 | ] 75 | ); 76 | }); 77 | test('1111m', () => { 78 | expect( 79 | tilesToCounts([ 80 | { type: 'm', n: 1 }, 81 | { type: 'm', n: 1 }, 82 | { type: 'm', n: 1 }, 83 | { type: 'm', n: 1 } 84 | ]) 85 | ).toEqual( 86 | // biome-ignore format: 87 | [ 88 | 4, 0, 0, 0, 0, 0, 0, 0, 0, 89 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 90 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 91 | 0, 0, 0, 0, 0, 0, 0 92 | ] 93 | ); 94 | }); 95 | test('11111m', () => { 96 | expect(() => 97 | tilesToCounts([ 98 | { type: 'm', n: 1 }, 99 | { type: 'm', n: 1 }, 100 | { type: 'm', n: 1 }, 101 | { type: 'm', n: 1 }, 102 | { type: 'm', n: 1 } 103 | ]) 104 | ).toThrow(); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/components/tile/images/dark/2z.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/light/2z.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | tags: 8 | - 'v*' 9 | pull_request: 10 | branches: 11 | - '**' 12 | 13 | jobs: 14 | build: 15 | name: Build 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - name: Install Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: latest 24 | - name: Install Rust 25 | run: | 26 | rustup install stable 27 | - name: Install wasm-pack 28 | uses: taiki-e/install-action@wasm-pack 29 | - name: Prepare cache 30 | id: cache 31 | uses: actions/cache@v4 32 | with: 33 | path: node_modules 34 | key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 35 | - name: Install dependencies 36 | if: steps.cache.outputs.cache-hit != 'true' 37 | run: npm ci 38 | - name: Build wasm 39 | run: npm run build:wasm 40 | - name: Build app 41 | run: npm run build 42 | - name: Test 43 | run: npm test 44 | - name: Lint 45 | run: npm run lint 46 | - name: Archive production artifacts 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: dist 50 | path: dist 51 | staging: 52 | name: Staging 53 | needs: build 54 | runs-on: ubuntu-latest 55 | environment: 56 | name: ${{ github.event_name == 'pull_request' && 'Pull Request' || 'Staging' }} 57 | url: ${{ steps.deploy.outputs.NETLIFY_URL }} 58 | steps: 59 | - name: Download production artifacts 60 | uses: actions/download-artifact@v4 61 | with: 62 | name: dist 63 | path: dist 64 | - name: Deploy to Netlify 65 | id: deploy 66 | uses: netlify/actions/cli@master 67 | with: 68 | args: deploy --dir=dist 69 | env: 70 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 71 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 72 | deploy: 73 | name: Deploy 74 | if: startsWith(github.ref, 'refs/tags/v') 75 | needs: build 76 | runs-on: ubuntu-latest 77 | permissions: 78 | id-token: write 79 | contents: read 80 | environment: 81 | name: Production 82 | url: https://mahjong-calc.livewing.net/ 83 | steps: 84 | - name: Download production artifacts 85 | uses: actions/download-artifact@v4 86 | with: 87 | name: dist 88 | path: dist 89 | - name: Configure AWS Credentials 90 | uses: aws-actions/configure-aws-credentials@v4 91 | with: 92 | role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_DEPLOY_ROLE }} 93 | role-session-name: GitHubActions 94 | aws-region: ap-northeast-1 95 | - name: Deploy to S3 96 | run: aws s3 sync --exact-timestamp --delete dist/ ${{ secrets.AWS_S3_BUCKET_NAME }} 97 | - name: Invalidate CloudFront cache 98 | run: aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_CLOUDFRONT_DISTRIBUTION_ID }} --paths '/*' 99 | -------------------------------------------------------------------------------- /src/components/KeyboardHelp.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { formatKeys } from '../lib/os'; 4 | import { Tile } from './tile'; 5 | import { Button } from './ui/Button'; 6 | 7 | interface KeyboardHelpProps { 8 | onClose?: () => void; 9 | } 10 | 11 | export const KeyboardHelp: FC = ({ 12 | onClose = () => void 0 13 | }) => { 14 | const { t } = useTranslation(); 15 | return ( 16 |
17 | 64 |
65 |
66 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /src/components/Calculator.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { MdNavigateNext } from 'react-icons/md'; 4 | import { useStore } from '../contexts/store'; 5 | import { useBoundingClientRect } from '../hooks/dom'; 6 | import { generateResult } from '../lib/result'; 7 | import { compareRules } from '../lib/rule'; 8 | import { HandOptions } from './HandOptions'; 9 | import { InputGlance } from './InputGlance'; 10 | import { Result } from './Result'; 11 | import { ResultGlance } from './ResultGlance'; 12 | import { TableSettings } from './TableSettings'; 13 | import { Button } from './ui/Button'; 14 | import { ConfigItem } from './ui/ConfigItem'; 15 | import { TileInput } from './ui/TileInput'; 16 | 17 | export const Calculator: FC = () => { 18 | const [ruleRef, ruleRect] = useBoundingClientRect(); 19 | const [tableRef, tableRect] = useBoundingClientRect(); 20 | const [handOptionsRef, handOptionsRect] = 21 | useBoundingClientRect(); 22 | const [{ currentRule, savedRules, table, input, handOptions }, dispatch] = 23 | useStore(); 24 | const { t } = useTranslation(); 25 | 26 | const ruleName = 27 | Object.entries(savedRules).find(([, r]) => 28 | compareRules(r, currentRule) 29 | )?.[0] ?? t('settings.untitled-rule'); 30 | 31 | const result = generateResult(table, input, handOptions, currentRule); 32 | 33 | return ( 34 | <> 35 |
36 |
37 | 38 | 49 | 50 |
54 | 55 |
59 | 60 |
61 | 62 |
63 |
67 |
68 | 69 |
70 |
71 | 76 | 77 | 78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /src/components/TableSettings.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { useStore } from '../contexts/store'; 4 | import Stick100 from '../images/point-stick/100.svg?react'; 5 | import Stick1000 from '../images/point-stick/1000.svg?react'; 6 | import type { Wind } from '../lib/table'; 7 | import { ConfigItem } from './ui/ConfigItem'; 8 | import { Segment } from './ui/Segment'; 9 | import { Stepper } from './ui/Stepper'; 10 | 11 | const winds: Wind[] = ['east', 'south', 'west', 'north']; 12 | 13 | export const TableSettings: FC = () => { 14 | const [{ table }, dispatch] = useStore(); 15 | const { t } = useTranslation(); 16 | 17 | return ( 18 |
19 |
20 |
21 | 22 | t(`table-settings.${w}`))} 24 | index={winds.indexOf(table.round)} 25 | onChange={i => 26 | dispatch({ 27 | type: 'set-table', 28 | payload: { ...table, round: winds[i] as Wind } 29 | }) 30 | } 31 | /> 32 | 33 |
34 |
35 | 36 | t(`table-settings.${w}`))} 38 | index={winds.indexOf(table.seat)} 39 | onChange={i => 40 | dispatch({ 41 | type: 'set-table', 42 | payload: { ...table, seat: winds[i] as Wind } 43 | }) 44 | } 45 | /> 46 | 47 |
48 |
49 |
50 |
51 | 54 | {t('table-settings.deposit')} 55 | 56 |
57 | } 58 | > 59 | 0} 62 | onChange={value => 63 | dispatch({ 64 | type: 'set-table', 65 | payload: { ...table, deposit: value } 66 | }) 67 | } 68 | /> 69 | 70 |
71 |
72 | 75 | {t('table-settings.continue')} 76 | 77 |
78 | } 79 | > 80 | 0} 83 | onChange={value => 84 | dispatch({ 85 | type: 'set-table', 86 | payload: { ...table, continue: value } 87 | }) 88 | } 89 | /> 90 | 91 |
92 |
93 |
94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /src/components/AppearanceSettings.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useState } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { MdCheck } from 'react-icons/md'; 4 | import { useStore } from '../contexts/store'; 5 | import { getResources } from '../lib/i18n'; 6 | import { Checkbox } from './ui/Checkbox'; 7 | import { ConfigItem } from './ui/ConfigItem'; 8 | import { Dropdown } from './ui/Dropdown'; 9 | import { ThemeSwitcher } from './ui/ThemeSwitcher'; 10 | import { TileColorSwitcher } from './ui/TileColorSwitcher'; 11 | 12 | const languages = Object.keys(getResources()); 13 | 14 | export const AppearanceSettings: FC = () => { 15 | const [{ appConfig }, dispatch] = useStore(); 16 | const [openLanguageMenu, setOpenLanguageMenu] = useState(false); 17 | const { t, i18n } = useTranslation(); 18 | 19 | return ( 20 |
21 | 22 | 26 |
{t('locale:name')}
27 |
28 | {i18n.resolvedLanguage} 29 |
30 |
31 | } 32 | open={openLanguageMenu} 33 | onSetOpen={setOpenLanguageMenu} 34 | > 35 |
36 | {languages.map(lng => ( 37 | 64 | ))} 65 |
66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 79 | dispatch({ 80 | type: 'set-app-config', 81 | payload: { ...appConfig, showBazoro } 82 | }) 83 | } 84 | > 85 | {t('settings.show-bazoro')} 86 | 87 | 88 |
89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /src/components/ui/ScoringTableHeader.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useState } from 'react'; 2 | import type React from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { useStore } from '../../contexts/store'; 5 | import Stick100 from '../../images/point-stick/100.svg?react'; 6 | import Stick1000 from '../../images/point-stick/1000.svg?react'; 7 | import { SimpleStepper } from './SimpleStepper'; 8 | 9 | const HeaderButton: FC<{ 10 | children?: React.ReactNode; 11 | active?: boolean; 12 | onClick?: () => void; 13 | }> = ({ children, active = false, onClick }) => ( 14 | 25 | ); 26 | 27 | export const ScoringTableHeader: FC = () => { 28 | const [{ currentScoringTableTab, table }, dispatch] = useStore(); 29 | const [openTableSettings, setOpenTableSettings] = useState(false); 30 | const { t } = useTranslation(); 31 | 32 | const isDealer = table.seat === 'east'; 33 | 34 | return ( 35 |
36 | 38 | dispatch({ 39 | type: 'set-table', 40 | payload: { 41 | ...table, 42 | seat: table.seat === 'east' ? 'south' : 'east' 43 | } 44 | }) 45 | } 46 | > 47 | {t(isDealer ? 'scoring-table.dealer' : 'scoring-table.non-dealer')} 48 | 49 |
50 | setOpenTableSettings(o => !o)} 53 | > 54 |
55 | {currentScoringTableTab === 'diff' && ( 56 |
57 | 58 |
{table.deposit}
59 |
60 | )} 61 |
62 | 63 |
{table.continue}
64 |
65 |
66 |
67 | {openTableSettings && ( 68 |
69 | {currentScoringTableTab === 'diff' && ( 70 | 0} 72 | onChange={d => 73 | dispatch({ 74 | type: 'set-table', 75 | payload: { ...table, deposit: table.deposit + d } 76 | }) 77 | } 78 | /> 79 | )} 80 | 0} 82 | onChange={d => 83 | dispatch({ 84 | type: 'set-table', 85 | payload: { ...table, continue: table.continue + d } 86 | }) 87 | } 88 | /> 89 |
90 | )} 91 |
92 |
93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /src/components/tile/images/dark/2p.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/light/2p.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useEffect, useState } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { MdHelp, MdSettings, MdTableView, MdUpdate } from 'react-icons/md'; 4 | import { useStore } from '../contexts/store'; 5 | 6 | const buttonClasses = { 7 | default: 'flex items-center gap-1 p-1 hover:bg-blue-500 rounded transition', 8 | active: 9 | 'flex items-center gap-1 p-1 bg-blue-500 hover:bg-blue-400 rounded transition' 10 | } as const; 11 | 12 | export const Header: FC = () => { 13 | const [showUpdateButton, setShowUpdateButton] = useState(false); 14 | const { t } = useTranslation(); 15 | const [{ currentScreen }, dispatch] = useStore(); 16 | useEffect(() => { 17 | (async () => { 18 | if ('serviceWorker' in navigator) { 19 | const registration = await navigator.serviceWorker.getRegistration(); 20 | registration?.addEventListener('updatefound', () => { 21 | setShowUpdateButton(true); 22 | }); 23 | } 24 | })(); 25 | }, []); 26 | return ( 27 |
28 | 38 |
39 | {showUpdateButton && ( 40 | 48 | )} 49 | 55 | 56 |
{t('header.help')}
57 |
58 | 76 | 93 |
94 |
95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /src/components/tile/images/dark/index.ts: -------------------------------------------------------------------------------- 1 | import type { Tile } from '../../../../lib/tile'; 2 | import { unreachable } from '../../../../lib/util'; 3 | import m0 from './0m.svg?react'; 4 | import p0 from './0p.svg?react'; 5 | import s0 from './0s.svg?react'; 6 | import m1 from './1m.svg?react'; 7 | import p1 from './1p.svg?react'; 8 | import s1 from './1s.svg?react'; 9 | import z1 from './1z.svg?react'; 10 | import m2 from './2m.svg?react'; 11 | import p2 from './2p.svg?react'; 12 | import s2 from './2s.svg?react'; 13 | import z2 from './2z.svg?react'; 14 | import m3 from './3m.svg?react'; 15 | import p3 from './3p.svg?react'; 16 | import s3 from './3s.svg?react'; 17 | import z3 from './3z.svg?react'; 18 | import m4 from './4m.svg?react'; 19 | import p4 from './4p.svg?react'; 20 | import s4 from './4s.svg?react'; 21 | import z4 from './4z.svg?react'; 22 | import m5 from './5m.svg?react'; 23 | import p5 from './5p.svg?react'; 24 | import s5 from './5s.svg?react'; 25 | import z5 from './5z.svg?react'; 26 | import m6 from './6m.svg?react'; 27 | import p6 from './6p.svg?react'; 28 | import s6 from './6s.svg?react'; 29 | import z6 from './6z.svg?react'; 30 | import m7 from './7m.svg?react'; 31 | import p7 from './7p.svg?react'; 32 | import s7 from './7s.svg?react'; 33 | import z7 from './7z.svg?react'; 34 | import m8 from './8m.svg?react'; 35 | import p8 from './8p.svg?react'; 36 | import s8 from './8s.svg?react'; 37 | import m9 from './9m.svg?react'; 38 | import p9 from './9p.svg?react'; 39 | import s9 from './9s.svg?react'; 40 | 41 | export const tileImage = (tile: Tile) => { 42 | switch (tile.type) { 43 | case 'm': 44 | switch (tile.n) { 45 | case 1: 46 | return m1; 47 | case 2: 48 | return m2; 49 | case 3: 50 | return m3; 51 | case 4: 52 | return m4; 53 | case 5: 54 | return tile.red ? m0 : m5; 55 | case 6: 56 | return m6; 57 | case 7: 58 | return m7; 59 | case 8: 60 | return m8; 61 | case 9: 62 | return m9; 63 | default: 64 | return unreachable(); 65 | } 66 | case 'p': 67 | switch (tile.n) { 68 | case 1: 69 | return p1; 70 | case 2: 71 | return p2; 72 | case 3: 73 | return p3; 74 | case 4: 75 | return p4; 76 | case 5: 77 | return tile.red ? p0 : p5; 78 | case 6: 79 | return p6; 80 | case 7: 81 | return p7; 82 | case 8: 83 | return p8; 84 | case 9: 85 | return p9; 86 | default: 87 | return unreachable(); 88 | } 89 | case 's': 90 | switch (tile.n) { 91 | case 1: 92 | return s1; 93 | case 2: 94 | return s2; 95 | case 3: 96 | return s3; 97 | case 4: 98 | return s4; 99 | case 5: 100 | return tile.red ? s0 : s5; 101 | case 6: 102 | return s6; 103 | case 7: 104 | return s7; 105 | case 8: 106 | return s8; 107 | case 9: 108 | return s9; 109 | default: 110 | return unreachable(); 111 | } 112 | case 'z': 113 | switch (tile.n) { 114 | case 1: 115 | return z1; 116 | case 2: 117 | return z2; 118 | case 3: 119 | return z3; 120 | case 4: 121 | return z4; 122 | case 5: 123 | return z5; 124 | case 6: 125 | return z6; 126 | case 7: 127 | return z7; 128 | } 129 | } 130 | }; 131 | -------------------------------------------------------------------------------- /src/components/tile/images/light/index.ts: -------------------------------------------------------------------------------- 1 | import type { Tile } from '../../../../lib/tile'; 2 | import { unreachable } from '../../../../lib/util'; 3 | import m0 from './0m.svg?react'; 4 | import p0 from './0p.svg?react'; 5 | import s0 from './0s.svg?react'; 6 | import m1 from './1m.svg?react'; 7 | import p1 from './1p.svg?react'; 8 | import s1 from './1s.svg?react'; 9 | import z1 from './1z.svg?react'; 10 | import m2 from './2m.svg?react'; 11 | import p2 from './2p.svg?react'; 12 | import s2 from './2s.svg?react'; 13 | import z2 from './2z.svg?react'; 14 | import m3 from './3m.svg?react'; 15 | import p3 from './3p.svg?react'; 16 | import s3 from './3s.svg?react'; 17 | import z3 from './3z.svg?react'; 18 | import m4 from './4m.svg?react'; 19 | import p4 from './4p.svg?react'; 20 | import s4 from './4s.svg?react'; 21 | import z4 from './4z.svg?react'; 22 | import m5 from './5m.svg?react'; 23 | import p5 from './5p.svg?react'; 24 | import s5 from './5s.svg?react'; 25 | import z5 from './5z.svg?react'; 26 | import m6 from './6m.svg?react'; 27 | import p6 from './6p.svg?react'; 28 | import s6 from './6s.svg?react'; 29 | import z6 from './6z.svg?react'; 30 | import m7 from './7m.svg?react'; 31 | import p7 from './7p.svg?react'; 32 | import s7 from './7s.svg?react'; 33 | import z7 from './7z.svg?react'; 34 | import m8 from './8m.svg?react'; 35 | import p8 from './8p.svg?react'; 36 | import s8 from './8s.svg?react'; 37 | import m9 from './9m.svg?react'; 38 | import p9 from './9p.svg?react'; 39 | import s9 from './9s.svg?react'; 40 | 41 | export const tileImage = (tile: Tile) => { 42 | switch (tile.type) { 43 | case 'm': 44 | switch (tile.n) { 45 | case 1: 46 | return m1; 47 | case 2: 48 | return m2; 49 | case 3: 50 | return m3; 51 | case 4: 52 | return m4; 53 | case 5: 54 | return tile.red ? m0 : m5; 55 | case 6: 56 | return m6; 57 | case 7: 58 | return m7; 59 | case 8: 60 | return m8; 61 | case 9: 62 | return m9; 63 | default: 64 | return unreachable(); 65 | } 66 | case 'p': 67 | switch (tile.n) { 68 | case 1: 69 | return p1; 70 | case 2: 71 | return p2; 72 | case 3: 73 | return p3; 74 | case 4: 75 | return p4; 76 | case 5: 77 | return tile.red ? p0 : p5; 78 | case 6: 79 | return p6; 80 | case 7: 81 | return p7; 82 | case 8: 83 | return p8; 84 | case 9: 85 | return p9; 86 | default: 87 | return unreachable(); 88 | } 89 | case 's': 90 | switch (tile.n) { 91 | case 1: 92 | return s1; 93 | case 2: 94 | return s2; 95 | case 3: 96 | return s3; 97 | case 4: 98 | return s4; 99 | case 5: 100 | return tile.red ? s0 : s5; 101 | case 6: 102 | return s6; 103 | case 7: 104 | return s7; 105 | case 8: 106 | return s8; 107 | case 9: 108 | return s9; 109 | default: 110 | return unreachable(); 111 | } 112 | case 'z': 113 | switch (tile.n) { 114 | case 1: 115 | return z1; 116 | case 2: 117 | return z2; 118 | case 3: 119 | return z3; 120 | case 4: 121 | return z4; 122 | case 5: 123 | return z5; 124 | case 6: 125 | return z6; 126 | case 7: 127 | return z7; 128 | } 129 | } 130 | }; 131 | -------------------------------------------------------------------------------- /src/lib/store/state.ts: -------------------------------------------------------------------------------- 1 | import type { AppConfig } from '../config'; 2 | import type { HandOptions, Input, InputFocus } from '../input'; 3 | import type { Rule } from '../rule'; 4 | import type { Table } from '../table'; 5 | 6 | export interface AppState { 7 | currentScreen: 'main' | 'scoring-table' | 'settings'; 8 | currentScoringTableTab: 'score' | 'diff'; 9 | currentSettingsTab: 'rule' | 'appearance' | 'about'; 10 | appConfig: AppConfig; 11 | savedRules: { [name: string]: Rule }; 12 | currentRule: Rule; 13 | table: Table; 14 | input: Input; 15 | inputFocus: InputFocus; 16 | handOptions: HandOptions; 17 | } 18 | 19 | export const initialState: AppState = { 20 | currentScreen: 'main', 21 | currentScoringTableTab: 'score', 22 | currentSettingsTab: 'rule', 23 | appConfig: { theme: 'auto', tileColor: 'light', showBazoro: false }, 24 | savedRules: { 25 | 'Mリーグ (M.LEAGUE)': { 26 | red: { 27 | m: 1, 28 | p: 1, 29 | s: 1 30 | }, 31 | honbaBonus: 100, 32 | roundedMangan: true, 33 | doubleWindFu: 2, 34 | accumlatedYakuman: false, 35 | multipleYakuman: true, 36 | kokushi13DoubleYakuman: false, 37 | suankoTankiDoubleYakuman: false, 38 | daisushiDoubleYakuman: false, 39 | pureChurenDoubleYakuman: false 40 | }, 41 | '天鳳 赤あり (Tenhou with red)': { 42 | red: { 43 | m: 1, 44 | p: 1, 45 | s: 1 46 | }, 47 | honbaBonus: 100, 48 | roundedMangan: false, 49 | doubleWindFu: 4, 50 | accumlatedYakuman: true, 51 | multipleYakuman: true, 52 | kokushi13DoubleYakuman: false, 53 | suankoTankiDoubleYakuman: false, 54 | daisushiDoubleYakuman: false, 55 | pureChurenDoubleYakuman: false 56 | }, 57 | '天鳳 赤なし (Tenhou without red)': { 58 | red: { 59 | m: 0, 60 | p: 0, 61 | s: 0 62 | }, 63 | honbaBonus: 100, 64 | roundedMangan: false, 65 | doubleWindFu: 4, 66 | accumlatedYakuman: true, 67 | multipleYakuman: true, 68 | kokushi13DoubleYakuman: false, 69 | suankoTankiDoubleYakuman: false, 70 | daisushiDoubleYakuman: false, 71 | pureChurenDoubleYakuman: false 72 | }, 73 | '雀魂 -じゃんたま- (Mahjong Soul)': { 74 | red: { 75 | m: 1, 76 | p: 1, 77 | s: 1 78 | }, 79 | honbaBonus: 100, 80 | roundedMangan: false, 81 | doubleWindFu: 4, 82 | accumlatedYakuman: true, 83 | multipleYakuman: true, 84 | kokushi13DoubleYakuman: true, 85 | suankoTankiDoubleYakuman: true, 86 | daisushiDoubleYakuman: true, 87 | pureChurenDoubleYakuman: true 88 | } 89 | }, 90 | currentRule: { 91 | red: { 92 | m: 1, 93 | p: 1, 94 | s: 1 95 | }, 96 | honbaBonus: 100, 97 | roundedMangan: true, 98 | doubleWindFu: 2, 99 | accumlatedYakuman: false, 100 | multipleYakuman: true, 101 | kokushi13DoubleYakuman: false, 102 | suankoTankiDoubleYakuman: false, 103 | daisushiDoubleYakuman: false, 104 | pureChurenDoubleYakuman: false 105 | }, 106 | table: { 107 | round: 'east', 108 | seat: 'east', 109 | continue: 0, 110 | deposit: 0 111 | }, 112 | input: { 113 | dora: [], 114 | hand: [], 115 | melds: [] 116 | }, 117 | inputFocus: { type: 'hand' }, 118 | handOptions: { 119 | riichi: 'none', 120 | ippatsu: false, 121 | rinshan: false, 122 | chankan: false, 123 | haitei: false, 124 | tenho: false 125 | } 126 | }; 127 | 128 | export const defaultState = (): AppState => { 129 | const json = localStorage.getItem('store'); 130 | if (json === null) return initialState; 131 | const { store } = JSON.parse(json); 132 | return store as AppState; 133 | }; 134 | -------------------------------------------------------------------------------- /src/components/Result.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import type { FC } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import type { Result as ResultType } from '../lib/result'; 5 | import { compareTiles } from '../lib/tile'; 6 | import { sumBy } from '../lib/util'; 7 | import { DiscardItem } from './DiscardItem'; 8 | import { HoraItem } from './HoraItem'; 9 | import { Shanten } from './Shanten'; 10 | import { Tempai } from './Tempai'; 11 | 12 | const Message: FC<{ children?: React.ReactNode }> = ({ children }) => ( 13 |
14 | {children} 15 |
16 | ); 17 | 18 | interface ResultProps { 19 | result: ResultType; 20 | } 21 | export const Result: FC = ({ result }) => { 22 | const { t } = useTranslation(); 23 | 24 | if (result === null) { 25 | return {t('result.input-message')}; 26 | } 27 | 28 | if (result.type === 'just-hora') { 29 | return {t('result.hora')}; 30 | } 31 | 32 | if (result.type === 'hora-shanten') { 33 | if (result.info.type === 'hora') { 34 | if (result.info.hora.length === 0) { 35 | return {t('result.no-hora-tiles')}; 36 | } 37 | return ( 38 |
39 | {result.info.hora.map(hora => ( 40 | 44 | ))} 45 |
46 | ); 47 | } 48 | return ( 49 | 53 | ); 54 | } 55 | 56 | if (result.type === 'tempai') { 57 | if (result.tileAvailabilities.every(a => a.count === 0)) { 58 | return {t('result.no-hora-tiles')}; 59 | } 60 | return ; 61 | } 62 | 63 | if (result.type === 'discard-shanten') { 64 | const shanten = result.discards.reduce( 65 | (acc, cur) => 66 | Math.min( 67 | acc, 68 | cur.next.type === 'tempai' 69 | ? 0 70 | : cur.next.info.type === 'hora' 71 | ? 0 72 | : cur.next.info.shanten 73 | ), 74 | Number.POSITIVE_INFINITY 75 | ); 76 | const discards = [...result.discards]; 77 | if ( 78 | discards.every( 79 | d => d.next.type === 'hora-shanten' && d.next.info.type === 'shanten' 80 | ) 81 | ) { 82 | discards.sort((a, b) => { 83 | if ( 84 | a.next.type !== 'hora-shanten' || 85 | a.next.info.type !== 'shanten' || 86 | b.next.type !== 'hora-shanten' || 87 | b.next.info.type !== 'shanten' 88 | ) 89 | throw new Error(); 90 | 91 | const ac = sumBy(a.next.info.tileAvailabilities, a => a.count); 92 | const bc = sumBy(b.next.info.tileAvailabilities, a => a.count); 93 | 94 | if (ac === bc) return compareTiles(a.tile, b.tile); 95 | return bc - ac; 96 | }); 97 | } 98 | 99 | return ( 100 |
101 |
102 | {shanten === 0 103 | ? t('result.tempai') 104 | : t('result.shanten', { count: shanten })} 105 |
106 |
107 | {discards.map((d, i) => ( 108 | 109 | ))} 110 |
111 |
112 | ); 113 | } 114 | 115 | return ( 116 | 117 |
{JSON.stringify(result, null, 2)}
118 |
119 | ); 120 | }; 121 | -------------------------------------------------------------------------------- /src/components/tile/images/dark/1m.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/light/1m.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wasm/decomposer/src/counts.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Add; 2 | 3 | use serde::{Serialize, Serializer}; 4 | 5 | #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] 6 | pub struct PackedCounts(u32); 7 | 8 | impl PackedCounts { 9 | pub fn with(counts: &[u8]) -> Self { 10 | if counts.len() != N { 11 | panic!(); 12 | } 13 | let mut d = 0; 14 | (0..N).for_each(|i| { 15 | d |= (counts[i] as u32 & 0b111) << (3 * i); 16 | }); 17 | Self(d) 18 | } 19 | 20 | pub fn get(&self, index: usize) -> u8 { 21 | ((self.0 >> (3 * index)) & 0b111) as u8 22 | } 23 | 24 | pub fn set(&mut self, index: usize, value: u8) { 25 | self.0 = (self.0 & !(0b111 << (3 * index))) | ((value as u32 & 0b111) << (3 * index)); 26 | } 27 | 28 | pub fn set_by(&mut self, index: usize, f: impl FnOnce(u8) -> u8) { 29 | self.set(index, f(self.get(index))) 30 | } 31 | 32 | pub fn iter(&self) -> PackedCountsIterator { 33 | PackedCountsIterator::new(self) 34 | } 35 | } 36 | 37 | impl Add for PackedCounts { 38 | type Output = Self; 39 | 40 | fn add(self, rhs: Self) -> Self::Output { 41 | let a: Vec<_> = self.iter().zip(rhs.iter()).map(|(a, b)| a + b).collect(); 42 | Self::with(&a) 43 | } 44 | } 45 | 46 | impl Serialize for PackedCounts { 47 | fn serialize(&self, serializer: S) -> Result 48 | where 49 | S: Serializer, 50 | { 51 | serializer.collect_seq(self.iter()) 52 | } 53 | } 54 | 55 | pub struct PackedCountsIterator<'a, const N: usize> { 56 | source: &'a PackedCounts, 57 | index: usize, 58 | } 59 | 60 | impl<'a, const N: usize> PackedCountsIterator<'a, N> { 61 | fn new(source: &'a PackedCounts) -> Self { 62 | Self { source, index: 0 } 63 | } 64 | } 65 | 66 | impl Iterator for PackedCountsIterator<'_, N> { 67 | type Item = u8; 68 | 69 | fn next(&mut self) -> Option { 70 | if self.index == N { 71 | None 72 | } else { 73 | let value = self.source.get(self.index); 74 | self.index += 1; 75 | Some(value) 76 | } 77 | } 78 | } 79 | 80 | pub type PackedNumberCounts = PackedCounts<9>; 81 | pub type PackedCharacterCounts = PackedCounts<7>; 82 | 83 | #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] 84 | pub struct Counts { 85 | pub m: PackedNumberCounts, 86 | pub p: PackedNumberCounts, 87 | pub s: PackedNumberCounts, 88 | pub z: PackedCharacterCounts, 89 | } 90 | 91 | impl Counts { 92 | pub fn new() -> Self { 93 | Default::default() 94 | } 95 | 96 | pub fn with(counts: &[u8]) -> Self { 97 | if counts.len() != 34 { 98 | panic!(); 99 | } 100 | Self { 101 | m: PackedNumberCounts::with(&counts[0..9]), 102 | p: PackedNumberCounts::with(&counts[9..18]), 103 | s: PackedNumberCounts::with(&counts[18..27]), 104 | z: PackedCharacterCounts::with(&counts[27..34]), 105 | } 106 | } 107 | } 108 | 109 | impl Add for Counts { 110 | type Output = Self; 111 | 112 | fn add(self, rhs: Self) -> Self::Output { 113 | Self { 114 | m: self.m + rhs.m, 115 | p: self.p + rhs.p, 116 | s: self.s + rhs.s, 117 | z: self.z + rhs.z, 118 | } 119 | } 120 | } 121 | 122 | impl Serialize for Counts { 123 | fn serialize(&self, serializer: S) -> Result 124 | where 125 | S: Serializer, 126 | { 127 | serializer.collect_seq( 128 | self.m 129 | .iter() 130 | .chain(self.p.iter()) 131 | .chain(self.s.iter()) 132 | .chain(self.z.iter()), 133 | ) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/components/tile/images/dark/2m.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/light/2m.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/dark/8m.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/light/8m.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/dark/3m.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/light/3m.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/dark/7m.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/tile/images/light/7m.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ResultGlance.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { useWindowSize } from '../hooks/dom'; 4 | import type { Result } from '../lib/result'; 5 | import { compareTiles } from '../lib/tile'; 6 | import { uniqueSorted } from '../lib/util'; 7 | import { Tile } from './tile'; 8 | 9 | const scrollMargin = 48; 10 | 11 | interface ResultGlanceProps { 12 | result: Result; 13 | handOptionsPosition?: number | undefined; 14 | } 15 | export const ResultGlance: FC = ({ 16 | result, 17 | handOptionsPosition = Number.POSITIVE_INFINITY 18 | }) => { 19 | const { t } = useTranslation(); 20 | const { height } = useWindowSize(); 21 | 22 | if (handOptionsPosition - height + scrollMargin < 0 || result === null) 23 | return null; 24 | 25 | return ( 26 |
27 | {result.type === 'just-hora' &&
{t('result.hora')}
} 28 | {result.type === 'tempai' && 29 | result.tileAvailabilities.every(a => a.count === 0) && ( 30 |
{t('result.no-hora-tiles')}
31 | )} 32 | {result.type === 'tempai' && 33 | result.tileAvailabilities.some(a => a.count > 0) && ( 34 |
35 |
{t('result.tempai')}
36 |
37 | {result.tileAvailabilities 38 | .filter(a => a.count > 0) 39 | .map((a, i) => ( 40 |
41 | 42 |
43 | ))} 44 |
45 |
46 | )} 47 | {result.type === 'discard-shanten' && ( 48 |
49 |
50 | {(() => { 51 | const shanten = result.discards.reduce( 52 | (acc, cur) => 53 | Math.min( 54 | acc, 55 | cur.next.type === 'tempai' 56 | ? 0 57 | : cur.next.info.type === 'hora' 58 | ? 0 59 | : cur.next.info.shanten 60 | ), 61 | Number.POSITIVE_INFINITY 62 | ); 63 | return shanten === 0 64 | ? t('result.tempai') 65 | : t('result.shanten', { count: shanten }); 66 | })()} 67 |
68 |
{t('result.discard')}
69 |
70 | {result.discards.map((d, i) => ( 71 |
72 | 73 |
74 | ))} 75 |
76 |
77 | )} 78 | {result.type === 'hora-shanten' && result.info.type === 'shanten' && ( 79 |
80 |
81 | {t('result.shanten', { count: result.info.shanten })} 82 |
83 |
{t('result.acceptance')}
84 |
85 | {result.info.tileAvailabilities.map((a, i) => ( 86 |
87 | 88 |
89 | ))} 90 |
91 |
92 | )} 93 | {result.type === 'hora-shanten' && result.info.type === 'hora' && ( 94 |
95 |
{t('result.tempai')}
96 |
{t('result.waiting')}
97 |
98 | {uniqueSorted( 99 | result.info.hora.map(h => h.horaTile), 100 | (a, b) => compareTiles(a, b) === 0 101 | ).map((t, i) => ( 102 |
103 | 104 |
105 | ))} 106 |
107 |
108 | )} 109 |
110 | ); 111 | }; 112 | -------------------------------------------------------------------------------- /src/components/HandOptions.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { useStore } from '../contexts/store'; 4 | import { ConfigItem } from './ui/ConfigItem'; 5 | import { Segment } from './ui/Segment'; 6 | 7 | const riichiOptions = ['none', 'riichi', 'double-riichi'] as const; 8 | 9 | export const HandOptions: FC = () => { 10 | const [{ table, handOptions }, dispatch] = useStore(); 11 | const { t } = useTranslation(); 12 | 13 | return ( 14 |
15 |
16 |
17 | 18 | t(`hand-options.${o}`))} 20 | index={riichiOptions.indexOf(handOptions.riichi)} 21 | onChange={i => 22 | dispatch({ 23 | type: 'set-hand-options', 24 | payload: { 25 | ...handOptions, 26 | riichi: riichiOptions[i as 0 | 1 | 2] 27 | } 28 | }) 29 | } 30 | /> 31 | 32 |
33 |
34 | 35 | 39 | dispatch({ 40 | type: 'set-hand-options', 41 | payload: { ...handOptions, ippatsu: i === 1 } 42 | }) 43 | } 44 | /> 45 | 46 |
47 |
48 |
49 |
50 | 51 | 55 | dispatch({ 56 | type: 'set-hand-options', 57 | payload: { ...handOptions, rinshan: i === 1 } 58 | }) 59 | } 60 | /> 61 | 62 |
63 |
64 | 65 | 69 | dispatch({ 70 | type: 'set-hand-options', 71 | payload: { ...handOptions, chankan: i === 1 } 72 | }) 73 | } 74 | /> 75 | 76 |
77 |
78 |
79 |
80 | 81 | 85 | dispatch({ 86 | type: 'set-hand-options', 87 | payload: { ...handOptions, haitei: i === 1 } 88 | }) 89 | } 90 | /> 91 | 92 |
93 |
94 | 101 | 112 | dispatch({ 113 | type: 'set-hand-options', 114 | payload: { ...handOptions, tenho: i === 1 } 115 | }) 116 | } 117 | /> 118 | 119 |
120 |
121 |
122 | ); 123 | }; 124 | -------------------------------------------------------------------------------- /src/components/ui/TileKeyboard.tsx: -------------------------------------------------------------------------------- 1 | import React, { type FC } from 'react'; 2 | import { useStore } from '../../contexts/store'; 3 | import { instantiateMeld } from '../../lib/input'; 4 | import { type Tile, isAvailableTiles } from '../../lib/tile'; 5 | import { TileButton } from './TileButton'; 6 | 7 | export const TileKeyboard: FC = () => { 8 | const [ 9 | { 10 | input, 11 | currentRule: { red }, 12 | inputFocus 13 | }, 14 | dispatch 15 | ] = useStore(); 16 | 17 | const showNonRed = red.m !== 4 || red.p !== 4 || red.s !== 4; 18 | const showRed = red.m !== 0 || red.p !== 0 || red.s !== 0; 19 | 20 | const allInputTiles = [ 21 | ...input.hand, 22 | ...input.melds.flatMap(meld => instantiateMeld(meld, red)), 23 | ...input.dora 24 | ]; 25 | const isDisabled = (tile: Tile) => 26 | inputFocus.type === 'hand' 27 | ? !isAvailableTiles(red, allInputTiles, [tile]) || 28 | input.hand.length >= 14 - input.melds.length * 3 29 | : inputFocus.type === 'dora' 30 | ? !isAvailableTiles(red, allInputTiles, [tile]) || 31 | input.dora.length >= 10 32 | : input.melds[inputFocus.i]?.tile !== null || 33 | (input.melds[inputFocus.i]?.type === 'chii' 34 | ? tile.type === 'z' || 35 | tile.n >= 8 || 36 | !isAvailableTiles( 37 | red, 38 | allInputTiles, 39 | instantiateMeld({ type: 'chii', tile, includeRed: false }, red) 40 | ) 41 | : input.melds[inputFocus.i]?.type === 'pon' 42 | ? !isAvailableTiles( 43 | red, 44 | allInputTiles, 45 | instantiateMeld({ type: 'pon', tile }, red) 46 | ) 47 | : input.melds[inputFocus.i]?.type === 'kan' 48 | ? !isAvailableTiles( 49 | red, 50 | allInputTiles, 51 | instantiateMeld({ type: 'kan', tile, closed: true }, red) 52 | ) 53 | : true); 54 | return ( 55 |
56 | {(['m', 'p', 's'] as const).map(type => ( 57 |
58 | {([1, 2, 3, 4, 5, 6, 7, 8, 9] as const).map(n => ( 59 | 60 | {n === 5 && showNonRed && ( 61 | 67 | dispatch({ 68 | type: 'click-tile-keyboard', 69 | payload: { type, n, red: false } 70 | }) 71 | } 72 | /> 73 | )} 74 | {n === 5 && showRed && ( 75 | 81 | dispatch({ 82 | type: 'click-tile-keyboard', 83 | payload: { type, n, red: true } 84 | }) 85 | } 86 | /> 87 | )} 88 | {n !== 5 && ( 89 | 93 | dispatch({ 94 | type: 'click-tile-keyboard', 95 | payload: { type, n } 96 | }) 97 | } 98 | /> 99 | )} 100 | 101 | ))} 102 |
103 | ))} 104 |
105 | {([1, 2, 3, 4, 5, 6, 7] as const).map(n => ( 106 | 111 | dispatch({ 112 | type: 'click-tile-keyboard', 113 | payload: { type: 'z', n } 114 | }) 115 | } 116 | /> 117 | ))} 118 |
119 |
120 | {showNonRed && showRed &&
} 121 |
122 |
123 | ); 124 | }; 125 | --------------------------------------------------------------------------------