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

寿司打風の日本語タイピングゲームを /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 が必要。
/sushida でペインを開く1/2/3 でコースを選択(お手軽3,000円60秒 / おすすめ5,000円90秒 / 高級10,000円120秒)し=si/shi/ci など複数表記の揺れに対応)R を押すともう一度プレイできるEsc でペインを閉じる。ハイスコアはセッションをまたいで保存される。
cd sushida
npm install
npm test # ローマ字オートマトンのテスト
npm run typecheck
設計の詳細は docs/design.md を参照。
hooks/register.tsx 96 lines1/* @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}
96hooks/game.tsx 375 lines1/* @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
375hooks/romaji/index.ts 3 lines1export { acceptChar, createRomajiState, isComplete, nextAcceptableChars } from './engine.ts'
2export type { RomajiState, RomajiStepResult } from './types.ts'
3hooks/words.ts 71 lines1export 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]
71hooks/romaji/engine.ts 75 lines1import { 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}
75hooks/romaji/types.ts 23 lines1/**
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 }
23hooks/romaji/candidates.ts 66 lines1import { 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}
66hooks/romaji/tokenize.ts 26 lines1import { 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}
26hooks/romaji/kana-table.ts 73 lines1/**
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