@mp-gumi/riddle-progress
v0.1.0
Published
Headless, local-first progress state for browser-based riddles
Maintainers
Readme
@mp-gumi/riddle-progress
ブラウザ謎解き向けの、UIを持たない進行管理ライブラリです。回答、カウンター、チェックポイント、独自ギミック、閲覧済みヒントを localStorage に保存します。
インストール
npm install @mp-gumi/riddle-progress zustandReactから使う場合はReact 18以上とZustand 5以上が必要です。コアAPIだけを使う場合、ReactとZustandは不要です。
基本設定
// progress.ts
import { createRiddleProgress } from '@mp-gumi/riddle-progress/zustand'
const steps = {
q1: {
kind: 'answer',
correctAnswers: ['あいこ', '愛子'],
hintIds: ['q1-hint-1', 'q1-hint-2'],
},
hiddenImage: {
kind: 'counter',
target: 5,
},
prologueRead: {
kind: 'checkpoint',
},
collectedPieces: {
kind: 'custom',
initialData: { ids: [] as string[] },
validateData: (value: unknown): value is { ids: string[] } =>
typeof value === 'object' &&
value !== null &&
Array.isArray((value as { ids?: unknown }).ids),
isComplete: (data: { ids: string[] }) => data.ids.length >= 3,
},
} as const
export const useProgress = createRiddleProgress({
storageKey: 'my-riddle-progress',
version: 1,
steps,
})storageKey は作品ごとに固有の値にしてください。同一オリジン上の別作品との衝突を防ぎます。
Reactで使う
回答を保存・判定する
import { useProgress } from './progress'
export function AnswerForm() {
const state = useProgress((store) => store.progress.steps.q1)
const setAnswerDraft = useProgress((store) => store.setAnswerDraft)
const submitAnswer = useProgress((store) => store.submitAnswer)
return (
<form
onSubmit={(event) => {
event.preventDefault()
submitAnswer('q1', state.answer)
}}
>
<input
value={state.answer}
disabled={state.completed}
onChange={(event) => setAnswerDraft('q1', event.target.value)}
/>
<button disabled={state.completed}>回答する</button>
{state.result === 'incorrect' && <p>不正解です</p>}
</form>
)
}正解済みステップは固定され、その後の回答やデータ更新では巻き戻りません。
画像を5回タップする
const tapState = useProgress((store) => store.progress.steps.hiddenImage)
const incrementCounter = useProgress((store) => store.incrementCounter)
<img
src="/hidden.png"
onClick={() => incrementCounter('hiddenImage')}
aria-label={`${tapState.value}回タップ済み`}
/>ヒントを順番に開く
ヒント本文はサイト側で管理し、ライブラリにはIDだけを設定します。
const hints = {
'q1-hint-1': '最初の文字に注目',
'q1-hint-2': '縦に読んでみよう',
}
const step = useProgress((store) => store.progress.steps.q1)
const revealNextHint = useProgress((store) => store.revealNextHint)
const visibleHints = step.revealedHintIds.map((id) => hints[id])
revealNextHint('q1')revealHint(stepId, hintId) で指定したヒントだけを開くこともできます。
サイト側で進行を判定する
解放条件やクリア条件はライブラリに含まれません。保存状態からサイト側で判定します。
const progress = useProgress((store) => store.progress)
const finalUnlocked =
progress.steps.q1.completed && progress.steps.hiddenImage.completed独自ギミックを更新する
const updateStep = useProgress((store) => store.updateStep)
updateStep('collectedPieces', (step) => ({
...step,
data: {
ids: step.data.ids.includes('red')
? step.data.ids
: [...step.data.ids, 'red'],
},
}))custom の validateData は、保存データの復元時と汎用更新時の実行時検証にも使われます。
全進行をリセットする
const resetAll = useProgress((store) => store.resetAll)
<button
onClick={() => {
if (window.confirm('進行状況をすべてリセットしますか?')) {
resetAll()
}
}}
>
リセット
</button>確認UIやリセットを起動するクリック箇所はサイト側で自由に実装できます。
回答の正規化
標準では次の処理を行います。
- 前後の空白を除去
- Unicode NFKC正規化(全角英数字などを統一)
- 英字の大小文字を無視
- ひらがな・カタカナを同一視
- 途中の空白は保持
したがって アイコ と あいこ は同じ回答として扱われます。入力値そのものを判定したい問題では exact: true を指定します。
const steps = {
strictAnswer: {
kind: 'answer',
correctAnswers: ['ABC'],
exact: true,
},
} as const正規化項目はステップごとに変更できます。
{
kind: 'answer',
correctAnswers: ['A B'],
normalization: {
trim: true,
caseInsensitive: true,
unicode: 'NFKC',
kanaInsensitive: true,
internalWhitespace: 'remove',
},
}独自判定も同期関数として指定できます。
{
kind: 'answer',
validate: (raw, { normalizedAnswer, normalize }) =>
normalizedAnswer === normalize('特別な答え') && raw.length < 20,
}非同期判定はサイト側で行い、結果に応じて独自ステップを更新してください。
バージョン移行
保存形式を変更したら version を上げ、移行先バージョンをキーにした関数を追加します。
createRiddleProgress({
storageKey: 'my-riddle-progress',
version: 2,
steps,
migrations: {
2: (oldProgress) => ({
...oldProgress,
steps: {
...oldProgress.steps,
newId: oldProgress.steps.oldId,
},
}),
},
})追加されたステップは自動的に初期状態で補完され、削除されたステップの保存値は読み込み時に除外されます。必要な移行関数がない場合、初期状態で起動し、元データは resetAll() まで上書きしません。
保存エラー
createRiddleProgress({
storageKey: 'my-riddle-progress',
version: 1,
steps,
onStorageError: (error, { operation, storageKey }) => {
console.error(operation, storageKey, error)
},
})壊れたJSON、未対応バージョン、容量超過などがあっても、サイトは初期状態で動作を続けます。破損データを自動で上書きしないため、保存は resetAll() が成功するまで停止します。
複数タブ同期
同じ作品を複数タブで開いた場合、標準では storage イベントによって状態を同期します。不要なら無効化できます。
createRiddleProgress({
storageKey: 'my-riddle-progress',
version: 1,
steps,
syncTabs: false,
})ストアを破棄する環境では、リスナーも解除してください。
useProgress.dispose()Zustandを使わない場合
パッケージ本体から、純粋な状態更新関数と localStorage 永続化APIを利用できます。
import {
createInitialProgress,
createProgressPersistence,
incrementCounter,
} from '@mp-gumi/riddle-progress'
let progress = createInitialProgress(steps, 1)
progress = incrementCounter(progress, steps, 'hiddenImage')
const persistence = createProgressPersistence({
storageKey: 'my-riddle-progress',
version: 1,
steps,
})
persistence.save(progress)開発
npm install
npm test
npm run pack:check