SLOPSHOPPER

sushida

寿司打風の日本語タイピングゲームを /sushida でペインに開く。かな入力のローマ字表記揺れに対応。

newpanecommand
v0.1.1no licenseupdated 2026-09-18sontixyou/sushi-uchi/sushida
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sushida
│ ┃ 寿司打 ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./game.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sushida │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 寿司打
▣ client module ./game.tsx
README

sushi-uchi

寿司打風の日本語タイピングゲームを /sushida で開く Claude Code mod(sushida)。

インストール

/plugin marketplace add <user>/sushi-uchi
/plugin install sushida@sushi-uchi

ソースから直接読ませる場合(開発時):

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir sushida

function hooks は早期アクセス機能のため、起動には環境変数 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 が必要。

使い方

  1. /sushida でペインを開く
  2. キー入力が効かない場合はペインをクリックしてフォーカスしてから操作する
  3. 1/2/3 でコースを選択(お手軽3,000円60秒 / おすすめ5,000円90秒 / 高級10,000円120秒)
  4. 表示されたお題をローマ字で入力する(し=si/shi/ci など複数表記の揺れに対応)
  5. 制限時間内に食べた金額でコース料金を元が取れたか競う
  6. 結果画面で R を押すともう一度プレイできる

Esc でペインを閉じる。ハイスコアはセッションをまたいで保存される。

開発

cd sushida
npm install
npm test        # ローマ字オートマトンのテスト
npm run typecheck

設計の詳細は docs/design.md を参照。

Source 9 files
hooks/register.tsx 96 lines
1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { On, RenderElement } from 'claude-code'
5
6import type { GameProps, GameResultMessage } from './game'
7
8const PANE_ID = 'sushida'
9const PANE_TITLE = '寿司打'
10const COMMAND_NAME = 'sushida'
11const HIGH_SCORE_KEY = 'sushida:high-score'
12
13/**
14 * `{ highScore: undefined }` は JsonValue として無効で `<Client props>` が
15 * 描画拒否されるため、未確定時はキーごと省く。
16 */
17function gamePropsFrom(highScore: number | undefined): GameProps {
18  return highScore === undefined ? {} : { highScore }
19}
20
21/**
22 * `/sushida`: 寿司打クローンのペインを開く。ゲーム本体は Client サーフェス
23 * モジュール(`game.tsx`)が担う。この register.tsx は `$` を触る唯一の層。
24 *
25 * @param on the engine's registrar
26 */
27export function register(on: On) {
28  on('session.start', async ($, e, next) => {
29    try {
30      await $.command.register({
31        name: COMMAND_NAME,
32        description: '寿司打クローンのタイピングゲームを開く',
33      })
34    } catch {
35      // 既に built-in 等が同名コマンドを持つ場合は無視する
36    }
37
38    return next(e)
39  })
40
41  on('command.run', { command: COMMAND_NAME }, async ($) => {
42    await $.ui.open({
43      id: PANE_ID,
44      title: PANE_TITLE,
45      focus: true,
46      closeOnEscape: true,
47      holdToasts: true,
48    })
49
50    return {}
51  })
52
53  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
54    if (e.requestId !== PANE_ID) {
55      return next(e)
56    }
57
58    if (e.surface !== 'terminal' && e.surface !== 'desktop') {
59      const { Box, Text } = $.ui.resolve(e)
60      return (
61        <Box>
62          <Text dimColor>この画面では寿司打を表示できません</Text>
63        </Box>
64      ) as RenderElement
65    }
66
67    const { Box, Client } = $.ui.resolve(e)
68    const highScore = Number((await $.store.get(HIGH_SCORE_KEY)) ?? 0) || undefined
69
70    return (
71      <Box flexGrow={1}>
72        <Client key="game" module="./game.tsx" props={gamePropsFrom(highScore)} flexGrow={1} />
73      </Box>
74    ) as RenderElement
75  })
76
77  on('ui.message', async ($, e, next) => {
78    if (e.requestId !== PANE_ID || e.element !== 'game') {
79      return next(e)
80    }
81
82    const data = e.data as GameResultMessage | undefined
83
84    if (data !== undefined && data.kind === 'result') {
85      const stored = Number((await $.store.get(HIGH_SCORE_KEY)) ?? 0)
86      if (data.money > stored) {
87        await $.store.set(HIGH_SCORE_KEY, data.money)
88      }
89    }
90
91    const highScore = Number((await $.store.get(HIGH_SCORE_KEY)) ?? 0) || undefined
92
93    return { props: gamePropsFrom(highScore) }
94  })
95}
96
hooks/game.tsx 375 lines
1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { ClientElements, ClientKeyEvent, ClientModule, RenderElement } from 'claude-code'
5
6import { acceptChar, createRomajiState, type RomajiState } from './romaji/index.ts'
7import { WORDS } from './words.ts'
8
9const TICK_MS = 120
10const COUNTDOWN_SECONDS = 3
11const YEN_PER_KANA = 50
12
13const COURSES = [
14  { label: 'お手軽', price: 3000, seconds: 60 },
15  { label: 'おすすめ', price: 5000, seconds: 90 },
16  { label: '高級', price: 10000, seconds: 120 },
17] as const
18
19function courseOf(index: number): (typeof COURSES)[number] {
20  return COURSES[index]!
21}
22
23function wordOf(index: number): (typeof WORDS)[number] {
24  return WORDS[index]!
25}
26
27type Phase = 'title' | 'countdown' | 'playing' | 'result'
28
29type CurrentPlate = {
30  readonly wordIndex: number
31  readonly romaji: RomajiState
32  readonly typed: string
33}
34
35type GameState = {
36  readonly phase: Phase
37  readonly courseIndex: number
38  readonly countdownMs: number
39  readonly remainingMs: number
40  readonly laneOffset: number
41  readonly wordOrder: readonly number[]
42  readonly wordCursor: number
43  readonly current: CurrentPlate
44  readonly money: number
45  readonly correctKeystrokes: number
46  readonly missKeystrokes: number
47  readonly missByChar: Readonly<Record<string, number>>
48  readonly hasPostedResult: boolean
49}
50
51export type GameProps = {
52  readonly highScore?: number
53}
54
55export type GameResultMessage = {
56  readonly kind: 'result'
57  readonly money: number
58  readonly coursePrice: number
59  readonly correctKeystrokes: number
60  readonly missKeystrokes: number
61}
62
63function shuffledIndices(length: number): number[] {
64  const indices = Array.from({ length }, (_, i) => i)
65  for (let i = indices.length - 1; i > 0; i -= 1) {
66    const j = Math.floor(Math.random() * (i + 1))
67    const tmp = indices[i]!
68    indices[i] = indices[j]!
69    indices[j] = tmp
70  }
71  return indices
72}
73
74function drawPlate(wordOrder: readonly number[], wordCursor: number): { wordIndex: number; wordOrder: readonly number[]; wordCursor: number } {
75  if (wordCursor >= wordOrder.length) {
76    const reshuffled = shuffledIndices(WORDS.length)
77    return { wordIndex: reshuffled[0]!, wordOrder: reshuffled, wordCursor: 1 }
78  }
79  return { wordIndex: wordOrder[wordCursor]!, wordOrder, wordCursor: wordCursor + 1 }
80}
81
82function newPlate(wordIndex: number): CurrentPlate {
83  return { wordIndex, romaji: createRomajiState(wordOf(wordIndex).kana), typed: '' }
84}
85
86function initialState(): GameState {
87  const wordOrder = shuffledIndices(WORDS.length)
88  return {
89    phase: 'title',
90    courseIndex: 0,
91    countdownMs: COUNTDOWN_SECONDS * 1000,
92    remainingMs: 0,
93    laneOffset: 0,
94    wordOrder,
95    wordCursor: 1,
96    current: newPlate(wordOrder[0]!),
97    money: 0,
98    correctKeystrokes: 0,
99    missKeystrokes: 0,
100    missByChar: {},
101    hasPostedResult: false,
102  }
103}
104
105function tick(state: GameState): GameState {
106  if (state.phase === 'countdown') {
107    const countdownMs = state.countdownMs - TICK_MS
108    if (countdownMs <= 0) {
109      return {
110        ...state,
111        phase: 'playing',
112        countdownMs: 0,
113        remainingMs: courseOf(state.courseIndex).seconds * 1000,
114      }
115    }
116    return { ...state, countdownMs }
117  }
118
119  if (state.phase === 'playing') {
120    const remainingMs = state.remainingMs - TICK_MS
121    const laneOffset = state.laneOffset + 1
122    if (remainingMs <= 0) {
123      return { ...state, phase: 'result', remainingMs: 0, laneOffset }
124    }
125    return { ...state, remainingMs, laneOffset }
126  }
127
128  return state
129}
130
131function startCourse(state: GameState, courseIndex: number): GameState {
132  if (state.phase !== 'title') return state
133  return { ...state, courseIndex, phase: 'countdown', countdownMs: COUNTDOWN_SECONDS * 1000 }
134}
135
136function restartToTitle(): GameState {
137  return initialState()
138}
139
140function handleTypingKey(state: GameState, char: string): GameState {
141  const result = acceptChar(state.current.romaji, char)
142
143  if (!result.ok) {
144    const missByChar = { ...state.missByChar, [char]: (state.missByChar[char] ?? 0) + 1 }
145    return { ...state, missKeystrokes: state.missKeystrokes + 1, missByChar }
146  }
147
148  const current: CurrentPlate = { ...state.current, romaji: result.state, typed: result.state.typed }
149  const afterKeystroke = { ...state, current, correctKeystrokes: state.correctKeystrokes + 1 }
150
151  if (!result.isComplete) {
152    return afterKeystroke
153  }
154
155  const word = wordOf(state.current.wordIndex)
156  const money = afterKeystroke.money + word.kana.length * YEN_PER_KANA
157  const drawn = drawPlate(state.wordOrder, state.wordCursor)
158
159  return {
160    ...afterKeystroke,
161    money,
162    wordOrder: drawn.wordOrder,
163    wordCursor: drawn.wordCursor,
164    current: newPlate(drawn.wordIndex),
165  }
166}
167
168function handleKey(state: GameState, event: ClientKeyEvent): GameState {
169  if (event.ctrl === true || event.meta === true) return state
170
171  if (state.phase === 'title') {
172    if (event.key === '1') return startCourse(state, 0)
173    if (event.key === '2') return startCourse(state, 1)
174    if (event.key === '3') return startCourse(state, 2)
175    return state
176  }
177
178  if (state.phase === 'result') {
179    if (event.key === 'r' || event.key === 'R') return restartToTitle()
180    return state
181  }
182
183  if (state.phase === 'playing') {
184    if (event.key.length === 1 && /^[a-z-]$/.test(event.key)) {
185      return handleTypingKey(state, event.key)
186    }
187    return state
188  }
189
190  return state
191}
192
193function formatSeconds(ms: number): string {
194  return (Math.max(0, ms) / 1000).toFixed(1)
195}
196
197function sushiLane(offset: number, columns: number): string {
198  const laneWidth = Math.max(10, columns - 4)
199  const cells: string[] = []
200  for (let i = 0; i < laneWidth; i += 1) {
201    cells.push((i + offset) % 4 === 0 ? '🍣' : ' ')
202  }
203  return cells.join('')
204}
205
206function worstChar(missByChar: Readonly<Record<string, number>>): string | undefined {
207  let worst: string | undefined
208  let worstCount = 0
209  for (const [char, count] of Object.entries(missByChar)) {
210    if (count > worstCount) {
211      worst = char
212      worstCount = count
213    }
214  }
215  return worst
216}
217
218function renderTitle(ui: ClientElements, props: GameProps): RenderElement {
219  const { Box, Text } = ui
220  return (
221    <Box flexDirection="column">
222      <Text bold>寿司打</Text>
223      <Text>コースを選んでください(数字キー)</Text>
224      <Box flexDirection="column" marginTop={1}>
225        {COURSES.map((course, index) => (
226          <Text key={`course-${index}`}>
227            {index + 1}: {course.label}({course.price}円 / {course.seconds}秒)
228          </Text>
229        ))}
230      </Box>
231      {props.highScore !== undefined && <Text dimColor>ハイスコア: {props.highScore}円</Text>}
232      <Box marginTop={1}>
233        <Text dimColor>キー入力が効かない場合はこのペインをクリックしてから数字キーを押してください</Text>
234      </Box>
235    </Box>
236  )
237}
238
239function renderCountdown(ui: ClientElements, state: GameState): RenderElement {
240  const { Box, Text } = ui
241  const seconds = Math.ceil(state.countdownMs / 1000)
242  return (
243    <Box flexDirection="column">
244      <Text bold>{courseOf(state.courseIndex).label}コース</Text>
245      <Text>{seconds > 0 ? seconds : 'スタート!'}</Text>
246    </Box>
247  )
248}
249
250function renderPlaying(ui: ClientElements, state: GameState, columns: number): RenderElement {
251  const { Box, Text } = ui
252  const word = wordOf(state.current.wordIndex)
253  const typed = state.current.typed
254  const course = courseOf(state.courseIndex)
255  const remainRatio = course.seconds > 0 ? state.remainingMs / (course.seconds * 1000) : 0
256  const barWidth = Math.max(10, columns - 10)
257  const filled = Math.round(barWidth * Math.max(0, Math.min(1, remainRatio)))
258
259  return (
260    <Box flexDirection="column">
261      <Text>{sushiLane(state.laneOffset, columns)}</Text>
262      <Box flexDirection="column" marginTop={1}>
263        <Text bold>{word.display}</Text>
264        <Text dimColor>{word.kana}</Text>
265        <Text>
266          <Text color="green">{typed}</Text>
267        </Text>
268      </Box>
269      <Box flexDirection="column" marginTop={1}>
270        <Text>{'▓'.repeat(filled) + '░'.repeat(Math.max(0, barWidth - filled))}</Text>
271        <Text>
272          残り {formatSeconds(state.remainingMs)}秒 / {state.money}円
273        </Text>
274      </Box>
275    </Box>
276  )
277}
278
279function renderResult(ui: ClientElements, state: GameState, props: GameProps): RenderElement {
280  const { Box, Text } = ui
281  const course = courseOf(state.courseIndex)
282  const diff = state.money - course.price
283  const totalKeystrokes = state.correctKeystrokes + state.missKeystrokes
284  const speed = course.seconds > 0 ? (state.correctKeystrokes / course.seconds).toFixed(2) : '0'
285  const weakChar = worstChar(state.missByChar)
286  const isProfit = diff >= 0
287
288  return (
289    <Box flexDirection="column">
290      <Text bold>結果</Text>
291      <Text>
292        食べた金額: {state.money}円 / コース料金: {course.price}円
293      </Text>
294      <Text color={isProfit ? 'green' : 'red'}>
295        {isProfit ? `${diff}円分、元を取りました!` : `${-diff}円分、損しました…`}
296      </Text>
297      <Box flexDirection="column" marginTop={1}>
298        <Text>
299          正打数: {state.correctKeystrokes} / ミス数: {state.missKeystrokes} / 総打鍵: {totalKeystrokes}
300        </Text>
301        <Text>平均キー/秒: {speed}</Text>
302        {weakChar !== undefined && <Text>苦手キー: {weakChar}</Text>}
303        {props.highScore !== undefined && <Text dimColor>ハイスコア: {Math.max(props.highScore, state.money)}円</Text>}
304      </Box>
305      <Box marginTop={1}>
306        <Text dimColor>Rキーでもう一度</Text>
307      </Box>
308    </Box>
309  )
310}
311
312function render(ui: ClientElements, state: GameState, props: GameProps, columns: number): RenderElement {
313  const { Box } = ui
314
315  if (state.phase === 'title') return renderTitle(ui, props)
316  if (state.phase === 'countdown') return renderCountdown(ui, state)
317  if (state.phase === 'playing') return renderPlaying(ui, state, columns)
318  return <Box flexDirection="column">{renderResult(ui, state, props)}</Box>
319}
320
321/**
322 * `every`/`onKey` は初回登録の1回きり。以降のtick/keyでも最新のstateを
323 * 読み書きできるよう、インスタンス単位のミュータブルセルをモジュールスコープに持つ
324 * (1ペイン1ゲームインスタンスの前提。同時に複数走らせない)。
325 */
326let latestState: GameState | undefined
327
328const GameModule: ClientModule<GameProps, GameState> = (props, surface) => {
329  const { elements, state, setState, onKey, every, post, columns } = surface
330
331  if (state === undefined) {
332    const initial = initialState()
333    latestState = initial
334    setState(initial)
335
336    every(TICK_MS, () => {
337      if (latestState === undefined) return
338      const next = tick(latestState)
339      latestState = next
340      setState(next)
341    })
342
343    onKey(event => {
344      if (latestState === undefined) return
345      const next = handleKey(latestState, event)
346      latestState = next
347      setState(next)
348    })
349
350    return render(elements, initial, props, columns)
351  }
352
353  latestState = state
354
355  if (state.phase === 'result' && !state.hasPostedResult) {
356    const course = courseOf(state.courseIndex)
357    const message: GameResultMessage = {
358      kind: 'result',
359      money: state.money,
360      coursePrice: course.price,
361      correctKeystrokes: state.correctKeystrokes,
362      missKeystrokes: state.missKeystrokes,
363    }
364    post(message)
365    const posted = { ...state, hasPostedResult: true }
366    latestState = posted
367    setState(posted)
368    return render(elements, posted, props, columns)
369  }
370
371  return render(elements, state, props, columns)
372}
373
374export default GameModule
375
hooks/romaji/index.ts 3 lines
1export { acceptChar, createRomajiState, isComplete, nextAcceptableChars } from './engine.ts'
2export type { RomajiState, RomajiStepResult } from './types.ts'
3
hooks/words.ts 71 lines
1export type Word = {
2  readonly display: string
3  readonly kana: string
4}
5
6/**
7 * お題60語。単価はゲーム側で `kana` の長さから動的に決める(本家準拠)。
8 */
9export const WORDS: readonly Word[] = [
10  { display: '寿司', kana: 'すし' },
11  { display: '醤油', kana: 'しょうゆ' },
12  { display: '玉子', kana: 'たまご' },
13  { display: '鮪', kana: 'まぐろ' },
14  { display: '鰤', kana: 'ぶり' },
15  { display: '鯛', kana: 'たい' },
16  { display: '鰈', kana: 'かれい' },
17  { display: '穴子', kana: 'あなご' },
18  { display: '鰆', kana: 'さわら' },
19  { display: '鰺', kana: 'あじ' },
20  { display: '鯖', kana: 'さば' },
21  { display: '鰹', kana: 'かつお' },
22  { display: '海老', kana: 'えび' },
23  { display: '蟹', kana: 'かに' },
24  { display: '烏賊', kana: 'いか' },
25  { display: '蛸', kana: 'たこ' },
26  { display: '貝', kana: 'かい' },
27  { display: '帆立', kana: 'ほたて' },
28  { display: '牡蠣', kana: 'かき' },
29  { display: '鰊', kana: 'にしん' },
30  { display: '雲丹', kana: 'うに' },
31  { display: '数の子', kana: 'かずのこ' },
32  { display: '筋子', kana: 'すじこ' },
33  { display: '軍艦', kana: 'ぐんかん' },
34  { display: '巻物', kana: 'まきもの' },
35  { display: '手巻き', kana: 'てまき' },
36  { display: '軍艦巻き', kana: 'ぐんかんまき' },
37  { display: '刺身', kana: 'さしみ' },
38  { display: '握り', kana: 'にぎり' },
39  { display: '押し寿司', kana: 'おしずし' },
40  { display: '稲荷寿司', kana: 'いなりずし' },
41  { display: '茶碗蒸し', kana: 'ちゃわんむし' },
42  { display: '味噌汁', kana: 'みそしる' },
43  { display: '茶', kana: 'おちゃ' },
44  { display: 'がり', kana: 'がり' },
45  { display: '山葵', kana: 'わさび' },
46  { display: '大将', kana: 'たいしょう' },
47  { display: '板前', kana: 'いたまえ' },
48  { display: '出前', kana: 'でまえ' },
49  { display: '回転寿司', kana: 'かいてんずし' },
50  { display: '職人', kana: 'しょくにん' },
51  { display: '仕入れ', kana: 'しいれ' },
52  { display: '旬', kana: 'しゅん' },
53  { display: '新鮮', kana: 'しんせん' },
54  { display: '注文', kana: 'ちゅうもん' },
55  { display: '会計', kana: 'かいけい' },
56  { display: '常連', kana: 'じょうれん' },
57  { display: '暖簾', kana: 'のれん' },
58  { display: '湯呑み', kana: 'ゆのみ' },
59  { display: '割り箸', kana: 'わりばし' },
60  { display: '醤油差し', kana: 'しょうゆさし' },
61  { display: 'お通し', kana: 'おとおし' },
62  { display: '追加', kana: 'ついか' },
63  { display: '満腹', kana: 'まんぷく' },
64  { display: '食べ放題', kana: 'たべほうだい' },
65  { display: '一人前', kana: 'いちにんまえ' },
66  { display: '本日限定', kana: 'ほんじつげんてい' },
67  { display: '大トロ', kana: 'おおとろ' },
68  { display: '中トロ', kana: 'ちゅうとろ' },
69  { display: '赤身', kana: 'あかみ' },
70]
71
hooks/romaji/engine.ts 75 lines
1import { candidatesOf } from './candidates.ts'
2import { tokenizeKana } from './tokenize.ts'
3import type { RomajiState, RomajiStepResult } from './types.ts'
4
5const ALPHABET = 'abcdefghijklmnopqrstuvwxyz-'.split('')
6
7function tokenCandidates(kana: string): readonly (readonly string[])[] {
8  return candidatesOf(tokenizeKana(kana))
9}
10
11/**
12 * `input` が、`tokens[tokenIndex..]` の候補を連結したいずれかの文字列の
13 * "接頭辞" として成立するかを判定する(`input` が空なら常に成立)。
14 */
15function isValidPrefix(tokens: readonly (readonly string[])[], tokenIndex: number, input: string): boolean {
16  if (input.length === 0) return true
17  if (tokenIndex >= tokens.length) return false
18
19  for (const candidate of tokens[tokenIndex]!) {
20    if (candidate.startsWith(input)) return true
21    if (input.startsWith(candidate) && isValidPrefix(tokens, tokenIndex + 1, input.slice(candidate.length))) {
22      return true
23    }
24  }
25
26  return false
27}
28
29/**
30 * `input` が `tokens` の連結として過不足なく一致するか(打ち切り=完成)。
31 */
32function isExactMatch(tokens: readonly (readonly string[])[], tokenIndex: number, input: string): boolean {
33  if (tokenIndex === tokens.length) return input.length === 0
34
35  for (const candidate of tokens[tokenIndex]!) {
36    if (input.startsWith(candidate) && isExactMatch(tokens, tokenIndex + 1, input.slice(candidate.length))) {
37      return true
38    }
39  }
40
41  return false
42}
43
44export function createRomajiState(kana: string): RomajiState {
45  return { typed: '', remainingKana: kana }
46}
47
48/**
49 * 1文字 `char` を打った結果を返す。受理できない場合 `{ ok: false }`。
50 */
51export function acceptChar(state: RomajiState, char: string): RomajiStepResult {
52  const tokens = tokenCandidates(state.remainingKana)
53  const nextTyped = state.typed + char
54
55  if (!isValidPrefix(tokens, 0, nextTyped)) {
56    return { ok: false }
57  }
58
59  const nextState: RomajiState = { typed: nextTyped, remainingKana: state.remainingKana }
60  return { ok: true, state: nextState, isComplete: isExactMatch(tokens, 0, nextTyped) }
61}
62
63/**
64 * 次に受理される文字の集合。空集合なら(バグでない限り)既に完成している。
65 */
66export function nextAcceptableChars(state: RomajiState): readonly string[] {
67  const tokens = tokenCandidates(state.remainingKana)
68  return ALPHABET.filter(char => isValidPrefix(tokens, 0, state.typed + char))
69}
70
71export function isComplete(state: RomajiState): boolean {
72  const tokens = tokenCandidates(state.remainingKana)
73  return isExactMatch(tokens, 0, state.typed)
74}
75
hooks/romaji/types.ts 23 lines
1/**
2 * かな1文字(拗音は「きゃ」のように2文字)に対する許容ローマ字表記の集合。
3 */
4export type KanaRomaji = {
5  readonly kana: string
6  readonly romaji: readonly string[]
7}
8
9/**
10 * オートマトンの状態: 確定済みの入力文字列と、まだ打っていないかな列。
11 */
12export type RomajiState = {
13  readonly typed: string
14  readonly remainingKana: string
15}
16
17/**
18 * 1文字の入力を受理した結果。
19 */
20export type RomajiStepResult =
21  | { readonly ok: true; readonly state: RomajiState; readonly isComplete: boolean }
22  | { readonly ok: false }
23
hooks/romaji/candidates.ts 66 lines
1import { bareNIsAmbiguousBefore, BASE_ROMAJI, YOUON_DIRECT, youonKindOf } from './kana-table.ts'
2
3const VOWEL_START = new Set(['a', 'i', 'u', 'e', 'o'])
4
5function romajiOfPlainToken(token: string): readonly string[] {
6  const chars = Array.from(token)
7
8  if (chars.length === 2) {
9    const base = chars[0]!
10    const small = chars[1]!
11    const kind = youonKindOf(small)
12    const direct = YOUON_DIRECT[base]
13
14    if (kind !== undefined && direct !== undefined) {
15      const stems = BASE_ROMAJI[base] ?? []
16      const suffix = kind === 'ya' ? ['xya', 'lya'] : kind === 'yu' ? ['xyu', 'lyu'] : ['xyo', 'lyo']
17      const decomposed = stems.flatMap(stem => suffix.map(s => stem + s))
18
19      return [...direct[kind], ...decomposed]
20    }
21  }
22
23  return BASE_ROMAJI[token] ?? []
24}
25
26/**
27 * 促音「っ」の候補: 次トークンの子音を重ねた形(子音始まりの候補のみ)+ xtu/ltu。
28 */
29function romajiOfSokuon(nextToken: string | undefined): readonly string[] {
30  const doubled = new Set<string>()
31
32  if (nextToken !== undefined) {
33    for (const candidate of romajiOfPlainToken(nextToken)) {
34      const first = candidate[0]
35      if (first !== undefined && !VOWEL_START.has(first)) {
36        doubled.add(first)
37      }
38    }
39  }
40
41  return [...doubled, 'xtu', 'ltu']
42}
43
44/**
45 * 撥音「ん」の候補: n / nn / xn。次のかなが母音・な行・や行なら単独 n は不可。
46 */
47function romajiOfHatsuon(nextToken: string | undefined): readonly string[] {
48  const base = ['nn', 'xn']
49  return bareNIsAmbiguousBefore(nextToken) ? base : ['n', ...base]
50}
51
52/**
53 * トークン列の各要素について、許容ローマ字候補の配列を返す。
54 * っ・ん は前後関係(次トークン)に依存するためここでまとめて解決する。
55 */
56export function candidatesOf(tokens: readonly string[]): readonly (readonly string[])[] {
57  return tokens.map((token, index) => {
58    const next = tokens[index + 1]
59
60    if (token === 'っ') return romajiOfSokuon(next)
61    if (token === 'ん') return romajiOfHatsuon(next)
62
63    return romajiOfPlainToken(token)
64  })
65}
66
hooks/romaji/tokenize.ts 26 lines
1import { YOUON_BASE_KANA, youonKindOf } from './kana-table.ts'
2
3/**
4 * かな列を「1打鍵単位」に分割する。拗音(きゃ等)は2文字1トークン、
5 * それ以外(促音っ・撥音ん・長音ー・通常のかな)は1文字1トークン。
6 */
7export function tokenizeKana(kana: string): string[] {
8  const tokens: string[] = []
9  const chars = Array.from(kana)
10
11  for (let i = 0; i < chars.length; i += 1) {
12    const current = chars[i]!
13    const next = chars[i + 1]
14
15    if (next !== undefined && YOUON_BASE_KANA.has(current) && youonKindOf(next) !== undefined) {
16      tokens.push(current + next)
17      i += 1
18      continue
19    }
20
21    tokens.push(current)
22  }
23
24  return tokens
25}
26
hooks/romaji/kana-table.ts 73 lines
1/**
2 * 単独かな(拗音・促音・撥音・長音を除く)のローマ字候補表。
3 * 揺れは全て列挙する(例: し = si/shi/ci)。
4 */
5export const BASE_ROMAJI: Readonly<Record<string, readonly string[]>> = {
6  あ: ['a'], い: ['i'], う: ['u'], え: ['e'], お: ['o'],
7  か: ['ka'], き: ['ki'], く: ['ku'], け: ['ke'], こ: ['ko'],
8  さ: ['sa'], し: ['si', 'shi', 'ci'], す: ['su'], せ: ['se'], そ: ['so'],
9  た: ['ta'], ち: ['ti', 'chi'], つ: ['tu', 'tsu'], て: ['te'], と: ['to'],
10  な: ['na'], に: ['ni'], ぬ: ['nu'], ね: ['ne'], の: ['no'],
11  は: ['ha'], ひ: ['hi'], ふ: ['hu', 'fu'], へ: ['he'], ほ: ['ho'],
12  ま: ['ma'], み: ['mi'], む: ['mu'], め: ['me'], も: ['mo'],
13  や: ['ya'], ゆ: ['yu'], よ: ['yo'],
14  ら: ['ra'], り: ['ri'], る: ['ru'], れ: ['re'], ろ: ['ro'],
15  わ: ['wa'], を: ['wo'],
16  が: ['ga'], ぎ: ['gi'], ぐ: ['gu'], げ: ['ge'], ご: ['go'],
17  ざ: ['za'], じ: ['zi', 'ji'], ず: ['zu'], ぜ: ['ze'], ぞ: ['zo'],
18  だ: ['da'], ぢ: ['di'], づ: ['du'], で: ['de'], ど: ['do'],
19  ば: ['ba'], び: ['bi'], ぶ: ['bu'], べ: ['be'], ぼ: ['bo'],
20  ぱ: ['pa'], ぴ: ['pi'], ぷ: ['pu'], ぺ: ['pe'], ぽ: ['po'],
21  ー: ['-'],
22  '、': [','], '。': ['.'],
23} as const
24
25/**
26 * 拗音(きゃ等)を構成できる「い段」かな。この集合に属するかなの直後に
27 * 小書きの ゃ/ゅ/ょ が続くと、拗音として1トークンにまとめられる。
28 */
29export const YOUON_BASE_KANA = new Set([
30  'き', 'し', 'ち', 'に', 'ひ', 'み', 'り', 'ぎ', 'じ', 'ぢ', 'び', 'ぴ',
31])
32
33/**
34 * 拗音の直接表記(きゃ = kya 等)。デコンポーズ表記(ki + xya/lya = kixya/kilya)は
35 * BASE_ROMAJI のステムから自動生成する。
36 */
37export const YOUON_DIRECT: Readonly<Record<string, { ya: readonly string[]; yu: readonly string[]; yo: readonly string[] }>> = {
38  き: { ya: ['kya'], yu: ['kyu'], yo: ['kyo'] },
39  し: { ya: ['sya', 'sha'], yu: ['syu', 'shu'], yo: ['syo', 'sho'] },
40  ち: { ya: ['tya', 'cha'], yu: ['tyu', 'chu'], yo: ['tyo', 'cho'] },
41  に: { ya: ['nya'], yu: ['nyu'], yo: ['nyo'] },
42  ひ: { ya: ['hya'], yu: ['hyu'], yo: ['hyo'] },
43  み: { ya: ['mya'], yu: ['myu'], yo: ['myo'] },
44  り: { ya: ['rya'], yu: ['ryu'], yo: ['ryo'] },
45  ぎ: { ya: ['gya'], yu: ['gyu'], yo: ['gyo'] },
46  じ: { ya: ['zya', 'ja'], yu: ['zyu', 'ju'], yo: ['zyo', 'jo'] },
47  ぢ: { ya: ['dya'], yu: ['dyu'], yo: ['dyo'] },
48  び: { ya: ['bya'], yu: ['byu'], yo: ['byo'] },
49  ぴ: { ya: ['pya'], yu: ['pyu'], yo: ['pyo'] },
50}
51
52const YOUON_SMALL_KANA: Readonly<Record<string, 'ya' | 'yu' | 'yo'>> = {
53  ゃ: 'ya', ゅ: 'yu', ょ: 'yo',
54}
55
56export function youonKindOf(smallKana: string): 'ya' | 'yu' | 'yo' | undefined {
57  return YOUON_SMALL_KANA[smallKana]
58}
59
60/**
61 * `ん` の直後(次のかなユニット)が母音・な行・や行なら、単独の `n` は
62 * 次のかなに吸収され曖昧になるため許容しない(`nn` / `xn` のみ許容)。
63 * 語末(次のかなが無い)は単独 `n` を許容する。
64 */
65const VOWEL_KANA = new Set(['あ', 'い', 'う', 'え', 'お'])
66const NA_ROW_KANA = new Set(['な', 'に', 'ぬ', 'ね', 'の', 'にゃ', 'にゅ', 'にょ'])
67const YA_ROW_KANA = new Set(['や', 'ゆ', 'よ'])
68
69export function bareNIsAmbiguousBefore(nextKanaUnit: string | undefined): boolean {
70  if (nextKanaUnit === undefined) return false
71  return VOWEL_KANA.has(nextKanaUnit) || NA_ROW_KANA.has(nextKanaUnit) || YA_ROW_KANA.has(nextKanaUnit)
72}
73