diff --git a/README.md b/README.md index 63bb40b..f8d6645 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ - 全員フォールドしたら残った人が自動でポットを獲得。誰もコールしなかった分は自動で返る。 - 「離席」にすると次のハンドから配られない(手番が来たら自動でチェック/フォールド)。「復帰する」で戻る。 - チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。 +- テーブル上部のトロフィーのボタンで、着席している全員のランキングを開ける。「スタック」(持っているチップ。ハンド中は、まだ降りていない人がそのハンドで出した額も含める)と「収支」(持っているチップ − バイインの合計。リバイを含む)を切り替えられる。同じ値は同じ順位。 - ハンド中に退席するとフォールド扱い(出したチップはポットに残る。ただし誰もコールしていない分は返る)。オールインしている人の手は退席しても最後まで生きている。 - チェックできるのに降りるとサイドポットの対象者がいなくなる場合、エンジンが受け付けないので「チェックしてください」と出る(退席のときは自動でチェックになる)。 - ホストの一時停止中は、アクション・勝者の確定・オールイン後の場札・ブラインドタイマー・次のハンドの開始がすべて止まる。 @@ -62,6 +63,15 @@ http://localhost:4671 を開く。 | `PUBLIC_ORIGIN` | なし | 本番の公開URL `https://poker.kanawo.net`。設定するとこのドメイン以外(IP直打ちや別ドメイン)へのアクセスと、他オリジンからの操作を拒否する。ローカル開発では設定しない | | `SESSION_SECRET` | 自動生成 | Cookie 署名用。未指定なら `data/session-secret` に生成・保存 | +## 統計 + +ロビーの「統計」(`/stats`)で、自分の成績を見られる。ハンドが終わるたびに、参加した人間のプレイヤーごと(ブラウザのプレイヤーIDごと。CPU は数えない)に自動で記録する。 + +- **まとめ**: プレイスタイル(タイト/ルーズ × アグレッシブ/パッシブのレーダーと、TAG・LAG・ロック・フィッシュのどれか)、VPIP・PFR・AFq・プレイ時間。VPIP 25% 以上をルーズ、PFR が VPIP の半分以上をアグレッシブとして判定する。レーダーを押すと4つのスタイルの説明を横に送って見られる。 +- **すべての統計**: 合計・キャッシュ(キャッシュオンリー)・友人戦・CPU戦(レビュー付きCPU戦を含む)ごとに、VPIP・PFR・ATS(全員が降りて CO・BTN・SB に回ってきたときのレイズ率)・3BET・WTSD・W$SD・WWSF・ストリートごとのフォールド率・アグレッション頻度(全部の行動のうちベット・レイズの割合)・ハンドの勝率・収支(BB と 100ハンドあたり)。各数字の「?」で段階(ニット〜フィッシュなど)と説明を開き、「友達に共有」で文章を共有・コピーできる。 +- 離席・退席で自動で行った行動は本人の判断として数えない。VPIP・PFR の分母は、プリフロップで判断したハンド(全員が降りて BB に回ってきたハンドなどは除く)。 +- 保存先は `data/stats/`(git 管理外)。ファイル名はプレイヤーIDの SHA-256 で、中身は回数の合計だけ(ハンドの内容は残さない)。プレイヤーIDは URL に載せないよう POST の本文で送る。 + ## 管理者(ハンドレンジ学習・レビュー付きCPU戦) `ADMIN_KEY` を入力して入室すると管理者になり、ロビーに「ハンドレンジ学習」(`/range`:レンジ表と色・分類クイズ。レンジ表には CO のオープンを BTN で受けたときの対応も枠で示す。3bet は水色、コールは黄)と「CPU戦のレビュー」(`/review`)が表示され、テーブル作成で「レビュー付きCPU戦」を選べる。ロビーのテーブルの行を左へスワイプすると「削除」が出て(キーボードでは Tab で削除ボタンに移ると開く)、どのテーブルでも削除できる(着席者・観戦者には「管理者がテーブルを削除しました」と表示される)。管理者は通常のプレイもそのままできる。 @@ -83,6 +93,7 @@ http://localhost:4671 を開く。 - **その他**: コールに必要な勝率と推定勝率の比較、オールイン時の勝率と期待値(運の影響)、成績(VPIP・PFR・3bet・Cベット・WTSD・W$SD・AF)、色ごと・ポジションごとの参加率とレンジどおりの割合、気をつけたいクセ、見直したいハンド(失った期待値の大きい順に5つ)、実際の収支と運を除いた収支の推移グラフ。 - **AIに質問する用にコピー**: テーブルのレビューとリプレイ画面のボタンで、そのハンドの状況(ゲームの条件・席とスタック・自分の手札・ストリートごとの場札とポット・全員のアクション・ショーダウンで公開された手札・結果・サイトの判定・質問文)を、ChatGPT や Claude などに貼り付けてそのまま質問できる文章にしてコピーする。判断の時点で見えていた情報だけを書き、公開されなかった相手の手札は含めない(`server/review/prompt.js`)。自動でコピーできないブラウザでは文章を表示するので手でコピーする。 - レビューは `data/reviews/`(git 管理外)に対局ごとに保存し、テーブルを閉じた後も `/review` で見返せる(新しい50対局まで。1対局1000ハンドまで)。 +- テーブルのレビューの「対局全体のレビュー」は同じタブで開き、左上の「テーブル」でそのテーブルに戻れる(リプレイを開いても引き継ぐ。URL の `t=テーブルID`)。 - 初めて開くときは鍵入力画面で `ADMIN_KEY` を入れる。すでに参加キーで入室済みのブラウザは `https://poker.kanawo.net/key?admin` を開いて管理者キーを入れる。 - 管理者以外には `/range` とその画像・スクリプトは 404 を返す(ページがあること自体を見せない)。ファイルは `public/` の外の `admin/` に置いている。 @@ -112,8 +123,9 @@ http://localhost:4671 を開く。 | `server/cards.js` | 役の名前と最強の5枚(@pokertools/evaluator) | | `server/ai.js` | CPU の思考の割り当て(種類と性格)と呼び出し | | `server/review/*.js` | レビュー付きCPU戦のレビュー(`preflop` ハンドレンジの判定 / `equity` 勝率 / `analyze` 1ハンドの点数 / `summary` 対局のまとめ / `prompt` AI に質問する文章 / `store` 保存) | +| `server/stats/*.js` | プレイヤーごとの統計(`hand` 1ハンドから数える / `store` 保存 / `summary` 割合にまとめる) | | `server/bots/*.js` | CPU の思考の種類ごとの実装(`montecarlo` / `calculator` / `range` / `madehand`)と共通の道具(`common.js`) | -| `test/` | チップ処理・役の判定・CPU のテスト(`npm test`) | +| `test/` | チップ処理・役の判定・CPU・レビュー・統計のテスト(`npm test`) | | `server/config.js` | `.env` の読み込みと各種上限値 | | `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) | | `public/*.html`, `public/assets/*.js` | 各画面 | @@ -123,6 +135,7 @@ http://localhost:4671 を開く。 - テーブルはメモリ上にのみ保持(サーバ再起動で消える)。誰も接続していない状態が30分続くと自動削除。 - 名前とプレイヤーID はブラウザの localStorage に保存。リロードしても同じ席に戻れる。 - 作成者がホスト(START・一時停止・ショーダウン後のポットの分配ができる。ボタンはテーブル名の右に出る)。ホストが退席すると次の着席者に移る。 +- ホストはテーブル名の右の「席順を変更」(↑↓のボタン、3人以上のとき)で、着席者の並びを変えられる。自分を一番上に固定し、その下に左隣から時計回りの順で並び、行の右端のつまみをドラッグ(キーボードでは ↑↓)して並べ替える。小さなテーブルの図で仕上がりを確かめられ、「シャッフル」でランダムにもできる。ハンドの途中(結果の表示中を含む)に決めた席順は次のハンドの始めに反映し(ボタンに印が付く)、決めた後に座った人は並びの最後に入る。ディーラーボタンは同じ人に付いたまま、次のハンドで新しい並びの次の人へ回る。 ## VPS へのデプロイ例(Ubuntu + nginx + systemd + HTTPS) diff --git a/admin/review.js b/admin/review.js index 3bd1a8b..3654175 100644 --- a/admin/review.js +++ b/admin/review.js @@ -40,14 +40,26 @@ return `${rank}${SUIT_SYMBOL[c[1]]}`; } - // ---------- 画面の切り替え(URL: ?s=対局ID&h=ハンド番号) ---------- + // ---------- 画面の切り替え(URL: ?s=対局ID&h=ハンド番号&t=テーブルID) ---------- function show(view) { for (const v of ['list', 'session', 'replay']) $(`view-${v}`).hidden = v !== view; window.scrollTo(0, 0); } + // テーブルのレビューから開いたときのテーブルID。どの画面に移っても引き継ぎ、対局の画面からテーブルへ戻れるようにする + function fromTable() { + const t = new URLSearchParams(location.search).get('t'); + return t && /^[0-9a-f]{8}$/.test(t) ? t : null; + } + + function setBack(href, label) { + $('back').href = href; + $('back-label').textContent = label; + } + function go(params, push = true) { - const q = new URLSearchParams(params).toString(); + const table = fromTable(); + const q = new URLSearchParams(table ? { ...params, t: table } : params).toString(); const url = location.pathname + (q ? `?${q}` : ''); if (push) history.pushState(null, '', url); else history.replaceState(null, '', url); @@ -58,10 +70,11 @@ const p = new URLSearchParams(location.search); const id = p.get('s'); const hand = Number(p.get('h')); + const table = fromTable(); if (!id) { $('title').textContent = 'CPU戦のレビュー'; - $('back').href = '/'; - $('back-label').textContent = 'ロビー'; + if (table) setBack(`/table/${table}`, 'テーブル'); + else setBack('/', 'ロビー'); show('list'); return loadList(); } @@ -77,22 +90,21 @@ if (hand) { const h = session.hands.find((x) => x.number === hand); if (h) { - $('back').href = `?s=${encodeURIComponent(id)}`; - $('back-label').textContent = '対局'; + setBack(`?s=${encodeURIComponent(id)}`, '対局'); return openReplay(h); } } - $('back').href = '/review'; - $('back-label').textContent = '一覧'; + if (table) setBack(`/table/${table}`, 'テーブル'); + else setBack('/review', '一覧'); show('session'); renderSession(); } window.addEventListener('popstate', route); - // 戻るボタンは履歴を積まずに一つ上へ + // 戻るボタンは履歴を積まずに一つ上へ(ロビー・テーブルへはそのままページを移る) $('back').addEventListener('click', (e) => { const href = $('back').getAttribute('href'); - if (href === '/') return; + if (href === '/' || href.startsWith('/table/')) return; e.preventDefault(); const p = new URLSearchParams(location.search); go(p.get('h') ? { s: p.get('s') } : {}); diff --git a/public/assets/stats.js b/public/assets/stats.js new file mode 100644 index 0000000..d16afbc --- /dev/null +++ b/public/assets/stats.js @@ -0,0 +1,612 @@ +// 統計: プレイスタイル(タイト/ルーズ × アグレッシブ/パッシブ)と、VPIP・PFR などの数字。 +// まとめの画面と、テーブルの種類ごとにすべての数字を出す画面(?v=all)。数字の「?」で段階と説明のシートを開く +(function () { + 'use strict'; + + const { Store, esc, api, toast } = App; + const $ = (id) => document.getElementById(id); + const clamp = Fluid.clamp; + + Fluid.navBar($('nav'), $('title')); + + let data = null; // { since, modes: { total, chips, friends, cpu } } + let mode = 'total'; + + // ---------- 段階と色 ---------- + // upper: 各段階の上限(整数の %)。最後の段階は上限なし。tone: 段階の色の並び + const STYLE = ['ニット', 'タイト', 'バランス', 'ルーズ', 'フィッシュ']; + const AGGR = ['マウス', 'パッシブ', 'バランス', 'アグレッシブ', 'マニアック']; + const QUALITY = ['低い', '平均', '良い', '高い']; + const QUALITY_TOP = ['低い', '平均', '良い', '優秀']; + + const ICON = { + vpip: '✌️', + pfr: '🤞', + ats: '🫳', + threeBet: '☝️', + wtsd: '🤘', + wsd: '🫰', + wwsf: '👌', + fold: '🙅', + afq: '👊', + hands: '✋', + net: '💰', + time: '⏱️', + }; + + const STATS = { + vpip: { + short: 'VPIP', + title: '自発的な参加率(VPIP)', + upper: [10, 20, 30, 40], + labels: STYLE, + tone: 'style', + sample: (m) => `${m.hands}ハンド`, + desc: 'プリフロップで自分からチップを入れた(コール・レイズした)ハンドの割合です。BB のチェックは含みません。いちばん基本の数字で、たくさんの手で参加するか(ルーズ)、強い手だけで参加するか(タイト)が分かります。', + }, + pfr: { + short: 'PFR', + title: 'プリフロップレイズ(PFR)', + upper: [5, 12, 20, 30], + labels: AGGR, + tone: 'style', + sample: (m) => `${m.hands}ハンド`, + desc: 'プリフロップでコールではなくレイズしたハンドの割合です。VPIP と組み合わせると打ち方が分かります。よくレイズする人は相手に圧力をかけ、ハンドの主導権を握ります。', + }, + ats: { + short: 'ATS', + title: 'スチールの試み(ATS)', + upper: [15, 25, 40, 55], + labels: AGGR, + tone: 'style', + sample: (m) => `機会 ${m.atsOpp}回`, + desc: '全員が降りて CO・BTN・SB の自分に回ってきたときに、レイズした割合です。後ろの位置の有利を使ってブラインドを奪いにいけているかが分かります。', + }, + threeBet: { + short: '3BET', + title: 'スリーベット(3BET)', + upper: [2, 4, 7, 12], + labels: AGGR, + tone: 'style', + sample: (m) => `機会 ${m.threeBetOpp}回`, + desc: 'プリフロップで誰かのレイズに直面したときに、リレイズした割合です。強い手でポットを大きくしたり、ブラフで圧力をかけたりする積極的なプレイで、相手のレイズへの向き合い方が分かります。', + }, + wtsd: { + short: 'WTSD', + title: 'ショーダウン到達率(WTSD)', + upper: [20, 24, 28, 32], + labels: STYLE, + tone: 'style', + sample: (m) => `フロップを見た ${m.sawFlop}回`, + desc: 'フロップを見たハンドのうち、ショーダウンまで行った割合です。最後まで手を見に行くか、途中で降りるかの傾向が分かります。高すぎるとコールしすぎ、低すぎると降りすぎの目安です。', + }, + wsd: { + short: 'W$SD', + title: 'ショーダウン勝率(W$SD)', + upper: [40, 45, 56], + labels: QUALITY, + tone: 'quality', + sample: (m) => `ショーダウン ${m.showdowns}回`, + desc: 'ショーダウンまで行ったハンドのうち、ポットを取った(山分けを含む)割合です。ショーダウンに持っていく手の質が分かります。高いほど、強い手でバリューを取り、弱い手でのショーダウンを避けられています。', + }, + wwsf: { + short: 'WWSF', + title: 'フロップ後の勝率(WWSF)', + upper: [37, 42, 47], + labels: QUALITY_TOP, + tone: 'quality', + sample: (m) => `フロップを見た ${m.sawFlop}回`, + desc: 'フロップを見たハンドのうち、ポットを取った割合です。相手を降ろして取った場合もショーダウンで勝った場合も含みます。フロップ以降の上手さを総合的に表す数字です。', + }, + afq: { + short: 'AFq', + title: 'アグレッション頻度(AFq)', + upper: [20, 40, 60, 80], + labels: AGGR, + tone: 'style', + sample: (m) => `${m.hands}ハンド`, + desc: '自分の行動(フォールド・チェック・コール・ベット・レイズ)のうち、ベット・レイズの割合です。積極的に打つと相手に圧力がかかり、ショーダウンなしで取れるポットが増え、強い手でポットを大きくできます。', + }, + hands: { + short: 'ハンド', + title: 'ハンドの勝率', + upper: [8, 14, 20], + labels: QUALITY_TOP, + tone: 'quality', + sample: (m) => `${m.hands}ハンド`, + desc: '配られたハンドのうち、ポットを取った(山分けを含む)割合と、プレイしたハンド数・勝ったハンド数です。プレイの量と結果の移り変わりを追えます。', + }, + fold: { + short: 'フォールド率', + title: 'フォールド率', + sample: (m) => `${m.hands}ハンド`, + desc: '各ストリート(プリフロップ・フロップ・ターン・リバー)で自分が判断したハンドのうち、降りた割合です。すぐにあきらめすぎていないか、逆にコールしすぎていないかが分かります。ストリートごとのバランスを見てみましょう。', + }, + net: { + short: '収支', + title: '収支(BB)', + sample: (m) => `${m.hands}ハンド`, + desc: 'チップの増減をそのハンドの BB の何倍かに直して合計したものです。ブラインドの大きさが違うテーブルの結果もまとめて比べられます。「100ハンドあたり」は勝ちの速さの目安です(BB/100)。', + }, + time: { + short: 'プレイ時間', + title: 'プレイ時間', + sample: (m) => `${m.hands}ハンド`, + desc: 'ハンドを遊んでいた時間の合計です。続けて遊んでいるあいだ(前のハンドから3分以内)は、ハンドの間の待ち時間も含めます。', + }, + }; + + const MODE_LABEL = { total: '合計', chips: 'キャッシュ', friends: '友人戦', cpu: 'CPU戦' }; + const STREETS = ['プリフロップ', 'フロップ', 'ターン', 'リバー']; + + // 値(%)がどの段階か。端数は四捨五入してから比べる + function bandOf(def, v) { + const r = Math.round(v); + const i = def.upper.findIndex((u) => r <= u); + return i < 0 ? def.upper.length : i; + } + const rangeText = (def, i) => { + const lo = i === 0 ? 0 : def.upper[i - 1] + 1; + return i < def.upper.length ? `${lo}–${def.upper[i]}` : `${lo}+`; + }; + const toneClass = (def, i) => `tone-${def.tone}-${i}`; + const pctNum = (v) => (v == null ? '—' : String(Math.round(v))); + const bb = (v) => `${v > 0 ? '+' : v < 0 ? '−' : '±'}${Math.abs(Math.round(v * 10) / 10).toLocaleString('ja-JP')}`; + + // 段階のバー(カード用): 今の段階まで塗り、今の段階は中の位置まで塗る + function bandBar(def, v) { + const n = def.upper.length + 1; + if (v == null) return `
${''.repeat(n)}
`; + const band = bandOf(def, v); + const segs = []; + for (let i = 0; i < n; i++) { + let fill = 0; + if (i < band) fill = 1; + else if (i === band) { + const lo = i === 0 ? 0 : def.upper[i - 1]; + const width = i < def.upper.length ? def.upper[i] - lo : def.upper[i - 1] - (i >= 2 ? def.upper[i - 2] : 0); + fill = v > 0 ? clamp((v - lo) / width, 0.12, 1) : 0; + } + segs.push(``); + } + return `
${segs.join('')}
`; + } + + // 段階の一覧(説明シート用): 名前・バー・範囲。今の段階だけ塗る + function bandScale(def, v) { + const band = v == null ? -1 : bandOf(def, v); + const cols = def.labels + .map( + (label, i) => ` +
+ ${label} + + ${rangeText(def, i)} +
` + ) + .join(''); + return `
${cols}
`; + } + + // ---------- 図形(SVG) ---------- + // 角度は真上を 0 度として時計回り + function arcPath(cx, cy, r, a0, a1) { + const p = (a) => [cx + r * Math.sin((a * Math.PI) / 180), cy - r * Math.cos((a * Math.PI) / 180)]; + const [x0, y0] = p(a0); + const [x1, y1] = p(a1); + return `M${x0.toFixed(2)} ${y0.toFixed(2)}A${r} ${r} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`; + } + + // 小さなメーター(フォールド率): 270度の弧 + function miniGauge(v) { + const track = arcPath(40, 40, 32, -135, 135); + const value = v ? `` : ''; + return ``; + } + + // 区切りのある輪(アグレッション頻度・勝率)。値の段階の色で塗る + function ring(v, def) { + const SEGS = 6; + const START = -150; + const SPAN = 300; + const GAP = 7; + const size = SPAN / SEGS; + const tone = v == null ? '' : toneClass(def, bandOf(def, v)); + let out = ''; + for (let i = 0; i < SEGS; i++) { + const a0 = START + i * size + GAP / 2; + const a1 = START + (i + 1) * size - GAP / 2; + out += ``; + if (v == null) continue; + const f = clamp(((v / 100) * SPAN - i * size) / size, 0, 1); + if (f > 0) out += ``; + } + return ``; + } + + // ---------- プレイスタイル ---------- + // ルーズさ = VPIP(25% でちょうど真ん中)、積極性 = PFR ÷ VPIP(半分でちょうど真ん中) + const TYPES = [ + { + id: 'TAG', + chip: 'TAG', + emoji: '🦅', + name: 'TAG', + full: 'タイト・アグレッシブ', + desc: '参加するハンドは少ないものの、参加したら強気にベット・レイズする打ち方です。強い手をじっくり待ち、来たら攻めます。いちばん勝ちやすいスタイルの一つとされますが、手の選び方が硬すぎると相手に読まれやすくなります。', + }, + { + id: 'LAG', + chip: 'LAG', + emoji: '🐒', + name: 'LAG', + full: 'ルーズ・アグレッシブ', + desc: '広い範囲のハンドで参加し、大きなベットもためらわない打ち方です。何を持っているか読みにくく、良い手でもタイミングの良いブラフでもポットを取れます。大きく勝てる可能性がある一方、結果の振れ幅が大きく、高い技術が必要です。', + }, + { + id: 'ROCK', + chip: 'ロック', + emoji: '🦥', + name: 'ロック', + full: 'タイト・パッシブ', + desc: 'めったに参加せず、ベットよりコールを好むとても慎重な打ち方です。ブラフで降ろすのは難しい相手ですが、強い手で取りきるチャンスを逃しがちです。負けは小さく抑えられますが勝ちも伸びにくく、とてもルーズな卓でいちばん効果を発揮します。', + }, + { + id: 'FISH', + chip: 'フィッシュ', + emoji: '🐟', + name: 'フィッシュ', + full: 'ルーズ・パッシブ', + desc: 'たくさんのハンドに参加するものの、自分からベット・レイズすることは少ない打ち方です。微妙な手でもコールし、ドローを追いかけがちです。たまに大きな手を当てますが、長い目で見ると負けやすいスタイルです。ブラフが効きにくいので、相手にすると厄介でもあります。', + }, + ]; + + function playStyle(m) { + if (!m || m.vpip == null || m.pfr == null) return null; + const loose = clamp(m.vpip / 50, 0.12, 0.92); + const aggr = clamp(m.vpip > 0 ? m.pfr / m.vpip : 0, 0.12, 0.92); + const id = loose >= 0.5 ? (aggr >= 0.5 ? 'LAG' : 'FISH') : aggr >= 0.5 ? 'TAG' : 'ROCK'; + return { loose, aggr, id }; + } + + // 菱形のレーダー(上: アグレッシブ、右: ルーズ、下: パッシブ、左: タイト)と、四隅のスタイル + function styleCardHtml(m) { + const st = playStyle(m); + const W = 360; + const H = 300; + const cx = W / 2; + const cy = 150; + const R = 92; + const pt = (dir, f) => { + const d = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] }[dir]; + return [cx + d[0] * R * f, cy + d[1] * R * f]; + }; + const diamond = (f) => ['top', 'right', 'bottom', 'left'].map((d) => pt(d, f).map((x) => x.toFixed(1)).join(',')).join(' '); + const grid = [1 / 3, 2 / 3] + .map((f) => ``) + .concat(``) + .join(''); + const axes = ``; + + // 四隅のスタイル + const CHIP_W = 74; + const CHIP_H = 34; + const corners = { TAG: [14, 16], LAG: [W - 14 - CHIP_W, 16], ROCK: [14, H - 16 - CHIP_H], FISH: [W - 14 - CHIP_W, H - 16 - CHIP_H] }; + const chips = TYPES.map((t) => { + const [x, y] = corners[t.id]; + const on = st && st.id === t.id; + return ` + + ${esc(t.chip)}`; + }).join(''); + + let shape = ''; + let link = ''; + if (st) { + const poly = [pt('top', st.aggr), pt('right', st.loose), pt('bottom', 1 - st.aggr), pt('left', 1 - st.loose)]; + const points = poly.map((p) => p.map((x) => x.toFixed(1)).join(',')).join(' '); + shape = ``; + // 自分のスタイルの隅へ線を引く(菱形の、その隅の側の辺の中点から) + const side = { TAG: [0, 3], LAG: [0, 1], ROCK: [2, 3], FISH: [1, 2] }[st.id]; + const from = [(poly[side[0]][0] + poly[side[1]][0]) / 2, (poly[side[0]][1] + poly[side[1]][1]) / 2]; + const [x, y] = corners[st.id]; + const to = [st.id === 'TAG' || st.id === 'ROCK' ? x + CHIP_W - 8 : x + 8, st.id === 'TAG' || st.id === 'LAG' ? y + CHIP_H - 6 : y + 6]; + link = ``; + } + const label = (x, y, cls, a, b) => + `${a}${b}`; + + return ` + + + + + + + + + + + ${grid}${axes}${link}${shape}${chips} + ${label(cx, 36, 'aggr', 'アグレッシブ', 'ベット・レイズが多い')} + ${label(cx, H - 22, 'passive', 'パッシブ', 'コールが多い')} + ${label(46, cy - 2, 'tight', 'タイト', '参加が少ない')} + ${label(W - 46, cy - 2, 'loose', 'ルーズ', '参加が多い')} + + ${st ? '' : '
まだデータがありません
'}`; + } + + // ---------- カード ---------- + const helpBtn = (key) => + ``; + const head = (key, label = STATS[key].short) => + `
${esc(label)}${helpBtn(key)}
`; + const value = (v, unit = '%') => `
${pctNum(v)}${v == null ? '' : `${unit}`}
`; + + function metricCard(key, m, compact = false) { + const def = STATS[key]; + const v = m[key]; + return `
${head(key)}${value(v)}${bandBar(def, v)} +
${v == null ? 'データなし' : def.labels[bandOf(def, v)]}
`; + } + + function liquidCard(key, m) { + const v = m[key]; + return `
${head(key)}${value(v)}
`; + } + + // ストリートごとのフォールド率(4つのメーター) + const foldMinis = (m) => + `
${m.fold + .map( + (v, i) => + `
${miniGauge(v)}${pctNum(v)}${v == null ? '' : '%'}
${STREETS[i]}
` + ) + .join('')}
`; + + function foldCard(m) { + return `
${head('fold')}${foldMinis(m)}
`; + } + + function ringCard(key, m, v, sub, rows) { + const def = STATS[key]; + const list = rows.map(([k, val]) => `
${k}${val}
`).join(''); + return `
${head(key)} +
+
${ring(v, def)}
${value(v)}${esc(sub)}
+
${list}
+
`; + } + + function netCard(m) { + const rows = [ + ['100ハンドあたり', m.bb100 == null ? '—' : `${bb(m.bb100)} BB`], + ['プレイ時間', `${m.hours} 時間`], + ]; + return `
${head('net')} +
+
${m.hands ? bb(m.netBB) : '—'}BB
+
${rows.map(([k, val]) => `
${k}${val}
`).join('')}
+
`; + } + + // 塗る量・水位は CSSOM で入れる(CSP でインラインスタイルを禁止しているため)。 + // 入れた直後は 0 から伸ばす + function paintFills(root) { + const fills = root.querySelectorAll('[data-fill]'); + const levels = root.querySelectorAll('[data-level]'); + requestAnimationFrame(() => + requestAnimationFrame(() => { + for (const el of fills) el.style.transform = `scaleX(${el.dataset.fill})`; + for (const el of levels) el.style.setProperty('--level', `${el.dataset.level}%`); + }) + ); + } + + // ---------- まとめ ---------- + function renderSummary() { + const m = data.modes.total; + $('style-card').innerHTML = styleCardHtml(m); + const since = data.since ? `${new Date(data.since).toLocaleDateString('ja-JP')} から` : 'まだ記録がありません'; + $('summary-tiles').innerHTML = ` + ${metricCard('vpip', m, true)} +
${head('time', 'プレイ時間')}
${m.hours}時間
${since}
+ ${metricCard('pfr', m, true)} + ${metricCard('afq', m, true)}`; + paintFills($('summary-tiles')); + } + + // ---------- すべての統計 ---------- + function renderAll() { + const m = data.modes[mode]; + for (const b of $('mode-tabs').querySelectorAll('[data-mode]')) b.classList.toggle('active', b.dataset.mode === mode); + modeSeg.update(); + const afqRows = STREETS.map((s, i) => [s, m.afqStreets[i] == null ? '—' : `${pctNum(m.afqStreets[i])}%`]); + $('all-body').innerHTML = ` + ${m.hands ? '' : `
${MODE_LABEL[mode] === '合計' ? 'まだ' : `${MODE_LABEL[mode]}では`}記録がありません。テーブルで遊ぶとハンドごとに記録されます。
`} +
+ ${metricCard('vpip', m)}${metricCard('pfr', m)}${metricCard('ats', m)}${metricCard('threeBet', m)} +
+
${liquidCard('wtsd', m)}${liquidCard('wsd', m)}${liquidCard('wwsf', m)}
+ ${foldCard(m)} + ${ringCard('afq', m, m.afq, m.afq == null ? 'データなし' : STATS.afq.labels[bandOf(STATS.afq, m.afq)], afqRows)} + ${ringCard('hands', m, m.winRate, '勝率', [['プレイ', m.hands.toLocaleString('ja-JP')], ['勝ち', m.won.toLocaleString('ja-JP')]])} + ${netCard(m)}`; + paintFills($('all-body')); + } + + const modeSeg = Fluid.Segmented($('mode-tabs')); + $('mode-tabs').addEventListener('click', (e) => { + const b = e.target.closest('[data-mode]'); + if (!b || b.dataset.mode === mode) return; + mode = b.dataset.mode; + Fluid.haptic(6); + renderAll(); + }); + + // ---------- 説明シート ---------- + const infoSheet = Fluid.Sheet($('info-modal')); + let shareText = ''; + + function openInfo(key) { + if (!data) return; + const def = STATS[key]; + const m = data.modes[$('view-all').hidden ? 'total' : mode]; + const where = $('view-all').hidden || mode === 'total' ? '' : `(${MODE_LABEL[mode]})`; + let body = ''; + let v = null; + if (key === 'fold') { + body = foldMinis(m); + shareText = `Hold'em Night のフォールド率${where}: ${STREETS.map((s, i) => `${s} ${pctNum(m.fold[i])}%`).join(' / ')}`; + } else if (key === 'net') { + body = `
${m.hands ? `${bb(m.netBB)} BB` : '—'}
`; + shareText = `Hold'em Night の収支${where}: ${bb(m.netBB)} BB(${m.hands}ハンド)`; + } else if (key === 'time') { + body = `
${m.hours} 時間
`; + shareText = `Hold'em Night のプレイ時間: ${m.hours} 時間(${m.hands}ハンド)`; + } else { + v = key === 'hands' ? m.winRate : m[key]; + body = `
${pctNum(v)}${v == null ? '' : '%'}
${bandScale(def, v)}`; + shareText = + v == null + ? `Hold'em Night の ${def.title}${where}: まだデータがありません` + : `Hold'em Night の ${def.title}${where}: ${Math.round(v)}%(${def.labels[bandOf(def, v)]})`; + } + $('info-body').innerHTML = ` + +

${esc(def.title)}

+ ${body} +

${esc(def.desc)}

+

${esc(MODE_LABEL[$('view-all').hidden ? 'total' : mode])} ・ ${esc(def.sample(m))}

`; + paintFills($('info-body')); + infoSheet.open(); + } + + document.addEventListener('click', (e) => { + const b = e.target.closest('[data-info]'); + if (b) openInfo(b.dataset.info); + }); + + async function share(text) { + if (!text) return; + if (navigator.share) { + try { + await navigator.share({ text }); + return; + } catch (err) { + if (err && err.name === 'AbortError') return; // 共有シートを閉じた + } + } + try { + if (await App.copyText(() => Promise.resolve(text))) toast('コピーしました。友達に送れます'); + } catch { + toast('コピーできませんでした'); + } + } + $('info-share').addEventListener('click', () => share(shareText)); + + // ---------- プレイスタイルのシート(横に送る) ---------- + const typeSheet = Fluid.Sheet($('type-modal')); + $('type-carousel').innerHTML = TYPES.map( + (t) => ` +
+ +

${esc(t.name)}

+
(${esc(t.full)})
+

${esc(t.desc)}

+ +
` + ).join(''); + $('type-dots').innerHTML = TYPES.map(() => '').join(''); + const typeCards = [...$('type-carousel').children]; + + function currentType() { + const c = $('type-carousel'); + return clamp(Math.round(c.scrollLeft / (typeCards[1].offsetLeft - typeCards[0].offsetLeft || 1)), 0, TYPES.length - 1); + } + function syncDots() { + const i = currentType(); + [...$('type-dots').children].forEach((d, k) => d.classList.toggle('on', k === i)); + } + $('type-carousel').addEventListener('scroll', syncDots, { passive: true }); + + function openTypes(id) { + const st = data && playStyle(data.modes.total); + for (const el of typeCards) el.querySelector('.st-type-mine').hidden = !(st && st.id === el.dataset.type); + typeSheet.open(); + const i = Math.max(0, TYPES.findIndex((t) => t.id === (id || (st && st.id)))); + const c = $('type-carousel'); + c.scrollLeft = typeCards[i].offsetLeft - (c.clientWidth - typeCards[i].offsetWidth) / 2; + syncDots(); + } + $('style-card').addEventListener('click', (e) => { + const chip = e.target.closest && e.target.closest('[data-type]'); + openTypes(chip ? chip.dataset.type : null); + }); + $('style-card').addEventListener('keydown', (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + openTypes(null); + }); + $('type-share').addEventListener('click', () => { + const t = TYPES[currentType()]; + const st = data && playStyle(data.modes.total); + share( + st && st.id === t.id + ? `Hold'em Night での私のプレイスタイルは「${t.name}(${t.full})」${t.emoji} VPIP ${pctNum(data.modes.total.vpip)}% / PFR ${pctNum(data.modes.total.pfr)}%` + : `${t.emoji} ${t.name}(${t.full}): ${t.desc}` + ); + }); + + // ---------- 画面の切り替え(?v=all ですべての統計) ---------- + function route() { + const all = new URLSearchParams(location.search).get('v') === 'all'; + $('view-summary').hidden = all; + $('view-all').hidden = !all; + $('title').textContent = all ? 'すべての統計' : '統計'; + $('back').href = all ? '/stats' : '/'; + $('back-label').textContent = all ? '統計' : 'ロビー'; + if (!data) return; + if (all) renderAll(); + else renderSummary(); + } + window.addEventListener('popstate', () => { + route(); + window.scrollTo(0, 0); + }); + $('see-all').addEventListener('click', () => { + history.pushState({ fromSummary: true }, '', '/stats?v=all'); + route(); + window.scrollTo(0, 0); + }); + $('back').addEventListener('click', (e) => { + if ($('view-all').hidden) return; // ロビーへはそのまま移る + e.preventDefault(); + if (history.state && history.state.fromSummary) history.back(); + else { + history.replaceState(null, '', '/stats'); + route(); + } + window.scrollTo(0, 0); + }); + + // ---------- 読み込み ---------- + async function load() { + const btn = $('refresh'); + btn.classList.add('spinning'); + btn.disabled = true; + try { + data = await api('POST', '/api/stats', { token: Store.token }); + route(); + } catch (err) { + toast(err.message); + } finally { + btn.disabled = false; + btn.classList.remove('spinning'); + } + } + $('refresh').addEventListener('click', load); + + route(); + load(); +})(); diff --git a/public/assets/style.css b/public/assets/style.css index 28ab6ad..ea26334 100644 --- a/public/assets/style.css +++ b/public/assets/style.css @@ -3016,6 +3016,876 @@ body.page-table { } } +/* ---------- 席順の変更(ホスト) ---------- */ +.glass-btn:disabled { + opacity: 0.4; + cursor: default; +} +/* 次のハンドから反映する席順があるときの印 */ +.glass-dot { + position: absolute; + top: 5px; + right: 5px; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--orange); + box-shadow: 0 0 0 2px var(--material-thick); +} +/* 小さなテーブル(自分が一番下で時計回り) */ +.seating-preview { + position: relative; + width: min(100%, 300px); + aspect-ratio: 1.45; + margin: 2px auto 6px; +} +.seating-felt { + position: absolute; + inset: 16% 14%; + border-radius: 999px; + background: radial-gradient(120% 120% at 50% 30%, var(--felt-hi), var(--felt) 55%, var(--felt-lo)); + box-shadow: 0 0 0 5px #2a2a2c, var(--shadow-s); +} +.seating-dot { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + width: 64px; + transform: translate(-50%, -50%); + transition: left 0.5s var(--ease-spring), top 0.5s var(--ease-spring); +} +.seating-dot .rank-avatar { + width: 30px; + height: 30px; + font-size: 0.875rem; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25); +} +.seating-dot .rank-avatar.bot { + border-radius: 9px; +} +.seating-dot .rank-avatar svg { + width: 17px; + height: 17px; +} +.seating-dot.me .rank-avatar { + box-shadow: 0 0 0 2px var(--sheet-bg), 0 0 0 4px var(--tint); +} +.seating-name { + max-width: 100%; + padding: 0 5px; + border-radius: 6px; + background: var(--material-thick); + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.seating-tools { + display: flex; + align-items: center; + gap: 12px; + margin: 4px 0 10px; +} +.seating-tools .hint { + flex: 1; + margin: 0; +} +.seating-tools .btn { + flex: none; +} +.seating-list { + margin: 0; + padding: 0; + list-style: none; + overflow: visible; +} +.seat-row { + gap: 12px; + min-height: 60px; + background: var(--bg-2); + will-change: transform; +} +.seat-row:first-child { + border-radius: 14px 14px 0 0; +} +.seat-row:last-child { + border-radius: 0 0 14px 14px; +} +.seat-row:only-child { + border-radius: 14px; +} +.seat-row.me { + background: var(--tint-soft); +} +.cell.seat-row + .cell::before { + left: 64px; +} +/* つかんでいる行は持ち上げて手前に */ +.seat-row.dragging { + z-index: 2; + border-radius: 14px; + box-shadow: var(--shadow-l); +} +.seat-row.dragging::before, +.seat-row.dragging + .cell::before { + opacity: 0; +} +.seat-row .cell-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} +.seat-pin { + flex: none; + padding: 2px 9px; + border-radius: 999px; + background: var(--fill-3); + color: var(--label-2); + font-size: 0.75rem; + font-weight: 600; +} +.seat-handle { + -webkit-appearance: none; + appearance: none; + flex: none; + width: 44px; + height: 44px; + margin: -8px -10px -8px 0; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--label-3); + cursor: grab; + touch-action: none; +} +.seat-row.dragging .seat-handle { + color: var(--label); + cursor: grabbing; +} +.seating-pending { + padding: 10px 16px 0; +} + +/* ---------- テーブル内のランキング ---------- */ +.ranking-list { + margin: 12px 0 0; + padding: 0; + list-style: none; +} +.rank-row { + gap: 12px; + background: var(--bg-2); + will-change: transform; +} +.rank-row.me { + background: var(--tint-soft); +} +.cell.rank-row:has(.rank-avatar) + .cell::before { + left: 98px; +} +.rank-no { + flex: none; + width: 26px; + height: 26px; + display: grid; + place-items: center; + border-radius: 50%; + font-family: var(--font-num); + font-size: 0.9375rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--label-2); +} +.rank-no.top-1 { + background: linear-gradient(180deg, #ffd96a, #e7a913); + color: #4a3200; +} +.rank-no.top-2 { + background: linear-gradient(180deg, #e9ecf0, #aeb5bf); + color: #2f343b; +} +.rank-no.top-3 { + background: linear-gradient(180deg, #f0b98a, #c47a3e); + color: #3d1f06; +} +.rank-avatar { + --hue: 210; + flex: none; + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 50%; + background: linear-gradient(180deg, hsl(var(--hue) 48% 64%), hsl(var(--hue) 46% 42%)); + color: #fff; + font-size: 1rem; + font-weight: 600; + line-height: 1; +} +.rank-avatar.bot { + border-radius: 10px; +} +.rank-avatar svg { + width: 20px; + height: 20px; + fill: #fff; +} +.rank-row .cell-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} +.rank-value { + flex: none; + font-family: var(--font-num); + font-size: 1.0625rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.ranking-note { + padding: 8px 16px 0; +} + +/* ---------- 統計 ---------- */ +:root { + /* レーダーの軸の色と、自分のスタイルの印 */ + --st-aggr: #e8378a; + --st-passive: #1a9fd0; + --st-tight: #9a45dd; + --st-loose: #e08700; + --st-pick: #f0896a; + --st-on-pick: #2b1206; +} +@media (prefers-color-scheme: dark) { + :root { + --st-aggr: #ff4f9a; + --st-passive: #3fc8f4; + --st-tight: #c278ff; + --st-loose: #ffab3d; + } +} +/* 段階の色: スタイル(参加・積極性)は緑 → ピンク、成績(勝率など)は赤 → 緑 */ +.tone-style-0 { --tone: #5fd38f; --tone-2: #85e0aa; } +.tone-style-1 { --tone: #8fcfa3; --tone-2: #aac9b0; } +.tone-style-2 { --tone: #b8b0b9; --tone-2: #c9a3b9; } +.tone-style-3 { --tone: #d48db5; --tone-2: #e07fb1; } +.tone-style-4 { --tone: #e5649f; --tone-2: #f04a95; } +.tone-quality-0 { --tone: #ef5f73; --tone-2: #e7998f; } +.tone-quality-1 { --tone: #e5a564; --tone-2: #e2c17c; } +.tone-quality-2 { --tone: #a9cf74; --tone-2: #96d98c; } +.tone-quality-3 { --tone: #4fcd88; --tone-2: #3cc38a; } + +.nav-action { + -webkit-appearance: none; + appearance: none; + grid-column: 3; + justify-self: end; + width: 44px; + height: 44px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--tint); + cursor: pointer; + transition: opacity 0.25s; +} +.nav-action:active { + opacity: 0.4; + transition: none; +} +.nav-action.spinning .i { + animation: spin 0.8s linear infinite; +} + +.st-card { + position: relative; + padding: 14px 16px 16px; + border-radius: 18px; + background: var(--bg-2); + box-shadow: inset 0 0 0 1px var(--fill-3); +} +.st-grid { + display: grid; + gap: 12px; +} +.st-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.st-grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +#all-body > * { + margin-top: 12px; +} +.st-tabs { + margin-bottom: 4px; +} +.st-empty { + color: var(--label-2); + font-size: 0.9375rem; + text-align: center; +} +.st-foot { + margin: 20px 4px 0; +} + +/* 見出し(アイコン・名前・?) */ +.st-head { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--label-2); + font-size: 0.875rem; + font-weight: 500; +} +.st-icon { + font-size: 0.9375rem; + line-height: 1; + filter: grayscale(1); + opacity: 0.85; +} +.st-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.st-help { + -webkit-appearance: none; + appearance: none; + flex: none; + width: 30px; + height: 30px; + margin: -6px -8px -6px 0; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--label-3); + cursor: pointer; +} +.st-help .i { + width: 19px; + height: 19px; + stroke-width: 1.7; +} +.st-help:active { + background: var(--fill-3); +} +.st-value { + position: relative; + z-index: 1; + margin-top: 6px; + font-family: var(--font-num); + font-size: 2.75rem; + font-weight: 500; + line-height: 1.05; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.st-value small { + margin-left: 2px; + font-size: 1.0625rem; + font-weight: 500; + letter-spacing: 0; +} +.st-metric.compact .st-value { + font-size: 1.75rem; +} +.st-metric.compact .st-value small { + font-size: 0.9375rem; +} +.st-band { + margin-top: 8px; + font-size: 0.9375rem; + font-weight: 500; +} +.st-metric.compact .st-band { + margin-top: 6px; + font-size: 0.8125rem; + color: var(--label-2); +} + +/* 段階のバー */ +.st-bar { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: 4px; + margin-top: 12px; +} +.st-seg { + display: block; + height: 6px; + border-radius: 3px; + background: var(--fill-2); + overflow: hidden; +} +.st-seg-fill { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--tone), var(--tone-2)); + transform: scaleX(0); + transform-origin: left center; + transition: transform 0.7s var(--ease-spring); +} + +/* 水位で見せるタイル(WTSD・W$SD・WWSF) */ +.st-liquid-card { + min-height: 118px; + overflow: hidden; + padding: 12px 12px 14px; +} +/* 3つ並ぶので見出しを詰める(WTSD などが省略されないように) */ +.st-liquid-card .st-head { + gap: 4px; + font-size: 0.8125rem; +} +.st-liquid-card .st-icon { + font-size: 0.8125rem; +} +.st-liquid-card .st-name { + overflow: visible; + text-overflow: clip; +} +.st-liquid-card .st-help { + width: 24px; + height: 24px; + margin: -4px -6px -4px 0; +} +.st-liquid-card .st-help .i { + width: 17px; + height: 17px; +} +.st-liquid-card .st-value { + font-size: 2rem; +} +.st-liquid-card .st-value small { + font-size: 0.875rem; +} +.st-liquid { + --level: 0%; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: var(--level); + background: var(--fill-2); + transition: height 1s var(--ease-spring); +} +.st-liquid::before { + content: ""; + position: absolute; + left: 0; + bottom: 100%; + width: 200%; + height: 8px; + background: inherit; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 8' preserveAspectRatio='none'%3E%3Cpath d='M0 4C15 0 15 0 30 4S45 8 60 4 75 0 90 4 105 8 120 4V8H0z'/%3E%3C/svg%3E") repeat-x; + -webkit-mask-size: 50% 100%; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 8' preserveAspectRatio='none'%3E%3Cpath d='M0 4C15 0 15 0 30 4S45 8 60 4 75 0 90 4 105 8 120 4V8H0z'/%3E%3C/svg%3E") repeat-x; + mask-size: 50% 100%; + animation: st-wave 6s linear infinite; +} +@keyframes st-wave { + to { + transform: translateX(-50%); + } +} + +/* 小さなメーター(フォールド率) */ +.st-minis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 4px; + margin-top: 10px; + text-align: center; +} +.st-mini-gauge { + position: relative; + width: 100%; + max-width: 76px; + margin: 0 auto; + aspect-ratio: 1; +} +.st-gauge { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} +.st-gauge path { + fill: none; + stroke-width: 6; + stroke-linecap: round; +} +.st-gauge-track { + stroke: var(--fill); +} +.st-gauge-val { + stroke: var(--label); +} +.st-mini-gauge > span { + position: absolute; + inset: 0; + display: grid; + place-items: center; + font-family: var(--font-num); + font-size: 1.0625rem; + font-variant-numeric: tabular-nums; +} +.st-mini-gauge small { + font-size: 0.6875rem; +} +.st-mini-label { + display: block; + margin-top: -2px; + font-size: 0.8125rem; + color: var(--label-2); +} + +/* 輪と一覧(アグレッション頻度・ハンド・収支) */ +.st-split { + display: grid; + grid-template-columns: minmax(0, 132px) minmax(0, 1fr); + gap: 16px; + align-items: center; + margin-top: 8px; +} +.st-ring-box { + position: relative; + aspect-ratio: 1; +} +.st-ring { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} +.st-ring path { + fill: none; + stroke-width: 8; + stroke-linecap: round; +} +.st-ring-track { + stroke: var(--fill-2); +} +.st-ring-val { + stroke: var(--tone); +} +.st-ring-text { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} +.st-ring-text .st-value { + margin: 0; + font-size: 2rem; +} +.st-ring-text .st-value small { + font-size: 0.875rem; +} +.st-ring-text > span { + font-size: 0.8125rem; + color: var(--label-2); +} +.st-rows { + align-self: stretch; + display: flex; + flex-direction: column; + justify-content: center; + padding-left: 14px; + border-left: 0.5px solid var(--separator); +} +.st-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 9px 0; + font-size: 0.9375rem; + color: var(--label-2); +} +.st-row + .st-row { + border-top: 0.5px solid var(--separator); +} +.st-row b { + color: var(--label); + font-family: var(--font-num); + font-size: 1.0625rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.st-net { + text-align: center; +} +.st-net b { + display: block; + font-family: var(--font-num); + font-size: 2rem; + font-weight: 500; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} +.st-net span { + font-size: 0.8125rem; + color: var(--label-2); +} + +/* プレイスタイルのレーダー */ +.st-style { + padding: 6px; + cursor: pointer; + transition: transform 0.45s var(--ease-spring); +} +.st-style:active { + transform: scale(0.985); + transition: transform 0.1s ease-out; +} +.st-radar { + display: block; + width: 100%; + height: auto; + overflow: visible; +} +.st-radar-ring { + fill: none; + stroke: var(--label-3); + stroke-width: 1; + stroke-dasharray: 4 4; +} +.st-radar-outer { + fill: none; + stroke: var(--label-3); + stroke-width: 1.5; +} +.st-radar-axis { + stroke: var(--label-3); + stroke-width: 1; + stroke-dasharray: 2 4; +} +.st-radar-shape { + stroke: none; +} +.st-radar-link { + stroke: var(--st-pick); + stroke-width: 2; +} +.st-chip rect { + fill: transparent; + stroke: var(--separator); + stroke-width: 1; +} +.st-chip text { + fill: var(--label-2); + font-size: 14px; + font-weight: 500; +} +.st-chip.on rect { + fill: var(--st-pick); + stroke: none; +} +.st-chip.on text { + fill: var(--st-on-pick); + font-weight: 600; +} +.st-axis { + font-size: 17px; + font-weight: 600; +} +.st-axis.aggr { + fill: var(--st-aggr); +} +.st-axis.passive { + fill: var(--st-passive); +} +.st-axis.tight { + fill: var(--st-tight); +} +.st-axis.loose { + fill: var(--st-loose); +} +.st-axis-sub { + fill: var(--label-2); + font-size: 11.5px; +} +.st-empty-note { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--label-2); + font-size: 0.9375rem; + pointer-events: none; +} + +/* 説明シート */ +.st-info-icon { + margin-top: 4px; + font-size: 3.25rem; + line-height: 1.1; + text-align: center; +} +.st-info-title { + margin: 8px 0 14px; + font-size: 1.5rem; + font-weight: 600; + letter-spacing: -0.015em; + text-align: center; +} +.st-info-value { + margin-bottom: 16px; + font-family: var(--font-num); + font-size: 2.25rem; + font-weight: 500; + line-height: 1.1; + text-align: center; + font-variant-numeric: tabular-nums; +} +.st-info-value small { + font-size: 1rem; +} +.st-scale { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: 4px; + text-align: center; +} +.st-scale-col { + display: grid; + gap: 6px; + min-width: 0; +} +.st-scale-col .st-seg { + height: 10px; + border-radius: 5px; +} +.st-scale-label, +.st-scale-range { + font-size: 0.75rem; + line-height: 1.2; + color: var(--label-2); + white-space: nowrap; +} +.st-scale-col.on .st-scale-label, +.st-scale-col.on .st-scale-range { + color: var(--label); + font-weight: 600; +} +.st-info-desc { + margin-top: 20px; + color: var(--label-2); + font-size: 1rem; + line-height: 1.65; + text-align: center; +} +.st-info-sample { + margin: 10px 0 4px; + color: var(--label-3); + font-size: 0.8125rem; + text-align: center; +} +.btn.st-share { + background: var(--label); + color: var(--bg-2); +} + +/* プレイスタイルのシート(横に送る) */ +.st-carousel { + display: flex; + gap: 12px; + overflow-x: auto; + overscroll-behavior-x: contain; + scroll-snap-type: x mandatory; + padding: 4px 9% 2px; + scrollbar-width: none; +} +.st-carousel::-webkit-scrollbar { + display: none; +} +.st-type { + flex: 0 0 82%; + scroll-snap-align: center; + padding: 26px 20px 24px; + border-radius: 22px; + background: var(--bg-2); + box-shadow: inset 0 0 0 1px var(--fill-2); + text-align: center; +} +.st-type-emoji { + font-size: 4rem; + line-height: 1.1; +} +.st-type-name { + margin-top: 10px; + font-size: 1.625rem; + font-weight: 600; + letter-spacing: -0.015em; +} +.st-type-full { + color: var(--label-2); +} +.st-type-desc { + margin-top: 14px; + color: var(--label-2); + font-size: 0.9375rem; + line-height: 1.65; +} +.st-type-mine { + display: inline-block; + margin-top: 14px; + padding: 4px 12px; + border-radius: 999px; + background: var(--st-pick); + color: var(--st-on-pick); + font-size: 0.8125rem; + font-weight: 600; +} +.st-dots { + display: flex; + justify-content: center; + gap: 8px; + padding: 14px 0 2px; +} +.st-dots span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--label-3); + transition: background-color 0.2s; +} +.st-dots span.on { + background: var(--label); +} + /* ---------- 視差効果を減らす: 動きは短いクロスフェードに ---------- */ @media (prefers-reduced-motion: reduce) { .pcard.deal, @@ -3054,6 +3924,13 @@ body.page-table { .nav-title { transform: none; } + .st-liquid::before { + animation: none; + } + .st-seg-fill, + .st-liquid { + transition: none; + } } @keyframes fade-in { from { diff --git a/public/assets/table.js b/public/assets/table.js index 02f4831..30311f0 100644 --- a/public/assets/table.js +++ b/public/assets/table.js @@ -30,6 +30,8 @@ let reviewKey = ''; const reviewSheet = Fluid.Sheet($('review-modal')); + const rankingSheet = Fluid.Sheet($('ranking-modal')); + const seatingSheet = Fluid.Sheet($('seating-modal')); const nameSheet = Fluid.Sheet($('name-modal'), { dismissible: false, focus: '#name-input' }); // 払って閉じた・背景を押した・Esc のときも「戻る」と同じ const betSheet = Fluid.Sheet($('bet-modal'), { @@ -117,6 +119,8 @@ $('main').hidden = true; $('action-bar').hidden = true; reviewSheet.close(); + rankingSheet.close(); + seatingSheet.close(); raising = false; betSheet.close(); $('not-found').hidden = false; @@ -244,6 +248,10 @@ $('pause').title = s.paused ? '再開' : '一時停止'; $('pause').setAttribute('aria-label', $('pause').title); $('pause').classList.toggle('paused', s.paused); + const seated = s.seats.filter(Boolean).length; + $('seating-open').disabled = seated < 3; // 2人までは並びが1通り + $('seating-open').title = seated < 3 ? '3人以上で席順を変えられます' : '席順を変更'; + $('seating-dot').hidden = !s.pendingSeating; // 自分 $('action-bar').hidden = !me; @@ -259,6 +267,8 @@ if (me) renderActionBar(s, me); else betSheet.close(); renderReview(s); + if (rankingSheet.isOpen) renderRanking(s); + if (seatingSheet.isOpen) syncSeating(s); renderTimer(); // 自分の番になったら知らせる @@ -579,7 +589,9 @@ reviewSheet.close(); return; } - $('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}` : '/review'; + // 同じタブで開き、レビューの画面の「テーブル」で戻ってこられるようにテーブルIDを渡す + const back = `t=${encodeURIComponent(tableId)}`; + $('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}&${back}` : `/review?${back}`; $('review-modal-next').hidden = !r.waiting; const key = JSON.stringify(r); if (key !== reviewKey) { @@ -616,6 +628,361 @@ $('review-next').addEventListener('click', nextHand); $('review-modal-next').addEventListener('click', nextHand); + // ---------- ランキング(スタック順・収支順を切り替える) ---------- + // スタックはまだ降りていない人がこのハンドで出した額を含む(ベットのたびに順位が揺れないように)。 + // 収支は持っているチップ − バイインの合計(リバイを含む)。同じ値は同じ順位 + const RANK_STORE = 'poker.rankBy'; + let rankBy = 'stack'; + try { + if (localStorage.getItem(RANK_STORE) === 'net') rankBy = 'net'; + } catch { /* プライベートモード等 */ } + const rankRows = new Map(); // 席番号 → 行。並びが変わっても行は使い回し、元の位置から滑らせる + const RANK_NOTE = { + stack: 'ハンド中は、まだ降りていない人がこのハンドで出したチップも含めています。', + net: '収支 = 持っているチップ − バイインの合計(リバイを含む)', + }; + + function rankingEntries(s) { + const key = rankBy === 'net' ? 'net' : 'held'; + const list = s.seats.filter(Boolean).sort((a, b) => b[key] - a[key] || a.seat - b.seat); + let rank = 0; + return list.map((p, i) => { + if (i === 0 || p[key] !== list[i - 1][key]) rank = i + 1; + return { p, rank, value: p[key] }; + }); + } + + // 並べ替えられる行(縦の位置をばねで動かす) + function movableRow(className) { + const el = document.createElement('li'); + el.className = className; + el.y = new Fluid.Spring(0, { precision: 0.3 }); + el.y.onUpdate = (v) => { + el.style.transform = v ? `translate3d(0, ${v.toFixed(2)}px, 0)` : ''; + }; + return el; + } + const rowTops = (rows) => new Map([...rows].map((el) => [el, el.getBoundingClientRect().top])); + // 並べ替えた行を、画面上の元の位置(before: 行 → 並べ替える前の top)からばねで新しい位置へ + function slideFrom(before) { + for (const [el, top] of before) { + if (!el.isConnected) continue; + const offset = top - (el.getBoundingClientRect().top - el.y.value); + if (Math.abs(offset - el.y.value) < 0.5) continue; + el.y.set(offset); + el.y.to(0, { damping: 0.86, response: 0.42 }); + } + } + + function rankRow(seat) { + let el = rankRows.get(seat); + if (el) return el; + el = movableRow('cell rank-row'); + rankRows.set(seat, el); + return el; + } + + function renderRanking(s) { + const listEl = $('ranking-list'); + const empty = listEl.querySelector('.empty'); + if (empty) empty.remove(); + const before = rowTops(rankRows.values()); + const live = new Set(); + rankingEntries(s).forEach(({ p, rank, value }, i) => { + live.add(p.seat); + const el = rankRow(p.seat); + const sub = [p.seat === s.mySeat && 'あなた', p.bot && 'CPU', p.sittingOut && '離席中', !p.connected && 'オフライン'] + .filter(Boolean) + .join(' ・ '); + const net = rankBy === 'net'; + patch( + el, + `${rank}` + + `${p.bot ? BOT_SVG : esc(initialOf(p.name))}` + + `${esc(p.name)}${sub ? `${sub}` : ''}` + + `${net ? signed(value) : chips(value)}` + ); + el.style.setProperty('--hue', hueOf(p.name)); + el.classList.toggle('me', p.seat === s.mySeat); + if (listEl.children[i] !== el) listEl.insertBefore(el, listEl.children[i] || null); + }); + for (const [seat, el] of rankRows) { + if (live.has(seat)) continue; + el.remove(); + rankRows.delete(seat); + } + slideFrom(before); // 順位が変わった行は元の位置から滑らせる + $('ranking-note').textContent = RANK_NOTE[rankBy]; + if (!s.seats.some(Boolean)) listEl.innerHTML = '
  • まだ誰も座っていません
  • '; + } + + Fluid.Segmented($('ranking-tabs')); + function syncRankTabs() { + for (const b of $('ranking-tabs').querySelectorAll('[data-rank]')) { + const on = b.dataset.rank === rankBy; + b.classList.toggle('active', on); + b.setAttribute('aria-pressed', String(on)); + } + } + syncRankTabs(); + $('ranking-tabs').addEventListener('click', (e) => { + const b = e.target.closest('[data-rank]'); + if (!b || b.dataset.rank === rankBy) return; + rankBy = b.dataset.rank; + try { + localStorage.setItem(RANK_STORE, rankBy); + } catch { /* プライベートモード等 */ } + syncRankTabs(); + Fluid.haptic(6); + if (state) renderRanking(state); + }); + $('ranking-open').addEventListener('click', () => { + if (!state) return; + renderRanking(state); // 中身を入れてから開く(シートの高さを測るため) + rankingSheet.open(); + }); + $('ranking-close').addEventListener('click', () => rankingSheet.close()); + + // ---------- 席順の変更(ホストのみ) ---------- + // 席の並びは輪なので、自分を一番上に固定し、その下に左隣から時計回りの順で並べる(テーブルの画面と同じ向き)。 + // 行は右端のつまみをつかむと指に付いてきて、ほかの行がよける。キーボードではつまみにフォーカスして ↑↓。 + // ハンド中に決めた席順は次のハンドの始めにサーバが並べ替える + const HANDLE_SVG = ''; + let seatMe = -1; // 自分の席(観戦中のホストは -1) + let seatOrder = []; // 自分以外の席番号(左隣から時計回り) + let seatNames = new Map(); // 席番号 → 名前(開いている間に席番号が振り直されたか確かめる) + let seatDrag = null; + const seatRows = new Map(); // 席番号 → 行 + const previewDots = new Map(); // 席番号 → プレビューの点 + + const occupiedSeats = (s) => s.seats.filter(Boolean).map((p) => p.seat); + const seatingList = () => (seatMe >= 0 ? [seatMe] : []).concat(seatOrder); + + // いまの並び(次のハンドからの席順が決めてあればそれ)を、自分から時計回りに読み込む + function loadSeating(s) { + const occupied = occupiedSeats(s); + const pending = s.pendingSeating; + const base = pending + ? pending.filter((i) => occupied.includes(i)).concat(occupied.filter((i) => !pending.includes(i))) + : occupied; + const k = base.indexOf(s.mySeat); + seatMe = k >= 0 ? s.mySeat : -1; + seatOrder = k >= 0 ? base.slice(k + 1).concat(base.slice(0, k)) : base; + seatNames = new Map(occupied.map((i) => [i, s.seats[i].name])); + } + + // 開いている間に座った人は最後に加え、いなくなった人は外す(並べた順は崩さない)。 + // 次のハンドで席番号が振り直されたら読み込み直す + function syncSeating(s) { + if (!s.isHost) return seatingSheet.close(); + if (seatDrag) return; + const occupied = occupiedSeats(s); + const renumbered = + (seatMe >= 0 && s.mySeat !== seatMe) || [...seatNames].some(([i, name]) => s.seats[i] && s.seats[i].name !== name); + if (renumbered) { + loadSeating(s); + toast('席が並べ替わったので、いまの席順を読み込み直しました'); + } else { + const kept = seatOrder.filter((i) => occupied.includes(i)); + const added = occupied.filter((i) => i !== seatMe && !kept.includes(i)); + seatOrder = kept.concat(added); + for (const i of added) seatNames.set(i, s.seats[i].name); + } + renderSeating(rowTops(seatRows.values())); + } + + function seatRow(seat) { + let el = seatRows.get(seat); + if (el) return el; + el = movableRow('cell seat-row'); + el.dataset.seat = seat; + seatRows.set(seat, el); + return el; + } + + function whereText(seat) { + if (seat === seatMe) return 'あなた'; + const k = seatOrder.indexOf(seat); + const n = seatOrder.length; + if (seatMe < 0) return `${k + 1}番目`; + if (n === 1) return 'あなたの隣'; + if (k === 0) return 'あなたの左隣'; + if (k === n - 1) return 'あなたの右隣'; + return `あなたから時計回りに${k + 1}人目`; + } + + // before: 並べ替える前の行の位置(渡すと元の位置から滑らせる) + function renderSeating(before) { + const s = state; + const list = $('seating-list'); + const seats = seatingList().filter((i) => s.seats[i]); + seats.forEach((seat, i) => { + const p = s.seats[seat]; + const mine = seat === seatMe; + const sub = [whereText(seat), p.bot && 'CPU', p.sittingOut && '離席中'].filter(Boolean).join(' ・ '); + const el = seatRow(seat); + patch( + el, + `${p.bot ? BOT_SVG : esc(initialOf(p.name))}` + + `${esc(p.name)}${sub}` + + (mine + ? '固定' + : ``) + ); + el.style.setProperty('--hue', hueOf(p.name)); + el.classList.toggle('me', mine); + if (list.children[i] !== el) list.insertBefore(el, list.children[i] || null); + }); + for (const [seat, el] of seatRows) { + if (seats.includes(seat)) continue; + el.remove(); + seatRows.delete(seat); + } + if (before) slideFrom(before); + + const note = $('seating-pending'); + note.hidden = !s.hand && !s.pendingSeating; + note.textContent = s.hand + ? 'ハンドの途中なので、次のハンドの始めに並べ替えます。' + : 'まだ反映していない席順があります。'; + renderSeatingPreview(seats); + } + + // 小さなテーブル: 自分が一番下で時計回り(テーブルの画面と同じ)。点は新しい位置へ滑る + function renderSeatingPreview(seats) { + const box = $('seating-preview'); + const n = seats.length; + seats.forEach((seat, idx) => { + const p = state.seats[seat]; + let dot = previewDots.get(seat); + if (!dot) { + dot = document.createElement('span'); + dot.className = 'seating-dot'; + dot.innerHTML = ''; + previewDots.set(seat, dot); + box.append(dot); + } + const angle = Math.PI / 2 + (idx * 2 * Math.PI) / Math.max(n, 1); + dot.style.left = `${(50 + 40 * Math.cos(angle)).toFixed(2)}%`; + dot.style.top = `${(50 + 36 * Math.sin(angle)).toFixed(2)}%`; + dot.style.setProperty('--hue', hueOf(p.name)); + dot.classList.toggle('me', seat === seatMe); + const face = dot.firstChild; + face.classList.toggle('bot', p.bot); + patch(face, p.bot ? BOT_SVG : esc(initialOf(p.name))); + dot.lastChild.textContent = seat === seatMe ? 'あなた' : p.name; + }); + for (const [seat, dot] of previewDots) { + if (seats.includes(seat)) continue; + dot.remove(); + previewDots.delete(seat); + } + } + + function moveSeat(from, to) { + const before = rowTops(seatRows.values()); + if (from !== to) { + const [seat] = seatOrder.splice(from, 1); + seatOrder.splice(to, 0, seat); + } + renderSeating(before); + } + + const seatingListEl = $('seating-list'); + seatingListEl.addEventListener('pointerdown', (e) => { + const handle = e.target.closest('.seat-handle'); + if (!handle || seatDrag || (e.pointerType === 'mouse' && e.button !== 0)) return; + const row = handle.closest('[data-seat]'); + const seat = Number(row.dataset.seat); + e.preventDefault(); + handle.setPointerCapture(e.pointerId); + row.y.stop(); + const from = seatOrder.indexOf(seat); + seatDrag = { id: e.pointerId, seat, row, from, to: from, y0: e.clientY - row.y.value, h: row.offsetHeight }; + row.classList.add('dragging'); + Fluid.haptic(8); + }); + seatingListEl.addEventListener('pointermove', (e) => { + const d = seatDrag; + if (!d || e.pointerId !== d.id) return; + const n = seatOrder.length; + // 端より先へはゴムのように抵抗する + const min = -d.from * d.h; + const max = (n - 1 - d.from) * d.h; + let dy = e.clientY - d.y0; + if (dy < min) dy = min - Fluid.rubberband(min - dy, d.h * 2); + else if (dy > max) dy = max + Fluid.rubberband(dy - max, d.h * 2); + d.row.y.set(dy); + const to = Fluid.clamp(Math.round(d.from + dy / d.h), 0, n - 1); + if (to !== d.to) { + d.to = to; + Fluid.haptic(6); + } + // 間の行は1行ぶんよける + seatOrder.forEach((seat, j) => { + if (seat === d.seat) return; + const shift = d.from < j && j <= d.to ? -d.h : d.to <= j && j < d.from ? d.h : 0; + const el = seatRows.get(seat); + if (el && el.y.target !== shift) el.y.to(shift, { damping: 1, response: 0.28 }); + }); + }); + const dropSeat = (e) => { + const d = seatDrag; + if (!d || e.pointerId !== d.id) return; + seatDrag = null; + d.row.classList.remove('dragging'); + moveSeat(d.from, e.type === 'pointercancel' ? d.from : d.to); + }; + seatingListEl.addEventListener('pointerup', dropSeat); + seatingListEl.addEventListener('pointercancel', dropSeat); + seatingListEl.addEventListener('keydown', (e) => { + const handle = e.target.closest('.seat-handle'); + const dir = e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 0; + if (!handle || !dir) return; + e.preventDefault(); + const seat = Number(handle.closest('[data-seat]').dataset.seat); + const from = seatOrder.indexOf(seat); + if (from + dir < 0 || from + dir >= seatOrder.length) return; + moveSeat(from, from + dir); + const again = seatRows.get(seat).querySelector('.seat-handle'); + if (again) again.focus(); + }); + + $('seating-shuffle').addEventListener('click', () => { + const before = rowTops(seatRows.values()); + const r = new Uint32Array(seatOrder.length); + crypto.getRandomValues(r); + for (let i = seatOrder.length - 1; i > 0; i--) { + const j = r[i] % (i + 1); + [seatOrder[i], seatOrder[j]] = [seatOrder[j], seatOrder[i]]; + } + Fluid.haptic(10); + renderSeating(before); + }); + $('seating-open').addEventListener('click', () => { + if (!state || !state.isHost) return; + loadSeating(state); + renderSeating(null); // 中身を入れてから開く(シートの高さを測るため) + seatingSheet.open(); + }); + $('seating-cancel').addEventListener('click', () => seatingSheet.close()); + $('seating-save').addEventListener('click', () => { + if (!socket || !state) return; + const later = Boolean(state.hand); // ハンドがある間(結果の表示中を含む)は次のハンドから + const btn = $('seating-save'); + btn.disabled = true; + socket.emit('table:seating', { order: seatingList() }, (res) => { + btn.disabled = false; + if (!res || !res.ok) { + toast((res && res.error) || '席順を変更できませんでした'); + if (state) syncSeating(state); + return; + } + seatingSheet.close(); + toast(later ? '次のハンドから新しい席順になります' : '席順を変更しました'); + }); + }); + // ---------- 勝者選択 ---------- function renderAwardPanel(pots) { const key = `${state.handNumber}:${JSON.stringify(pots)}`; diff --git a/public/lobby.html b/public/lobby.html index e022008..eb3b451 100644 --- a/public/lobby.html +++ b/public/lobby.html @@ -33,6 +33,11 @@ + + + 統計プレイスタイル・VPIP・PFR など + + diff --git a/public/stats.html b/public/stats.html new file mode 100644 index 0000000..773b6a7 --- /dev/null +++ b/public/stats.html @@ -0,0 +1,79 @@ + + + + + + + + + 統計 - Hold'em Night + + + + + + +
    +

    統計

    + + +
    +
    +
    + +
    + + + + +

    統計はこのブラウザのプレイヤーIDごとに、ハンドが終わるたびに記録しています。別の端末やブラウザで遊んだ分は別に数えます。CPU戦にはレビュー付きCPU戦を含みます。

    +
    + + + + + + + + + + + + diff --git a/public/table.html b/public/table.html index 242b9da..24bc5f6 100644 --- a/public/table.html +++ b/public/table.html @@ -27,11 +27,18 @@ + + @@ -150,6 +157,54 @@ + + + + + +