Replace the look of every page (key, lobby, create, table, range, review) with light/dark semantic colors, translucent materials, iOS-style grouped lists and system typography. Add fluid.js, a small spring engine with interruptible, velocity-aware animations, and use it for draggable bottom sheets, sliding segmented controls, a custom bet slider with rubber-banding, swipe-to-delete in the lobby, and dealt/flipped cards. Seats and cards are now reused between renders so animations are not cut off. Honors reduced motion, reduced transparency and increased contrast. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
7.6 KiB
JavaScript
219 lines
7.6 KiB
JavaScript
// ハンドレンジ学習(管理者専用): レンジ表と色・分類クイズ
|
||
(function () {
|
||
'use strict';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const { RANKS, CATEGORIES, FACING_ACTIONS, handAt, categoryOf, labelOf, facingOf, facingAction } = window.HandRange;
|
||
|
||
Fluid.navBar($('nav'), $('large-title'));
|
||
const tabsSeg = Fluid.Segmented($('tabs'));
|
||
Fluid.Segmented($('count'));
|
||
|
||
function swatch(id) {
|
||
const s = document.createElement('span');
|
||
s.className = `swatch-dot cat-${id}`;
|
||
return s;
|
||
}
|
||
|
||
// レイズを受けたときの対応の枠(3bet は水色、コールは黄)
|
||
function frameSwatch(id) {
|
||
const s = document.createElement('span');
|
||
s.className = `swatch-dot swatch-frame facing-${id}`;
|
||
return s;
|
||
}
|
||
|
||
// ---------- タブ ----------
|
||
function showView(name) {
|
||
$('view-range').hidden = name !== 'range';
|
||
$('view-quiz').hidden = name !== 'quiz';
|
||
for (const b of $('tabs').querySelectorAll('[data-view]')) b.classList.toggle('active', b.dataset.view === name);
|
||
tabsSeg.update();
|
||
}
|
||
$('tabs').addEventListener('click', (e) => {
|
||
const b = e.target.closest('[data-view]');
|
||
if (b) showView(b.dataset.view);
|
||
});
|
||
|
||
// ---------- レンジ表 ----------
|
||
for (const c of CATEGORIES) {
|
||
const item = document.createElement('span');
|
||
item.append(swatch(c.id), c.label);
|
||
$('legend').append(item);
|
||
}
|
||
for (const a of FACING_ACTIONS) {
|
||
const item = document.createElement('span');
|
||
item.append(frameSwatch(a.id), `レイズを受けたら${a.label}`);
|
||
$('legend').append(item);
|
||
}
|
||
|
||
for (let r = 0; r < 13; r++) {
|
||
for (let c = 0; c < 13; c++) {
|
||
const hand = handAt(r, c);
|
||
const cat = categoryOf(hand);
|
||
const b = document.createElement('button');
|
||
b.type = 'button';
|
||
const facing = facingOf(hand);
|
||
b.className = `range-cell cat-${cat}${facing ? ` facing-${facing}` : ''}`;
|
||
b.textContent = hand;
|
||
b.title = `${hand}:${labelOf(cat)}${facing ? `/レイズを受けたら${facingAction(facing).label}` : ''}`;
|
||
b.addEventListener('click', () => select(b, hand, cat));
|
||
$('matrix').append(b);
|
||
}
|
||
}
|
||
|
||
function select(cell, hand, cat) {
|
||
document.querySelectorAll('.range-cell.selected').forEach((x) => x.classList.remove('selected'));
|
||
cell.classList.add('selected');
|
||
const box = $('selected');
|
||
box.replaceChildren();
|
||
const name = document.createElement('div');
|
||
name.className = 'sel-hand';
|
||
name.textContent = hand;
|
||
const label = document.createElement('div');
|
||
label.className = 'sel-label';
|
||
label.append(swatch(cat), labelOf(cat));
|
||
const facing = facingOf(hand);
|
||
const vs = document.createElement('div');
|
||
vs.className = 'sel-label';
|
||
if (facing) vs.append(frameSwatch(facing), `レイズを受けたら${facingAction(facing).label}`);
|
||
else vs.append('レイズを受けたらフォールド');
|
||
const note = document.createElement('div');
|
||
note.className = 'hint';
|
||
note.textContent =
|
||
(cat === 'gray' ? 'どのポジションからもオープンしない手です。' : `${labelOf(cat)} からオープンできる一番狭い分類です。`) +
|
||
'枠は CO のオープンを BTN で受けたときの対応です。';
|
||
box.append(name, label, vs, note);
|
||
}
|
||
|
||
$('start-from-range').addEventListener('click', () => {
|
||
showView('quiz');
|
||
startQuiz();
|
||
});
|
||
|
||
// ---------- クイズ ----------
|
||
const SUITS = ['hearts', 'clubs', 'diamonds', 'spades'];
|
||
const RANK_FILE = { A: 'ace', K: 'king', Q: 'queen', J: 'jack', T: '10' };
|
||
let quizCount = 10;
|
||
let deck = [];
|
||
let index = 0;
|
||
let correct = 0;
|
||
let answered = 0;
|
||
let locked = false;
|
||
|
||
$('count').addEventListener('click', (e) => {
|
||
const b = e.target.closest('[data-count]');
|
||
if (!b) return;
|
||
quizCount = Number(b.dataset.count);
|
||
for (const x of $('count').querySelectorAll('[data-count]')) x.classList.toggle('active', x === b);
|
||
});
|
||
|
||
function shuffle(a) {
|
||
for (let i = a.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[a[i], a[j]] = [a[j], a[i]];
|
||
}
|
||
return a;
|
||
}
|
||
|
||
function startQuiz() {
|
||
const pool = [];
|
||
for (let r = 0; r < 13; r++) for (let c = 0; c < 13; c++) pool.push(handAt(r, c));
|
||
deck = shuffle(pool);
|
||
if (quizCount > 0) deck = deck.slice(0, quizCount);
|
||
index = 0;
|
||
correct = 0;
|
||
answered = 0;
|
||
$('start-quiz').textContent = '最初から';
|
||
renderQuestion();
|
||
}
|
||
|
||
// スーテッドは同じマーク、それ以外は別々のマーク
|
||
function showCards(hand) {
|
||
const s1 = SUITS[Math.floor(Math.random() * 4)];
|
||
let s2 = s1;
|
||
if (hand[2] !== 's') while (s2 === s1) s2 = SUITS[Math.floor(Math.random() * 4)];
|
||
[hand[0], hand[1]].forEach((rank, i) => {
|
||
const img = $(`card-${i + 1}`);
|
||
const suit = i === 0 ? s1 : s2;
|
||
img.hidden = true;
|
||
img.onload = () => { img.hidden = false; };
|
||
img.onerror = () => { img.hidden = true; };
|
||
img.alt = `${RANK_FILE[rank] || rank} of ${suit}`;
|
||
img.src = `/range/files/cards/${RANK_FILE[rank] || rank}_of_${suit}.png`;
|
||
});
|
||
}
|
||
|
||
function updateStats() {
|
||
$('q-score').textContent = `正解 ${correct} / ${answered}`;
|
||
$('q-accuracy').textContent = `正答率 ${answered ? Math.round((correct / answered) * 100) + '%' : '--'}`;
|
||
}
|
||
|
||
function renderQuestion() {
|
||
locked = false;
|
||
$('answers').replaceChildren();
|
||
$('feedback').hidden = true;
|
||
$('next-question').disabled = true;
|
||
if (index >= deck.length) return finishQuiz();
|
||
|
||
const hand = deck[index];
|
||
$('q-hand').textContent = hand;
|
||
$('q-prompt').textContent = 'このハンドの色・分類は?';
|
||
$('q-count').textContent = `問題 ${index + 1} / ${deck.length}`;
|
||
$('progress-bar').style.width = `${(index / deck.length) * 100}%`;
|
||
updateStats();
|
||
showCards(hand);
|
||
|
||
for (const cat of CATEGORIES) {
|
||
const b = document.createElement('button');
|
||
b.type = 'button';
|
||
b.className = 'btn';
|
||
b.dataset.cat = cat.id;
|
||
b.append(swatch(cat.id), cat.label);
|
||
b.addEventListener('click', () => answer(cat.id, b));
|
||
$('answers').append(b);
|
||
}
|
||
}
|
||
|
||
function answer(choice, button) {
|
||
if (locked) return;
|
||
locked = true;
|
||
const hand = deck[index];
|
||
const right = categoryOf(hand);
|
||
const ok = choice === right;
|
||
if (ok) correct++;
|
||
answered++;
|
||
for (const b of $('answers').children) {
|
||
if (b.dataset.cat === right) b.classList.add('correct');
|
||
else if (b === button) b.classList.add('wrong');
|
||
b.disabled = true;
|
||
}
|
||
const fb = $('feedback');
|
||
fb.className = `quiz-feedback ${ok ? 'good' : 'bad'}`;
|
||
fb.textContent = `${ok ? '正解!' : '不正解'} ${hand} は「${labelOf(right)}」です。`;
|
||
fb.hidden = false;
|
||
// 間違えたら選んだボタンが揺れる(振動と同じ瞬間に)
|
||
if (!ok) Fluid.shake(button);
|
||
Fluid.haptic(ok ? 10 : [18, 50, 18]);
|
||
updateStats();
|
||
$('next-question').disabled = false;
|
||
}
|
||
|
||
function finishQuiz() {
|
||
$('q-hand').textContent = '終了!';
|
||
$('q-prompt').textContent = `${deck.length}問中 ${correct}問正解(${deck.length ? Math.round((correct / deck.length) * 100) : 0}%)`;
|
||
$('q-count').textContent = '完了';
|
||
$('progress-bar').style.width = '100%';
|
||
$('card-1').hidden = $('card-2').hidden = true;
|
||
const fb = $('feedback');
|
||
fb.className = 'quiz-feedback';
|
||
fb.textContent = '「最初から」で再挑戦できます。';
|
||
fb.hidden = false;
|
||
}
|
||
|
||
$('start-quiz').addEventListener('click', startQuiz);
|
||
$('next-question').addEventListener('click', () => {
|
||
index++;
|
||
renderQuestion();
|
||
});
|
||
})();
|