Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,17 @@ 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)

![履歴](docs/images/history.jpg)

### 練習

- **手順練習**: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)
Expand Down
2 changes: 1 addition & 1 deletion internal/db/migrations/0004_cases.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
6 changes: 6 additions & 0 deletions internal/db/migrations/0010_v_perm_pattern.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- V perm の図(pattern)を、標準手順でそのまま揃う向きに直す。
-- 途中に y がある手順の形を、向きを戻さないまま作っていたため、1つ回した向きになっていた。
-- 0004_cases.sql も同じ値に直してあるので、新しく作った DB では何も変わらない。
UPDATE cases
SET pattern = 'UUUUUUUUULBRRRRRRRFFBFFFFFFDDDDDDDDDRLLLLLLLLBRFBBBBBB'
WHERE id = 'pll-V';
15 changes: 15 additions & 0 deletions web/src/cases/alg-info.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
28 changes: 28 additions & 0 deletions web/src/cases/alg-info.ts
Original file line number Diff line number Diff line change
@@ -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(' ・ ')
}
11 changes: 2 additions & 9 deletions web/src/cases/algs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
29 changes: 16 additions & 13 deletions web/src/cases/algs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// ケースごとの別手順(標準手順のほかに選べる手順)。標準手順と同じ向き・同じ前提で書く。
// 同じケースを解けるかは algs.test.ts で検証する。
import { slotAlg, type F2LSlot } from './slots'

export const ALT_ALGS: Record<string, string[]> = {
'oll-1': ["R U B' l U l2 x' U' R' F R F'"],
Expand Down Expand Up @@ -85,40 +86,42 @@ export const ALT_ALGS: Record<string, string[]> = {
'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')

/**
* 初めて選ぶときの手順。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 {
// 保存できなくても練習はできる
}
Expand Down
8 changes: 6 additions & 2 deletions web/src/cases/f2l-view.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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<number> {
export function f2lGrayStickers(facelets: string, slot: F2LSlot = 'FR'): Set<number> {
const { corner, edge } = SLOT_COLORS[slot]
const gray = new Set<number>()
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
Expand Down
10 changes: 10 additions & 0 deletions web/src/cases/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
32 changes: 32 additions & 0 deletions web/src/cases/next.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
47 changes: 47 additions & 0 deletions web/src/cases/next.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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 }
}
Loading
Loading