From 9e2eb9ec075d5c2ff617713eead641061013af27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=82=E3=83=BC=E3=82=8B?= Date: Wed, 11 Nov 2026 00:34:36 +0900 Subject: [PATCH] =?UTF-8?q?=E6=89=8B=E9=A0=86=E7=B7=B4=E7=BF=92=E3=81=AB?= =?UTF-8?q?=20F2L=20=E3=81=AE=E3=82=B9=E3=83=AD=E3=83=83=E3=83=88=E3=81=A8?= =?UTF-8?q?=E6=89=8B=E6=95=B0=E3=82=92=E3=80=81=E7=B5=B1=E8=A8=88=E3=81=AB?= =?UTF-8?q?=E6=AC=A1=E3=81=AB=E8=A6=9A=E3=81=88=E3=82=8B=E3=82=B1=E3=83=BC?= =?UTF-8?q?=E3=82=B9=E3=82=92=E3=80=81=E7=B7=B4=E7=BF=92=E3=81=AB=20PLL=20?= =?UTF-8?q?=E3=81=AE=E8=AA=8D=E8=AD=98=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 手順練習の F2L を、右手前・左手前・右奥・左奥のスロットごとに練習できるようにする(手順は右手前の鏡写し、図はスロットの角から見た向き) - 手順の選択肢に、手数・使う面・持ち替えの回数を出す - 統計のケース別(OLL・PLL)に、2-look で揃えているケースを縮む時間の見込みが大きい順に挙げる「次に覚えるケース」を足す - 練習に、前と右の2面を見て答える「PLL の認識」を足す(キューブなしでできる。記録は端末のブラウザに保存) - y で持ち替える手順の形を、向きを戻してから作る(F2L の F を使わない手順の図と「この形にする」が別のスロットの形になっていた。V perm の図も直し、既存の DB はマイグレーションで直す) Co-Authored-By: Claude --- README.md | 5 +- internal/db/migrations/0004_cases.sql | 2 +- .../db/migrations/0010_v_perm_pattern.sql | 6 + web/src/cases/alg-info.test.ts | 15 ++ web/src/cases/alg-info.ts | 28 +++ web/src/cases/algs.test.ts | 11 +- web/src/cases/algs.ts | 29 +-- web/src/cases/f2l-view.ts | 8 +- web/src/cases/geometry.ts | 10 + web/src/cases/next.test.ts | 32 +++ web/src/cases/next.ts | 47 ++++ web/src/cases/recog.test.ts | 63 ++++++ web/src/cases/recog.ts | 91 ++++++++ web/src/cases/seed.ts | 4 +- web/src/cases/setup.ts | 5 +- web/src/cases/slots.test.ts | 89 ++++++++ web/src/cases/slots.ts | 84 ++++++++ web/src/components/CaseDiagram.tsx | 18 +- web/src/components/CaseStatsPanel.tsx | 38 ++++ web/src/index.css | 107 ++++++++++ web/src/pages/AlgDrillPage.tsx | 99 +++++++-- web/src/pages/PllRecogPractice.tsx | 200 ++++++++++++++++++ web/src/pages/PracticePage.tsx | 7 +- 23 files changed, 942 insertions(+), 56 deletions(-) create mode 100644 internal/db/migrations/0010_v_perm_pattern.sql create mode 100644 web/src/cases/alg-info.test.ts create mode 100644 web/src/cases/alg-info.ts create mode 100644 web/src/cases/next.test.ts create mode 100644 web/src/cases/next.ts create mode 100644 web/src/cases/recog.test.ts create mode 100644 web/src/cases/recog.ts create mode 100644 web/src/cases/slots.test.ts create mode 100644 web/src/cases/slots.ts create mode 100644 web/src/pages/PllRecogPractice.tsx diff --git a/README.md b/README.md index 5de480f..d5108b1 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Bluetooth でつないだスマートキューブの回転をそのまま記録 ### 統計と履歴 -メニューの「解析」に、統計・履歴・AI解析をタブでまとめています。統計はベスト・ao5・ao12 とその推移、タイムの分布、段階ごとの平均を表示します。フルソルブでは、各段階を始めたときのケース(OLL・PLL・F2L)ごとに、時間・認識・手数を並べ、全体の平均より遅い分が大きい順に出します。そこから手順練習へ移れます。目標タイムを入れると、自分の速い回の配分から段階ごとの目標と、どの段階をどれだけ縮めるかを出します。ソルブの詳細では、標準手順より遠回りした段階や 2-look で揃えた段階を「この手順なら速かった」として、縮められた時間の見込みと一緒に挙げます。履歴は期間やモードで絞り込み、JSON・CSV・テキストで書き出せます。 +メニューの「解析」に、統計・履歴・AI解析をタブでまとめています。統計はベスト・ao5・ao12 とその推移、タイムの分布、段階ごとの平均を表示します。フルソルブでは、各段階を始めたときのケース(OLL・PLL・F2L)ごとに、時間・認識・手数を並べ、全体の平均より遅い分が大きい順に出します。そこから手順練習へ移れます。OLL・PLL では、2-look で揃えていることが多いケースを「次に覚えるケース」として、覚えたときに縮む時間の見込みが大きい順に挙げます。目標タイムを入れると、自分の速い回の配分から段階ごとの目標と、どの段階をどれだけ縮めるかを出します。ソルブの詳細では、標準手順より遠回りした段階や 2-look で揃えた段階を「この手順なら速かった」として、縮められた時間の見込みと一緒に挙げます。履歴は期間やモードで絞り込み、JSON・CSV・テキストで書き出せます。 ![統計](docs/images/stats.jpg) @@ -32,8 +32,9 @@ Bluetooth でつないだスマートキューブの回転をそのまま記録 ### 練習 -- **手順練習**:OLL 57・PLL 21・F2L 41 のケースから1つ選び、その手順を何度も回して1回ごとの時間を測ります。キューブが揃っていなくても、手順を回し終えるたびに記録します。「この形にする」を押すと、今のキューブの状態からそのケースの形へ持っていく手順を出し、回し終えたところから計測します。ケースによっては別の手順も選べ、覚えたケースに印を付けられます +- **手順練習**:OLL 57・PLL 21・F2L 41 のケースから1つ選び、その手順を何度も回して1回ごとの時間を測ります。キューブが揃っていなくても、手順を回し終えるたびに記録します。「この形にする」を押すと、今のキューブの状態からそのケースの形へ持っていく手順を出し、回し終えたところから計測します。ケースによっては別の手順も選べ(手数と使う面を並べて出します)、覚えたケースに印を付けられます。F2L は、右手前・左手前・右奥・左奥のスロットごとに、そのスロットの形と手順で練習できます(記録はケースごとで、スロットでは分けません) - **ステップ練習**:Cross・F2L・OLL・PLL の直前の状態から、その段階だけを測ります。「覚えた手順だけ」にすると、印を付けたケースだけが出ます +- **PLL の認識**:前と右の2面だけを見て、どの PLL かを答えます。キューブをつながずにスマホでもでき、間違えやすいケースと遅いケースを多めに出します。記録はその端末のブラウザにだけ保存します - **補助付きフルソルブ**:ふつうに解きながら、今の F2L のペア・OLL・PLL のケースと手順を画面に出します。記録はタイマーとは分けて保存します ![手順練習](docs/images/alg-drill.jpg) diff --git a/internal/db/migrations/0004_cases.sql b/internal/db/migrations/0004_cases.sql index 04097eb..d306c26 100644 --- a/internal/db/migrations/0004_cases.sql +++ b/internal/db/migrations/0004_cases.sql @@ -88,7 +88,7 @@ INSERT INTO cases (id, case_set, name, case_group, alg, pattern, sort) VALUES ('pll-Gd', 'PLL', 'Gd perm', 'G', 'R U R'' U'' D R2 U'' R U'' R'' U R'' U R2 D''', 'UUUUUUUUUBBFRRRRRRFLRFFFFFFDDDDDDDDDLRLLLLLLLRFBBBBBBB', 74), ('pll-Na', 'PLL', 'Na perm', 'Diagonal', 'R U R'' U R U R'' F'' R U R'' U'' R'' F R2 U'' R'' U2 R U'' R''', 'UUUUUUUUURLLRRRRRRBFFFFFFFFDDDDDDDDDLRRLLLLLLFBBBBBBBB', 75), ('pll-Nb', 'PLL', 'Nb perm', 'Diagonal', 'R'' U R U'' R'' F'' U'' F R U R'' F R'' F'' R U'' R', 'UUUUUUUUULLRRRRRRRFFBFFFFFFDDDDDDDDDRRLLLLLLLBBFBBBBBB', 76), - ('pll-V', 'PLL', 'V perm', 'Diagonal', 'R'' U R'' U'' y R'' F'' R2 U'' R'' U R'' F R F', 'UUUUUUUUURFLRRRRRRBRFFFFFFFDDDDDDDDDLLRLLLLLLFBBBBBBBB', 77), + ('pll-V', 'PLL', 'V perm', 'Diagonal', 'R'' U R'' U'' y R'' F'' R2 U'' R'' U R'' F R F', 'UUUUUUUUULBRRRRRRRFFBFFFFFFDDDDDDDDDRLLLLLLLLBRFBBBBBB', 77), ('pll-Y', 'PLL', 'Y perm', 'Diagonal', 'F R U'' R'' U'' R U R'' F'' R U R'' U'' R'' F R F''', 'UUUUUUUUULRRRRRRRRFFBFFFFFFDDDDDDDDDRBLLLLLLLBLFBBBBBB', 78), ('f2l-1', 'F2L', 'F2L 1', '角・エッジとも上段', 'F'' U'' F', 'RUURUUUUFRBBURRURRFFDFFRFFRDDBDDDDDDFFLLLLLLLLLUBBBBBB', 79), ('f2l-2', 'F2L', 'F2L 2', '角・エッジとも上段', 'R U R''', 'FFUUUUUURDRRFRRFRRLLFFFUFFUDDLDDDDDDUBBLLLLLLBRRBBBBBB', 80), diff --git a/internal/db/migrations/0010_v_perm_pattern.sql b/internal/db/migrations/0010_v_perm_pattern.sql new file mode 100644 index 0000000..372e1b7 --- /dev/null +++ b/internal/db/migrations/0010_v_perm_pattern.sql @@ -0,0 +1,6 @@ +-- V perm の図(pattern)を、標準手順でそのまま揃う向きに直す。 +-- 途中に y がある手順の形を、向きを戻さないまま作っていたため、1つ回した向きになっていた。 +-- 0004_cases.sql も同じ値に直してあるので、新しく作った DB では何も変わらない。 +UPDATE cases +SET pattern = 'UUUUUUUUULBRRRRRRRFFBFFFFFFDDDDDDDDDRLLLLLLLLBRFBBBBBB' +WHERE id = 'pll-V'; diff --git a/web/src/cases/alg-info.test.ts b/web/src/cases/alg-info.test.ts new file mode 100644 index 0000000..3d5a891 --- /dev/null +++ b/web/src/cases/alg-info.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { algInfo, formatAlgInfo } from './alg-info' + +describe('algInfo', () => { + it('counts turns and lists faces, without rotations', () => { + expect(algInfo("R U R' U' R' F R2 U' R' U' R U R' F'")).toEqual({ moves: 14, faces: ['R', 'U', 'F'], rotations: 0 }) + expect(algInfo("x R' U R' D2 R U' R' D2 R2 x'")).toEqual({ moves: 9, faces: ['R', 'U', 'D'], rotations: 2 }) + expect(algInfo("M2 U M U2 M' U M2")).toEqual({ moves: 7, faces: ['M', 'U'], rotations: 0 }) + }) + + it('formats for the alg list', () => { + expect(formatAlgInfo("y L' U' L")).toBe('3手 ・ L U ・ 持ち替え1回') + expect(formatAlgInfo("R U R'")).toBe('3手 ・ R U') + }) +}) diff --git a/web/src/cases/alg-info.ts b/web/src/cases/alg-info.ts new file mode 100644 index 0000000..58daf02 --- /dev/null +++ b/web/src/cases/alg-info.ts @@ -0,0 +1,28 @@ +// 手順の手数と、使う面。別の手順を選ぶときの目安にする。 + +export type AlgInfo = { + /** 手数(R2 やスライス・ワイドムーブも1手。持ち替えは数えない)。 */ + moves: number + /** 使う面(出てきた順。ワイドムーブは小文字、スライスは M・S・E)。 */ + faces: string[] + /** 持ち替え(x・y・z)の回数。 */ + rotations: number +} + +const isRotation = (move: string) => 'xyz'.includes(move[0]) + +export function algInfo(alg: string): AlgInfo { + const tokens = alg.trim().split(/\s+/).filter(Boolean) + const turns = tokens.filter((m) => !isRotation(m)) + return { + moves: turns.length, + faces: [...new Set(turns.map((m) => m[0]))], + rotations: tokens.length - turns.length, + } +} + +/** 「8手 ・ R U ・ 持ち替え1回」の形。 */ +export function formatAlgInfo(alg: string): string { + const info = algInfo(alg) + return [`${info.moves}手`, info.faces.join(' '), info.rotations > 0 && `持ち替え${info.rotations}回`].filter(Boolean).join(' ・ ') +} diff --git a/web/src/cases/algs.test.ts b/web/src/cases/algs.test.ts index d21be64..c8e04ba 100644 --- a/web/src/cases/algs.test.ts +++ b/web/src/cases/algs.test.ts @@ -5,19 +5,12 @@ import { patternToFacelets } from '../cube/facelets' import { stageLevel } from '../solve/cfop' import { algsFor, ALT_ALGS, defaultAlg, usesF } from './algs' import { ALL_CASES } from './data' -import { applyNormalized, caseSignature, ROTATIONS } from './geometry' +import { applyNormalized, caseSignature, closeRotation } from './geometry' import { caseForAlg } from './seed' const kpuzzle = await cube3x3x3.kpuzzle() -/** 手順の最後に、全体の向きを元に戻す持ち替えを足す(y を含む手順は、回し終えると向きが変わっているため)。 */ -function closeRotation(alg: string): string { - const solved = kpuzzle.defaultPattern() - const r = ROTATIONS.find((r) => solved.applyAlg(`${alg} ${r}`).patternData.CENTERS.pieces.every((v, i) => v === i)) - return r ? `${alg} ${r}` : alg -} - -const caseState = (alg: string) => applyNormalized(kpuzzle, new Alg(closeRotation(alg)).invert().toString()) +const caseState = (alg: string) => applyNormalized(kpuzzle, new Alg(closeRotation(kpuzzle, alg)).invert().toString()) const level = (alg: string) => stageLevel(patternToFacelets(caseState(alg)), 'yellow') const rows = Object.entries(ALT_ALGS).flatMap(([id, algs]) => algs.map((alg) => [id, alg] as const)) diff --git a/web/src/cases/algs.ts b/web/src/cases/algs.ts index 2f08a45..b607b4e 100644 --- a/web/src/cases/algs.ts +++ b/web/src/cases/algs.ts @@ -1,5 +1,6 @@ // ケースごとの別手順(標準手順のほかに選べる手順)。標準手順と同じ向き・同じ前提で書く。 // 同じケースを解けるかは algs.test.ts で検証する。 +import { slotAlg, type F2LSlot } from './slots' export const ALT_ALGS: Record = { 'oll-1': ["R U B' l U l2 x' U' R' F R F'"], @@ -85,12 +86,13 @@ export const ALT_ALGS: Record = { 'f2l-41': ["R U R' U2 R U' R' U R U R'"], } -/** ケースで選べる手順(先頭が標準手順)。 */ -export function algsFor(c: { id: string; alg: string }): string[] { - return [c.alg, ...(ALT_ALGS[c.id] ?? [])] +/** ケースで選べる手順(先頭が標準手順)。F2L は、スロットを渡すとそのスロットの手順にする。 */ +export function algsFor(c: { id: string; alg: string }, slot: F2LSlot = 'FR'): string[] { + return [c.alg, ...(ALT_ALGS[c.id] ?? [])].map((a) => slotAlg(a, slot)) } -const algKey = (id: string) => `cubalyst.drillAlg.${id}` +// 右手前の手順は、スロットを分ける前からのキーのまま +const algKey = (id: string, slot: F2LSlot) => (slot === 'FR' ? `cubalyst.drillAlg.${id}` : `cubalyst.drillAlg.${id}.${slot}`) /** 手順に F(F・F'・F2)が入っているか。 */ export const usesF = (alg: string) => alg.split(/\s+/).some((m) => m[0] === 'F') @@ -98,27 +100,28 @@ export const usesF = (alg: string) => alg.split(/\s+/).some((m) => m[0] === 'F') /** * 初めて選ぶときの手順。F2L は、標準手順が F を使うなら、F を使わない別の手順にする。 * F2L の F はエッジの向きを直すためなので、R・U(と持ち替えて左手)で回せる手順を先に練習できるようにする。 + * ほかのスロットでは、右手前で選ぶ手順を鏡写しにしたものにする。 */ -export function defaultAlg(c: { id: string; alg: string }): string { - if (!c.id.startsWith('f2l-') || !usesF(c.alg)) return c.alg - return algsFor(c).find((a) => !usesF(a)) ?? c.alg +export function defaultAlg(c: { id: string; alg: string }, slot: F2LSlot = 'FR'): string { + if (!c.id.startsWith('f2l-') || !usesF(c.alg)) return slotAlg(c.alg, slot) + return slotAlg(algsFor(c).find((a) => !usesF(a)) ?? c.alg, slot) } /** 手順練習で前回選んだ手順(選べる手順に無ければ初めて選ぶときの手順)。 */ -export function preferredAlg(c: { id: string; alg: string }): string { +export function preferredAlg(c: { id: string; alg: string }, slot: F2LSlot = 'FR'): string { try { - const v = localStorage.getItem(algKey(c.id)) - if (v && algsFor(c).includes(v)) return v + const v = localStorage.getItem(algKey(c.id, slot)) + if (v && algsFor(c, slot).includes(v)) return v } catch { // 読めなければ初めて選ぶときの手順 } - return defaultAlg(c) + return defaultAlg(c, slot) } /** 手順練習で選んだ手順を覚えておく。 */ -export function savePreferredAlg(id: string, alg: string) { +export function savePreferredAlg(id: string, alg: string, slot: F2LSlot = 'FR') { try { - localStorage.setItem(algKey(id), alg) + localStorage.setItem(algKey(id, slot), alg) } catch { // 保存できなくても練習はできる } diff --git a/web/src/cases/f2l-view.ts b/web/src/cases/f2l-view.ts index 7a6605b..1db2530 100644 --- a/web/src/cases/f2l-view.ts +++ b/web/src/cases/f2l-view.ts @@ -1,6 +1,8 @@ // F2L のケースの図で、どのシールを色付きで見せるか。 // facelets は 54 文字(U 0-8・R 9-17・F 18-26・D 27-35・L 36-44・B 45-53)、標準の向き(上面=最終層、FR スロット)。 +import { SLOT_COLORS, type F2LSlot } from './slots' + /** 上段と FR スロットの、パーツごとのシールの位置。 */ const PIECES: number[][] = [ // 上段の角(URF・UFL・ULB・UBR) @@ -23,12 +25,14 @@ const sameColors = (a: string[], b: string[]) => a.length === b.length && [...a] /** * 灰色にするシールの位置。上段と FR スロットにあるパーツのうち、FR のペア(D・F・R の角と F・R のエッジ)でないものを灰色にする。 * 下の2段のほかの場所は揃っているので、色のまま残す。 + * ほかのスロットの図は、そのスロットを右手前に回した facelets(slotView)と、スロットを渡す(ペアの色が変わる)。 */ -export function f2lGrayStickers(facelets: string): Set { +export function f2lGrayStickers(facelets: string, slot: F2LSlot = 'FR'): Set { + const { corner, edge } = SLOT_COLORS[slot] const gray = new Set() for (const piece of PIECES) { const colors = piece.map((i) => facelets[i]) - const pair = piece.length === 3 ? ['D', 'F', 'R'] : ['F', 'R'] + const pair = piece.length === 3 ? corner : edge if (!sameColors(colors, pair)) for (const i of piece) gray.add(i) } return gray diff --git a/web/src/cases/geometry.ts b/web/src/cases/geometry.ts index e32d91c..3e6207f 100644 --- a/web/src/cases/geometry.ts +++ b/web/src/cases/geometry.ts @@ -27,6 +27,16 @@ export function normalizeOrientation(kpuzzle: KPuzzle, pattern: KPattern): KPatt throw new Error('normalizeOrientation: no rotation found') } +/** + * 手順の最後に、全体の向きを元に戻す持ち替えを足す。 + * y などを含む手順は回し終えると向きが変わっているので、逆手順でケースの形を作る前にそろえる。 + */ +export function closeRotation(kpuzzle: KPuzzle, alg: string): string { + const solved = kpuzzle.defaultPattern() + const r = ROTATIONS.find((r) => solved.applyAlg(`${alg} ${r}`).patternData.CENTERS.pieces.every((v, i) => v === i)) + return r ? `${alg} ${r}` : alg +} + /** 手順を当てた状態(向きは正規化済み)。 */ export function applyNormalized(kpuzzle: KPuzzle, alg: string, start?: KPattern): KPattern { return normalizeOrientation(kpuzzle, (start ?? kpuzzle.defaultPattern()).applyAlg(alg)) diff --git a/web/src/cases/next.test.ts b/web/src/cases/next.test.ts new file mode 100644 index 0000000..c870678 --- /dev/null +++ b/web/src/cases/next.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { nextToLearn } from './next' + +describe('nextToLearn', () => { + it('ranks 2-look cases by the time lost against 1-look cases', () => { + const r = nextToLearn([ + { id: 'pll-T', count: 3, avg: 5000, steps: 1 }, + { id: 'pll-Ub', count: 1, avg: 7000, steps: 1 }, + { id: 'pll-V', count: 4, avg: 13500, steps: 2 }, + { id: 'pll-E', count: 3, avg: 17500, steps: 2.3 }, + { id: 'pll-Ja', count: 1, avg: 5000, steps: 2 }, + { id: '', count: 2, avg: 30000, steps: 2 }, + ])! + expect(r.oneLookAvg).toBe(5500) + expect(r.cases.map((c) => c.id)).toEqual(['pll-E', 'pll-V']) + expect(r.cases[0].loss).toBe(36000) + }) + + it('leaves out skipped cases, but keeps them in the baseline', () => { + const rows = [ + { id: 'pll-T', count: 1, avg: 5000, steps: 1 }, + { id: 'pll-V', count: 4, avg: 13500, steps: 2 }, + { id: 'pll-E', count: 3, avg: 17500, steps: 2.3 }, + ] + expect(nextToLearn(rows, new Set(['pll-E']))!.cases.map((c) => c.id)).toEqual(['pll-V']) + expect(nextToLearn(rows, new Set(['pll-T']))!.oneLookAvg).toBe(5000) + }) + + it('returns null when no case is solved with one alg', () => { + expect(nextToLearn([{ id: 'pll-V', count: 4, avg: 13500, steps: 2 }])).toBeNull() + }) +}) diff --git a/web/src/cases/next.ts b/web/src/cases/next.ts new file mode 100644 index 0000000..7865df5 --- /dev/null +++ b/web/src/cases/next.ts @@ -0,0 +1,47 @@ +// 次に覚えるケース:2-look で揃えていることが多い OLL・PLL のケースを、覚えたときに縮む時間の見込みが大きい順に出す。 + +export type LastLayerRow = { + id: string + count: number + /** 平均時間(ms)。 */ + avg: number + /** 揃えるのに使った手順の数の平均(1 なら 1-look)。 */ + steps: number +} + +export type NextCase = { + id: string + count: number + avg: number + /** (このケースの平均 − 1つの手順で揃えたケースの平均) × 回数(ms)。 */ + loss: number +} + +/** これ以上なら、ふだん 2-look で揃えているケースとみなす。 */ +const TWO_LOOK = 1.5 + +export type NextToLearn = { + /** 1つの手順で揃えているケースの平均時間(ms)。 */ + oneLookAvg: number + cases: NextCase[] +} + +/** + * 1つの手順で揃えているケースの平均を基準に、2-look のケースで失っている時間を見積もる。 + * 基準にできる(1つの手順で揃えた)ケースが無ければ null。 + * skip のケース(覚えた印を付けたケース)は、基準には入れるが、挙げない。 + */ +export function nextToLearn(rows: LastLayerRow[], skip: ReadonlySet = new Set(), limit = 5): NextToLearn | null { + const known = rows.filter((r) => r.id && r.count > 0) + const one = known.filter((r) => r.steps < TWO_LOOK) + const n = one.reduce((s, r) => s + r.count, 0) + if (n === 0) return null + const oneLookAvg = one.reduce((s, r) => s + r.avg * r.count, 0) / n + const cases = known + .filter((r) => r.steps >= TWO_LOOK && !skip.has(r.id)) + .map((r) => ({ id: r.id, count: r.count, avg: r.avg, loss: Math.max(0, r.avg - oneLookAvg) * r.count })) + .filter((c) => c.loss > 0) + .sort((a, b) => b.loss - a.loss || b.count - a.count) + .slice(0, limit) + return { oneLookAvg, cases } +} diff --git a/web/src/cases/recog.test.ts b/web/src/cases/recog.test.ts new file mode 100644 index 0000000..823c90a --- /dev/null +++ b/web/src/cases/recog.test.ts @@ -0,0 +1,63 @@ +import { cube3x3x3 } from 'cubing/puzzles' +import { describe, expect, it } from 'vitest' +import { faceletsToPattern, patternToFacelets, SOLVED_FACELETS } from '../cube/facelets' +import { PLL_CASES } from './data' +import { caseSignature } from './geometry' +import { addRecogResult, pickRecogCase, recogAvg, recogFacelets, recogWeight } from './recog' +import { casePattern } from './seed' + +const kpuzzle = await cube3x3x3.kpuzzle() + +describe('recogFacelets', () => { + it('keeps the solved cube solved from the front', () => { + expect(recogFacelets(SOLVED_FACELETS, 0, 0)).toBe(SOLVED_FACELETS) + }) + + it('shows the same case from every angle and AUF', () => { + for (const c of PLL_CASES) { + const pattern = casePattern(kpuzzle, c.alg) + for (let turn = 0; turn < 4; turn++) { + for (let auf = 0; auf < 4; auf++) { + // 見る角度を戻す(側面の色を標準に戻す)と、同じ PLL になっている + const f = recogFacelets(pattern, turn, auf) + const back = [...f].map((ch) => ('FRBL'.includes(ch) ? 'FRBL'[('FRBL'.indexOf(ch) + 4 - turn) % 4] : ch)).join('') + const state = faceletsToPattern(kpuzzle, back) + expect(state, `${c.id} ${turn} ${auf}`).not.toBeNull() + expect(caseSignature('PLL', state!)).toBe(caseSignature('PLL', faceletsToPattern(kpuzzle, pattern)!)) + } + } + } + }) + + it('turns the view with the lower layers', () => { + const pattern = patternToFacelets(kpuzzle.defaultPattern().applyAlg("R U R' U' R' F R2 U' R' U' R U R' F'")) + const f = recogFacelets(pattern, 1, 0) + // 右の面が前に来る + expect(f.slice(21, 27)).toBe('RRRRRR') + expect(f.slice(18, 21)).toBe(pattern.slice(9, 12)) + }) +}) + +describe('recog stats', () => { + it('counts correct answers and their time', () => { + let s = addRecogResult({}, 'pll-T', true, 1000) + s = addRecogResult(s, 'pll-T', false, 5000) + s = addRecogResult(s, 'pll-T', true, 2000) + expect(s['pll-T']).toEqual({ n: 3, ok: 2, ms: 3000 }) + expect(recogAvg(s['pll-T'])).toBe(1500) + expect(recogAvg({ n: 1, ok: 0, ms: 0 })).toBeNull() + }) + + it('weights unseen, missed and slow cases more', () => { + const good = { n: 10, ok: 10, ms: 10000 } + expect(recogWeight(undefined, 1000)).toBeGreaterThan(recogWeight(good, 1000)) + expect(recogWeight({ n: 10, ok: 5, ms: 5000 }, 1000)).toBeGreaterThan(recogWeight(good, 1000)) + expect(recogWeight({ n: 10, ok: 10, ms: 20000 }, 1000)).toBeGreaterThan(recogWeight(good, 1000)) + }) + + it('does not pick the same case twice in a row', () => { + const ids = ['a', 'b', 'c'] + for (const r of [0, 0.3, 0.6, 0.999]) expect(pickRecogCase(ids, {}, 'a', r)).not.toBe('a') + expect(pickRecogCase(['a'], {}, 'a', 0.5)).toBe('a') + }) +}) diff --git a/web/src/cases/recog.ts b/web/src/cases/recog.ts new file mode 100644 index 0000000..d7b563a --- /dev/null +++ b/web/src/cases/recog.ts @@ -0,0 +1,91 @@ +// PLL の認識練習:前と右の2面だけを見て、どの PLL かを答える。キューブは使わない。 +// facelets は 54 文字(U 0-8・R 9-17・F 18-26・D 27-35・L 36-44・B 45-53)、標準の向き(上面=最終層)。 + +import { SOLVED_FACELETS } from '../cube/facelets' + +/** 最終層の側面のシール。前の左から、上から見て反時計回りに1周(前・右・奥・左)。 */ +const RING = [18, 19, 20, 9, 10, 11, 45, 46, 47, 36, 37, 38] +const SIDES = ['F', 'R', 'B', 'L'] + +/** + * 出題する向きの facelets。turn(0〜3)は見る角度(どの面を前にするか)、auf(0〜3)は最終層のずれ。 + * 下の2段は揃っていて、最終層の側面だけがケースの並びになる。 + */ +export function recogFacelets(pattern: string, turn: number, auf: number): string { + const out = [...SOLVED_FACELETS] + // 見る角度:下の2段の色が回る + const side = (k: number) => SIDES[(k + turn) % 4] + for (const [start, k] of [[18, 0], [9, 1], [45, 2], [36, 3]] as const) { + for (let i = 0; i < 9; i++) out[start + i] = side(k) + } + // 最終層:同じ角度から見た並びを、auf のぶん色を巡回させる(最終層だけを回したのと同じ) + RING.forEach((pos, i) => { + const color = pattern[RING[(i + 3 * turn) % 12]] + out[pos] = SIDES[(SIDES.indexOf(color) + auf) % 4] + }) + return out.join('') +} + +export type RecogStat = { + /** 出題された回数。 */ + n: number + /** 正解した回数。 */ + ok: number + /** 正解したときの時間の合計(ms)。 */ + ms: number +} + +export type RecogStats = Record + +const KEY = 'cubalyst.pllRecog' + +export function loadRecogStats(): RecogStats { + try { + const v: unknown = JSON.parse(localStorage.getItem(KEY) ?? '{}') + return v && typeof v === 'object' ? (v as RecogStats) : {} + } catch { + return {} + } +} + +export function saveRecogStats(stats: RecogStats) { + try { + localStorage.setItem(KEY, JSON.stringify(stats)) + } catch { + // 保存できなくても練習はできる + } +} + +export function addRecogResult(stats: RecogStats, id: string, correct: boolean, ms: number): RecogStats { + const cur = stats[id] ?? { n: 0, ok: 0, ms: 0 } + return { ...stats, [id]: { n: cur.n + 1, ok: cur.ok + (correct ? 1 : 0), ms: cur.ms + (correct ? ms : 0) } } +} + +/** 正解したときの平均時間(ms)。正解が無ければ null。 */ +export const recogAvg = (s: RecogStat | undefined) => (s && s.ok > 0 ? s.ms / s.ok : null) + +/** + * 出やすさ。まだ出ていないケースと、間違えやすいケース、遅いケースを多めに出す。 + * mean は全体の(正解したときの)平均時間。 + */ +export function recogWeight(s: RecogStat | undefined, mean: number | null): number { + if (!s || s.n === 0) return 3 + const miss = 1 - s.ok / s.n + const avg = recogAvg(s) + const slow = avg != null && mean != null && mean > 0 ? Math.max(0, avg / mean - 1) : 0 + return 1 + 3 * miss + Math.min(2, 2 * slow) +} + +/** 次に出すケース。直前のケースは続けて出さない。rand は 0 以上 1 未満。 */ +export function pickRecogCase(ids: string[], stats: RecogStats, last: string | null, rand: number): string { + const pool = ids.length > 1 ? ids.filter((id) => id !== last) : ids + const total = Object.values(stats).reduce((a, s) => ({ ok: a.ok + s.ok, ms: a.ms + s.ms }), { ok: 0, ms: 0 }) + const mean = total.ok > 0 ? total.ms / total.ok : null + const weights = pool.map((id) => recogWeight(stats[id], mean)) + let x = rand * weights.reduce((a, b) => a + b, 0) + for (let i = 0; i < pool.length; i++) { + x -= weights[i] + if (x < 0) return pool[i] + } + return pool[pool.length - 1] +} diff --git a/web/src/cases/seed.ts b/web/src/cases/seed.ts index 78b5eff..28dc1ba 100644 --- a/web/src/cases/seed.ts +++ b/web/src/cases/seed.ts @@ -4,11 +4,11 @@ import { Alg } from 'cubing/alg' import type { KPuzzle } from 'cubing/kpuzzle' import { patternToFacelets } from '../cube/facelets' import { ALL_CASES } from './data' -import { applyNormalized } from './geometry' +import { applyNormalized, closeRotation } from './geometry' /** ケースの状態(標準の向き)の facelets。画像生成用。 */ export function casePattern(kpuzzle: KPuzzle, alg: string): string { - return patternToFacelets(applyNormalized(kpuzzle, new Alg(alg).invert().toString())) + return patternToFacelets(applyNormalized(kpuzzle, new Alg(closeRotation(kpuzzle, alg)).invert().toString())) } const patterns = new Map() diff --git a/web/src/cases/setup.ts b/web/src/cases/setup.ts index 03bfd72..3bd877c 100644 --- a/web/src/cases/setup.ts +++ b/web/src/cases/setup.ts @@ -6,7 +6,7 @@ import type { KPattern, KPuzzle } from 'cubing/kpuzzle' import type { CubeStore } from '../cube/store' import { ScrambleTracker, type ScrambleProgress } from '../solve/scramble-tracker' import { scrambleBetween } from './exercise' -import { applyNormalized } from './geometry' +import { applyNormalized, closeRotation } from './geometry' export type CaseSetupState = | { phase: 'generating' } @@ -17,9 +17,10 @@ export type CaseSetupState = /** * ケースの状態(キューブ座標系)。持ち方で手順を回すと揃う形=完成状態に持ち方で手順の逆を当てた形。 * rotation は「キューブ自身の向き → 持ち方の向き」への持ち替え(holdRotation)。最後の持ち替えは正規化で消える。 + * y で持ち替える手順は、回し終えたときの向きを元に戻してから逆にする(戻さないと、別のスロットの形になる)。 */ export function caseSetupTarget(kpuzzle: KPuzzle, alg: string, rotation: string): KPattern { - return applyNormalized(kpuzzle, `${rotation} ${new Alg(alg).invert().toString()}`) + return applyNormalized(kpuzzle, `${rotation} ${new Alg(closeRotation(kpuzzle, alg)).invert().toString()}`) } export class CaseSetup { diff --git a/web/src/cases/slots.test.ts b/web/src/cases/slots.test.ts new file mode 100644 index 0000000..6e501ca --- /dev/null +++ b/web/src/cases/slots.test.ts @@ -0,0 +1,89 @@ +import { Alg } from 'cubing/alg' +import { cube3x3x3 } from 'cubing/puzzles' +import { describe, expect, it } from 'vitest' +import { patternToFacelets } from '../cube/facelets' +import { stageLevel } from '../solve/cfop' +import { algsFor, defaultAlg } from './algs' +import { F2L_CASES } from './data' +import { f2lGrayStickers } from './f2l-view' +import { applyNormalized, closeRotation } from './geometry' +import { casePattern } from './seed' +import { F2L_SLOTS, SLOT_PIECES, slotAlg, slotView, type F2LSlot } from './slots' + +const kpuzzle = await cube3x3x3.kpuzzle() +const solved = kpuzzle.defaultPattern() +const AUF = ['', 'U', 'U2', "U'"] + +const caseState = (alg: string) => applyNormalized(kpuzzle, new Alg(closeRotation(kpuzzle, alg)).invert().toString()) + +/** スロットのペアの位置と向き(前 AUF の違いは同じ形とみなす)。 */ +function signature(slot: F2LSlot, alg: string): string { + const { corner, edge } = SLOT_PIECES[slot] + const state = caseState(alg) + return AUF.map((a) => { + const d = (a ? state.applyAlg(a) : state).patternData + const c = d.CORNERS.pieces.indexOf(corner) + const e = d.EDGES.pieces.indexOf(edge) + return `${c}:${d.CORNERS.orientation[c]}/${e}:${d.EDGES.orientation[e]}` + }).sort()[0] +} + +describe('slotAlg', () => { + it('mirrors moves for each slot', () => { + expect(slotAlg("R U R'", 'FR')).toBe("R U R'") + expect(slotAlg("R U R'", 'FL')).toBe("L' U' L") + expect(slotAlg("R U R'", 'BR')).toBe("R' U' R") + expect(slotAlg("R U R'", 'BL')).toBe("L U L'") + expect(slotAlg("y L' U2 L F", 'FL')).toBe("y' R U2 R' F'") + expect(slotAlg("y L' U2 L F", 'BR')).toBe("y' L U2 L' B'") + expect(slotAlg("y L' U2 L F", 'BL')).toBe("y R' U2 R B") + }) + + it('keeps the direction of slices and rotations on the mirror axis', () => { + expect(slotAlg("M x S z", 'FL')).toBe("M x S' z'") + expect(slotAlg("M x S z", 'BR')).toBe("M' x' S z") + expect(slotAlg("M x S z", 'BL')).toBe("M' x' S' z'") + }) +}) + +describe.each(F2L_SLOTS.map((s) => s.slot))('F2L in the %s slot', (slot) => { + const others = F2L_SLOTS.map((s) => s.slot).filter((s) => s !== slot) + + it('every alg of a case solves the same shape, and only that slot', () => { + for (const c of F2L_CASES) { + const algs = algsFor(c, slot) + const sig = signature(slot, algs[0]) + for (const alg of algs) { + expect(signature(slot, alg), `${c.id} ${alg}`).toBe(sig) + // クロスとほかの3つのスロットは揃ったまま + expect(stageLevel(patternToFacelets(caseState(alg)), 'yellow'), `${c.id} ${alg}`).toBe( + stageLevel(patternToFacelets(caseState(c.alg)), 'yellow'), + ) + for (const o of others) expect(signature(o, alg), `${c.id} ${alg} ${o}`).toBe(signature(o, '')) + } + } + }) + + it('has 41 different shapes', () => { + expect(new Set(F2L_CASES.map((c) => signature(slot, defaultAlg(c, slot)))).size).toBe(41) + }) + + it('draws the pair in color, seen from the corner of the slot', () => { + for (const c of F2L_CASES) { + const fr = f2lGrayStickers(casePattern(kpuzzle, c.alg)) + const view = slotView(casePattern(kpuzzle, defaultAlg(c, slot)), slot) + expect(f2lGrayStickers(view, slot).size, c.id).toBe(fr.size) + } + }) +}) + +describe('slotView', () => { + it('turns the whole cube so that the slot comes to the front right', () => { + const state = solved.applyAlg("R U R' F' U2 F L D") + const f = patternToFacelets(state) + expect(slotView(f, 'FR')).toBe(f) + expect(slotView(f, 'BR')).toBe(patternToFacelets(state.applyAlg('y'))) + expect(slotView(f, 'BL')).toBe(patternToFacelets(state.applyAlg('y2'))) + expect(slotView(f, 'FL')).toBe(patternToFacelets(state.applyAlg("y'"))) + }) +}) diff --git a/web/src/cases/slots.ts b/web/src/cases/slots.ts new file mode 100644 index 0000000..f0f38e6 --- /dev/null +++ b/web/src/cases/slots.ts @@ -0,0 +1,84 @@ +// F2L のスロット(右手前・左手前・右奥・左奥)。 +// ケースと手順は FR スロット(右手前)で持ち、ほかのスロットは左右・前後の鏡写しで作る。 +// 左手前は左右の鏡写し、右奥は前後の鏡写し、左奥はその両方(y2 で回したのと同じ)。 +// 鏡写しなので、左手前と右奥では、ペアの形も FR のケースを鏡に映した形になる。 + +export type F2LSlot = 'FR' | 'FL' | 'BR' | 'BL' + +export const F2L_SLOTS: { slot: F2LSlot; label: string }[] = [ + { slot: 'FR', label: '右手前' }, + { slot: 'FL', label: '左手前' }, + { slot: 'BR', label: '右奥' }, + { slot: 'BL', label: '左奥' }, +] + +export const slotLabel = (slot: F2LSlot) => F2L_SLOTS.find((s) => s.slot === slot)!.label + +/** スロットのペアのパーツ(cubing.js の番号)。角は DRF・DFL・DBR・DLB、エッジは FR・FL・BR・BL。 */ +export const SLOT_PIECES: Record = { + FR: { corner: 4, edge: 8 }, + FL: { corner: 5, edge: 9 }, + BR: { corner: 7, edge: 10 }, + BL: { corner: 6, edge: 11 }, +} + +/** スロットのペアのシールの色(面の名前)。 */ +export const SLOT_COLORS: Record = { + FR: { corner: ['D', 'F', 'R'], edge: ['F', 'R'] }, + FL: { corner: ['D', 'F', 'L'], edge: ['F', 'L'] }, + BR: { corner: ['D', 'B', 'R'], edge: ['B', 'R'] }, + BL: { corner: ['D', 'B', 'L'], edge: ['B', 'L'] }, +} + +/** 鏡写しでの、手の面の入れ替えと、回す向きを逆にしない手。 */ +type Mirror = { swap: Record; keep: string } + +// 左右の鏡写し:R と L が入れ替わり、回す向きが逆になる。左右の軸で回す M・x は、鏡に映しても同じ向き +const LEFT_RIGHT: Mirror = { swap: { R: 'L', L: 'R', r: 'l', l: 'r' }, keep: 'Mx' } +// 前後の鏡写し:F と B が入れ替わる。前後の軸で回す S・z は同じ向き +const FRONT_BACK: Mirror = { swap: { F: 'B', B: 'F', f: 'b', b: 'f' }, keep: 'Sz' } + +const MIRRORS: Record = { + FR: [], + FL: [LEFT_RIGHT], + BR: [FRONT_BACK], + BL: [LEFT_RIGHT, FRONT_BACK], +} + +function mirrorMove(move: string, m: Mirror): string { + const face = move[0] + const suffix = move.slice(1) + const to = m.swap[face] ?? face + if (m.keep.includes(face) || suffix.startsWith('2')) return to + suffix + return to + (suffix === "'" ? '' : "'") +} + +/** FR スロットの手順を、そのスロットの手順にする。 */ +export function slotAlg(alg: string, slot: F2LSlot): string { + const mirrors = MIRRORS[slot] + if (mirrors.length === 0) return alg + return alg + .trim() + .split(/\s+/) + .map((move) => mirrors.reduce(mirrorMove, move)) + .join(' ') +} + +/** 上から見て時計回りに、キューブごと 90 度回した facelets(y。前に R 面が来る)。色はそのまま。 */ +function rotateY(f: string): string { + const face = (start: number) => f.slice(start, start + 9) + const [u, r, front, d, l, b] = [0, 9, 18, 27, 36, 45].map(face) + // 上面は時計回り、下面は(下から見て)反時計回りに回る + const cw = (s: string) => [6, 3, 0, 7, 4, 1, 8, 5, 2].map((i) => s[i]).join('') + const ccw = (s: string) => [2, 5, 8, 1, 4, 7, 0, 3, 6].map((i) => s[i]).join('') + return cw(u) + b + r + ccw(d) + front + l +} + +const VIEW_TURNS: Record = { FR: 0, BR: 1, BL: 2, FL: 3 } + +/** そのスロットが右手前に来るよう、キューブごと回した facelets(スロットの角から見た図を描くため)。 */ +export function slotView(facelets: string, slot: F2LSlot): string { + let f = facelets + for (let i = 0; i < VIEW_TURNS[slot]; i++) f = rotateY(f) + return f +} diff --git a/web/src/components/CaseDiagram.tsx b/web/src/components/CaseDiagram.tsx index 26cf2ff..8194084 100644 --- a/web/src/components/CaseDiagram.tsx +++ b/web/src/components/CaseDiagram.tsx @@ -2,6 +2,7 @@ import { useId } from 'react' import type { CaseInfo } from '../api' import { f2lGrayStickers } from '../cases/f2l-view' import { pllArrows } from '../cases/pll-arrows' +import { slotView, type F2LSlot } from '../cases/slots' // 標準の向き(上面=黄、前面=緑)での表示色。 const COLORS: Record = { @@ -21,15 +22,18 @@ const G = 2 // すき間 * ケースの図。OLL/PLL は上面(+側面の上段)、F2L は右手前の上から見た立体(上面・前面・右面)。 * OLLは最終層の色以外を灰色にし、向きだけが分かるようにする。 * arrows を付けると、PLL ではどのパーツをどこへ動かすかを矢印で重ねる(小さい図では見づらいので、大きい図だけで使う)。 + * slot を付けると、F2L をそのスロットの角から見た図にする(pattern はそのスロットの手順で作ったもの)。 */ export function CaseDiagram({ c, size = 72, arrows = false, + slot = 'FR', }: { c: Pick size?: number arrows?: boolean + slot?: F2LSlot }) { const markerId = useId() const f = c.pattern @@ -44,7 +48,10 @@ export function CaseDiagram({ const cells: React.ReactNode[] = [] const step = S + G - if (c.set === 'F2L') return + if (c.set === 'F2L') { + const view = slotView(f, slot) + return + } // 上面+側面の上段(細長いシール) const o = 6 // 側面ぶんの余白 @@ -120,12 +127,11 @@ function quad(o: Vec, a: Vec, b: Vec): string { } /** - * F2L のケースの図。キューブを右手前の上から見た立体で、上面・前面・右面を描く。 - * FR のペア(角とエッジ)だけ色を付け、上段のほかのパーツは灰色にする。 + * キューブを右手前の上から見た立体の図(上面・前面・右面)。gray のシールは灰色にする。 + * F2L のケースの図では、スロットのペア(角とエッジ)だけ色を付け、上段のほかのパーツを灰色にする。 */ -function F2LCube({ facelets, size }: { facelets: string; size: number }) { - const gray = f2lGrayStickers(facelets) - const fill = (i: number) => (gray.has(i) ? GRAY : (COLORS[facelets[i]] ?? GRAY)) +export function IsoCube({ facelets, gray, size = 72 }: { facelets: string; gray?: Set; size?: number }) { + const fill = (i: number) => (gray?.has(i) ? GRAY : (COLORS[facelets[i]] ?? GRAY)) const stickers: React.ReactNode[] = [] const add = (i: number, o: Vec, a: Vec, b: Vec) => stickers.push() diff --git a/web/src/components/CaseStatsPanel.tsx b/web/src/components/CaseStatsPanel.tsx index 428bfbd..0c71d4d 100644 --- a/web/src/components/CaseStatsPanel.tsx +++ b/web/src/components/CaseStatsPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router' import { getStageCaseSummary, listCases, type CaseInfo, type SolveMode, type StageCaseSummary } from '../api' +import { nextToLearn } from '../cases/next' import { formatTime } from '../solve/stats' import { CaseDiagram } from './CaseDiagram' @@ -115,6 +116,15 @@ export function CaseStatsPanel({ mode, from, to }: { mode: SolveMode; from?: str const counted = all.reduce((n, r) => n + r.count, 0) const mean = counted > 0 ? all.reduce((s, r) => s + r.avg * r.count, 0) / counted : 0 const unmatched = data && set === 'F2L' ? data.summary.pairs - counted : 0 + const next = useMemo(() => { + if (!data || set === 'F2L') return null + const list = data.summary[set === 'OLL' ? 'oll' : 'pll'] + const learned = new Set([...cases.values()].filter((c) => c.learned).map((c) => c.id)) + return nextToLearn( + list.map((c) => ({ id: c.id, count: c.count, avg: c.avg_ms, steps: c.avg_steps })), + learned, + ) + }, [data, set, cases]) return (
@@ -145,6 +155,34 @@ export function CaseStatsPanel({ mode, from, to }: { mode: SolveMode; from?: str {error &&

{error}

} {!data && !error &&

集計しています…

} {data && rows.length === 0 &&

この条件では {set} のケースを判定できたソルブがありません

} + {next && next.cases.length > 0 && ( +
+

次に覚えるケース

+

+ 2-look で揃えていることが多いケースです。1つの手順で揃えているケースの平均({formatTime(next.oneLookAvg, 2)} + )まで縮むとしたときの、縮む時間の合計が大きい順に並べています。覚えた印を付けたケースは除いています。 +

+
    + {next.cases.map((n) => { + const c = cases.get(n.id) + return ( +
  1. + {c && } +
    + {c?.name ?? n.id} +
    + {n.count}回 ・ 平均 {formatTime(n.avg, 2)} +
    +
    + −{(n.loss / 1000).toFixed(1)}秒 {c && 練習} +
    +
    +
  2. + ) + })} +
+
+ )} {data && rows.length > 0 && ( <>
diff --git a/web/src/index.css b/web/src/index.css index b40e900..7a78a85 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1947,3 +1947,110 @@ button.danger-solid { color: var(--warn); font-weight: 700; } + +/* 統計のケース別:次に覚えるケース */ +.next-cases { + margin: 4px 0 12px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 6px; +} + +.next-cases h4 { + margin: 0 0 4px; +} + +.next-cases-list { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + margin: 8px 0 0; + padding: 0; + list-style: none; +} + +.next-cases-list li { + display: flex; + align-items: center; + gap: 8px; +} + +/* PLL の認識練習 */ +.recog { + display: flex; + flex-direction: column; + gap: 12px; +} + +.recog-question { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px 32px; +} + +.recog-cube { + width: 200px; + height: 200px; +} + +.recog-result { + flex: 1; + min-width: 220px; + min-height: 130px; +} + +.recog-ok, +.recog-ng { + font-size: 1.2rem; + font-weight: 700; +} + +.recog-ok { + color: var(--ok); +} + +.recog-ng { + color: var(--error); +} + +.recog-answers { + display: flex; + flex-direction: column; + gap: 8px; +} + +.recog-answer-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.recog-answer-group > .muted { + width: 5.5em; +} + +.recog-answer { + min-width: 3.2em; + min-height: 2.6em; + font-family: var(--font-num); + font-size: 1.05rem; +} + +.recog-answer.correct { + border-color: var(--ok); + box-shadow: inset 0 0 0 2px var(--ok); + opacity: 1; +} + +.recog-answer.wrong { + border-color: var(--error); + box-shadow: inset 0 0 0 2px var(--error); + opacity: 1; +} + +.recog-stats { + width: auto; + margin-top: 8px; +} diff --git a/web/src/pages/AlgDrillPage.tsx b/web/src/pages/AlgDrillPage.tsx index 2a19d73..ef65002 100644 --- a/web/src/pages/AlgDrillPage.tsx +++ b/web/src/pages/AlgDrillPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { createSolve, listCases, setCaseLearned, type CaseInfo } from '../api' +import { formatAlgInfo } from '../cases/alg-info' import { algsFor, preferredAlg, savePreferredAlg } from '../cases/algs' import type { DrillAttempt } from '../cases/drill' import { useAlgDrill, useCaseSetup, useCaseSetupState, useDrillState } from '../cases/hooks' @@ -7,6 +8,7 @@ import type { CaseSetup, CaseSetupState } from '../cases/setup' import { scrambleBetween } from '../cases/exercise' import { invertRotation, normalizeOrientation } from '../cases/geometry' import { caseForAlg } from '../cases/seed' +import { F2L_SLOTS, slotLabel, type F2LSlot } from '../cases/slots' import { caseStats } from '../cases/stats' import { CaseDiagram } from '../components/CaseDiagram' import { FitLayout } from '../components/FitLayout' @@ -43,6 +45,25 @@ function saveTarget(id: string, sec: number) { } } +const SLOT_KEY = 'cubalyst.drillSlot' + +function loadSlot(): F2LSlot { + try { + const v = localStorage.getItem(SLOT_KEY) + return F2L_SLOTS.find((s) => s.slot === v)?.slot ?? 'FR' + } catch { + return 'FR' + } +} + +function saveSlot(slot: F2LSlot) { + try { + localStorage.setItem(SLOT_KEY, slot) + } catch { + // 保存できなくても練習はできる + } +} + /** ケース ID(oll-…・pll-…・f2l-…)の手順の種類。 */ function setOf(id: string | null | undefined): SetName | undefined { const set = id?.split('-')[0].toUpperCase() @@ -57,6 +78,8 @@ export function AlgDrillPage({ initialCaseId }: { initialCaseId?: string | null const [selectedId, setSelectedId] = useState(() => (setOf(initialCaseId) ? initialCaseId! : null)) const [running, setRunning] = useState(false) const [chosenAlg, setChosenAlg] = useState>({}) + // F2L を練習するスロット。?case= で来たときは、統計のケース(右手前の形)をそのまま出す + const [f2lSlot, setF2lSlot] = useState(() => (initialCaseId ? 'FR' : loadSlot())) const store = useCubeStore() const history = useSolveList({ mode: 'case', limit: 0 }) const stats = useMemo(() => caseStats(history.solves), [history.solves]) @@ -76,16 +99,21 @@ export function AlgDrillPage({ initialCaseId }: { initialCaseId?: string | null setError(`「覚えた」を保存できませんでした: ${String(err)}`) }) } - const algOf = (c: CaseInfo) => chosenAlg[c.id] ?? preferredAlg(c) + const slotOf = (c: CaseInfo): F2LSlot => (c.set === 'F2L' ? f2lSlot : 'FR') + const algOf = (c: CaseInfo) => chosenAlg[`${c.id}.${slotOf(c)}`] ?? preferredAlg(c, slotOf(c)) const alg = selected ? algOf(selected) : '' - const chooseAlg = (id: string, a: string) => { - setChosenAlg((prev) => ({ ...prev, [id]: a })) - savePreferredAlg(id, a) + const chooseAlg = (c: CaseInfo, a: string) => { + setChosenAlg((prev) => ({ ...prev, [`${c.id}.${slotOf(c)}`]: a })) + savePreferredAlg(c.id, a, slotOf(c)) + } + const chooseSlot = (slot: F2LSlot) => { + setF2lSlot(slot) + saveSlot(slot) } if (error) return

{error}

if (running && selected) { - return setRunning(false)} onSaved={history.reload} /> + return setRunning(false)} onSaved={history.reload} /> } const inTab = cases.filter((c) => c.set === tab) @@ -105,35 +133,57 @@ export function AlgDrillPage({ initialCaseId }: { initialCaseId?: string | null 練習したいケースを1つ選んでください。覚えたケースに印を付けると、ステップ練習で覚えた手順だけを出せます
+ {tab === 'F2L' && ( +
+ スロット + {F2L_SLOTS.map((s) => ( + + ))} + + 図は、そのスロットの角から見た向きです。左手前と右奥は、右手前のケースを鏡に映した形と手順になります + +
+ )} + {!selected &&
ケースを選ぶと、ここに手順と「この手順を練習」が出ます
} {selected && (
- +
- {selected.name} + + {selected.name} + {selected.set === 'F2L' && `(${slotLabel(f2lSlot)})`} +
- {algsFor(selected).length > 1 ? ( + {algsFor(selected, slotOf(selected)).length > 1 ? (
- {algsFor(selected).map((a, i) => ( + {algsFor(selected, slotOf(selected)).map((a, i) => ( ))}
) : ( -
{selected.alg}
+
+ {alg} {formatAlgInfo(alg)} +
)}
全期間:{stats.get(selected.id)?.count ?? 0}回 ・ 平均 {formatTime(stats.get(selected.id)?.mean)} ・ ベスト{' '} @@ -166,9 +216,9 @@ export function AlgDrillPage({ initialCaseId }: { initialCaseId?: string | null key={c.id} className={`case-card ${selectedId === c.id ? 'selected' : 'selectable'} ${c.learned ? 'learned' : ''}`} onClick={() => setSelectedId(c.id)} - title={c.alg} + title={algOf(c)} > - +
{c.learned && ✓} {c.name} @@ -185,7 +235,19 @@ export function AlgDrillPage({ initialCaseId }: { initialCaseId?: string | null } /** 練習中の画面。 */ -function DrillView({ c, alg, onBack, onSaved }: { c: CaseInfo; alg: string; onBack: () => void; onSaved: () => void }) { +function DrillView({ + c, + alg, + slot, + onBack, + onSaved, +}: { + c: CaseInfo + alg: string + slot: F2LSlot + onBack: () => void + onSaved: () => void +}) { const store = useCubeStore() const { settings } = useSettings() const { status, connection } = useCubeState() @@ -299,9 +361,12 @@ function DrillView({ c, alg, onBack, onSaved }: { c: CaseInfo; alg: string; onBa
- +
- {c.name} + + {c.name} + {c.set === 'F2L' && `(${slotLabel(slot)})`} +
{alg}
揃っていなくても大丈夫です。手順を回し終えるたびに自動で記録します(前後のAUF・別の面での実行もOK)。 diff --git a/web/src/pages/PllRecogPractice.tsx b/web/src/pages/PllRecogPractice.tsx new file mode 100644 index 0000000..e143e33 --- /dev/null +++ b/web/src/pages/PllRecogPractice.tsx @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { listCases, type CaseInfo } from '../api' +import { + addRecogResult, + loadRecogStats, + pickRecogCase, + recogAvg, + recogFacelets, + saveRecogStats, + type RecogStats, +} from '../cases/recog' +import { CaseDiagram, IsoCube } from '../components/CaseDiagram' +import { formatTime } from '../solve/stats' + +type Question = { id: string; facelets: string } +type Answer = { picked: string; correct: boolean; ms: number } + +/** 正解のあと、次の問題へ進むまでの時間。 */ +const NEXT_DELAY_MS = 700 + +const rand4 = () => Math.floor(Math.random() * 4) + +/** + * PLL の認識練習:前と右の2面だけを見て、どの PLL かを答える。キューブは使わないので、スマホでもできる。 + * 記録(ケースごとの正解率と時間)は、この端末のブラウザにだけ保存する。 + */ +export function PllRecogPractice() { + const [cases, setCases] = useState([]) + const [error, setError] = useState(null) + const [stats, setStats] = useState(loadRecogStats) + const [learnedOnly, setLearnedOnly] = useState(false) + const [question, setQuestion] = useState(null) + const [answer, setAnswer] = useState(null) + // 次の問題を選ぶときに、最新の記録を読む(記録が変わるたびに問題を出し直さないため) + const statsRef = useRef(stats) + + useEffect(() => { + listCases() + .then((list) => setCases(list.filter((c) => c.set === 'PLL'))) + .catch((err: unknown) => setError(String(err))) + }, []) + + const byId = useMemo(() => new Map(cases.map((c) => [c.id, c])), [cases]) + const pool = useMemo(() => { + const learned = cases.filter((c) => c.learned) + return learnedOnly && learned.length > 0 ? learned : cases + }, [cases, learnedOnly]) + + const next = useCallback( + (last: string | null) => { + if (pool.length === 0) return + const id = pickRecogCase( + pool.map((c) => c.id), + statsRef.current, + last, + Math.random(), + ) + const c = pool.find((x) => x.id === id)! + setAnswer(null) + setQuestion({ id, facelets: recogFacelets(c.pattern, rand4(), rand4()) }) + }, + [pool], + ) + + // 問題が画面に出た時刻(答えるまでの時間を測る) + const shownAt = useRef(0) + useEffect(() => { + shownAt.current = performance.now() + }, [question]) + + // 最初の問題と、出す範囲を変えたときの出し直し + useEffect(() => { + // eslint-disable-next-line react/set-state-in-effect + next(null) + }, [next]) + + /** at は押した時刻(イベントの timeStamp。performance.now と同じ基準)。 */ + const pick = (id: string, at: number) => { + if (!question || answer) return + const ms = Math.max(0, Math.round(at - shownAt.current)) + const correct = id === question.id + const updated = addRecogResult(statsRef.current, question.id, correct, ms) + statsRef.current = updated + setStats(updated) + saveRecogStats(updated) + setAnswer({ picked: id, correct, ms }) + } + + // 正解なら少し見せてから次へ。間違えたときは、正解の形を見てから自分で進む + useEffect(() => { + if (!answer?.correct || !question) return + const timer = setTimeout(() => next(question.id), NEXT_DELAY_MS) + return () => clearTimeout(timer) + }, [answer, question, next]) + + const reset = () => { + statsRef.current = {} + setStats({}) + saveRecogStats({}) + } + + if (error) return

{error}

+ + const groups = [...new Set(cases.map((c) => c.group))] + const correctCase = question ? byId.get(question.id) : undefined + const total = Object.values(stats).reduce((a, s) => ({ n: a.n + s.n, ok: a.ok + s.ok, ms: a.ms + s.ms }), { n: 0, ok: 0, ms: 0 }) + const rows = cases + .map((c) => ({ c, s: stats[c.id] })) + .filter((r) => r.s && r.s.n > 0) + .sort((a, b) => a.s!.ok / a.s!.n - b.s!.ok / b.s!.n || (recogAvg(b.s) ?? 0) - (recogAvg(a.s) ?? 0)) + + return ( +
+
+ 前と右の2面だけを見て、どの PLL かを答えます。間違えやすいケースと遅いケースが多めに出ます + + +
+ +
+
{question && }
+
+ {!answer &&

下のボタンで答えてください

} + {answer?.correct && ( +

+ 正解 {correctCase?.name} {formatTime(answer.ms, 2)} +

+ )} + {answer && !answer.correct && correctCase && question && ( + <> +

+ 正解は {correctCase.name}(答えたのは {byId.get(answer.picked)?.name}) +

+
+ + +
+ + )} +
+
+ +
+ {groups.map((g) => ( +
+ {g} + {cases + .filter((c) => c.group === g) + .map((c) => { + const mark = !answer ? '' : c.id === question?.id ? 'correct' : c.id === answer.picked ? 'wrong' : '' + return ( + + ) + })} +
+ ))} +
+ +
+
+

記録

+ + {total.n}問 ・ 正解 {total.n > 0 ? Math.round((total.ok / total.n) * 100) : 0}% ・ 平均{' '} + {formatTime(total.ok > 0 ? total.ms / total.ok : undefined, 2)}(この端末のブラウザにだけ保存しています) + + + {total.n > 0 && } +
+ {rows.length > 0 && ( + + + + + + + + + + + {rows.map(({ c, s }) => ( + + + + + + + ))} + +
ケース回数正解率平均
{c.name}{s!.n}{Math.round((s!.ok / s!.n) * 100)}%{formatTime(recogAvg(s) ?? undefined, 2)}
+ )} +
+
+ ) +} diff --git a/web/src/pages/PracticePage.tsx b/web/src/pages/PracticePage.tsx index cb41547..3df2dc0 100644 --- a/web/src/pages/PracticePage.tsx +++ b/web/src/pages/PracticePage.tsx @@ -2,18 +2,20 @@ import { useState } from 'react' import { useSearchParams } from 'react-router' import { AlgDrillPage } from './AlgDrillPage' import { AssistPractice } from './AssistPractice' +import { PllRecogPractice } from './PllRecogPractice' import { StepPractice } from './StepPractice' -type Mode = 'assist' | 'case' | 'step' +type Mode = 'assist' | 'case' | 'step' | 'recog' const MODES: { mode: Mode; label: string }[] = [ { mode: 'assist', label: '補助付きフルソルブ' }, { mode: 'case', label: '手順練習' }, { mode: 'step', label: 'ステップ練習' }, + { mode: 'recog', label: 'PLL の認識' }, ] /** - * 練習:モード選択(補助付きフルソルブ / 手順 / ステップ)。補助なしのフルソルブは上の「タイマー」。 + * 練習:モード選択(補助付きフルソルブ / 手順 / ステップ / PLL の認識)。補助なしのフルソルブは上の「タイマー」。 * ?case=ケースID なら、手順練習でそのケースを選んだ状態で開く(統計のケース別から来る)。 */ export function PracticePage() { @@ -31,6 +33,7 @@ export function PracticePage() { {mode === 'assist' && } {mode === 'case' && } {mode === 'step' && } + {mode === 'recog' && }
) }