Rebuild the UI in an Apple style with spring-driven motion
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>
This commit is contained in:
1 parent
d5ef2d310f
commit
c3fbfdd12e
18 files changed
+4827
-1228
No files matched your search
@@ -64,7 +64,7 @@ http://localhost:4671 を開く。
|
||||
|
||||
## 管理者(ハンドレンジ学習・レビュー付きCPU戦)
|
||||
|
||||
`ADMIN_KEY` を入力して入室すると管理者になり、ロビーに「♠ ハンドレンジ学習」(`/range`:レンジ表と色・分類クイズ。レンジ表には CO のオープンを BTN で受けたときの対応も枠で示す。3bet は水色、コールは黄)と「📝 CPU戦のレビュー」(`/review`)が表示され、テーブル作成で「レビュー付きCPU戦」を選べる。ロビーの各テーブルには「削除」ボタンが出て、どのテーブルでも削除できる(着席者・観戦者には「管理者がテーブルを削除しました」と表示される)。管理者は通常のプレイもそのままできる。
|
||||
`ADMIN_KEY` を入力して入室すると管理者になり、ロビーに「ハンドレンジ学習」(`/range`:レンジ表と色・分類クイズ。レンジ表には CO のオープンを BTN で受けたときの対応も枠で示す。3bet は水色、コールは黄)と「CPU戦のレビュー」(`/review`)が表示され、テーブル作成で「レビュー付きCPU戦」を選べる。ロビーのテーブルの行を左へスワイプすると「削除」が出て(キーボードでは Tab で削除ボタンに移ると開く)、どのテーブルでも削除できる(着席者・観戦者には「管理者がテーブルを削除しました」と表示される)。管理者は通常のプレイもそのままできる。
|
||||
|
||||
### レビュー付きCPU戦
|
||||
|
||||
@@ -117,6 +117,7 @@ http://localhost:4671 を開く。
|
||||
| `server/config.js` | `.env` の読み込みと各種上限値 |
|
||||
| `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) |
|
||||
| `public/*.html`, `public/assets/*.js` | 各画面 |
|
||||
| `public/assets/style.css`, `public/assets/fluid.js` | 見た目の土台(light / dark の色・半透明の素材・字の大きさ)と、動きの共通部品(ばね・下から出るシート・滑るセグメント・指の速度と勢いの先読み) |
|
||||
| `admin/` | 管理者専用ページ(ハンドレンジ学習・CPU戦のレビュー・登録してあるハンドレンジ・カード画像) |
|
||||
|
||||
- テーブルはメモリ上にのみ保持(サーバ再起動で消える)。誰も接続していない状態が30分続くと自動削除。
|
||||
|
||||
+179
-52
@@ -1,22 +1,8 @@
|
||||
/* ハンドレンジ学習(管理者専用)。基本のデザインは /assets/style.css を使う */
|
||||
/* ハンドレンジ学習(管理者専用)。基本のデザインとセグメントは /assets/style.css を使う */
|
||||
|
||||
.range-wrap { padding-bottom: 32px; }
|
||||
|
||||
/* タブ */
|
||||
.seg { display: flex; gap: 6px; background: var(--panel); border-radius: 12px; padding: 4px; margin: 12px 0 16px; }
|
||||
.seg-btn {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
padding: 10px 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
.range-tabs {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.seg-btn.active { background: var(--panel-2); color: var(--text); }
|
||||
|
||||
/* レンジ表の分類ごとの色(レンジ表のデータとしての色なのでサイトの配色とは別) */
|
||||
.cat-navy { --c: #20365d; --fg: #fff; }
|
||||
@@ -30,57 +16,198 @@
|
||||
/* レイズを受けたときの対応の枠(3bet は水色、コールは黄)。下地の色と紛れないよう内側に暗い線を添える */
|
||||
.facing-3bet { --frame: #3fd4ff; }
|
||||
.facing-call { --frame: #ffe23a; }
|
||||
.range-cell.facing-3bet, .range-cell.facing-call { box-shadow: inset 0 0 0 3px var(--frame), inset 0 0 0 4px rgba(0, 0, 0, 0.45); }
|
||||
.range-cell.facing-3bet,
|
||||
.range-cell.facing-call {
|
||||
box-shadow: inset 0 0 0 3px var(--frame), inset 0 0 0 4px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* 13×13 の表 */
|
||||
.range-matrix { display: grid; grid-template-columns: repeat(13, 1fr); gap: 2px; }
|
||||
.matrix-card {
|
||||
padding: 6px;
|
||||
border-radius: 16px;
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.range-matrix {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(13, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
.range-cell {
|
||||
aspect-ratio: 1;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
border-radius: 4px;
|
||||
background: var(--c);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: clamp(8px, 2.4vw, 12px);
|
||||
font-weight: 800;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: transform 0.45s var(--ease-spring);
|
||||
}
|
||||
.range-cell:active {
|
||||
transform: scale(0.9);
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
.range-cell.selected {
|
||||
z-index: 1;
|
||||
outline: 2px solid var(--label);
|
||||
outline-offset: 1px;
|
||||
transform: scale(1.12);
|
||||
}
|
||||
.range-cell.selected { outline: 2px solid var(--gold); outline-offset: 1px; z-index: 1; }
|
||||
|
||||
/* 凡例 */
|
||||
.range-legend { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 12px; font-size: 12px; color: var(--muted); }
|
||||
.range-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.swatch-dot { width: 14px; height: 14px; border-radius: 4px; background: var(--c); flex: none; }
|
||||
.swatch-dot.swatch-frame { background: transparent; box-shadow: inset 0 0 0 3px var(--frame); }
|
||||
.range-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 14px;
|
||||
margin: 12px 8px 0;
|
||||
color: var(--label-2);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.range-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.swatch-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: none;
|
||||
border-radius: 4px;
|
||||
background: var(--c);
|
||||
box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.swatch-dot.swatch-frame {
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 3px var(--frame);
|
||||
}
|
||||
|
||||
/* 選択したハンド */
|
||||
.sel-hand { font-size: 28px; font-weight: 900; line-height: 1.1; }
|
||||
.sel-label { display: flex; align-items: center; gap: 8px; font-weight: 800; margin-top: 6px; }
|
||||
.selected-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.sel-hand {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.sel-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.selected-card .hint {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* クイズ */
|
||||
.quiz-progress { height: 6px; background: var(--input); border-radius: 999px; overflow: hidden; margin: 16px 0 8px; }
|
||||
.quiz-progress i { display: block; height: 100%; width: 0; background: var(--green); transition: width 0.2s; }
|
||||
.quiz-stats { display: flex; justify-content: center; gap: 12px; font-size: 13px; color: var(--muted); }
|
||||
.quiz-hand { text-align: center; margin: 12px 0; padding: 18px 12px; }
|
||||
.quiz-cards { display: flex; justify-content: center; gap: 10px; min-height: 0; }
|
||||
.quiz-cards img { width: min(32%, 120px); height: auto; filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4)); }
|
||||
.quiz-hand-name { font-size: 40px; font-weight: 900; margin-top: 8px; line-height: 1.1; }
|
||||
.quiz-answers { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.quiz-answers .btn {
|
||||
font-size: 15px;
|
||||
padding: 12px 8px;
|
||||
justify-content: flex-start;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 2px solid transparent;
|
||||
.quiz-progress {
|
||||
height: 6px;
|
||||
margin: 18px 0 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--fill-2);
|
||||
}
|
||||
.quiz-progress i {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--tint);
|
||||
transition: width 0.5s var(--ease-spring);
|
||||
}
|
||||
.quiz-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
color: var(--label-2);
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.quiz-hand {
|
||||
margin: 14px 0;
|
||||
padding: 20px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.quiz-cards {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.quiz-cards img {
|
||||
width: min(32%, 120px);
|
||||
height: auto;
|
||||
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.3));
|
||||
animation: card-in 0.55s var(--ease-spring) both;
|
||||
}
|
||||
.quiz-cards img + img {
|
||||
animation-delay: 70ms;
|
||||
}
|
||||
@keyframes card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-14px) scale(0.92) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
.quiz-hand-name {
|
||||
margin-top: 10px;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.quiz-answers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.quiz-answers .btn {
|
||||
justify-content: flex-start;
|
||||
padding: 12px 12px;
|
||||
background: var(--bg-2);
|
||||
color: var(--label);
|
||||
font-size: 0.9375rem;
|
||||
box-shadow: inset 0 0 0 0 transparent;
|
||||
transition: transform 0.45s var(--ease-spring), box-shadow 0.25s ease, background-color 0.25s ease;
|
||||
}
|
||||
.quiz-answers .btn:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
.quiz-answers .btn.correct {
|
||||
background: var(--tint-soft);
|
||||
box-shadow: inset 0 0 0 2px var(--tint);
|
||||
}
|
||||
.quiz-answers .btn.wrong {
|
||||
background: rgba(255, 59, 48, 0.12);
|
||||
box-shadow: inset 0 0 0 2px var(--red);
|
||||
}
|
||||
.quiz-feedback {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-2);
|
||||
font-size: 0.9375rem;
|
||||
animation: fade-up 0.45s var(--ease-spring) both;
|
||||
}
|
||||
.quiz-feedback.good {
|
||||
box-shadow: inset 0 0 0 2px var(--tint);
|
||||
}
|
||||
.quiz-feedback.bad {
|
||||
box-shadow: inset 0 0 0 2px var(--red);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.quiz-cards img,
|
||||
.quiz-feedback {
|
||||
animation: fade-in 0.2s ease both;
|
||||
}
|
||||
.range-cell.selected {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.quiz-answers .btn:disabled { opacity: 1; }
|
||||
.quiz-answers .btn.correct { border-color: var(--green); background: rgba(46, 204, 113, 0.18); }
|
||||
.quiz-answers .btn.wrong { border-color: var(--red); background: rgba(255, 90, 90, 0.15); }
|
||||
.quiz-feedback { margin-top: 12px; padding: 12px 14px; border-radius: 10px; background: var(--panel); font-size: 14px; }
|
||||
.quiz-feedback.good { box-shadow: inset 0 0 0 2px var(--green); }
|
||||
.quiz-feedback.bad { box-shadow: inset 0 0 0 2px var(--red); }
|
||||
+20
-12
@@ -3,42 +3,49 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#24493b">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f2f7">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>ハンドレンジ - Hold'em Night</title>
|
||||
<link rel="icon" href="/assets/favicon.ico">
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
<link rel="stylesheet" href="/range/files/range.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap range-wrap">
|
||||
<div class="topbar">
|
||||
<a class="icon-btn" href="/" aria-label="ロビーへ戻る">←</a>
|
||||
<h1>ハンドレンジ</h1>
|
||||
<body class="page-range">
|
||||
<header class="nav-bar" id="nav">
|
||||
<div class="nav-inner">
|
||||
<a class="nav-back" href="/" aria-label="ロビーへ戻る"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>ロビー</a>
|
||||
<span class="nav-title"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="seg" id="tabs">
|
||||
<main class="wrap range-wrap">
|
||||
<h1 class="large-title" id="large-title">ハンドレンジ</h1>
|
||||
|
||||
<div class="segmented range-tabs" id="tabs">
|
||||
<button type="button" class="seg-btn active" data-view="range">レンジ表</button>
|
||||
<button type="button" class="seg-btn" data-view="quiz">クイズ</button>
|
||||
</div>
|
||||
|
||||
<!-- レンジ表 -->
|
||||
<section id="view-range">
|
||||
<div class="matrix-card">
|
||||
<div class="range-matrix" id="matrix" aria-label="13×13 ハンドレンジ表"></div>
|
||||
</div>
|
||||
<div class="range-legend" id="legend"></div>
|
||||
|
||||
<div class="card mt-12" id="selected">
|
||||
<div class="card selected-card" id="selected">
|
||||
<div class="sel-hand">—</div>
|
||||
<div class="hint">セルをタップすると分類を確認できます</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-orange btn-block mt-12" id="start-from-range">クイズを始める</button>
|
||||
<button type="button" class="btn btn-tint btn-lg btn-block mt-12" id="start-from-range">クイズを始める</button>
|
||||
</section>
|
||||
|
||||
<!-- クイズ -->
|
||||
<section id="view-quiz" hidden>
|
||||
<div class="label">問題数(全169ハンドからランダム)</div>
|
||||
<div class="seg" id="count">
|
||||
<div class="segmented" id="count">
|
||||
<button type="button" class="seg-btn active" data-count="10">10問</button>
|
||||
<button type="button" class="seg-btn" data-count="20">20問</button>
|
||||
<button type="button" class="seg-btn" data-count="0">全問</button>
|
||||
@@ -64,12 +71,13 @@
|
||||
<div class="quiz-feedback" id="feedback" aria-live="polite" hidden></div>
|
||||
|
||||
<div class="row mt-12">
|
||||
<button type="button" class="btn btn-dark" id="start-quiz">開始</button>
|
||||
<button type="button" class="btn btn-blue" id="next-question" disabled>次の問題 →</button>
|
||||
<button type="button" class="btn btn-lg" id="start-quiz">開始</button>
|
||||
<button type="button" class="btn btn-tint btn-lg" id="next-question" disabled>次の問題</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/assets/fluid.js"></script>
|
||||
<script src="/range/files/range-data.js"></script>
|
||||
<script src="/range/files/range.js"></script>
|
||||
</body>
|
||||
|
||||
+10
-2
@@ -5,6 +5,10 @@
|
||||
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}`;
|
||||
@@ -22,7 +26,8 @@
|
||||
function showView(name) {
|
||||
$('view-range').hidden = name !== 'range';
|
||||
$('view-quiz').hidden = name !== 'quiz';
|
||||
for (const b of $('tabs').children) b.classList.toggle('active', b.dataset.view === name);
|
||||
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]');
|
||||
@@ -99,7 +104,7 @@
|
||||
const b = e.target.closest('[data-count]');
|
||||
if (!b) return;
|
||||
quizCount = Number(b.dataset.count);
|
||||
for (const x of $('count').children) x.classList.toggle('active', x === b);
|
||||
for (const x of $('count').querySelectorAll('[data-count]')) x.classList.toggle('active', x === b);
|
||||
});
|
||||
|
||||
function shuffle(a) {
|
||||
@@ -186,6 +191,9 @@
|
||||
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;
|
||||
}
|
||||
|
||||
+440
-121
@@ -1,146 +1,465 @@
|
||||
/* CPU戦のレビュー(管理者専用)。基本のデザインは /assets/style.css、タブは range.css、
|
||||
/* CPU戦のレビュー(管理者専用)。基本のデザイン・セグメント・リストは /assets/style.css、
|
||||
ハンドレンジの色・点数の印(.rv-color / .rv-mark など)は style.css の「レビュー付きCPU戦」を使う */
|
||||
|
||||
.review-wrap { padding-bottom: 40px; }
|
||||
.plus { color: var(--green); }
|
||||
.minus { color: var(--red); }
|
||||
|
||||
/* 点数の色(◎ ○ △ ✕ と同じ段階) */
|
||||
.score-great { color: var(--green); }
|
||||
.score-good { color: #8fd18f; }
|
||||
.score-good { color: #64b96a; }
|
||||
.score-warn { color: var(--orange); }
|
||||
.score-bad { color: var(--red); }
|
||||
.score-none { color: var(--muted); }
|
||||
.score-none { color: var(--label-2); }
|
||||
|
||||
/* 対局の一覧 */
|
||||
.rv-sessions { display: grid; gap: 8px; margin-top: 12px; }
|
||||
.rv-session {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--panel);
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
.rv-sessions {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.rv-session {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.rv-session-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rv-session:hover, .rv-session:focus-visible { background: var(--panel-2); }
|
||||
.rv-session-name { font-weight: 800; font-size: 16px; display: flex; align-items: center; gap: 8px; }
|
||||
.rv-session-meta { color: var(--muted); font-size: 12px; }
|
||||
|
||||
/* 概要とタイル */
|
||||
.rv-overview { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 12px; }
|
||||
.rv-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
|
||||
.rv-tile { background: var(--panel); border-radius: 12px; padding: 10px 12px; display: grid; gap: 2px; min-width: 0; }
|
||||
.rv-tile span { font-size: 12px; color: var(--muted); font-weight: 700; }
|
||||
.rv-tile b { font-size: 22px; font-variant-numeric: tabular-nums; }
|
||||
.rv-tile small { font-size: 11px; color: var(--muted); line-height: 1.4; }
|
||||
.rv-tabs .seg-btn { font-size: 13px; padding: 9px 2px; }
|
||||
/* 概要とタイル(ウィジェットのような小さな面) */
|
||||
.rv-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.rv-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.rv-tile {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.rv-tile span {
|
||||
color: var(--label-2);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.rv-tile b {
|
||||
font-family: var(--font-num);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rv-tile small {
|
||||
color: var(--label-2);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.rv-tabs {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.rv-tabs .seg-btn {
|
||||
padding: 4px 2px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
#tab-body > .hint,
|
||||
.review-wrap p.hint {
|
||||
margin: 10px 4px;
|
||||
}
|
||||
|
||||
/* 表 */
|
||||
.rv-table-wrap { overflow-x: auto; margin-top: 8px; border-radius: 12px; background: var(--panel); }
|
||||
.rv-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.rv-table th, .rv-table td { padding: 8px 6px; text-align: left; white-space: nowrap; border-bottom: 1px solid var(--line); }
|
||||
.rv-table th { color: var(--muted); font-size: 11px; font-weight: 700; }
|
||||
.rv-table .num { text-align: right; font-variant-numeric: tabular-nums; font-weight: 700; }
|
||||
.rv-table tbody tr[data-hand] { cursor: pointer; }
|
||||
.rv-table tbody tr[data-hand]:hover, .rv-table tbody tr[data-hand]:focus-visible { background: var(--panel-2); outline: none; }
|
||||
.rv-table tfoot td { font-weight: 800; border-bottom: 0; }
|
||||
.rv-hand-cell b { margin-right: 2px; }
|
||||
.rv-table .rv-color { font-size: 11px; padding: 1px 6px; }
|
||||
.rv-table-wrap {
|
||||
margin-top: 8px;
|
||||
overflow-x: auto;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.rv-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.rv-table th,
|
||||
.rv-table td {
|
||||
padding: 9px 8px;
|
||||
border-bottom: 0.5px solid var(--separator);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rv-table th {
|
||||
color: var(--label-2);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.rv-table .num {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rv-table tbody tr[data-hand] {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.35s ease;
|
||||
}
|
||||
.rv-table tbody tr[data-hand]:active {
|
||||
background: var(--fill-3);
|
||||
transition: none;
|
||||
}
|
||||
.rv-table tbody tr[data-hand]:focus-visible {
|
||||
outline: 2px solid var(--blue);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.rv-table tbody tr[data-hand]:hover {
|
||||
background: var(--fill-3);
|
||||
}
|
||||
}
|
||||
.rv-table tfoot td {
|
||||
border-bottom: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.rv-hand-cell b {
|
||||
margin-right: 2px;
|
||||
}
|
||||
.rv-table .rv-color {
|
||||
padding: 1px 6px;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.rv-leaks { margin: 0; padding-left: 20px; display: grid; gap: 4px; font-size: 14px; }
|
||||
.rv-leaks b { color: var(--gold); }
|
||||
.rv-leaks {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 12px 14px 12px 32px;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-2);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.rv-leaks b {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
/* 見直したいハンド */
|
||||
.rv-worst { display: grid; gap: 8px; }
|
||||
.rv-worst-item {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: 2px 8px;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--panel);
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
.rv-worst {
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.rv-worst-item {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr;
|
||||
gap: 2px 10px;
|
||||
align-items: center;
|
||||
padding: 11px 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--label);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.35s ease;
|
||||
}
|
||||
.rv-worst-item + .rv-worst-item::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 56px;
|
||||
border-top: 0.5px solid var(--separator);
|
||||
}
|
||||
.rv-worst-item:active {
|
||||
background: var(--fill-3);
|
||||
transition: none;
|
||||
}
|
||||
.rv-rank {
|
||||
grid-row: span 2;
|
||||
color: var(--orange);
|
||||
font-family: var(--font-num);
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
.rv-worst-meta {
|
||||
color: var(--label-2);
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rv-worst-item .rv-color {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
.rv-worst-item:hover, .rv-worst-item:focus-visible { background: var(--panel-2); }
|
||||
.rv-rank { grid-row: span 2; font-size: 20px; font-weight: 900; color: var(--gold); text-align: center; }
|
||||
.rv-worst-meta { font-size: 12px; color: var(--muted); }
|
||||
.rv-worst-item .rv-color { width: 14px; height: 14px; padding: 0; vertical-align: -2px; }
|
||||
|
||||
/* 収支のグラフ(実際 = 実線、運を除く = 破線。色だけに頼らない) */
|
||||
.rv-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 13px; margin-bottom: 6px; }
|
||||
.sw { display: inline-block; width: 16px; height: 0; border-top: 2px solid; vertical-align: middle; margin-right: 6px; }
|
||||
.sw.actual { border-color: #b8891c; }
|
||||
.sw.ev { border-color: #4a90e2; border-top-style: dashed; }
|
||||
.rv-chart { position: relative; background: var(--panel); border-radius: 12px; padding: 8px; }
|
||||
.rv-chart svg { display: block; width: 100%; height: auto; touch-action: pan-y; }
|
||||
.rv-chart .gl { stroke: var(--line); stroke-width: 1; }
|
||||
.rv-chart .zero { stroke: var(--muted); stroke-width: 1; stroke-dasharray: 2 3; }
|
||||
.rv-chart .tick { fill: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.rv-chart .line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.rv-chart .line.actual { stroke: #b8891c; }
|
||||
.rv-chart .line.ev { stroke: #4a90e2; stroke-dasharray: 6 4; }
|
||||
.rv-chart .cross { stroke: var(--muted); stroke-width: 1; }
|
||||
.rv-chart .dot { stroke: var(--panel); stroke-width: 2; }
|
||||
.rv-chart .dot.actual { fill: #b8891c; }
|
||||
.rv-chart .dot.ev { fill: #4a90e2; }
|
||||
.rv-chart .hit { fill: transparent; cursor: crosshair; }
|
||||
.rv-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
margin: 0 4px 8px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.sw {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 0;
|
||||
margin-right: 6px;
|
||||
border-top: 2px solid;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sw.actual {
|
||||
border-color: var(--orange);
|
||||
}
|
||||
.sw.ev {
|
||||
border-color: var(--blue);
|
||||
border-top-style: dashed;
|
||||
}
|
||||
.rv-chart {
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.rv-chart svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
.rv-chart .gl {
|
||||
stroke: var(--separator);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
.rv-chart .zero {
|
||||
stroke: var(--label-3);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 2 3;
|
||||
}
|
||||
.rv-chart .tick {
|
||||
fill: var(--label-2);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rv-chart .line {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.rv-chart .line.actual {
|
||||
stroke: var(--orange);
|
||||
}
|
||||
.rv-chart .line.ev {
|
||||
stroke: var(--blue);
|
||||
stroke-dasharray: 6 4;
|
||||
}
|
||||
.rv-chart .cross {
|
||||
stroke: var(--label-3);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.rv-chart .dot {
|
||||
stroke: var(--bg-2);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.rv-chart .dot.actual {
|
||||
fill: var(--orange);
|
||||
}
|
||||
.rv-chart .dot.ev {
|
||||
fill: var(--blue);
|
||||
}
|
||||
.rv-chart .hit {
|
||||
fill: transparent;
|
||||
cursor: crosshair;
|
||||
}
|
||||
.rv-tip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
top: 10px;
|
||||
padding: 7px 11px;
|
||||
border-radius: 12px;
|
||||
background: var(--material-thick);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
box-shadow: var(--shadow-m), inset 0 0 0 0.5px var(--material-edge);
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* リプレイ */
|
||||
.rv-note { margin-top: 10px; font-size: 13px; background: var(--panel); border-radius: 10px; padding: 8px 10px; }
|
||||
.rv-copy { width: 100%; margin-top: 10px; }
|
||||
.rv-board-box { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 14px; background: var(--felt-dark); border-radius: 12px; padding: 10px; }
|
||||
.rv-board { display: flex; gap: 3px; }
|
||||
.rv-pot { font-weight: 900; color: #fff; font-variant-numeric: tabular-nums; }
|
||||
.rv-step { margin-top: 10px; background: var(--panel); border-radius: 12px; padding: 10px 12px; font-size: 14px; min-height: 64px; }
|
||||
.rv-step-title { font-size: 12px; font-weight: 800; color: var(--gold); margin-bottom: 2px; }
|
||||
.rv-controls { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 8px; }
|
||||
.rv-controls .btn { padding: 10px 0; font-size: 16px; }
|
||||
.rv-analysis { margin-top: 10px; background: var(--panel); border: 1px solid var(--gold); border-radius: 12px; padding: 6px 12px 10px; }
|
||||
.rv-analysis .rv-item:first-child { border-top: 0; }
|
||||
.rv-ev { margin-top: 6px; }
|
||||
.rv-ev tr.actual td { background: rgba(246, 199, 68, 0.08); }
|
||||
.rv-ev .best { color: var(--green); }
|
||||
.rv-players { display: grid; gap: 6px; margin-top: 12px; }
|
||||
.rv-player { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; background: var(--panel); border-radius: 10px; padding: 6px 10px; border: 2px solid transparent; }
|
||||
.rv-player.hero { border-color: rgba(246, 199, 68, 0.35); }
|
||||
.rv-player.acting { border-color: var(--gold); }
|
||||
.rv-player.folded { opacity: 0.55; }
|
||||
.rv-player-info { min-width: 0; font-size: 14px; }
|
||||
.rv-player-act { font-size: 12px; font-weight: 800; color: var(--muted); text-align: right; }
|
||||
.rv-pos { font-size: 11px; font-weight: 800; color: var(--gold); margin-left: 4px; }
|
||||
.rv-log { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.rv-log-street { width: 100%; font-size: 11px; color: var(--muted); font-weight: 800; margin-top: 6px; }
|
||||
.rv-log-item {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
.rv-note {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-2);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.rv-copy {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.rv-board-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: radial-gradient(120% 140% at 30% 20%, var(--felt-hi), var(--felt) 50%, var(--felt-lo));
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25), inset 0 8px 20px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.rv-board {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.rv-pot {
|
||||
color: #fff;
|
||||
font-family: var(--font-num);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rv-step {
|
||||
min-height: 68px;
|
||||
margin-top: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-2);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.rv-step-title {
|
||||
margin-bottom: 2px;
|
||||
color: var(--label-2);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rv-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.rv-controls .btn {
|
||||
padding: 10px 0;
|
||||
}
|
||||
.rv-controls .btn .i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.rv-analysis {
|
||||
margin-top: 10px;
|
||||
padding: 4px 14px 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-2);
|
||||
box-shadow: inset 0 0 0 1.5px var(--orange);
|
||||
}
|
||||
.rv-ev {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.rv-ev tr.actual td {
|
||||
background: rgba(255, 149, 0, 0.1);
|
||||
}
|
||||
.rv-ev .best {
|
||||
color: var(--green);
|
||||
}
|
||||
.rv-players {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.rv-player {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-2);
|
||||
box-shadow: inset 0 0 0 0 transparent;
|
||||
transition: box-shadow 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
.rv-player.hero {
|
||||
box-shadow: inset 0 0 0 1.5px var(--tint-soft);
|
||||
}
|
||||
.rv-player.acting {
|
||||
box-shadow: inset 0 0 0 2px var(--orange);
|
||||
}
|
||||
.rv-player.folded {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.rv-player-info {
|
||||
min-width: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.rv-player-act {
|
||||
color: var(--label-2);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
.rv-pos {
|
||||
margin-left: 4px;
|
||||
color: var(--tint);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.rv-log {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.rv-log-street {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
color: var(--label-2);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rv-log-item {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
padding: 5px 10px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-2);
|
||||
color: var(--label);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 0 0 0.5px var(--separator);
|
||||
transition: background-color 0.25s ease, color 0.25s ease, transform 0.45s var(--ease-spring);
|
||||
}
|
||||
.rv-log-item:active {
|
||||
transform: scale(0.94);
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
.rv-log-item .hint {
|
||||
margin: 0 4px;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.rv-log-item.mine {
|
||||
box-shadow: inset 0 0 0 1.5px var(--tint);
|
||||
}
|
||||
.rv-log-item.current {
|
||||
background: var(--orange);
|
||||
color: var(--on-orange);
|
||||
}
|
||||
.rv-log-item.current .hint {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.plus {
|
||||
color: var(--green);
|
||||
}
|
||||
.minus {
|
||||
color: var(--red);
|
||||
}
|
||||
.rv-log-item .hint { margin: 0 4px; font-size: 11px; }
|
||||
.rv-log-item.mine { border-color: rgba(246, 199, 68, 0.6); }
|
||||
.rv-log-item.current { background: var(--gold); color: #1a1a1a; }
|
||||
.rv-log-item.current .hint { color: #333; }
|
||||
+19
-14
@@ -3,31 +3,35 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#24493b">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f2f7">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>CPU戦のレビュー - Hold'em Night</title>
|
||||
<link rel="icon" href="/assets/favicon.ico">
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
<link rel="stylesheet" href="/range/files/range.css">
|
||||
<link rel="stylesheet" href="/range/files/review.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap review-wrap">
|
||||
<div class="topbar">
|
||||
<a class="icon-btn" id="back" href="/" aria-label="戻る">←</a>
|
||||
<h1 id="title">CPU戦のレビュー</h1>
|
||||
<body class="page-review">
|
||||
<header class="nav-bar" id="nav">
|
||||
<div class="nav-inner">
|
||||
<a class="nav-back" id="back" href="/" aria-label="戻る"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg><span id="back-label">ロビー</span></a>
|
||||
<span class="nav-title"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="wrap review-wrap">
|
||||
<h1 class="large-title" id="title">CPU戦のレビュー</h1>
|
||||
|
||||
<!-- 対局の一覧 -->
|
||||
<section id="view-list">
|
||||
<p class="hint">レビュー付きCPU戦の対局です(新しい順・最大50件)。</p>
|
||||
<div id="sessions" class="rv-sessions"></div>
|
||||
<div class="group-header">レビュー付きCPU戦の対局(新しい順・最大50件)</div>
|
||||
<div id="sessions" class="list rv-sessions"></div>
|
||||
</section>
|
||||
|
||||
<!-- 1回の対局 -->
|
||||
<section id="view-session" hidden>
|
||||
<div class="rv-overview" id="overview"></div>
|
||||
<div class="seg rv-tabs" id="tabs">
|
||||
<div class="segmented rv-tabs" id="tabs">
|
||||
<button type="button" class="seg-btn active" data-tab="scores">点数表</button>
|
||||
<button type="button" class="seg-btn" data-tab="stats">成績</button>
|
||||
<button type="button" class="seg-btn" data-tab="range">色・位置</button>
|
||||
@@ -46,10 +50,10 @@
|
||||
</div>
|
||||
<div class="rv-step" id="replay-step" aria-live="polite"></div>
|
||||
<div class="rv-controls">
|
||||
<button type="button" class="btn btn-dark" data-step="first" aria-label="最初へ">⏮</button>
|
||||
<button type="button" class="btn btn-dark" data-step="prev" aria-label="前へ">◀</button>
|
||||
<button type="button" class="btn btn-blue" data-step="next" aria-label="次へ">▶</button>
|
||||
<button type="button" class="btn btn-dark" data-step="last" aria-label="最後へ">⏭</button>
|
||||
<button type="button" class="btn" data-step="first" aria-label="最初へ"><svg class="i i-fill" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5.5" width="2.4" height="13" rx="1"/><path d="M19 6.6v10.8a.9.9 0 0 1-1.4.75L9.6 12.9a1.1 1.1 0 0 1 0-1.8l8-5.25a.9.9 0 0 1 1.4.75z"/></svg></button>
|
||||
<button type="button" class="btn" data-step="prev" aria-label="前へ"><svg class="i i-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M17 6.6v10.8a.9.9 0 0 1-1.4.75L7.6 12.9a1.1 1.1 0 0 1 0-1.8l8-5.25a.9.9 0 0 1 1.4.75z"/></svg></button>
|
||||
<button type="button" class="btn btn-tint" data-step="next" aria-label="次へ"><svg class="i i-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 6.6v10.8a.9.9 0 0 0 1.4.75l8-5.25a1.1 1.1 0 0 0 0-1.8l-8-5.25A.9.9 0 0 0 7 6.6z"/></svg></button>
|
||||
<button type="button" class="btn" data-step="last" aria-label="最後へ"><svg class="i i-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 6.6v10.8a.9.9 0 0 0 1.4.75l8-5.25a1.1 1.1 0 0 0 0-1.8l-8-5.25A.9.9 0 0 0 5 6.6z"/><rect x="16.6" y="5.5" width="2.4" height="13" rx="1"/></svg></button>
|
||||
</div>
|
||||
<div id="replay-analysis"></div>
|
||||
<div class="rv-players" id="replay-players"></div>
|
||||
@@ -58,6 +62,7 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/assets/fluid.js"></script>
|
||||
<script src="/assets/common.js"></script>
|
||||
<script src="/range/files/review.js"></script>
|
||||
</body>
|
||||
|
||||
+17
-4
@@ -19,6 +19,12 @@
|
||||
let tab = 'scores';
|
||||
let replay = null; // { hand, step }
|
||||
|
||||
const CHEVRON = '<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>';
|
||||
const SPARKLES =
|
||||
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 3.5l1.6 4.4L16 9.5l-4.4 1.6L10 15.5l-1.6-4.4L4 9.5l4.4-1.6z"/><path d="M18 14l.8 2.2L21 17l-2.2.8L18 20l-.8-2.2L15 17l2.2-.8z"/></svg>';
|
||||
Fluid.navBar($('nav'), $('title'));
|
||||
const tabsSeg = Fluid.Segmented($('tabs'));
|
||||
|
||||
const markOf = (score) => (score == null ? '' : score >= 90 ? '◎' : score >= 70 ? '○' : score >= 40 ? '△' : '✕');
|
||||
const scoreClass = (score) => (score == null ? 'none' : MARK_CLASS[markOf(score)]);
|
||||
const signed = (n) => (n > 0 ? '+' : n < 0 ? '−' : '±') + chips(Math.abs(n));
|
||||
@@ -55,6 +61,7 @@
|
||||
if (!id) {
|
||||
$('title').textContent = 'CPU戦のレビュー';
|
||||
$('back').href = '/';
|
||||
$('back-label').textContent = 'ロビー';
|
||||
show('list');
|
||||
return loadList();
|
||||
}
|
||||
@@ -71,10 +78,12 @@
|
||||
const h = session.hands.find((x) => x.number === hand);
|
||||
if (h) {
|
||||
$('back').href = `?s=${encodeURIComponent(id)}`;
|
||||
$('back-label').textContent = '対局';
|
||||
return openReplay(h);
|
||||
}
|
||||
}
|
||||
$('back').href = '/review';
|
||||
$('back-label').textContent = '一覧';
|
||||
show('session');
|
||||
renderSession();
|
||||
}
|
||||
@@ -105,9 +114,12 @@
|
||||
$('sessions').innerHTML = list
|
||||
.map(
|
||||
(s) => `
|
||||
<button type="button" class="rv-session" data-id="${esc(s.id)}">
|
||||
<button type="button" class="cell cell-link rv-session" data-id="${esc(s.id)}">
|
||||
<span class="cell-title">
|
||||
<span class="rv-session-name">${esc(s.tableName)}${s.active ? '<span class="badge live">PLAYING</span>' : ''}</span>
|
||||
<span class="rv-session-meta">${dateText(s.createdAt)} ・ ${s.hands}ハンド ・ ${esc(s.heroName)}</span>
|
||||
<span class="cell-sub">${dateText(s.createdAt)} ・ ${s.hands}ハンド ・ ${esc(s.heroName)}</span>
|
||||
</span>
|
||||
${CHEVRON}
|
||||
</button>`
|
||||
)
|
||||
.join('');
|
||||
@@ -125,7 +137,8 @@
|
||||
<div class="rv-tile"><span>ハンド数</span><b>${s.stats.hands}</b></div>
|
||||
<div class="rv-tile"><span>平均点</span><b class="score-${scoreClass(s.total)}">${s.total == null ? '—' : s.total}</b></div>
|
||||
<div class="rv-tile"><span>収支</span><b class="${signClass(net)}">${signed(net)}</b></div>`;
|
||||
for (const b of $('tabs').children) b.classList.toggle('active', b.dataset.tab === tab);
|
||||
for (const b of $('tabs').querySelectorAll('[data-tab]')) b.classList.toggle('active', b.dataset.tab === tab);
|
||||
tabsSeg.update();
|
||||
const render = { scores: renderScores, stats: renderStats, range: renderRange, worst: renderWorst, graph: renderGraph }[tab];
|
||||
$('tab-body').innerHTML = '';
|
||||
render();
|
||||
@@ -370,7 +383,7 @@
|
||||
</div>
|
||||
<div class="rv-streets">${STREETS.map((st) => `<div class="rv-street"><span>${STREET_SHORT[st]}</span><b class="score-${scoreClass(h.streetScores[st])}">${h.streetScores[st] == null ? '—' : h.streetScores[st]}</b></div>`).join('')}</div>
|
||||
${allInHtml(h)}
|
||||
<button type="button" class="btn btn-dark rv-copy" id="replay-copy">🤖 AIに質問する用にコピー</button>`;
|
||||
<button type="button" class="btn btn-block rv-copy" id="replay-copy">${SPARKLES}AIに質問する用にコピー</button>`;
|
||||
$('replay-copy').addEventListener('click', () => copyHandPrompt(h));
|
||||
$('replay-log').innerHTML = logHtml(h);
|
||||
renderStep();
|
||||
|
||||
+51
-29
@@ -38,21 +38,25 @@
|
||||
return '$' + Number(n).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
// 上から降りてきて、少ししたら同じ道を上へ戻る通知
|
||||
let toastTimer;
|
||||
function toast(msg) {
|
||||
let el = document.querySelector('.toast');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.className = 'toast';
|
||||
el.setAttribute('role', 'status');
|
||||
el.setAttribute('aria-live', 'polite');
|
||||
document.body.appendChild(el);
|
||||
void el.offsetWidth; // 最初の1回も画面の外から動かす
|
||||
}
|
||||
el.textContent = msg;
|
||||
el.hidden = false;
|
||||
el.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { el.hidden = true; }, 2500);
|
||||
toastTimer = setTimeout(() => el.classList.remove('show'), 2600);
|
||||
}
|
||||
|
||||
// ブラウザ標準の confirm() の代わりの確認ダイアログ。OK なら true で解決する。
|
||||
// ブラウザ標準の confirm() の代わりの確認ダイアログ(iOS のアラートの形)。OK なら true で解決する。
|
||||
// opts: { title, message(改行可), ok, cancel, danger(OK を赤にする) }
|
||||
// 新しいダイアログを開くと、開いていたものはキャンセル扱いで閉じる
|
||||
let closeConfirm = null;
|
||||
@@ -61,22 +65,24 @@
|
||||
if (closeConfirm) closeConfirm(false);
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop confirm-backdrop';
|
||||
backdrop.className = 'alert-backdrop';
|
||||
backdrop.innerHTML =
|
||||
'<div class="modal confirm-modal" role="alertdialog" aria-modal="true">' +
|
||||
(o.title ? '<h2 class="confirm-title"></h2>' : '') +
|
||||
'<p class="confirm-message"></p>' +
|
||||
'<div class="confirm-buttons">' +
|
||||
'<button type="button" class="btn btn-dark" data-confirm="cancel"></button>' +
|
||||
`<button type="button" class="btn ${o.danger ? 'btn-red' : 'btn-orange'}" data-confirm="ok"></button>` +
|
||||
'<div class="alert" role="alertdialog" aria-modal="true">' +
|
||||
'<div class="alert-body">' +
|
||||
(o.title ? '<h2 class="alert-title"></h2>' : '') +
|
||||
'<p class="alert-message"></p>' +
|
||||
'</div>' +
|
||||
'<div class="alert-buttons">' +
|
||||
'<button type="button" class="alert-btn" data-confirm="cancel"></button>' +
|
||||
`<button type="button" class="alert-btn ${o.danger ? 'destructive' : 'default'}" data-confirm="ok"></button>` +
|
||||
'</div></div>';
|
||||
const dialog = backdrop.firstChild;
|
||||
const msgEl = dialog.querySelector('.confirm-message');
|
||||
const msgEl = dialog.querySelector('.alert-message');
|
||||
msgEl.textContent = o.message || '';
|
||||
msgEl.id = 'confirm-message-' + randomHex(4);
|
||||
dialog.setAttribute('aria-describedby', msgEl.id);
|
||||
if (o.title) {
|
||||
const titleEl = dialog.querySelector('.confirm-title');
|
||||
const titleEl = dialog.querySelector('.alert-title');
|
||||
titleEl.textContent = o.title;
|
||||
titleEl.id = 'confirm-title-' + randomHex(4);
|
||||
dialog.setAttribute('aria-labelledby', titleEl.id);
|
||||
@@ -86,13 +92,24 @@
|
||||
okBtn.textContent = o.ok || 'OK';
|
||||
cancelBtn.textContent = o.cancel || 'キャンセル';
|
||||
|
||||
// 現れるときは少し大きい所から縮み、消えるときはその場で薄くなる
|
||||
let closing = false;
|
||||
const appear = new Fluid.Spring(0, { precision: 0.002 });
|
||||
appear.onUpdate = (v) => {
|
||||
backdrop.style.setProperty('--appear', v.toFixed(3));
|
||||
dialog.style.opacity = v >= 1 ? '' : v.toFixed(3);
|
||||
dialog.style.transform = closing || Fluid.reducedMotion() ? '' : `scale(${(1.12 - 0.12 * v).toFixed(4)})`;
|
||||
};
|
||||
|
||||
const prevFocus = document.activeElement;
|
||||
return new Promise((resolve) => {
|
||||
function close(result) {
|
||||
if (closeConfirm !== close) return;
|
||||
closeConfirm = null;
|
||||
closing = true;
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
backdrop.remove();
|
||||
backdrop.style.pointerEvents = 'none';
|
||||
appear.to(0, { damping: 1, response: 0.2, allowReduced: true, onRest: () => backdrop.remove() });
|
||||
if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus();
|
||||
resolve(result);
|
||||
}
|
||||
@@ -109,15 +126,18 @@
|
||||
okBtn.addEventListener('click', () => close(true));
|
||||
cancelBtn.addEventListener('click', () => close(false));
|
||||
// 背景を押したらキャンセル(ダイアログ内から背景へのドラッグは除く)
|
||||
let downOnBackdrop = false;
|
||||
backdrop.addEventListener('pointerdown', (e) => {
|
||||
backdrop.dataset.downOnBackdrop = e.target === backdrop ? '1' : '';
|
||||
downOnBackdrop = e.target === backdrop;
|
||||
});
|
||||
backdrop.addEventListener('click', (e) => {
|
||||
if (e.target === backdrop && backdrop.dataset.downOnBackdrop) close(false);
|
||||
if (e.target === backdrop && downOnBackdrop) close(false);
|
||||
});
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
closeConfirm = close;
|
||||
document.body.appendChild(backdrop);
|
||||
appear.set(0);
|
||||
appear.to(1, { damping: 1, response: 0.3, allowReduced: true });
|
||||
// 危険な操作はうっかり Enter で確定しないようキャンセルにフォーカスする
|
||||
(o.danger ? cancelBtn : okBtn).focus();
|
||||
});
|
||||
@@ -161,24 +181,26 @@
|
||||
}
|
||||
|
||||
function showCopyDialog(text) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop confirm-backdrop';
|
||||
backdrop.innerHTML =
|
||||
'<div class="modal copy-modal" role="dialog" aria-modal="true">' +
|
||||
'<h2 class="confirm-title">コピーしてください</h2>' +
|
||||
const root = document.createElement('div');
|
||||
root.className = 'sheet-backdrop';
|
||||
root.hidden = true;
|
||||
root.innerHTML =
|
||||
'<div class="sheet" role="dialog" aria-modal="true" aria-label="コピーしてください" tabindex="-1">' +
|
||||
'<div class="sheet-header" data-sheet-drag><span class="sheet-grabber" aria-hidden="true"></span>' +
|
||||
'<h2 class="sheet-title">コピーしてください</h2></div>' +
|
||||
'<div class="sheet-scroll">' +
|
||||
'<p class="hint">自動でコピーできませんでした。下の文章を全選択してコピーしてください。</p>' +
|
||||
'<textarea class="copy-text" readonly></textarea>' +
|
||||
'<button type="button" class="btn btn-dark copy-close">閉じる</button>' +
|
||||
'</div>' +
|
||||
'<div class="sheet-footer"><button type="button" class="btn btn-lg btn-block copy-close">閉じる</button></div>' +
|
||||
'</div>';
|
||||
const area = backdrop.querySelector('textarea');
|
||||
const area = root.querySelector('textarea');
|
||||
area.value = text;
|
||||
const close = () => backdrop.remove();
|
||||
backdrop.querySelector('.copy-close').addEventListener('click', close);
|
||||
backdrop.addEventListener('click', (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
document.body.appendChild(backdrop);
|
||||
area.focus();
|
||||
document.body.appendChild(root);
|
||||
const sheet = Fluid.Sheet(root, { onClosed: () => root.remove() });
|
||||
root.querySelector('.copy-close').addEventListener('click', () => sheet.close());
|
||||
sheet.open();
|
||||
area.focus({ preventScroll: true });
|
||||
area.select();
|
||||
area.scrollTop = 0; // select() で末尾までスクロールするので先頭に戻す
|
||||
}
|
||||
|
||||
+12
-10
@@ -16,8 +16,8 @@
|
||||
const timerInput = $('timer');
|
||||
const intervalInput = $('interval');
|
||||
const scheduleEl = $('schedule');
|
||||
const modeHint = $('mode-hint');
|
||||
const cpuOptions = $('cpu-options');
|
||||
const intervalRow = $('interval-row');
|
||||
const blindHint = $('blind-hint');
|
||||
const errorEl = $('error');
|
||||
const submitBtn = $('submit');
|
||||
@@ -49,7 +49,7 @@
|
||||
const stack = int(stackInput);
|
||||
const bb = int(bbInput);
|
||||
if (blindsTouched && stack > 0 && bb > 0) {
|
||||
blindHint.innerHTML = `開始スタック = ${Math.floor(stack / bb)} BB ・ <a href="#" id="reset-blinds" class="link-gold">おすすめに戻す</a>`;
|
||||
blindHint.innerHTML = `開始スタック = ${Math.floor(stack / bb)} BB ・ <a href="#" id="reset-blinds" class="link">おすすめに戻す</a>`;
|
||||
} else {
|
||||
blindHint.textContent = 'スタックに合わせて自動設定(BB ≒ スタックの1/100)';
|
||||
}
|
||||
@@ -60,6 +60,7 @@
|
||||
const bb = int(bbInput);
|
||||
const on = timerInput.checked;
|
||||
intervalInput.disabled = !on;
|
||||
setOpen(intervalRow, on);
|
||||
scheduleEl.hidden = !on || !(sb > 0 && bb > sb);
|
||||
if (scheduleEl.hidden) return;
|
||||
const mins = int(intervalInput) > 0 ? int(intervalInput) : 0;
|
||||
@@ -68,18 +69,17 @@
|
||||
.join('');
|
||||
}
|
||||
|
||||
const MODE_HINTS = {
|
||||
chips: '実物のカードで遊び、サイトはチップだけを管理。ショーダウンの勝者はホストが選ぶ',
|
||||
friends: '各自のスマホに手札が配られ、勝敗とポットの分配は自動',
|
||||
cpu: 'CPU と対戦(友達も空いている席に参加できる)。カードの配布・勝敗の判定は自動',
|
||||
review: 'あなたと CPU だけで対戦(友達は入れない)。ハンドごとにハンドレンジの色・行動の点数を表示し、対局全体の成績も振り返れる',
|
||||
};
|
||||
const radioValue = (name) => (form.querySelector(`input[name="${name}"]:checked`) || {}).value;
|
||||
|
||||
// 行の開け閉め(高さはばねで変わる)。閉じている間はフォーカスも届かないようにする
|
||||
function setOpen(el, open) {
|
||||
el.classList.toggle('collapsed', !open);
|
||||
el.inert = !open;
|
||||
}
|
||||
|
||||
function updateMode() {
|
||||
const mode = radioValue('mode');
|
||||
modeHint.textContent = MODE_HINTS[mode] || '';
|
||||
cpuOptions.hidden = mode !== 'cpu' && mode !== 'review';
|
||||
setOpen(cpuOptions, mode === 'cpu' || mode === 'review');
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
@@ -110,6 +110,8 @@
|
||||
intervalInput.addEventListener('input', refresh);
|
||||
form.querySelectorAll('input[type="radio"]').forEach((el) => el.addEventListener('change', refresh));
|
||||
$('reroll').addEventListener('click', suggestName);
|
||||
Fluid.Segmented(form.querySelector('.segmented.numbers'));
|
||||
Fluid.navBar($('nav'), $('large-title'));
|
||||
|
||||
function validate() {
|
||||
const name = nameInput.value.trim();
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
// 動きと手ざわりの共通部品。
|
||||
// Apple の "Designing Fluid Interfaces"(WWDC 2018)の考え方をウェブで使える形にしたもの:
|
||||
// - Spring: 減衰比(damping)と応答(response, 秒)で決めるばね。途中で目標が変わっても、
|
||||
// 画面上のいまの位置と速度から続くので、動いている最中につかんだり戻したりできる
|
||||
// - VelocityTracker / project / rubberband: 指の速度・勢いから止まる位置の先読み・端での抵抗
|
||||
// - Sheet: 下から出るシート(つまみを下へ払うと閉じる)
|
||||
// - Segmented: つまみが滑るセグメント(つまみをドラッグしても選べる)
|
||||
// - presence / countTo / shake / haptic / navBar: 出し入れ・数字の変化・揺れ・振動・ナビゲーションバー
|
||||
// CSP でインラインスタイルを禁止しているので、動きはすべて CSSOM(el.style)で付ける
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const reducedMotion = () => motionQuery.matches;
|
||||
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
||||
|
||||
// ---------- ばね ----------
|
||||
const running = new Set();
|
||||
let frame = 0;
|
||||
let lastTime = 0;
|
||||
|
||||
function tick(now) {
|
||||
// タブが裏にあった後などに大きく飛ばないよう、1フレームは最大 50ms として進める
|
||||
const dt = clamp((now - lastTime) / 1000, 0, 0.05);
|
||||
lastTime = now;
|
||||
for (const s of [...running]) s.step(dt);
|
||||
frame = running.size ? requestAnimationFrame(tick) : 0;
|
||||
}
|
||||
|
||||
class Spring {
|
||||
// opts: { damping, response, precision(止まったとみなす差), onUpdate(value) }
|
||||
constructor(value = 0, opts = {}) {
|
||||
this.value = value;
|
||||
this.target = value;
|
||||
this.velocity = 0;
|
||||
this.precision = opts.precision || 0.01;
|
||||
this.onUpdate = opts.onUpdate || null;
|
||||
this.onRest = null;
|
||||
this.configure(opts.damping ?? 1, opts.response ?? 0.4);
|
||||
}
|
||||
|
||||
// 質量 1 のばねに直す: 硬さ = (2π / response)²、抵抗 = 4π · damping / response
|
||||
// damping 1 = 行き過ぎずに止まる、1 未満 = 少し弾む
|
||||
configure(damping, response) {
|
||||
this.damping = damping;
|
||||
this.response = response;
|
||||
this.stiffness = ((2 * Math.PI) / response) ** 2;
|
||||
this.friction = (4 * Math.PI * damping) / response;
|
||||
}
|
||||
|
||||
get animating() {
|
||||
return running.has(this);
|
||||
}
|
||||
|
||||
// 目標へ動かす。いまの値と速度から続ける(velocity を渡すと指の速さを引き継ぐ)。
|
||||
// 視差効果を減らす設定のときはすぐに目標の値にする(allowReduced: 透明度など、そのままでよい動き)
|
||||
to(target, opts = {}) {
|
||||
if (opts.damping != null || opts.response != null) {
|
||||
this.configure(opts.damping ?? this.damping, opts.response ?? this.response);
|
||||
}
|
||||
if (opts.velocity != null) this.velocity = opts.velocity;
|
||||
this.target = target;
|
||||
this.onRest = opts.onRest || null;
|
||||
if (reducedMotion() && !opts.allowReduced) {
|
||||
this.set(target);
|
||||
this.rest();
|
||||
return this;
|
||||
}
|
||||
running.add(this);
|
||||
if (!frame) {
|
||||
lastTime = performance.now();
|
||||
frame = requestAnimationFrame(tick);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// すぐにその値にする(ドラッグ中に指へ 1:1 で付いていくときもこれ)
|
||||
set(value) {
|
||||
running.delete(this);
|
||||
this.value = this.target = value;
|
||||
this.velocity = 0;
|
||||
if (this.onUpdate) this.onUpdate(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
// 画面上のいまの位置で止める(動いている最中につかんだとき)
|
||||
stop() {
|
||||
running.delete(this);
|
||||
this.target = this.value;
|
||||
this.velocity = 0;
|
||||
this.onRest = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
step(dt) {
|
||||
// 硬いばねでも発散しないよう 1/240 秒刻みで積分する
|
||||
const n = Math.max(1, Math.ceil(dt * 240));
|
||||
const h = dt / n;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const force = -this.stiffness * (this.value - this.target) - this.friction * this.velocity;
|
||||
this.velocity += force * h;
|
||||
this.value += this.velocity * h;
|
||||
}
|
||||
const settled =
|
||||
Math.abs(this.value - this.target) < this.precision && Math.abs(this.velocity) < this.precision * 10;
|
||||
if (settled) {
|
||||
this.value = this.target;
|
||||
this.velocity = 0;
|
||||
running.delete(this);
|
||||
}
|
||||
if (this.onUpdate) this.onUpdate(this.value);
|
||||
if (settled) this.rest();
|
||||
}
|
||||
|
||||
rest() {
|
||||
const fn = this.onRest;
|
||||
this.onRest = null;
|
||||
if (fn) fn();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 指の動き ----------
|
||||
// 直近 100ms の位置から速度(px/秒)を求める。離す直前に指を止めていれば速度は 0 になる
|
||||
class VelocityTracker {
|
||||
constructor() {
|
||||
this.samples = [];
|
||||
}
|
||||
reset() {
|
||||
this.samples.length = 0;
|
||||
}
|
||||
add(x, y, t = performance.now()) {
|
||||
this.samples.push({ x, y, t });
|
||||
while (this.samples.length > 2 && t - this.samples[0].t > 100) this.samples.shift();
|
||||
}
|
||||
velocity() {
|
||||
const s = this.samples;
|
||||
if (s.length < 2) return { x: 0, y: 0 };
|
||||
const a = s[0];
|
||||
const b = s[s.length - 1];
|
||||
const dt = (b.t - a.t) / 1000;
|
||||
return dt > 0 ? { x: (b.x - a.x) / dt, y: (b.y - a.y) / dt } : { x: 0, y: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// 勢いで進む距離(スクロールの減速と同じ指数関数の形)。0.998 = 普通、0.99 = 早く止まる
|
||||
function project(velocity, decelerationRate = 0.998) {
|
||||
return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);
|
||||
}
|
||||
|
||||
// 端を越えた分は、越えるほど付いてこなくなる(いきなり止めない)
|
||||
function rubberband(overshoot, dimension, constant = 0.55) {
|
||||
return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
|
||||
}
|
||||
|
||||
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), textarea, select, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function trapFocus(box, e) {
|
||||
const list = [...box.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null);
|
||||
if (!list.length) return;
|
||||
const first = list[0];
|
||||
const last = list[list.length - 1];
|
||||
if (!box.contains(document.activeElement)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
} else if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- シート ----------
|
||||
// root は背景(.sheet-backdrop)、中に .sheet。
|
||||
// [data-sheet-drag](つまみ・見出し)をつかむと指に 1:1 で付いてくる。離したときは速度から止まる位置を
|
||||
// 先読みし、シートの半分より下なら閉じる(閉じる途中でつかみ直すこともできる)。
|
||||
// opts: { dismissible(false なら閉じられない), focus(開いたときにフォーカスする要素のセレクタ), onClose, onClosed }
|
||||
function Sheet(root, opts = {}) {
|
||||
const sheet = root.querySelector('.sheet');
|
||||
const dismissible = opts.dismissible !== false;
|
||||
const y = new Spring(0, { precision: 0.5 });
|
||||
const tracker = new VelocityTracker();
|
||||
let height = 0;
|
||||
let isOpen = false;
|
||||
let prevFocus = null;
|
||||
let drag = null;
|
||||
let downOnBackdrop = false;
|
||||
|
||||
const measure = () => {
|
||||
height = sheet.offsetHeight + 24; // 影まで画面の外へ出す
|
||||
};
|
||||
|
||||
y.onUpdate = (v) => {
|
||||
sheet.style.transform = v ? `translate3d(0, ${v.toFixed(2)}px, 0)` : '';
|
||||
root.style.setProperty('--sheet-progress', height ? clamp(1 - v / height, 0, 1).toFixed(3) : '1');
|
||||
};
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape' && dismissible) {
|
||||
e.preventDefault();
|
||||
close();
|
||||
} else if (e.key === 'Tab') {
|
||||
trapFocus(sheet, e);
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (isOpen) return;
|
||||
isOpen = true;
|
||||
if (root.hidden) {
|
||||
root.hidden = false;
|
||||
measure();
|
||||
y.set(height);
|
||||
}
|
||||
y.to(0, { damping: 1, response: 0.38 });
|
||||
prevFocus = document.activeElement;
|
||||
document.addEventListener('keydown', onKey);
|
||||
const target = opts.focus ? sheet.querySelector(opts.focus) : null;
|
||||
(target || sheet).focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function close(velocity) {
|
||||
if (!isOpen) return;
|
||||
isOpen = false;
|
||||
document.removeEventListener('keydown', onKey);
|
||||
measure();
|
||||
y.to(height, {
|
||||
damping: 1,
|
||||
response: 0.3,
|
||||
velocity: typeof velocity === 'number' ? velocity : undefined,
|
||||
onRest: () => {
|
||||
if (isOpen) return;
|
||||
root.hidden = true;
|
||||
if (opts.onClosed) opts.onClosed();
|
||||
},
|
||||
});
|
||||
if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus({ preventScroll: true });
|
||||
if (opts.onClose) opts.onClose();
|
||||
}
|
||||
|
||||
root.addEventListener('pointerdown', (e) => {
|
||||
downOnBackdrop = e.target === root;
|
||||
});
|
||||
root.addEventListener('click', (e) => {
|
||||
if (dismissible && e.target === root && downOnBackdrop) close();
|
||||
});
|
||||
|
||||
sheet.addEventListener('pointerdown', (e) => {
|
||||
if (!dismissible || root.hidden || (e.pointerType === 'mouse' && e.button !== 0)) return;
|
||||
if (!e.target.closest('[data-sheet-drag]')) return;
|
||||
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, origin: 0, active: false };
|
||||
tracker.reset();
|
||||
tracker.add(e.clientX, e.clientY, e.timeStamp);
|
||||
});
|
||||
sheet.addEventListener('pointermove', (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tracker.add(e.clientX, e.clientY, e.timeStamp);
|
||||
if (!drag.active) {
|
||||
const dx = e.clientX - drag.x;
|
||||
const dy = e.clientY - drag.y;
|
||||
if (Math.hypot(dx, dy) < 8) return;
|
||||
if (Math.abs(dx) > Math.abs(dy)) {
|
||||
drag = null;
|
||||
return;
|
||||
}
|
||||
// ここで初めてつかむ。動いている最中なら画面上のいまの位置から
|
||||
drag.active = true;
|
||||
drag.y = e.clientY;
|
||||
y.stop();
|
||||
drag.origin = y.value;
|
||||
measure();
|
||||
sheet.setPointerCapture(e.pointerId);
|
||||
sheet.classList.add('dragging');
|
||||
if (!isOpen) {
|
||||
isOpen = true;
|
||||
document.addEventListener('keydown', onKey);
|
||||
}
|
||||
}
|
||||
const raw = drag.origin + (e.clientY - drag.y);
|
||||
y.set(raw < 0 ? -rubberband(-raw, height) : raw);
|
||||
});
|
||||
const end = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
const wasActive = drag.active;
|
||||
drag = null;
|
||||
sheet.classList.remove('dragging');
|
||||
if (!wasActive) return;
|
||||
tracker.add(e.clientX, e.clientY, e.timeStamp);
|
||||
const v = tracker.velocity().y;
|
||||
if (e.type !== 'pointercancel' && y.value + project(v) > height * 0.5) close(v);
|
||||
// 払った勢いがあるので少しだけ弾ませて戻す
|
||||
else y.to(0, { damping: 0.8, response: 0.3, velocity: v });
|
||||
};
|
||||
sheet.addEventListener('pointerup', end);
|
||||
sheet.addEventListener('pointercancel', end);
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
get isOpen() {
|
||||
return isOpen;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- セグメント ----------
|
||||
// container の子(label > input[type=radio] か、.active の付いた button)を選ぶつまみを滑らせる。
|
||||
// 選ばれている所を押すとつまみが少し縮み、そのまま横へドラッグすると指に付いてくる。
|
||||
// 離すと速度から止まる位置を先読みして、一番近い選択肢を選ぶ
|
||||
function Segmented(container) {
|
||||
const thumb = document.createElement('span');
|
||||
thumb.className = 'segmented-thumb';
|
||||
thumb.setAttribute('aria-hidden', 'true');
|
||||
container.prepend(thumb);
|
||||
container.classList.add('has-thumb');
|
||||
|
||||
const x = new Spring(0, { precision: 0.1 });
|
||||
const w = new Spring(0, { precision: 0.1 });
|
||||
const press = new Spring(1, { precision: 0.001 });
|
||||
const tracker = new VelocityTracker();
|
||||
let placed = false;
|
||||
let drag = null;
|
||||
let suppressClick = false;
|
||||
|
||||
const paint = () => {
|
||||
thumb.style.width = `${w.value.toFixed(2)}px`;
|
||||
thumb.style.transform = `translate3d(${x.value.toFixed(2)}px, 0, 0) scale(${press.value.toFixed(4)})`;
|
||||
};
|
||||
x.onUpdate = w.onUpdate = press.onUpdate = paint;
|
||||
|
||||
const items = () => [...container.children].filter((el) => el !== thumb && !el.hidden);
|
||||
const isSelected = (el) =>
|
||||
el.matches('label') ? Boolean(el.querySelector('input:checked')) : el.classList.contains('active');
|
||||
const center = (el) => el.offsetLeft + el.offsetWidth / 2;
|
||||
|
||||
function sync(animate = true) {
|
||||
if (drag && drag.active) return;
|
||||
const el = items().find(isSelected);
|
||||
thumb.hidden = !el;
|
||||
if (!el || !el.offsetWidth) return; // 非表示の間は測れない(表示されたら ResizeObserver で合わせる)
|
||||
if (animate && placed) {
|
||||
if (x.target !== el.offsetLeft) x.to(el.offsetLeft, { damping: 1, response: 0.32 });
|
||||
if (w.target !== el.offsetWidth) w.to(el.offsetWidth, { damping: 1, response: 0.32 });
|
||||
} else {
|
||||
x.set(el.offsetLeft);
|
||||
w.set(el.offsetWidth);
|
||||
}
|
||||
placed = true;
|
||||
}
|
||||
|
||||
function select(el) {
|
||||
if (isSelected(el)) return;
|
||||
if (el.matches('label')) {
|
||||
const input = el.querySelector('input');
|
||||
input.checked = true;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} else {
|
||||
el.click();
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('pointerdown', (e) => {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
const el = items().find((i) => i.contains(e.target));
|
||||
if (!el || !isSelected(el)) return;
|
||||
drag = { id: e.pointerId, x: e.clientX, origin: 0, active: false };
|
||||
tracker.reset();
|
||||
tracker.add(e.clientX, 0, e.timeStamp);
|
||||
press.to(0.95, { damping: 1, response: 0.2 });
|
||||
});
|
||||
container.addEventListener('pointermove', (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tracker.add(e.clientX, 0, e.timeStamp);
|
||||
if (!drag.active) {
|
||||
if (Math.abs(e.clientX - drag.x) < 6) return;
|
||||
drag.active = true;
|
||||
drag.x = e.clientX;
|
||||
x.stop();
|
||||
drag.origin = x.value;
|
||||
container.setPointerCapture(e.pointerId);
|
||||
}
|
||||
const list = items();
|
||||
const last = list[list.length - 1];
|
||||
const min = list[0].offsetLeft;
|
||||
const max = last.offsetLeft + last.offsetWidth - w.value;
|
||||
let raw = drag.origin + (e.clientX - drag.x);
|
||||
if (raw < min) raw = min - rubberband(min - raw, container.offsetWidth);
|
||||
else if (raw > max) raw = max + rubberband(raw - max, container.offsetWidth);
|
||||
x.set(raw);
|
||||
});
|
||||
const end = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
const wasActive = drag.active;
|
||||
drag = null;
|
||||
press.to(1, { damping: 1, response: 0.25 });
|
||||
if (!wasActive) return;
|
||||
suppressClick = true;
|
||||
setTimeout(() => {
|
||||
suppressClick = false;
|
||||
}, 0);
|
||||
tracker.add(e.clientX, 0, e.timeStamp);
|
||||
const v = tracker.velocity().x;
|
||||
const aim = x.value + w.value / 2 + project(v, 0.99);
|
||||
const best = items().reduce((a, b) => (Math.abs(center(b) - aim) < Math.abs(center(a) - aim) ? b : a));
|
||||
x.to(best.offsetLeft, { damping: 0.8, response: 0.3, velocity: v });
|
||||
w.to(best.offsetWidth, { damping: 1, response: 0.3 });
|
||||
select(best);
|
||||
};
|
||||
container.addEventListener('pointerup', end);
|
||||
container.addEventListener('pointercancel', end);
|
||||
// ドラッグで選んだ後のクリックで、指を離した所の選択肢まで選ばれないように
|
||||
container.addEventListener(
|
||||
'click',
|
||||
(e) => {
|
||||
if (!suppressClick) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
true
|
||||
);
|
||||
container.addEventListener('change', () => sync());
|
||||
// ボタンの .active はクリックの処理で付け替わるので、描画の直前に合わせる
|
||||
container.addEventListener('click', () => requestAnimationFrame(() => sync()));
|
||||
if ('ResizeObserver' in window) new ResizeObserver(() => sync(false)).observe(container);
|
||||
sync(false);
|
||||
|
||||
return { update: sync };
|
||||
}
|
||||
|
||||
// ---------- 出し入れ ----------
|
||||
// 要素を少し小さく透明な所からばねで出し、消すときは同じ道を戻る(hidden は消え終わってから付ける)。
|
||||
// from: { scale, y }。視差効果を減らす設定では透明度だけ変える
|
||||
function presence(el, show, from = {}) {
|
||||
let p = el._presence;
|
||||
if (!p) {
|
||||
p = el._presence = new Spring(show ? 1 : 0, { precision: 0.002 });
|
||||
el.hidden = !show;
|
||||
}
|
||||
const scale = from.scale ?? 0.96;
|
||||
const dy = from.y ?? 8;
|
||||
p.onUpdate = (v) => {
|
||||
el.style.opacity = v >= 1 ? '' : v.toFixed(3);
|
||||
el.style.transform =
|
||||
v >= 1 || reducedMotion()
|
||||
? ''
|
||||
: `translate3d(0, ${((1 - v) * dy).toFixed(2)}px, 0) scale(${(scale + (1 - scale) * v).toFixed(4)})`;
|
||||
};
|
||||
if (show) {
|
||||
if (el.hidden) {
|
||||
el.hidden = false;
|
||||
p.set(0);
|
||||
}
|
||||
if (p.target !== 1) p.to(1, { damping: 1, response: 0.32, allowReduced: true });
|
||||
} else if (!el.hidden && p.target !== 0) {
|
||||
p.to(0, {
|
||||
damping: 1,
|
||||
response: 0.22,
|
||||
allowReduced: true,
|
||||
onRest: () => {
|
||||
el.hidden = true;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 数字をばねで変える(ポット・スタックなど)
|
||||
function countTo(el, value, format) {
|
||||
const show = (v) => {
|
||||
el.textContent = format(Math.round(v));
|
||||
};
|
||||
let s = el._count;
|
||||
if (!s) {
|
||||
s = el._count = new Spring(value, { precision: 0.5 });
|
||||
s.onUpdate = show;
|
||||
show(value);
|
||||
return;
|
||||
}
|
||||
s.onUpdate = show;
|
||||
if (s.target !== value) s.to(value, { damping: 1, response: 0.5 });
|
||||
}
|
||||
|
||||
// 横に揺らす(鍵の入力ミスなど)。勢いを与えて、減衰するばねに任せる
|
||||
function shake(el) {
|
||||
if (reducedMotion()) return;
|
||||
let s = el._shake;
|
||||
if (!s) {
|
||||
s = el._shake = new Spring(0, { precision: 0.05 });
|
||||
s.onUpdate = (v) => {
|
||||
el.style.transform = v ? `translate3d(${v.toFixed(2)}px, 0, 0)` : '';
|
||||
};
|
||||
}
|
||||
s.to(0, { damping: 0.22, response: 0.26, velocity: -320 });
|
||||
}
|
||||
|
||||
// 振動(対応している端末だけ。画面に触れる前は振動できない)
|
||||
function haptic(pattern = 10) {
|
||||
try {
|
||||
const touched = !navigator.userActivation || navigator.userActivation.hasBeenActive;
|
||||
if (navigator.vibrate && touched) navigator.vibrate(pattern);
|
||||
} catch {
|
||||
/* 振動できない環境 */
|
||||
}
|
||||
}
|
||||
|
||||
// ナビゲーションバー: 中身が下に潜り込んだら半透明の素材と境目のぼかしを出し、
|
||||
// 大きな見出しが隠れたらバーの中に小さな見出しを出す
|
||||
function navBar(nav, largeTitle) {
|
||||
if (!nav) return;
|
||||
const update = () => nav.classList.toggle('scrolled', window.scrollY > 2);
|
||||
update();
|
||||
window.addEventListener('scroll', update, { passive: true });
|
||||
const inline = nav.querySelector('.nav-title');
|
||||
if (!largeTitle || !inline) return;
|
||||
const mirror = () => {
|
||||
inline.textContent = largeTitle.textContent;
|
||||
};
|
||||
mirror();
|
||||
new MutationObserver(mirror).observe(largeTitle, { childList: true, characterData: true, subtree: true });
|
||||
if ('IntersectionObserver' in window) {
|
||||
new IntersectionObserver(([entry]) => nav.classList.toggle('show-title', !entry.isIntersecting), {
|
||||
rootMargin: `-${nav.offsetHeight}px 0px 0px 0px`,
|
||||
}).observe(largeTitle);
|
||||
}
|
||||
}
|
||||
|
||||
window.Fluid = {
|
||||
Spring,
|
||||
VelocityTracker,
|
||||
project,
|
||||
rubberband,
|
||||
clamp,
|
||||
reducedMotion,
|
||||
Sheet,
|
||||
Segmented,
|
||||
presence,
|
||||
countTo,
|
||||
shake,
|
||||
haptic,
|
||||
navBar,
|
||||
};
|
||||
})();
|
||||
+12
-3
@@ -16,12 +16,18 @@
|
||||
|
||||
const value = () => inputs.map((i) => i.value).join('');
|
||||
|
||||
// 数字が入った枠は小さく弾む(空に戻ったら印を外す)
|
||||
function markFilled() {
|
||||
for (const i of inputs) i.classList.toggle('filled', Boolean(i.value));
|
||||
}
|
||||
|
||||
function fill(digits, from) {
|
||||
let i = from;
|
||||
for (const d of digits) {
|
||||
if (i >= inputs.length) break;
|
||||
inputs[i++].value = d;
|
||||
}
|
||||
markFilled();
|
||||
inputs[Math.min(i, inputs.length - 1)].focus();
|
||||
if (value().length === 6) submit();
|
||||
}
|
||||
@@ -31,11 +37,13 @@
|
||||
const digits = input.value.replace(/\D/g, '');
|
||||
input.value = '';
|
||||
if (digits) fill(digits, idx);
|
||||
else markFilled();
|
||||
errorEl.textContent = '';
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Backspace' && !input.value && idx > 0) {
|
||||
inputs[idx - 1].value = '';
|
||||
markFilled();
|
||||
inputs[idx - 1].focus();
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowLeft' && idx > 0) {
|
||||
@@ -68,10 +76,11 @@
|
||||
location.replace(App.safeNext(next));
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
box.classList.remove('shake');
|
||||
void box.offsetWidth;
|
||||
box.classList.add('shake');
|
||||
// 揺れと振動は同じ瞬間に(どちらも「違う」の合図)
|
||||
Fluid.shake(box);
|
||||
Fluid.haptic([18, 50, 18]);
|
||||
inputs.forEach((i) => { i.value = ''; });
|
||||
markFilled();
|
||||
inputs[0].focus();
|
||||
} finally {
|
||||
busy = false;
|
||||
|
||||
+185
-34
@@ -6,6 +6,9 @@
|
||||
const nameError = document.getElementById('name-error');
|
||||
const listEl = document.getElementById('list');
|
||||
const countEl = document.getElementById('count');
|
||||
const footerEl = document.getElementById('list-footer');
|
||||
|
||||
Fluid.navBar(document.getElementById('nav'), document.getElementById('large-title'));
|
||||
|
||||
nameInput.value = Store.name;
|
||||
nameInput.addEventListener('input', () => {
|
||||
@@ -25,13 +28,12 @@
|
||||
let admin = false;
|
||||
let tables = null;
|
||||
|
||||
// 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクとテーブルの削除ボタンを出す
|
||||
// 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクとテーブルの削除(左へスワイプ)を出す
|
||||
// (ページ・API 自体もサーバ側で管理者以外は 404)
|
||||
App.api('GET', '/api/me')
|
||||
.then((me) => {
|
||||
admin = Boolean(me.admin);
|
||||
document.getElementById('range-link').hidden = !admin;
|
||||
document.getElementById('review-link').hidden = !admin;
|
||||
document.getElementById('admin-group').hidden = !admin;
|
||||
if (tables) render(tables);
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -58,56 +60,205 @@
|
||||
ok: '削除する',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok || !document.contains(btn)) return;
|
||||
const row = btn.closest('.swipe-row');
|
||||
if (!ok || !document.contains(btn)) {
|
||||
if (row && row.swipe) row.swipe.close();
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
App.api('DELETE', '/api/tables/' + encodeURIComponent(id))
|
||||
.then(() => App.toast('テーブルを削除しました'))
|
||||
.catch((err) => {
|
||||
btn.disabled = false;
|
||||
if (row && row.swipe) row.swipe.close();
|
||||
App.toast(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
const MODE_LABEL = { chips: '💰 キャッシュオンリー', friends: '🃏 友人戦', cpu: '🤖 CPU戦', review: '📝 レビュー付きCPU戦' };
|
||||
const ICON = {
|
||||
chips:
|
||||
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="7" rx="7" ry="3"/><path d="M5 7v5c0 1.7 3.1 3 7 3s7-1.3 7-3V7"/><path d="M5 12v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5"/></svg>',
|
||||
cards:
|
||||
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.5 5.2l-3.9.7a2 2 0 0 0-1.6 2.3l1.8 10a2 2 0 0 0 2.3 1.6l1.4-.3"/><rect x="10.5" y="3.5" width="10" height="15" rx="2"/></svg>',
|
||||
cpu:
|
||||
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2.5"/><rect x="9.5" y="9.5" width="5" height="5" rx="1"/><path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3"/></svg>',
|
||||
review:
|
||||
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
|
||||
person: '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4.5 20a7.5 7.5 0 0 1 15 0"/></svg>',
|
||||
clock: '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>',
|
||||
trash:
|
||||
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 7h15M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12a2 2 0 0 0 2 1.8h5a2 2 0 0 0 2-1.8l1-12M10.5 11v6M13.5 11v6"/></svg>',
|
||||
};
|
||||
|
||||
function phaseBadge(t) {
|
||||
if (t.paused) return '<span class="badge paused">PAUSED</span>';
|
||||
if (t.phase === 'WAITING') return '<span class="badge">WAITING</span>';
|
||||
return '<span class="badge live">PLAYING</span>';
|
||||
const MODE = {
|
||||
chips: { label: 'キャッシュオンリー', icon: 'chips', tone: 'green' },
|
||||
friends: { label: '友人戦', icon: 'cards', tone: 'blue' },
|
||||
cpu: { label: 'CPU戦', icon: 'cpu', tone: 'indigo' },
|
||||
review: { label: 'レビュー付きCPU戦', icon: 'review', tone: 'orange' },
|
||||
};
|
||||
|
||||
function statusHtml(t) {
|
||||
if (t.paused) return '<span class="status paused">一時停止中</span>';
|
||||
if (t.phase === 'WAITING') return '<span class="status waiting">待機中</span>';
|
||||
return '<span class="status live">プレイ中</span>';
|
||||
}
|
||||
|
||||
function rowHtml(t) {
|
||||
const m = MODE[t.mode] || MODE.chips;
|
||||
const full = t.playerCount >= t.maxSeats;
|
||||
const timer = t.timerEnabled ? `${t.intervalMin}分` : 'なし';
|
||||
return `
|
||||
<span class="cell-icon tone-${m.tone}">${ICON[m.icon]}</span>
|
||||
<span class="table-row-main">
|
||||
<span class="name">${esc(t.name)}</span>
|
||||
<span class="table-row-meta">${statusHtml(t)}<span>${m.label}</span></span>
|
||||
<span class="table-row-meta">
|
||||
<span class="meta-item">${ICON.person}${t.playerCount}/${t.maxSeats}</span>
|
||||
<span>Lv.${t.level} ${chips(t.smallBlind)}/${chips(t.bigBlind)}</span>
|
||||
<span>開始 ${chips(t.startingStack)}</span>
|
||||
<span class="meta-item">${ICON.clock}${timer}</span>
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" class="btn-get" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>${full ? '観戦' : '参加'}</button>`;
|
||||
}
|
||||
|
||||
// ---------- 左へスワイプして削除(管理者) ----------
|
||||
// 行は指に 1:1 で付いてきて、削除ボタンの幅を越えるとゴムのように抵抗する。
|
||||
// 離したときは速度から止まる位置を先読みし、半分を越えていれば開いたまま止める
|
||||
const ACTION_WIDTH = 88;
|
||||
let openRow = null;
|
||||
|
||||
function attachSwipe(row) {
|
||||
const content = row.querySelector('.table-row');
|
||||
const action = row.querySelector('.swipe-action');
|
||||
const x = new Fluid.Spring(0, { precision: 0.2 });
|
||||
const tracker = new Fluid.VelocityTracker();
|
||||
let drag = null;
|
||||
let suppressClick = false;
|
||||
|
||||
x.onUpdate = (v) => {
|
||||
content.style.transform = v ? `translate3d(${v.toFixed(2)}px, 0, 0)` : '';
|
||||
row.style.setProperty('--reveal', Fluid.clamp(-v / ACTION_WIDTH, 0, 1).toFixed(3));
|
||||
};
|
||||
const settle = (open, velocity) => {
|
||||
x.to(open ? -ACTION_WIDTH : 0, { damping: velocity ? 0.8 : 1, response: 0.3, velocity });
|
||||
if (open) openRow = row;
|
||||
else if (openRow === row) openRow = null;
|
||||
};
|
||||
row.swipe = { close: () => settle(false) };
|
||||
|
||||
content.addEventListener('pointerdown', (e) => {
|
||||
if (!admin || (e.pointerType === 'mouse' && e.button !== 0)) return;
|
||||
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, origin: 0, active: false };
|
||||
tracker.reset();
|
||||
tracker.add(e.clientX, e.clientY, e.timeStamp);
|
||||
});
|
||||
content.addEventListener('pointermove', (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tracker.add(e.clientX, e.clientY, e.timeStamp);
|
||||
if (!drag.active) {
|
||||
const dx = e.clientX - drag.x;
|
||||
const dy = e.clientY - drag.y;
|
||||
if (Math.hypot(dx, dy) < 10) return;
|
||||
// 縦の動きはページのスクロールに任せる
|
||||
if (Math.abs(dy) > Math.abs(dx)) {
|
||||
drag = null;
|
||||
return;
|
||||
}
|
||||
drag.active = true;
|
||||
drag.x = e.clientX;
|
||||
x.stop();
|
||||
drag.origin = x.value;
|
||||
content.setPointerCapture(e.pointerId);
|
||||
if (openRow && openRow !== row) openRow.swipe.close();
|
||||
}
|
||||
let raw = drag.origin + (e.clientX - drag.x);
|
||||
if (raw > 0) raw = Fluid.rubberband(raw, row.offsetWidth);
|
||||
else if (raw < -ACTION_WIDTH) raw = -ACTION_WIDTH - Fluid.rubberband(-ACTION_WIDTH - raw, row.offsetWidth);
|
||||
x.set(raw);
|
||||
});
|
||||
const end = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
const wasActive = drag.active;
|
||||
drag = null;
|
||||
if (!wasActive) return;
|
||||
suppressClick = true;
|
||||
setTimeout(() => {
|
||||
suppressClick = false;
|
||||
}, 0);
|
||||
tracker.add(e.clientX, e.clientY, e.timeStamp);
|
||||
const v = tracker.velocity().x;
|
||||
settle(x.value + Fluid.project(v) < -ACTION_WIDTH / 2, v);
|
||||
};
|
||||
content.addEventListener('pointerup', end);
|
||||
content.addEventListener('pointercancel', end);
|
||||
content.addEventListener(
|
||||
'click',
|
||||
(e) => {
|
||||
// スワイプの後のクリックや、開いている行のタップ(参加ボタン以外)は「閉じる」だけにする
|
||||
const tapOnOpenRow = openRow === row && !e.target.closest('[data-join]');
|
||||
if (!suppressClick && !tapOnOpenRow) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!suppressClick) row.swipe.close();
|
||||
},
|
||||
true
|
||||
);
|
||||
// キーボードでは削除ボタンにフォーカスが来たら開く
|
||||
action.addEventListener('focus', () => settle(true));
|
||||
action.addEventListener('blur', () => settle(false));
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
if (openRow && !openRow.contains(e.target)) openRow.swipe.close();
|
||||
});
|
||||
|
||||
// ---------- 一覧(行は使い回す。スワイプの途中で作り直さない) ----------
|
||||
const rows = new Map();
|
||||
|
||||
function rowEl(id) {
|
||||
let row = rows.get(id);
|
||||
if (row) return row;
|
||||
row = document.createElement('div');
|
||||
row.className = 'swipe-row';
|
||||
row.innerHTML = `<button type="button" class="swipe-action" data-delete="${esc(id)}">${ICON.trash}<span>削除</span></button><div class="table-row"></div>`;
|
||||
attachSwipe(row);
|
||||
rows.set(id, row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function render(list) {
|
||||
tables = list;
|
||||
countEl.textContent = tables.length ? `${tables.length} テーブル` : '';
|
||||
footerEl.hidden = !admin || !tables.length;
|
||||
if (!tables.length) {
|
||||
rows.clear();
|
||||
openRow = null;
|
||||
listEl.innerHTML = '<div class="empty">開催中のテーブルはありません。<br>テーブルを作成して友達を招待しよう。</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = tables
|
||||
.map((t) => {
|
||||
const full = t.playerCount >= t.maxSeats;
|
||||
const timer = t.timerEnabled ? `⏱ ${t.intervalMin}分` : '⏱ なし';
|
||||
return `
|
||||
<div class="table-item">
|
||||
<div class="info">
|
||||
<div class="name">${esc(t.name)}${phaseBadge(t)}</div>
|
||||
<div class="meta">
|
||||
<span>👤 <b>${t.playerCount}/${t.maxSeats}</b></span>
|
||||
<span>開始 <b>${chips(t.startingStack)}</b></span>
|
||||
<span class="gold">Lv.${t.level} <b class="gold">${chips(t.smallBlind)}/${chips(t.bigBlind)}</b></span>
|
||||
<span>${timer}</span>
|
||||
<span>${MODE_LABEL[t.mode] || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
${admin ? `<button class="btn btn-red btn-sm" data-delete="${esc(t.id)}" title="テーブルを削除">削除</button>` : ''}
|
||||
<button class="btn btn-blue btn-sm" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>
|
||||
${full ? '観戦' : '参加'}
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
const empty = listEl.querySelector('.empty');
|
||||
if (empty) empty.remove();
|
||||
const ids = new Set(tables.map((t) => t.id));
|
||||
for (const [id, row] of rows) {
|
||||
if (ids.has(id)) continue;
|
||||
if (openRow === row) openRow = null;
|
||||
row.remove();
|
||||
rows.delete(id);
|
||||
}
|
||||
tables.forEach((t, i) => {
|
||||
const row = rowEl(t.id);
|
||||
const html = rowHtml(t);
|
||||
const content = row.querySelector('.table-row');
|
||||
if (content.html !== html) {
|
||||
content.html = html;
|
||||
content.innerHTML = html;
|
||||
}
|
||||
const action = row.querySelector('.swipe-action');
|
||||
action.hidden = !admin;
|
||||
action.tabIndex = admin ? 0 : -1;
|
||||
if (listEl.children[i] !== row) listEl.insertBefore(row, listEl.children[i] || null);
|
||||
});
|
||||
}
|
||||
|
||||
const socket = App.connectSocket();
|
||||
|
||||
+2748
-703
File diff suppressed because it is too large.
Load diff
+339
-132
@@ -1,5 +1,6 @@
|
||||
// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る
|
||||
// カード機能がオフのテーブルではカードは実物で配るので、ここで扱うのはチップのやり取りだけ
|
||||
// 席・場札・手札の要素は使い回し、変わった所だけ書き換える(配る・めくるなどの動きを途中で作り直さない)
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -7,10 +8,9 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const tableId = decodeURIComponent(location.pathname.split('/').pop());
|
||||
|
||||
const AVATAR_SVG =
|
||||
'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4.5"/><path d="M3 21c0-4.4 4-7.5 9-7.5s9 3.1 9 7.5z"/></svg>';
|
||||
const BOT_SVG =
|
||||
'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="11" y="1.5" width="2" height="3.5" rx="1"/><circle cx="12" cy="1.8" r="1.6"/><path d="M5 7.5A2.5 2.5 0 0 1 7.5 5h9A2.5 2.5 0 0 1 19 7.5v7a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 5 14.5zM9 9.2a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 0 0 0-3.2zm6 0a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 0 0 0-3.2zM2 9.5h2v4H2zm18 0h2v4h-2zM7 18.5h10a3 3 0 0 1 3 3V23H4v-1.5a3 3 0 0 1 3-3z"/></svg>';
|
||||
const CHECK_SVG = '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>';
|
||||
// U+FE0E: iOS で絵文字として描画されないように
|
||||
const SUIT_SYMBOL = { s: '♠︎', h: '♥︎', d: '♦︎', c: '♣︎' };
|
||||
const ACTION_LABEL = { FOLD: 'Fold', CHECK: 'Check', CALL: 'Call', BET: 'Bet', RAISE: 'Raise', ALLIN: 'All-in' };
|
||||
@@ -22,26 +22,31 @@
|
||||
let raising = false;
|
||||
let raiseValue = 0;
|
||||
let wasMyTurn = false;
|
||||
let seenBoard = ''; // 新しくめくられたカードだけアニメーションする
|
||||
let seenHand = 0;
|
||||
let seenHand = 0; // 新しいハンドの手札だけ配る動きを付ける
|
||||
// ショーダウンの勝者選択(ポットごとの席番号の Set)
|
||||
let awardKey = '';
|
||||
let awardSel = [];
|
||||
let seenReview = 0; // 自動で開いたレビューのハンド番号(レビュー付きCPU戦)
|
||||
let reviewKey = '';
|
||||
|
||||
const reviewSheet = Fluid.Sheet($('review-modal'));
|
||||
const nameSheet = Fluid.Sheet($('name-modal'), { dismissible: false, focus: '#name-input' });
|
||||
|
||||
// ---------- 名前 ----------
|
||||
function ensureName() {
|
||||
if (Store.name) return connect();
|
||||
$('name-modal').hidden = false;
|
||||
$('name-input').focus();
|
||||
nameSheet.open();
|
||||
}
|
||||
$('name-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const name = $('name-input').value.trim();
|
||||
if (!name) return $('name-input').classList.add('invalid');
|
||||
if (!name) {
|
||||
$('name-input').classList.add('invalid');
|
||||
Fluid.shake($('name-input'));
|
||||
return;
|
||||
}
|
||||
Store.name = name;
|
||||
$('name-modal').hidden = true;
|
||||
nameSheet.close();
|
||||
connect();
|
||||
});
|
||||
|
||||
@@ -102,6 +107,7 @@
|
||||
}
|
||||
$('main').hidden = true;
|
||||
$('action-bar').hidden = true;
|
||||
reviewSheet.close();
|
||||
$('not-found').hidden = false;
|
||||
// 自分から切断するので「接続が切れました」は出さない
|
||||
if (socket) {
|
||||
@@ -114,6 +120,14 @@
|
||||
|
||||
const nameOf = (seat) => (state.seats[seat] ? state.seats[seat].name : '退席者');
|
||||
|
||||
// 中身が変わったときだけ書き換える(書き換えたら true)。key を変えると同じ中身でも書き換える
|
||||
function patch(el, html, key = html) {
|
||||
if (el.patchKey === key) return false;
|
||||
el.patchKey = key;
|
||||
el.innerHTML = html;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- カード(カード機能ありのテーブルのみ) ----------
|
||||
function cardHtml(c, extra = '') {
|
||||
if (typeof c !== 'string' || !/^[2-9TJQKA][shdc]$/.test(c)) return `<span class="pcard back ${extra}"></span>`;
|
||||
@@ -121,6 +135,30 @@
|
||||
return `<span class="pcard suit-${c[1]} ${extra}"><b>${rank}</b><i>${SUIT_SYMBOL[c[1]]}</i></span>`;
|
||||
}
|
||||
|
||||
function cardNode(c) {
|
||||
const t = document.createElement('template');
|
||||
t.innerHTML = c ? cardHtml(c) : '<span class="pcard slot"></span>';
|
||||
return t.content.firstElementChild;
|
||||
}
|
||||
|
||||
// 入れたばかりのカードに動きを付ける。delay(i): i 枚目を何ms遅らせるか(1枚ずつ配る・めくる)
|
||||
function animateCards(container, cls, delay) {
|
||||
[...container.children].forEach((card, i) => {
|
||||
if (card.classList.contains('slot')) return;
|
||||
card.classList.add(cls);
|
||||
card.style.animationDelay = `${Math.round(delay(i))}ms`;
|
||||
});
|
||||
}
|
||||
|
||||
// 勝った5枚を強調し、それ以外を暗くする(カードは作り直さず印だけ付け替える)
|
||||
function markCards(container, list, markOf) {
|
||||
[...container.children].forEach((node, i) => {
|
||||
const m = list[i] ? markOf(list[i]) : '';
|
||||
node.classList.toggle('hl', m === 'hl');
|
||||
node.classList.toggle('dim', m === 'dim');
|
||||
});
|
||||
}
|
||||
|
||||
// ショーダウンで勝った5枚(メインポット)
|
||||
function winningCards(h) {
|
||||
return h && h.finished && h.showdown && h.results && h.results[0] && h.results[0].bestCards
|
||||
@@ -128,29 +166,34 @@
|
||||
: null;
|
||||
}
|
||||
|
||||
// 場札: 5つの枠を使い回し、新しく出たカードだけめくる(フロップは少しずつずらして)
|
||||
function renderBoard(s) {
|
||||
const el = $('board');
|
||||
const h = s.hand;
|
||||
el.hidden = !s.cardsEnabled;
|
||||
if (!s.cardsEnabled) return;
|
||||
if (!h) {
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
if (!el.slots) {
|
||||
el.innerHTML = '<span class="pcard slot"></span>'.repeat(5);
|
||||
el.slots = ['', '', '', '', ''];
|
||||
}
|
||||
const [seenNo, seenLen] = seenBoard.split(':').map(Number);
|
||||
const oldLen = seenNo === h.number ? seenLen : 0;
|
||||
const best = winningCards(h);
|
||||
let html = '';
|
||||
let fresh = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const c = h.board[i];
|
||||
if (!c) {
|
||||
html += '<span class="pcard slot"></span>';
|
||||
continue;
|
||||
const c = (h && h.board[i]) || '';
|
||||
if (el.slots[i] !== c) {
|
||||
el.slots[i] = c;
|
||||
const next = cardNode(c);
|
||||
if (c) {
|
||||
next.classList.add('flip-in');
|
||||
next.style.animationDelay = `${fresh++ * 90}ms`;
|
||||
}
|
||||
const cls = [i >= oldLen ? 'deal' : '', best ? (best.has(c) ? 'hl' : 'dim') : ''].join(' ');
|
||||
html += cardHtml(c, cls);
|
||||
el.children[i].replaceWith(next);
|
||||
}
|
||||
const node = el.children[i];
|
||||
const mark = c && best ? (best.has(c) ? 'hl' : 'dim') : '';
|
||||
node.classList.toggle('hl', mark === 'hl');
|
||||
node.classList.toggle('dim', mark === 'dim');
|
||||
}
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// ---------- 描画 ----------
|
||||
@@ -165,14 +208,14 @@
|
||||
$('table-name').textContent = s.name;
|
||||
|
||||
$('level').textContent = `Lv.${s.level}`;
|
||||
$('blinds').textContent = `SB: ${chips(s.smallBlind)} / BB: ${chips(s.bigBlind)}`;
|
||||
$('blinds').textContent = `SB ${chips(s.smallBlind)} / BB ${chips(s.bigBlind)}`;
|
||||
$('next-blinds').textContent = s.timer.enabled
|
||||
? `次: ${chips(s.nextBlinds.smallBlind)} / ${chips(s.nextBlinds.bigBlind)}(${s.timer.intervalMin}分毎)`
|
||||
? `次 ${chips(s.nextBlinds.smallBlind)} / ${chips(s.nextBlinds.bigBlind)}(${s.timer.intervalMin}分毎)`
|
||||
: 'ブラインドタイマーなし';
|
||||
$('stack-info').textContent = `開始スタック ${chips(s.startingStack)}`;
|
||||
|
||||
// 中央
|
||||
$('pot').textContent = chips(h ? h.potTotal : 0);
|
||||
Fluid.countTo($('pot'), h ? h.potTotal : 0, chips);
|
||||
$('phase').textContent = s.paused ? 'PAUSED' : App.PHASE_LABELS[s.phase] || s.phase;
|
||||
$('phase').parentElement.classList.toggle('paused', s.paused);
|
||||
$('phase').parentElement.classList.toggle('showdown', s.phase === 'SHOWDOWN' && !s.paused);
|
||||
@@ -187,7 +230,6 @@
|
||||
$('start').disabled = eligible < 2;
|
||||
$('start').title = eligible < 2 ? '2人以上で開始できます' : '';
|
||||
$('pause').hidden = !s.started;
|
||||
$('pause').textContent = s.paused ? '▶' : '⏸';
|
||||
$('pause').title = s.paused ? '再開' : '一時停止';
|
||||
$('pause').setAttribute('aria-label', $('pause').title);
|
||||
$('pause').classList.toggle('paused', s.paused);
|
||||
@@ -199,20 +241,18 @@
|
||||
$('take-seat').hidden = Boolean(me) || !hasFreeSeat;
|
||||
$('spectator-text').textContent = hasFreeSeat ? '観戦中です' : '満席のため観戦中です';
|
||||
$('leave').title = me ? '退席する' : 'ロビーへ戻る';
|
||||
$('leave').setAttribute('aria-label', $('leave').title);
|
||||
$('leave').classList.toggle('spectator', !me);
|
||||
|
||||
renderSeats(s);
|
||||
if (me) renderActionBar(s, me);
|
||||
renderReview(s);
|
||||
renderTimer();
|
||||
|
||||
// 自分の番になったら知らせる(タップ前は振動できない)
|
||||
const tapped = !navigator.userActivation || navigator.userActivation.hasBeenActive;
|
||||
if (myTurn && !wasMyTurn && navigator.vibrate && tapped) navigator.vibrate(80);
|
||||
// 自分の番になったら知らせる
|
||||
if (myTurn && !wasMyTurn) Fluid.haptic(80);
|
||||
wasMyTurn = myTurn;
|
||||
if (h) {
|
||||
seenBoard = `${h.number}:${h.board.length}`;
|
||||
seenHand = h.number;
|
||||
}
|
||||
if (h) seenHand = h.number;
|
||||
}
|
||||
|
||||
function renderResult(s) {
|
||||
@@ -222,17 +262,60 @@
|
||||
el.hidden = true;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = h.results
|
||||
patch(
|
||||
el,
|
||||
h.results
|
||||
.map((r, i) => {
|
||||
const label = h.results.length > 1 ? `<span class="pot-tag">${i === 0 ? 'メイン' : 'サイド'}</span>` : '';
|
||||
const who = r.winners.map((w) => esc(nameOf(w))).join('・');
|
||||
const hand = r.handName ? ` <span class="rhand">${esc(r.handName)}</span>` : '';
|
||||
return `<div>${label}<b>${who}</b> +${chips(r.amount)}${hand}</div>`;
|
||||
})
|
||||
.join('');
|
||||
.join('')
|
||||
);
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
// ---------- 席 ----------
|
||||
const seatEls = new Map(); // 席番号 → 要素
|
||||
|
||||
function seatEl(no) {
|
||||
let el = seatEls.get(no);
|
||||
if (el) return el;
|
||||
el = document.createElement('div');
|
||||
el.className = 'seat entering';
|
||||
el.innerHTML =
|
||||
'<div class="avatar"><span class="face"></span><span class="markers"></span><span class="hole"></span></div>' +
|
||||
'<div class="nameplate"><span class="tag-slot"></span><span class="pname"></span><span class="pstack"></span><span class="pnote"></span></div>' +
|
||||
'<span class="bet-slot"></span>';
|
||||
const q = (sel) => el.querySelector(sel);
|
||||
el.parts = {
|
||||
avatar: q('.avatar'),
|
||||
face: q('.face'),
|
||||
markers: q('.markers'),
|
||||
hole: q('.hole'),
|
||||
tag: q('.tag-slot'),
|
||||
name: q('.pname'),
|
||||
stack: q('.pstack'),
|
||||
note: q('.pnote'),
|
||||
bet: q('.bet-slot'),
|
||||
};
|
||||
el.addEventListener('animationend', (e) => {
|
||||
if (e.target === el) el.classList.remove('entering');
|
||||
});
|
||||
seatEls.set(no, el);
|
||||
$('seats').append(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
// 名前の頭文字と、名前から決まる色(同じ名前なら毎回同じ色)
|
||||
const initialOf = (name) => [...String(name).trim()][0] || '?';
|
||||
function hueOf(name) {
|
||||
let h = 0;
|
||||
for (const ch of String(name)) h = (h * 31 + ch.codePointAt(0)) % 360;
|
||||
return h;
|
||||
}
|
||||
|
||||
// 着席者を楕円上に配置。自分が一番下、そこから時計回り。
|
||||
function renderSeats(s) {
|
||||
const h = s.hand;
|
||||
@@ -240,84 +323,98 @@
|
||||
const startIdx = Math.max(0, occupied.findIndex((p) => p.seat === s.mySeat));
|
||||
const ordered = occupied.slice(startIdx).concat(occupied.slice(0, startIdx));
|
||||
const n = ordered.length;
|
||||
const angles = ordered.map((_, i) => Math.PI / 2 + (i * 2 * Math.PI) / Math.max(n, 1));
|
||||
const showdown = s.phase === 'SHOWDOWN';
|
||||
const finished = Boolean(h && h.finished);
|
||||
const best = winningCards(h);
|
||||
const newHand = Boolean(h && h.number !== seenHand);
|
||||
const area = $('felt-area');
|
||||
area.classList.toggle('crowded', n >= 7); // 7人以上は小さめに表示
|
||||
area.classList.toggle('with-cards', s.cardsEnabled);
|
||||
const live = new Set();
|
||||
|
||||
const container = $('seats');
|
||||
$('felt-area').classList.toggle('crowded', n >= 7); // 7人以上は小さめに表示
|
||||
$('felt-area').classList.toggle('with-cards', s.cardsEnabled);
|
||||
container.innerHTML = ordered
|
||||
.map((p, idx) => {
|
||||
const classes = ['seat'];
|
||||
if (p.seat === s.mySeat) classes.push('me');
|
||||
if (p.bot) classes.push('bot');
|
||||
if (!p.connected) classes.push('offline');
|
||||
if (p.inHand && p.folded) classes.push('folded');
|
||||
if (s.toAct === p.seat) classes.push('turn');
|
||||
if (showdown && p.inHand && !p.folded) classes.push('contender');
|
||||
if (finished && p.won > 0) classes.push('winner');
|
||||
if (p.sittingOut && !p.inHand) classes.push('away');
|
||||
ordered.forEach((p, idx) => {
|
||||
live.add(p.seat);
|
||||
const el = seatEl(p.seat);
|
||||
const q = el.parts;
|
||||
const angle = Math.PI / 2 + (idx * 2 * Math.PI) / Math.max(n, 1);
|
||||
const left = 50 + 38 * Math.cos(angle);
|
||||
const top = 50 + 41 * Math.sin(angle);
|
||||
// 位置は CSSOM で設定(CSP で style 属性を禁止しているため)
|
||||
el.style.left = `${left.toFixed(2)}%`;
|
||||
el.style.top = `${top.toFixed(2)}%`;
|
||||
// 配るカードはテーブルの中央(山札)から飛んでくる
|
||||
el.style.setProperty('--deal-x', `${(((50 - left) / 100) * area.clientWidth).toFixed(1)}px`);
|
||||
el.style.setProperty('--deal-y', `${(((50 - top) / 100) * area.clientHeight).toFixed(1)}px`);
|
||||
|
||||
const markers = h
|
||||
const set = (cls, on) => el.classList.toggle(cls, Boolean(on));
|
||||
set('me', p.seat === s.mySeat);
|
||||
set('bot', p.bot);
|
||||
set('offline', !p.connected);
|
||||
set('folded', p.inHand && p.folded);
|
||||
set('turn', s.toAct === p.seat);
|
||||
set('contender', showdown && p.inHand && !p.folded);
|
||||
set('winner', finished && p.won > 0);
|
||||
set('away', p.sittingOut && !p.inHand);
|
||||
|
||||
patch(q.face, p.bot ? BOT_SVG : `<span class="mono">${esc(initialOf(p.name))}</span>`);
|
||||
q.avatar.style.setProperty('--hue', hueOf(p.name));
|
||||
|
||||
patch(
|
||||
q.markers,
|
||||
h
|
||||
? [
|
||||
p.seat === h.button ? '<span class="chip-marker dealer" title="ディーラーボタン">D</span>' : '',
|
||||
p.seat === h.sbSeat ? '<span class="blind-marker sb">SB</span>' : '',
|
||||
p.seat === h.bbSeat ? '<span class="blind-marker bb">BB</span>' : '',
|
||||
].join('')
|
||||
: '';
|
||||
: ''
|
||||
);
|
||||
|
||||
// 手札(自分と公開された人は表、それ以外は裏)
|
||||
let cards = '';
|
||||
if (s.cardsEnabled && p.inHand && (!p.folded || p.cards)) {
|
||||
const deal = h.number !== seenHand ? 'deal' : '';
|
||||
const list = p.cards || [null, null];
|
||||
const mark = (c) => (best && c ? (best.has(c) ? 'hl' : 'dim') : p.folded ? 'dim' : '');
|
||||
cards = `<div class="hole">${list.map((c) => cardHtml(c, `${deal} ${mark(c)}`)).join('')}</div>`;
|
||||
const list = s.cardsEnabled && h && p.inHand && (!p.folded || p.cards) ? p.cards || [null, null] : [];
|
||||
const hadBacks = Boolean(q.hole.querySelector('.back'));
|
||||
if (patch(q.hole, list.map((c) => cardHtml(c)).join(''), `${h ? h.number : 0}|${list.join()}`) && list.length) {
|
||||
// 新しいハンドは中央から1人1枚ずつ2周配り、ショーダウンで公開された手札はめくる
|
||||
if (newHand) animateCards(q.hole, 'deal', (i) => (i * n + idx) * 55);
|
||||
else if (hadBacks && !q.hole.querySelector('.back')) animateCards(q.hole, 'flip-in', (i) => i * 90);
|
||||
}
|
||||
|
||||
// ベット(中央寄り)・獲得額
|
||||
const below = Math.sin(angles[idx]) <= 0.35;
|
||||
let bet = '';
|
||||
if (finished && p.won > 0) bet = `<div class="bet win ${below ? 'below' : 'above'}">+${chips(p.won)}</div>`;
|
||||
else if (p.bet > 0) bet = `<div class="bet ${below ? 'below' : 'above'}">${chips(p.bet)}</div>`;
|
||||
markCards(q.hole, list, (c) => (best ? (best.has(c) ? 'hl' : 'dim') : p.folded ? 'dim' : ''));
|
||||
|
||||
let tag = '';
|
||||
if (p.last && !(finished && p.won > 0)) {
|
||||
tag = `<div class="act-tag tag-${p.last.type.toLowerCase()}">${ACTION_LABEL[p.last.type] || ''}</div>`;
|
||||
tag = `<span class="act-tag tag-${p.last.type.toLowerCase()}">${ACTION_LABEL[p.last.type] || ''}</span>`;
|
||||
} else if (p.sittingOut && !p.inHand) {
|
||||
tag = '<div class="act-tag tag-away">離席中</div>';
|
||||
tag = '<span class="act-tag tag-away">離席中</span>';
|
||||
}
|
||||
patch(q.tag, tag);
|
||||
|
||||
return `
|
||||
<div class="${classes.join(' ')}">
|
||||
<div class="avatar">${p.bot ? BOT_SVG : AVATAR_SVG}${markers}${cards}</div>
|
||||
<div class="nameplate">
|
||||
${tag}
|
||||
<div class="pname">${esc(p.name)}</div>
|
||||
<div class="pstack">${p.allIn && !finished ? 'ALL-IN' : chips(p.stack)}</div>
|
||||
${p.handName && p.seat !== s.mySeat ? `<div class="phand">${esc(p.handName)}</div>` : ''}
|
||||
${p.connected ? '' : '<div class="offline-tag">オフライン</div>'}
|
||||
</div>
|
||||
${bet}
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
q.name.textContent = p.name;
|
||||
q.stack.textContent = p.allIn && !finished ? 'ALL-IN' : chips(p.stack);
|
||||
q.note.textContent = !p.connected ? 'オフライン' : p.handName && p.seat !== s.mySeat ? p.handName : '';
|
||||
q.note.classList.toggle('offline', !p.connected);
|
||||
|
||||
// 位置は CSSOM で設定(CSP で style 属性を禁止しているため)
|
||||
[...container.children].forEach((el, i) => {
|
||||
el.style.left = `${(50 + 38 * Math.cos(angles[i])).toFixed(2)}%`;
|
||||
el.style.top = `${(50 + 41 * Math.sin(angles[i])).toFixed(2)}%`;
|
||||
// ベット(中央寄り)・獲得額
|
||||
const side = Math.sin(angle) <= 0.35 ? 'below' : 'above';
|
||||
let bet = '';
|
||||
if (finished && p.won > 0) bet = `<span class="bet win ${side}">+${chips(p.won)}</span>`;
|
||||
else if (p.bet > 0) bet = `<span class="bet ${side}">${chips(p.bet)}</span>`;
|
||||
patch(q.bet, bet);
|
||||
});
|
||||
|
||||
for (const [no, el] of seatEls) {
|
||||
if (live.has(no)) continue;
|
||||
el.remove();
|
||||
seatEls.delete(no);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 自分の操作エリア ----------
|
||||
function renderActionBar(s, seat) {
|
||||
const m = s.me;
|
||||
const h = s.hand;
|
||||
const o = m.options;
|
||||
|
||||
$('my-stack').textContent = chips(m.stack);
|
||||
Fluid.countTo($('my-stack'), m.stack, chips);
|
||||
const net = m.net;
|
||||
$('my-net').textContent = `収支 ${net > 0 ? '+' : net < 0 ? '−' : '±'}${chips(Math.abs(net))}`;
|
||||
$('my-net').className = net > 0 ? 'plus' : net < 0 ? 'minus' : '';
|
||||
@@ -326,11 +423,16 @@
|
||||
|
||||
$('my-cards').hidden = !s.cardsEnabled;
|
||||
if (s.cardsEnabled) {
|
||||
const myCards = seat.inHand && seat.cards ? seat.cards : null;
|
||||
const best = winningCards(h);
|
||||
$('my-cards').innerHTML = myCards
|
||||
? myCards.map((c) => cardHtml(c, [seat.folded ? 'dim' : '', best && best.has(c) ? 'hl' : ''].join(' '))).join('')
|
||||
const el = $('my-cards');
|
||||
const myCards = seat.inHand && seat.cards ? seat.cards : [];
|
||||
const base = myCards.length
|
||||
? myCards.map((c) => cardHtml(c)).join('')
|
||||
: '<span class="pcard slot"></span><span class="pcard slot"></span>';
|
||||
if (patch(el, base, `${h ? h.number : 0}|${myCards.join()}`) && myCards.length) {
|
||||
animateCards(el, 'deal', (i) => 120 + i * 140);
|
||||
}
|
||||
const best = winningCards(h);
|
||||
markCards(el, myCards, (c) => (best && best.has(c) ? 'hl' : seat.folded ? 'dim' : ''));
|
||||
}
|
||||
$('sit-out').hidden = m.sittingOut;
|
||||
|
||||
@@ -352,8 +454,10 @@
|
||||
else if (h && !h.finished && !seat.inHand) status = '次のハンドから参加します';
|
||||
else if (h && h.finished) status = '次のハンドを準備中…';
|
||||
else if (!h) status = '参加者を待っています(2人以上で再開)';
|
||||
const myMove = Boolean(o) || (awaiting && m.canAward);
|
||||
$('status-line').textContent = status;
|
||||
$('status-line').classList.toggle('my-turn', Boolean(o) || (awaiting && m.canAward));
|
||||
$('status-line').classList.toggle('my-turn', myMove);
|
||||
$('action-bar').classList.toggle('my-turn', myMove && !s.paused);
|
||||
|
||||
// ショーダウンの勝者選択
|
||||
const awarding = awaiting && m.canAward && !s.paused;
|
||||
@@ -371,8 +475,8 @@
|
||||
$('rebuy').textContent = `リバイ(${chips(s.startingStack)})`;
|
||||
$('actions').hidden = alt || raising || awarding || reviewWaiting;
|
||||
$('raise-actions').hidden = alt || !raising;
|
||||
$('raise-panel').hidden = alt || !raising;
|
||||
$('status-line').hidden = raising;
|
||||
// レイズ額のパネルはレイズボタンから出て、閉じるときはボタンへ戻る
|
||||
Fluid.presence($('raise-panel'), !alt && raising, { scale: 0.86, y: 28 });
|
||||
|
||||
const fold = $('act-fold');
|
||||
const call = $('act-call');
|
||||
@@ -408,7 +512,7 @@
|
||||
const l = r.last;
|
||||
const h = l.hero;
|
||||
const pre = l.preflop.length
|
||||
? l.preflop
|
||||
? `<div class="rv-items">${l.preflop
|
||||
.map(
|
||||
(j) => `
|
||||
<div class="rv-item">${markHtml(j.mark)}<div>
|
||||
@@ -417,7 +521,7 @@
|
||||
${j.equity != null ? `<div class="rv-sub">推定勝率 ${j.equity}%(コールに必要 ${j.need}%)</div>` : ''}
|
||||
</div></div>`
|
||||
)
|
||||
.join('')
|
||||
.join('')}</div>`
|
||||
: '<div class="rv-sub">プリフロップで判断する場面はありませんでした</div>';
|
||||
const post = l.decisions
|
||||
.map(
|
||||
@@ -449,7 +553,7 @@
|
||||
</div>
|
||||
<div class="rv-streets">${scores}</div>
|
||||
<div class="rv-section">プリフロップ(ハンドレンジ)</div>${pre}
|
||||
${post ? `<div class="rv-section">フロップ以降(期待値の目安)</div>${post}` : ''}
|
||||
${post ? `<div class="rv-section">フロップ以降(期待値の目安)</div><div class="rv-items">${post}</div>` : ''}
|
||||
${allIn}
|
||||
<div class="rv-foot">このハンドの収支 ${signed(h.net)} ・ 平均 ${r.average == null ? '—' : r.average}点(${r.hands}ハンド)</div>`;
|
||||
}
|
||||
@@ -458,7 +562,7 @@
|
||||
const r = s.review;
|
||||
$('review-open').hidden = !(r && r.last);
|
||||
if (!r || !r.last) {
|
||||
$('review-modal').hidden = true;
|
||||
reviewSheet.close();
|
||||
return;
|
||||
}
|
||||
$('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}` : '/review';
|
||||
@@ -471,7 +575,7 @@
|
||||
// 新しいハンドのレビューが届いたら自動で開く
|
||||
if (r.last.number !== seenReview) {
|
||||
seenReview = r.last.number;
|
||||
if (r.waiting) $('review-modal').hidden = false;
|
||||
if (r.waiting) reviewSheet.open();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,15 +590,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
const openReview = () => { $('review-modal').hidden = false; };
|
||||
const openReview = () => reviewSheet.open();
|
||||
$('review-open').addEventListener('click', openReview);
|
||||
$('review-show').addEventListener('click', openReview);
|
||||
$('review-close').addEventListener('click', () => { $('review-modal').hidden = true; });
|
||||
$('review-modal').addEventListener('click', (e) => {
|
||||
if (e.target === $('review-modal')) $('review-modal').hidden = true;
|
||||
});
|
||||
$('review-close').addEventListener('click', () => reviewSheet.close());
|
||||
const nextHand = () => {
|
||||
$('review-modal').hidden = true;
|
||||
reviewSheet.close();
|
||||
emit('table:next');
|
||||
};
|
||||
$('review-copy').addEventListener('click', () => copyHandPrompt(state && state.review));
|
||||
@@ -509,18 +610,21 @@
|
||||
// 参加者が1人だけのポットは最初から選んでおく
|
||||
awardSel = pots.map((p) => new Set(p.eligible.length === 1 ? p.eligible : []));
|
||||
}
|
||||
$('award-pots').innerHTML = pots
|
||||
patch(
|
||||
$('award-pots'),
|
||||
pots
|
||||
.map((p, i) => {
|
||||
const title = pots.length === 1 ? 'ポット' : i === 0 ? 'メインポット' : `サイドポット${pots.length > 2 ? i : ''}`;
|
||||
const names = p.eligible
|
||||
.map(
|
||||
(seat) =>
|
||||
`<button type="button" class="btn btn-sm pick${awardSel[i].has(seat) ? ' on' : ''}" data-pot="${i}" data-seat="${seat}">${esc(nameOf(seat))}</button>`
|
||||
)
|
||||
.map((seat) => {
|
||||
const on = awardSel[i].has(seat);
|
||||
return `<button type="button" class="pick${on ? ' on' : ''}" aria-pressed="${on}" data-pot="${i}" data-seat="${seat}">${CHECK_SVG}${esc(nameOf(seat))}</button>`;
|
||||
})
|
||||
.join('');
|
||||
return `<div class="award-pot"><div class="award-title">${title} <b>${chips(p.amount)}</b></div><div class="picks">${names}</div></div>`;
|
||||
})
|
||||
.join('');
|
||||
.join('')
|
||||
);
|
||||
const ready = awardSel.every((set) => set.size > 0);
|
||||
const split = awardSel.some((set) => set.size > 1);
|
||||
$('award-confirm').disabled = !ready || pending;
|
||||
@@ -580,20 +684,56 @@
|
||||
return Math.min(o.maxRaiseTo, Math.max(o.minRaiseTo, Math.round(v)));
|
||||
}
|
||||
|
||||
// ベット額のスライダー: つまみはつかんだ位置のまま指に付いてくる。溝を押せばそこへ飛ぶ。
|
||||
// 端を越えて引っぱるとゴムのように抵抗し、離すと端へ戻る
|
||||
const slider = $('raise-range');
|
||||
const sliderThumb = slider.querySelector('.bet-thumb');
|
||||
const sliderFill = slider.querySelector('.bet-fill');
|
||||
const THUMB = 28;
|
||||
const knob = new Fluid.Spring(0, { precision: 0.0005 }); // つまみの位置(0 = 最小、1 = オールイン)
|
||||
const sliderTracker = new Fluid.VelocityTracker();
|
||||
let sliderDrag = null;
|
||||
|
||||
function paintKnob(f) {
|
||||
const w = slider.clientWidth;
|
||||
if (!w) return;
|
||||
const x = f * (w - THUMB);
|
||||
sliderThumb.style.transform = `translate3d(${x.toFixed(2)}px, 0, 0)`;
|
||||
sliderFill.style.transform = `scaleX(${Fluid.clamp((x + THUMB / 2) / w, 0, 1).toFixed(4)})`;
|
||||
}
|
||||
knob.onUpdate = paintKnob;
|
||||
if ('ResizeObserver' in window) new ResizeObserver(() => paintKnob(knob.value)).observe(slider);
|
||||
|
||||
const fracOf = (o, v) => (o.maxRaiseTo > o.minRaiseTo ? (v - o.minRaiseTo) / (o.maxRaiseTo - o.minRaiseTo) : 1);
|
||||
// SB 単位に丸める(最大はオールイン額そのまま)
|
||||
function valueAt(o, f) {
|
||||
if (f >= 1) return o.maxRaiseTo;
|
||||
const unit = Math.max(1, state.smallBlind);
|
||||
const raw = o.minRaiseTo + f * (o.maxRaiseTo - o.minRaiseTo);
|
||||
return clampRaise(o, o.minRaiseTo + Math.round((raw - o.minRaiseTo) / unit) * unit);
|
||||
}
|
||||
const formatBB = (x) => (Math.round(x * 10) / 10).toLocaleString('en-US');
|
||||
|
||||
function renderRaisePanel(o) {
|
||||
raiseValue = clampRaise(o, raiseValue);
|
||||
const range = $('raise-range');
|
||||
range.min = o.minRaiseTo;
|
||||
range.max = o.maxRaiseTo;
|
||||
range.step = 1;
|
||||
range.value = raiseValue;
|
||||
const amount = $('raise-amount');
|
||||
if (document.activeElement !== amount) amount.value = raiseValue;
|
||||
amount.min = o.minRaiseTo;
|
||||
amount.max = o.maxRaiseTo;
|
||||
slider.setAttribute('aria-valuemin', o.minRaiseTo);
|
||||
slider.setAttribute('aria-valuemax', o.maxRaiseTo);
|
||||
slider.setAttribute('aria-valuenow', raiseValue);
|
||||
slider.setAttribute('aria-valuetext', chips(raiseValue));
|
||||
// 指で動かしている間は指の位置を優先する。それ以外(プリセット・数字の入力)はばねで滑らせる
|
||||
if (!sliderDrag) {
|
||||
const f = fracOf(o, raiseValue);
|
||||
if (knob.target !== f) knob.to(f, { damping: 1, response: 0.3 });
|
||||
}
|
||||
paintKnob(knob.value);
|
||||
for (const b of $('presets').children) {
|
||||
b.classList.toggle('active', presetValue(o, b.dataset.preset) === raiseValue);
|
||||
}
|
||||
$('raise-meta').textContent = state.bigBlind > 0 ? `${formatBB(raiseValue / state.bigBlind)} BB` : '';
|
||||
const allIn = raiseValue >= o.maxRaiseTo;
|
||||
$('raise-confirm').textContent = allIn
|
||||
? `All-in ${chips(raiseValue)}`
|
||||
@@ -601,6 +741,85 @@
|
||||
$('raise-confirm').disabled = pending;
|
||||
}
|
||||
|
||||
function setRaise(o, v) {
|
||||
raiseValue = clampRaise(o, v);
|
||||
$('raise-amount').value = raiseValue;
|
||||
renderRaisePanel(o);
|
||||
}
|
||||
|
||||
function dragKnob(e, animate) {
|
||||
const d = sliderDrag;
|
||||
const o = options();
|
||||
if (!d || !o) return;
|
||||
const span = d.width - THUMB;
|
||||
let px = e.clientX - d.left - d.grab;
|
||||
if (px < 0) px = -Fluid.rubberband(-px, span);
|
||||
else if (px > span) px = span + Fluid.rubberband(px - span, span);
|
||||
const f = span > 0 ? px / span : 1;
|
||||
if (animate) knob.to(f, { damping: 1, response: 0.25 });
|
||||
else knob.set(f);
|
||||
const before = raiseValue;
|
||||
raiseValue = valueAt(o, Fluid.clamp(f, 0, 1));
|
||||
if (raiseValue === before) return;
|
||||
// 端(最小・オールイン)に着いた瞬間だけ軽く振動する
|
||||
if (raiseValue === o.maxRaiseTo || raiseValue === o.minRaiseTo) Fluid.haptic(8);
|
||||
$('raise-amount').value = raiseValue;
|
||||
renderRaisePanel(o);
|
||||
}
|
||||
|
||||
slider.addEventListener('pointerdown', (e) => {
|
||||
const o = options();
|
||||
if (!o || (e.pointerType === 'mouse' && e.button !== 0)) return;
|
||||
e.preventDefault();
|
||||
slider.focus({ preventScroll: true });
|
||||
slider.setPointerCapture(e.pointerId);
|
||||
knob.stop();
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const thumbLeft = rect.left + knob.value * (rect.width - THUMB);
|
||||
const onThumb = e.clientX >= thumbLeft - 8 && e.clientX <= thumbLeft + THUMB + 8;
|
||||
sliderDrag = {
|
||||
id: e.pointerId,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
grab: onThumb ? e.clientX - thumbLeft : THUMB / 2,
|
||||
};
|
||||
sliderTracker.reset();
|
||||
sliderTracker.add(e.clientX, 0, e.timeStamp);
|
||||
slider.classList.add('active');
|
||||
if (!onThumb) dragKnob(e, true);
|
||||
});
|
||||
slider.addEventListener('pointermove', (e) => {
|
||||
if (!sliderDrag || e.pointerId !== sliderDrag.id) return;
|
||||
sliderTracker.add(e.clientX, 0, e.timeStamp);
|
||||
dragKnob(e, false);
|
||||
});
|
||||
const releaseKnob = (e) => {
|
||||
if (!sliderDrag || e.pointerId !== sliderDrag.id) return;
|
||||
const span = sliderDrag.width - THUMB;
|
||||
sliderDrag = null;
|
||||
slider.classList.remove('active');
|
||||
const o = options();
|
||||
if (!o) return;
|
||||
sliderTracker.add(e.clientX, 0, e.timeStamp);
|
||||
// 端を越えていたら、離したときの速さを引き継いで端へ戻る
|
||||
const out = knob.value < 0 || knob.value > 1;
|
||||
const velocity = out && span > 0 ? sliderTracker.velocity().x / span : 0;
|
||||
knob.to(fracOf(o, raiseValue), { damping: 1, response: 0.3, velocity });
|
||||
};
|
||||
slider.addEventListener('pointerup', releaseKnob);
|
||||
slider.addEventListener('pointercancel', releaseKnob);
|
||||
slider.addEventListener('keydown', (e) => {
|
||||
const o = options();
|
||||
if (!o) return;
|
||||
const unit = Math.max(1, state.bigBlind);
|
||||
const step = { ArrowRight: unit, ArrowUp: unit, ArrowLeft: -unit, ArrowDown: -unit, PageUp: unit * 5, PageDown: -unit * 5 }[e.key];
|
||||
if (e.key === 'Home') setRaise(o, o.minRaiseTo);
|
||||
else if (e.key === 'End') setRaise(o, o.maxRaiseTo);
|
||||
else if (step) setRaise(o, raiseValue + step);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
const options = () => (state && state.me && state.me.options) || null;
|
||||
|
||||
$('act-fold').addEventListener('click', async () => {
|
||||
@@ -629,6 +848,7 @@
|
||||
if (o.minRaiseTo >= o.maxRaiseTo) return sendAction('raise', o.maxRaiseTo); // オールインしかない
|
||||
raising = true;
|
||||
raiseValue = o.minRaiseTo;
|
||||
knob.set(0);
|
||||
render();
|
||||
});
|
||||
$('raise-cancel').addEventListener('click', () => {
|
||||
@@ -642,21 +862,7 @@
|
||||
$('presets').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-preset]');
|
||||
const o = options();
|
||||
if (!b || !o) return;
|
||||
raiseValue = presetValue(o, b.dataset.preset);
|
||||
$('raise-amount').value = raiseValue;
|
||||
renderRaisePanel(o);
|
||||
});
|
||||
$('raise-range').addEventListener('input', (e) => {
|
||||
const o = options();
|
||||
if (!o) return;
|
||||
// SB 単位に丸める(最大はオールイン額そのまま)
|
||||
const unit = Math.max(1, state.smallBlind);
|
||||
let v = Number(e.target.value);
|
||||
if (v < o.maxRaiseTo) v = o.minRaiseTo + Math.round((v - o.minRaiseTo) / unit) * unit;
|
||||
raiseValue = clampRaise(o, v);
|
||||
$('raise-amount').value = raiseValue;
|
||||
renderRaisePanel(o);
|
||||
if (b && o) setRaise(o, presetValue(o, b.dataset.preset));
|
||||
});
|
||||
$('raise-amount').addEventListener('input', (e) => {
|
||||
const o = options();
|
||||
@@ -724,16 +930,17 @@
|
||||
});
|
||||
|
||||
// iOS Safari は CSS だけではページのスクロール・引っ張り(バウンス)を止められないので、
|
||||
// 1本指のドラッグを止める。スライダーと、中身がはみ出している操作エリアは除く。
|
||||
// 1本指のドラッグを止める。スライダーと、中身がはみ出しているシート・勝者選択は除く。
|
||||
// 2本指(ピンチ拡大)はアクセシビリティのため止めない。
|
||||
document.addEventListener(
|
||||
'touchmove',
|
||||
(e) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.target;
|
||||
if (t.closest && t.closest('input[type="range"]')) return;
|
||||
const bar = t.closest && (t.closest('.action-bar') || t.closest('.review-modal'));
|
||||
if (bar && bar.scrollHeight > bar.clientHeight) return;
|
||||
if (!t.closest) return;
|
||||
if (t.closest('.bet-slider')) return;
|
||||
const box = t.closest('.sheet-scroll') || t.closest('.award-pots') || t.closest('.copy-text');
|
||||
if (box && box.scrollHeight > box.clientHeight) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
},
|
||||
{ passive: false }
|
||||
|
||||
+90
-35
@@ -3,38 +3,72 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#24493b">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f2f7">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>テーブル作成 - Hold'em Night</title>
|
||||
<link rel="icon" href="/assets/favicon.ico">
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<div class="topbar">
|
||||
<a class="icon-btn" href="/" aria-label="戻る">←</a>
|
||||
<h1>テーブルを作成</h1>
|
||||
<body class="page-create">
|
||||
<header class="nav-bar" id="nav">
|
||||
<div class="nav-inner">
|
||||
<a class="nav-back" href="/"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>ロビー</a>
|
||||
<span class="nav-title"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="wrap">
|
||||
<h1 class="large-title" id="large-title">テーブルを作成</h1>
|
||||
|
||||
<form id="form" novalidate>
|
||||
<label for="table-name">テーブル名</label>
|
||||
<div class="row">
|
||||
<section class="group">
|
||||
<div class="group-header"><label for="table-name">テーブル名</label></div>
|
||||
<div class="list">
|
||||
<div class="cell field-cell">
|
||||
<input id="table-name" type="text" maxlength="24" required>
|
||||
<button type="button" class="icon-btn" id="reroll" aria-label="別の名前にする">🎲</button>
|
||||
<button type="button" class="icon-plain" id="reroll" aria-label="別の名前にする" title="別の名前にする">
|
||||
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="4"/><circle cx="9" cy="9" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="15" cy="15" r="1.4" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="label" id="mode-label">テーブルの種類</div>
|
||||
<div class="segmented" role="radiogroup" aria-labelledby="mode-label">
|
||||
<label><input type="radio" name="mode" value="chips" checked><span>キャッシュ<br>オンリー</span></label>
|
||||
<label><input type="radio" name="mode" value="friends"><span>友人戦</span></label>
|
||||
<label><input type="radio" name="mode" value="cpu"><span>CPU戦</span></label>
|
||||
<section class="group">
|
||||
<div class="group-header" id="mode-label">テーブルの種類</div>
|
||||
<div class="list" role="radiogroup" aria-labelledby="mode-label">
|
||||
<label class="cell choice">
|
||||
<input type="radio" name="mode" value="chips" checked>
|
||||
<span class="cell-icon tone-green"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="7" rx="7" ry="3"/><path d="M5 7v5c0 1.7 3.1 3 7 3s7-1.3 7-3V7"/><path d="M5 12v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5"/></svg></span>
|
||||
<span class="choice-text"><span class="cell-title">キャッシュオンリー</span><span class="cell-sub">実物のカードで遊び、サイトはチップだけを管理。ショーダウンの勝者はホストが選ぶ</span></span>
|
||||
<svg class="i check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
|
||||
</label>
|
||||
<label class="cell choice">
|
||||
<input type="radio" name="mode" value="friends">
|
||||
<span class="cell-icon tone-blue"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.5 5.2l-3.9.7a2 2 0 0 0-1.6 2.3l1.8 10a2 2 0 0 0 2.3 1.6l1.4-.3"/><rect x="10.5" y="3.5" width="10" height="15" rx="2"/></svg></span>
|
||||
<span class="choice-text"><span class="cell-title">友人戦</span><span class="cell-sub">各自のスマホに手札が配られ、勝敗とポットの分配は自動</span></span>
|
||||
<svg class="i check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
|
||||
</label>
|
||||
<label class="cell choice">
|
||||
<input type="radio" name="mode" value="cpu">
|
||||
<span class="cell-icon tone-indigo"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2.5"/><rect x="9.5" y="9.5" width="5" height="5" rx="1"/><path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3"/></svg></span>
|
||||
<span class="choice-text"><span class="cell-title">CPU戦</span><span class="cell-sub">CPU と対戦(友達も空いている席に参加できる)。カードの配布・勝敗の判定は自動</span></span>
|
||||
<svg class="i check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
|
||||
</label>
|
||||
<!-- 管理者だけに表示 -->
|
||||
<label id="review-mode" hidden><input type="radio" name="mode" value="review"><span>レビュー付き<br>CPU戦</span></label>
|
||||
<label class="cell choice" id="review-mode" hidden>
|
||||
<input type="radio" name="mode" value="review">
|
||||
<span class="cell-icon tone-orange"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg></span>
|
||||
<span class="choice-text"><span class="cell-title">レビュー付きCPU戦</span><span class="cell-sub">あなたと CPU だけで対戦(友達は入れない)。ハンドごとにハンドレンジの色・行動の点数を表示し、対局全体の成績も振り返れる</span></span>
|
||||
<svg class="i check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
|
||||
</label>
|
||||
</div>
|
||||
<div class="hint" id="mode-hint"></div>
|
||||
</section>
|
||||
|
||||
<div id="cpu-options" hidden>
|
||||
<div class="label" id="cpu-label">CPU の人数</div>
|
||||
<div class="collapsible collapsed" id="cpu-options" inert>
|
||||
<div class="collapse-inner">
|
||||
<section class="group">
|
||||
<div class="group-header" id="cpu-label">CPU の人数</div>
|
||||
<div class="segmented numbers" role="radiogroup" aria-labelledby="cpu-label">
|
||||
<label><input type="radio" name="cpu-count" value="1"><span>1</span></label>
|
||||
<label><input type="radio" name="cpu-count" value="2"><span>2</span></label>
|
||||
@@ -45,39 +79,60 @@
|
||||
<label><input type="radio" name="cpu-count" value="7"><span>7</span></label>
|
||||
<label><input type="radio" name="cpu-count" value="8"><span>8</span></label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="group">
|
||||
<div class="group-header">チップ</div>
|
||||
<div class="list">
|
||||
<div class="cell field-cell value-right">
|
||||
<label for="stack">開始スタック</label>
|
||||
<input id="stack" type="number" inputmode="numeric" min="10" step="1" value="1000">
|
||||
|
||||
<div class="label">開始ブラインド(SB / BB)</div>
|
||||
<div class="row">
|
||||
<input id="sb" type="number" inputmode="numeric" min="1" step="1" aria-label="スモールブラインド">
|
||||
<span class="slash">/</span>
|
||||
<input id="bb" type="number" inputmode="numeric" min="1" step="1" aria-label="ビッグブラインド">
|
||||
</div>
|
||||
<div class="hint" id="blind-hint">スタックに合わせて自動設定(BB ≒ スタックの1/100)</div>
|
||||
<div class="cell field-cell value-right">
|
||||
<label for="sb">スモールブラインド</label>
|
||||
<input id="sb" type="number" inputmode="numeric" min="1" step="1">
|
||||
</div>
|
||||
<div class="cell field-cell value-right">
|
||||
<label for="bb">ビッグブラインド</label>
|
||||
<input id="bb" type="number" inputmode="numeric" min="1" step="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-footer" id="blind-hint">スタックに合わせて自動設定(BB ≒ スタックの1/100)</div>
|
||||
</section>
|
||||
|
||||
<div class="switch-row">
|
||||
<span>ブラインドタイマー</span>
|
||||
<label class="switch">
|
||||
<input id="timer" type="checkbox" aria-label="ブラインドタイマー">
|
||||
<section class="group">
|
||||
<div class="group-header">ブラインドタイマー</div>
|
||||
<div class="list">
|
||||
<div class="cell">
|
||||
<span class="cell-title" id="timer-label">ブラインドを自動で上げる</span>
|
||||
<span class="switch">
|
||||
<input id="timer" type="checkbox" aria-labelledby="timer-label">
|
||||
<span></span>
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="collapsible collapsed" id="interval-row" inert>
|
||||
<div class="collapse-inner">
|
||||
<div class="cell field-cell value-right">
|
||||
<label for="interval">インターバル</label>
|
||||
<div class="unit-input">
|
||||
<input id="interval" type="number" inputmode="numeric" min="1" max="240" step="1" value="10" disabled>
|
||||
<span class="unit">min</span>
|
||||
<span class="unit">分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule" id="schedule" hidden></div>
|
||||
</section>
|
||||
|
||||
<div class="error-text" id="error"></div>
|
||||
<button class="btn btn-orange btn-block mt-8" id="submit" type="submit">テーブルを作成</button>
|
||||
<div class="submit-area">
|
||||
<div class="error-text" id="error" role="alert"></div>
|
||||
<button class="btn btn-tint btn-lg btn-block" id="submit" type="submit">テーブルを作成</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<script src="/assets/fluid.js"></script>
|
||||
<script src="/assets/common.js"></script>
|
||||
<script src="/assets/blinds.js"></script>
|
||||
<script src="/assets/create.js"></script>
|
||||
|
||||
+14
-8
@@ -3,19 +3,24 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#24493b">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f2f7">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Hold'em Night</title>
|
||||
<link rel="icon" href="/assets/favicon.ico">
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<div class="logo">Hold'em<br>Night<small>PRIVATE CLUB</small></div>
|
||||
<body class="page-key">
|
||||
<main class="key-screen">
|
||||
<div class="app-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2.2c-2.3 3.1-7.6 6.3-7.6 10.4a4.1 4.1 0 0 0 7 2.9c-.1 2.3-1 4-2.6 5.3h6.4c-1.6-1.3-2.5-3-2.6-5.3a4.1 4.1 0 0 0 7-2.9c0-4.1-5.3-7.3-7.6-10.4z"/></svg>
|
||||
</div>
|
||||
<h1 class="brand">Hold'em Night</h1>
|
||||
<p class="brand-sub">PRIVATE CLUB</p>
|
||||
|
||||
<form id="form" class="card" autocomplete="off">
|
||||
<form id="form" class="key-form" autocomplete="off">
|
||||
<h2 class="key-title" id="key-title">参加キーを入力</h2>
|
||||
<p class="hint key-hint" id="key-hint">オーナーから教えてもらった6桁の数字</p>
|
||||
<p class="key-hint" id="key-hint">オーナーから教えてもらった6桁の数字</p>
|
||||
<div class="key-inputs" id="digits">
|
||||
<input type="tel" inputmode="numeric" aria-label="1桁目" autocomplete="one-time-code">
|
||||
<input type="tel" inputmode="numeric" aria-label="2桁目">
|
||||
@@ -24,10 +29,11 @@
|
||||
<input type="tel" inputmode="numeric" aria-label="5桁目">
|
||||
<input type="tel" inputmode="numeric" aria-label="6桁目">
|
||||
</div>
|
||||
<div class="error-text text-center" id="error"></div>
|
||||
<button class="btn btn-orange btn-block mt-8" id="submit" type="submit">入室する</button>
|
||||
<p class="error-text" id="error" role="alert"></p>
|
||||
<button class="btn btn-tint btn-lg btn-block" id="submit" type="submit">入室する</button>
|
||||
</form>
|
||||
</main>
|
||||
<script src="/assets/fluid.js"></script>
|
||||
<script src="/assets/common.js"></script>
|
||||
<script src="/assets/key.js"></script>
|
||||
</body>
|
||||
|
||||
+59
-16
@@ -3,34 +3,77 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#24493b">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f2f7">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Hold'em Night</title>
|
||||
<link rel="icon" href="/assets/favicon.ico">
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<body class="page-lobby">
|
||||
<header class="nav-bar" id="nav">
|
||||
<div class="nav-inner"><span class="nav-title"></span></div>
|
||||
</header>
|
||||
|
||||
<main class="wrap">
|
||||
<div class="logo">Hold'em<br>Night<small>PRIVATE CLUB</small></div>
|
||||
|
||||
<label for="name">あなたの名前</label>
|
||||
<input id="name" type="text" maxlength="12" placeholder="例: とらのすけ" autocomplete="nickname">
|
||||
<div class="error-text" id="name-error"></div>
|
||||
|
||||
<button id="create" class="btn btn-orange btn-block mt-4" type="button">+ テーブルを作成</button>
|
||||
<a id="range-link" class="btn btn-dark btn-block mt-8" href="/range" hidden>♠ ハンドレンジ学習</a>
|
||||
<a id="review-link" class="btn btn-dark btn-block mt-8" href="/review" hidden>📝 CPU戦のレビュー</a>
|
||||
|
||||
<div class="section-title">
|
||||
<span>開催中のテーブル</span>
|
||||
<span class="sub" id="count"></span>
|
||||
<div class="hero">
|
||||
<div class="app-mark small" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2.2c-2.3 3.1-7.6 6.3-7.6 10.4a4.1 4.1 0 0 0 7 2.9c-.1 2.3-1 4-2.6 5.3h6.4c-1.6-1.3-2.5-3-2.6-5.3a4.1 4.1 0 0 0 7-2.9c0-4.1-5.3-7.3-7.6-10.4z"/></svg>
|
||||
</div>
|
||||
<div class="table-list" id="list">
|
||||
<div>
|
||||
<h1 class="large-title" id="large-title">Hold'em Night</h1>
|
||||
<p class="brand-sub">PRIVATE CLUB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="group">
|
||||
<div class="group-header">プロフィール</div>
|
||||
<div class="list">
|
||||
<div class="cell field-cell">
|
||||
<label for="name">名前</label>
|
||||
<input id="name" type="text" maxlength="12" placeholder="例: とらのすけ" autocomplete="nickname">
|
||||
</div>
|
||||
</div>
|
||||
<div class="error-text" id="name-error" role="alert"></div>
|
||||
</section>
|
||||
|
||||
<div class="lobby-actions">
|
||||
<button id="create" class="btn btn-tint btn-lg btn-block" type="button">
|
||||
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>テーブルを作成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 管理者だけに表示 -->
|
||||
<section class="group" id="admin-group" hidden>
|
||||
<div class="group-header">管理者</div>
|
||||
<div class="list">
|
||||
<a id="range-link" class="cell cell-link" href="/range">
|
||||
<span class="cell-icon tone-purple"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.5"/></svg></span>
|
||||
<span class="cell-title">ハンドレンジ学習</span>
|
||||
<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
<a id="review-link" class="cell cell-link" href="/review">
|
||||
<span class="cell-icon tone-orange"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 20v-8M12 20V5M19 20v-9"/></svg></span>
|
||||
<span class="cell-title">CPU戦のレビュー</span>
|
||||
<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<div class="group-header">
|
||||
<span>開催中のテーブル</span>
|
||||
<span id="count"></span>
|
||||
</div>
|
||||
<div class="list" id="list">
|
||||
<div class="empty">読み込み中…</div>
|
||||
</div>
|
||||
<div class="group-footer" id="list-footer" hidden>行を左へスワイプすると削除できます(管理者)。</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="/assets/fluid.js"></script>
|
||||
<script src="/assets/common.js"></script>
|
||||
<script src="/assets/lobby.js"></script>
|
||||
</body>
|
||||
|
||||
+88
-52
@@ -3,28 +3,39 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#24493b">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#eceef1">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#070c0a">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>テーブル - Hold'em Night</title>
|
||||
<link rel="icon" href="/assets/favicon.ico">
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body class="table-page">
|
||||
<div class="conn-lost" id="conn-lost" hidden>接続が切れました。再接続中…</div>
|
||||
<body class="page-table">
|
||||
<div class="conn-lost" id="conn-lost" role="status" hidden><span class="spinner" aria-hidden="true"></span>接続が切れました。再接続中…</div>
|
||||
|
||||
<main class="wrap" id="main" hidden>
|
||||
<div class="table-header">
|
||||
<button class="icon-btn leave" id="leave" aria-label="退席する" title="退席する">⏏</button>
|
||||
<main class="table-main" id="main" hidden>
|
||||
<header class="table-bar">
|
||||
<button class="glass-btn" id="leave" aria-label="退席する" title="退席する">
|
||||
<svg class="i icon-leave" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 4h3.5A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5H14"/><path d="M10 8l-4 4 4 4M6 12h9"/></svg>
|
||||
<svg class="i icon-back" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>
|
||||
</button>
|
||||
<h1 id="table-name"></h1>
|
||||
<!-- ホストだけに表示 -->
|
||||
<span class="host-controls" id="host-controls" hidden>
|
||||
<button class="btn btn-green btn-sm" id="start">START</button>
|
||||
<button class="icon-btn" id="pause" aria-label="一時停止" title="一時停止">⏸</button>
|
||||
<button class="btn btn-tint btn-sm" id="start">START</button>
|
||||
<button class="glass-btn" id="pause" aria-label="一時停止" title="一時停止">
|
||||
<svg class="i i-fill icon-pause" viewBox="0 0 24 24" aria-hidden="true"><rect x="6.5" y="5" width="4" height="14" rx="1.3"/><rect x="13.5" y="5" width="4" height="14" rx="1.3"/></svg>
|
||||
<svg class="i i-fill icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.6v12.8a1 1 0 0 0 1.5.86l10.2-6.4a1 1 0 0 0 0-1.72L9.5 4.74A1 1 0 0 0 8 5.6z"/></svg>
|
||||
</button>
|
||||
</span>
|
||||
<!-- レビュー付きCPU戦のみ -->
|
||||
<button class="icon-btn" id="review-open" aria-label="直前のハンドのレビュー" title="直前のハンドのレビュー" hidden>📝</button>
|
||||
<button class="icon-btn" id="share" aria-label="サイトのリンクをコピー" title="サイトのリンクをコピー">🔗</button>
|
||||
</div>
|
||||
<button class="glass-btn" id="review-open" aria-label="直前のハンドのレビュー" title="直前のハンドのレビュー" hidden>
|
||||
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>
|
||||
</button>
|
||||
<button class="glass-btn" id="share" aria-label="サイトのリンクをコピー" title="サイトのリンクをコピー">
|
||||
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4.5 4.5 0 0 0 6.4 0l3-3a4.5 4.5 0 0 0-6.4-6.4l-1.2 1.2"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3 3a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="level-bar">
|
||||
<span class="lv" id="level"></span>
|
||||
@@ -37,6 +48,7 @@
|
||||
</div>
|
||||
|
||||
<div class="felt-area" id="felt-area">
|
||||
<div class="rail">
|
||||
<div class="felt">
|
||||
<div class="pot-label">POT</div>
|
||||
<div class="pot" id="pot">$0</div>
|
||||
@@ -44,45 +56,51 @@
|
||||
<div class="phase"><span id="phase">WAITING</span><span class="hand-no" id="hand-no"></span></div>
|
||||
<div class="result" id="result" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="seats"></div>
|
||||
</div>
|
||||
|
||||
<div class="spectator-note" id="spectator-note" hidden>
|
||||
<span id="spectator-text">満席のため観戦中です</span>
|
||||
<button class="btn btn-orange btn-sm" id="take-seat" hidden>空席に座る</button>
|
||||
<button class="btn btn-tint btn-sm" id="take-seat" hidden>空席に座る</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="action-bar" id="action-bar" hidden>
|
||||
<section class="action-bar" id="action-bar" hidden>
|
||||
<!-- レイズ額: レイズボタンから上に開く(すりガラスを重ねないよう .inner の外に置く) -->
|
||||
<div class="raise-panel" id="raise-panel" hidden>
|
||||
<div class="raise-head">
|
||||
<div class="raise-value"><span class="cur" aria-hidden="true">$</span><input type="number" class="raise-amount" id="raise-amount" inputmode="numeric" aria-label="レイズ額"></div>
|
||||
<span class="raise-meta" id="raise-meta"></span>
|
||||
</div>
|
||||
<div class="bet-slider" id="raise-range" role="slider" tabindex="0" aria-label="レイズ額">
|
||||
<div class="bet-track"><div class="bet-fill"></div></div>
|
||||
<div class="bet-thumb"></div>
|
||||
</div>
|
||||
<div class="presets" id="presets">
|
||||
<button type="button" class="chip-btn" data-preset="min">Min</button>
|
||||
<button type="button" class="chip-btn" data-preset="half">½ Pot</button>
|
||||
<button type="button" class="chip-btn" data-preset="threeq">¾ Pot</button>
|
||||
<button type="button" class="chip-btn" data-preset="pot">Pot</button>
|
||||
<button type="button" class="chip-btn" data-preset="max">All-in</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inner">
|
||||
<div class="me-row">
|
||||
<div class="my-cards" id="my-cards" hidden></div>
|
||||
<div class="me-info">
|
||||
<div class="my-stack"><span class="wallet">👛</span><span id="my-stack">$0</span></div>
|
||||
<div class="my-stack" id="my-stack">$0</div>
|
||||
<div class="me-sub"><span class="my-hand" id="my-hand"></span><span id="my-net"></span><span class="my-bet" id="my-bet"></span></div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" id="sit-out" title="次のハンドから参加しません">離席</button>
|
||||
<button class="btn btn-sm" id="sit-out" title="次のハンドから参加しません">離席</button>
|
||||
</div>
|
||||
|
||||
<div class="status-line" id="status-line"></div>
|
||||
<div class="status-line" id="status-line" aria-live="polite"></div>
|
||||
|
||||
<div class="award-panel" id="award-panel" hidden>
|
||||
<div id="award-pots"></div>
|
||||
<button class="btn btn-orange btn-block" id="award-confirm" disabled>確定</button>
|
||||
</div>
|
||||
|
||||
<div class="raise-panel" id="raise-panel" hidden>
|
||||
<div class="presets" id="presets">
|
||||
<button class="btn btn-dark btn-sm" data-preset="min">Min</button>
|
||||
<button class="btn btn-dark btn-sm" data-preset="half">½</button>
|
||||
<button class="btn btn-dark btn-sm" data-preset="threeq">¾</button>
|
||||
<button class="btn btn-dark btn-sm" data-preset="pot">Pot</button>
|
||||
<button class="btn btn-dark btn-sm" data-preset="max">All-in</button>
|
||||
</div>
|
||||
<div class="raise-row">
|
||||
<input type="range" id="raise-range" aria-label="レイズ額">
|
||||
<input type="number" id="raise-amount" inputmode="numeric" aria-label="レイズ額">
|
||||
</div>
|
||||
<div class="award-pots" id="award-pots"></div>
|
||||
<button class="btn btn-tint btn-lg btn-block" id="award-confirm" disabled>確定</button>
|
||||
</div>
|
||||
|
||||
<div class="actions" id="actions">
|
||||
@@ -91,49 +109,67 @@
|
||||
<button class="btn act-raise" id="act-raise" disabled>Raise</button>
|
||||
</div>
|
||||
<div class="actions raising" id="raise-actions" hidden>
|
||||
<button class="btn btn-dark" id="raise-cancel">戻る</button>
|
||||
<button class="btn" id="raise-cancel">戻る</button>
|
||||
<button class="btn act-raise" id="raise-confirm">Raise</button>
|
||||
</div>
|
||||
<div class="actions raising" id="review-actions" hidden>
|
||||
<button class="btn btn-dark" id="review-show">レビュー</button>
|
||||
<button class="btn btn-orange" id="review-next">次のハンドへ</button>
|
||||
<button class="btn" id="review-show">レビュー</button>
|
||||
<button class="btn btn-tint" id="review-next">次のハンドへ</button>
|
||||
</div>
|
||||
<div class="actions single" id="alt-actions" hidden>
|
||||
<button class="btn btn-orange" id="sit-in">復帰する</button>
|
||||
<button class="btn btn-tint" id="sit-in">復帰する</button>
|
||||
<button class="btn btn-blue" id="rebuy">リバイ</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="center-message" id="not-found" hidden>
|
||||
<div class="badge-icon" aria-hidden="true"><svg class="i" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.5v.01"/></svg></div>
|
||||
<h2 id="not-found-title">テーブルが見つかりません</h2>
|
||||
<p id="not-found-text">終了したか、URLが間違っている可能性があります。</p>
|
||||
<a class="btn btn-blue" href="/">ロビーへ戻る</a>
|
||||
<a class="btn btn-tint btn-lg" href="/">ロビーへ戻る</a>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="review-modal" hidden>
|
||||
<div class="modal review-modal" role="dialog" aria-labelledby="review-title">
|
||||
<h2 id="review-title">ハンドのレビュー</h2>
|
||||
<!-- 直前のハンドのレビュー(下から出るシート。つまみを下へ払うと閉じる) -->
|
||||
<div class="sheet-backdrop" id="review-modal" hidden>
|
||||
<div class="sheet review-sheet" role="dialog" aria-modal="true" aria-labelledby="review-title" tabindex="-1">
|
||||
<div class="sheet-header" data-sheet-drag>
|
||||
<span class="sheet-grabber" aria-hidden="true"></span>
|
||||
<h2 class="sheet-title" id="review-title">ハンドのレビュー</h2>
|
||||
</div>
|
||||
<div class="sheet-scroll">
|
||||
<div id="review-body"></div>
|
||||
<div class="review-buttons">
|
||||
<button type="button" class="btn btn-dark" id="review-close">閉じる</button>
|
||||
<button type="button" class="btn btn-orange" id="review-modal-next">次のハンドへ</button>
|
||||
<button type="button" class="btn btn-block mt-12" id="review-copy">
|
||||
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 3.5l1.6 4.4L16 9.5l-4.4 1.6L10 15.5l-1.6-4.4L4 9.5l4.4-1.6z"/><path d="M18 14l.8 2.2L21 17l-2.2.8L18 20l-.8-2.2L15 17l2.2-.8z"/></svg>AIに質問する用にコピー
|
||||
</button>
|
||||
<div class="list mt-12">
|
||||
<a class="cell cell-link" id="review-link" href="/review" target="_blank" rel="noopener">
|
||||
<span class="cell-title">対局全体のレビュー<span class="cell-sub">点数表・成績・リプレイ</span></span>
|
||||
<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sheet-footer">
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn btn-lg" id="review-close">閉じる</button>
|
||||
<button type="button" class="btn btn-tint btn-lg" id="review-modal-next">次のハンドへ</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-dark review-copy" id="review-copy">🤖 AIに質問する用にコピー</button>
|
||||
<a class="link-gold review-link" id="review-link" href="/review" target="_blank" rel="noopener">対局全体のレビュー(点数表・成績・リプレイ)→</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="name-modal" hidden>
|
||||
<form class="modal" id="name-form">
|
||||
<h2>名前を入力</h2>
|
||||
<p class="hint">テーブルで表示される名前です</p>
|
||||
<input id="name-input" type="text" maxlength="12" placeholder="例: とらのすけ" class="mt-12" autocomplete="nickname">
|
||||
<button class="btn btn-orange btn-block mt-12" type="submit">参加する</button>
|
||||
<!-- 名前の入力(閉じられないシート) -->
|
||||
<div class="sheet-backdrop" id="name-modal" hidden>
|
||||
<form class="sheet sheet-form" id="name-form" role="dialog" aria-modal="true" aria-labelledby="name-title" tabindex="-1">
|
||||
<h2 class="sheet-title" id="name-title">名前を入力</h2>
|
||||
<p class="hint mt-8">テーブルで表示される名前です</p>
|
||||
<input id="name-input" class="field mt-12" type="text" maxlength="12" placeholder="例: とらのすけ" autocomplete="nickname" aria-labelledby="name-title">
|
||||
<button class="btn btn-tint btn-lg btn-block mt-12" type="submit">参加する</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="/assets/fluid.js"></script>
|
||||
<script src="/assets/common.js"></script>
|
||||
<script src="/assets/table.js"></script>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user