diff --git a/www/html/Admin/admin.css b/www/html/Admin/admin.css index 988d16e..658c308 100644 --- a/www/html/Admin/admin.css +++ b/www/html/Admin/admin.css @@ -892,12 +892,14 @@ code { border: 1px solid var(--border); } -.input-coins { +.input-coins, +.input-score { width: 5.5rem; min-width: 0; } -.btn-coins-save { +.btn-coins-save, +.btn-score-save { font-size: 0.74rem; padding: 0.32rem 0.55rem; margin-top: 0.3rem; diff --git a/www/html/Admin/admin.js b/www/html/Admin/admin.js index be67fea..c7e1f8f 100644 --- a/www/html/Admin/admin.js +++ b/www/html/Admin/admin.js @@ -3895,6 +3895,11 @@ var bb = Number(data.balloonBossMissionTimeSec); inpT.value = String(Number.isFinite(bb) && bb > 0 ? Math.floor(bb) : 0); } + var inpBp = el('mega-virus-balloons-per-player'); + if (inpBp && Object.prototype.hasOwnProperty.call(data, 'balloonBossBalloonsPerPlayer')) { + var bp = Number(data.balloonBossBalloonsPerPlayer); + inpBp.value = String(Number.isFinite(bp) && bp > 0 ? Math.min(12, Math.floor(bp)) : 0); + } var bossInp = el('mega-virus-boss-url'); if (bossInp && Object.prototype.hasOwnProperty.call(data, 'balloonBossBossImageUrl')) { bossInp.value = data.balloonBossBossImageUrl != null ? String(data.balloonBossBossImageUrl) : ''; @@ -3939,12 +3944,16 @@ var fbUrl = normalizeMegaVirusAssetUrl(el('mega-virus-balloon-fallback-url') && el('mega-virus-balloon-fallback-url').value ? String(el('mega-virus-balloon-fallback-url').value).trim() : ''); + var balloonsPer = el('mega-virus-balloons-per-player') ? parseInt(String(el('mega-virus-balloons-per-player').value), 10) : 0; + if (Number.isNaN(balloonsPer) || balloonsPer < 0) balloonsPer = 0; + balloonsPer = Math.max(0, Math.min(12, balloonsPer)); gameTimingFetch('GET') .then(function (data) { data.balloonBossMissionTimeSec = limSs; data.balloonBossBossImageUrl = bossUrl; data.balloonBossPlayerBalloonImageUrls = balloonUrls; data.balloonBossPlayerBalloonFallbackUrl = fbUrl; + data.balloonBossBalloonsPerPlayer = balloonsPer; return gameTimingFetch('PUT', data); }) .then(function (res) { @@ -4477,6 +4486,7 @@ if (name === 'mega-virus') loadMegaVirusPanel(); if (name === 'game-timing') loadGameTimingPanel(); if (name === 'stack-game') loadStackGamePanel(); + if (name === 'highscore') loadHighscore(); } function boot() { @@ -4902,6 +4912,71 @@ }); } + /* ===== กระดานผู้นำ (High Score) ===== */ + function renderHighscore(rows) { + var tbEl = el('table-highscore'); + if (!tbEl) return; + var tb = tbEl.querySelector('tbody'); + tb.innerHTML = ''; + (rows || []).forEach(function (r) { + var rank = parseInt(r.rank, 10) || 0; + var score = Math.max(0, parseInt(r.score, 10) || 0); + var coins = Math.max(0, parseInt(r.coins, 10) || 0); + var medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : ('#' + rank); + var tr = document.createElement('tr'); + tr.innerHTML = + '' + medal + '' + + '' + escapeHtml(r.name || 'ผู้เล่น') + (r.blocked ? ' บล็อก' : '') + '' + + '' + + '' + + '' + + '' + + '' + coins + '' + + ''; + tb.appendChild(tr); + }); + tb.querySelectorAll('.btn-score-save').forEach(function (b) { + b.addEventListener('click', function () { + var id = b.getAttribute('data-id'); + var row = b.closest('tr'); + var inp = row ? row.querySelector('input.input-score') : null; + var score = Math.max(0, parseInt(inp && inp.value, 10) || 0); + api('accounts.php', { method: 'PATCH', body: { id: id, score: score } }) + .then(function () { setMsg('highscore-msg', 'บันทึกคะแนนแล้ว', 'ok'); return loadHighscore(); }) + .catch(function (err) { setMsg('highscore-msg', err.message, 'error'); }); + }); + }); + tb.querySelectorAll('.btn-score-reset').forEach(function (b) { + b.addEventListener('click', function () { + var id = b.getAttribute('data-id'); + if (!confirm('รีเซ็ตคะแนนคนนี้เป็น 0?')) return; + api('leaderboard-admin.php', { method: 'POST', body: { action: 'reset', id: id } }) + .then(function () { return loadHighscore(); }) + .catch(function (err) { setMsg('highscore-msg', err.message, 'error'); }); + }); + }); + } + + function loadHighscore() { + return api('leaderboard-admin.php').then(function (r) { + renderHighscore(r.rows); + var n = (r.rows && r.rows.length) || 0; + setMsg('highscore-msg', n ? ('ผู้เล่นมีคะแนน ' + r.total + ' คน') : 'ยังไม่มีผู้เล่นทำคะแนน', ''); + }).catch(function (err) { setMsg('highscore-msg', err.message, 'error'); }); + } + + (function bindHighscoreControls() { + var refresh = el('btn-highscore-refresh'); + if (refresh) refresh.addEventListener('click', function () { loadHighscore(); }); + var resetAll = el('btn-highscore-reset-all'); + if (resetAll) resetAll.addEventListener('click', function () { + if (!confirm('รีเซ็ตคะแนนสะสมของผู้เล่นทุกคนเป็น 0? (ย้อนกลับไม่ได้)')) return; + api('leaderboard-admin.php', { method: 'POST', body: { action: 'resetAll' } }) + .then(function (r) { setMsg('highscore-msg', 'รีเซ็ตแล้ว ' + (r.reset || 0) + ' คน', 'ok'); return loadHighscore(); }) + .catch(function (err) { setMsg('highscore-msg', err.message, 'error'); }); + }); + })(); + el('form-account-add').addEventListener('submit', function (e) { e.preventDefault(); var fd = new FormData(e.target); diff --git a/www/html/Admin/api/accounts.php b/www/html/Admin/api/accounts.php index a1804da..aa78d24 100644 --- a/www/html/Admin/api/accounts.php +++ b/www/html/Admin/api/accounts.php @@ -8,6 +8,7 @@ require_login(); function normalize_account_row(array $a): array { $a['coins'] = max(0, (int)($a['coins'] ?? 0)); + $a['score'] = max(0, (int)($a['score'] ?? 0)); return $a; } @@ -80,6 +81,9 @@ if ($method === 'PATCH') { if (array_key_exists('coins', $body)) { $store['accounts'][$i]['coins'] = max(0, (int)$body['coins']); } + if (array_key_exists('score', $body)) { + $store['accounts'][$i]['score'] = max(0, (int)$body['score']); + } if (array_key_exists('coinsDelta', $body)) { $cur = max(0, (int)($store['accounts'][$i]['coins'] ?? 0)); $store['accounts'][$i]['coins'] = max(0, $cur + (int)$body['coinsDelta']); diff --git a/www/html/Admin/api/game-award.php b/www/html/Admin/api/game-award.php new file mode 100644 index 0000000..c3c50b1 --- /dev/null +++ b/www/html/Admin/api/game-award.php @@ -0,0 +1,119 @@ +", + * "awards": [ { "playerKey": "...", "nickname": "...", "coins": 10 }, ... ] + * } + * - บวก coins (ใช้จ่ายได้) และ score (สะสมถาวร ไม่ลด) เท่ากับจำนวนเหรียญที่ได้ + * - cap ต่อรายการ 0–100 กันยิงมั่ว + */ +require __DIR__ . '/_common.php'; + +date_default_timezone_set('Asia/Bangkok'); + +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + json_response(['ok' => false, 'error' => 'Use POST'], 405); +} + +$secretFile = ADMIN_PRIVATE_DIR . '/game-award-secret.txt'; +$expected = is_file($secretFile) ? trim((string) @file_get_contents($secretFile)) : ''; + +$body = require_json_body(); +$secret = (string) ($body['secret'] ?? ''); +if ($expected === '' || strlen($secret) < 16 || !hash_equals($expected, $secret)) { + json_response(['ok' => false, 'error' => 'unauthorized'], 403); +} + +$awards = (isset($body['awards']) && is_array($body['awards'])) ? $body['awards'] : []; +if (!$awards) { + json_response(['ok' => true, 'updated' => 0]); +} + +$store = read_store(); +if (!isset($store['accounts']) || !is_array($store['accounts'])) { + $store['accounts'] = []; +} + +/* index บัญชี guest ตาม playerKey */ +$byKey = []; +foreach ($store['accounts'] as $i => $a) { + if (($a['loginType'] ?? '') === 'guest') { + $byKey[(string) ($a['providerUserId'] ?? '')] = $i; + } +} + +$updated = 0; +foreach ($awards as $aw) { + if (!is_array($aw)) { + continue; + } + $key = trim((string) ($aw['playerKey'] ?? '')); + if (!preg_match('/^[a-zA-Z0-9_-]{8,128}$/', $key)) { + continue; + } + $coins = (int) ($aw['coins'] ?? 0); + if ($coins < 0) $coins = 0; + if ($coins > 100) $coins = 100; + $nick = trim((string) ($aw['nickname'] ?? '')); + if (function_exists('mb_substr')) { + $nick = $nick !== '' ? mb_substr($nick, 0, 24) : ''; + } else { + $nick = $nick !== '' ? substr($nick, 0, 24) : ''; + } + + $caseId = preg_replace('/[^0-9]/', '', (string) ($aw['caseId'] ?? '')); + $noScore = !empty($aw['noScore']); // Fund ฯลฯ: บวกแค่ coins ไม่บวก high-score + + if (isset($byKey[$key])) { + $i = $byKey[$key]; + if (!empty($store['accounts'][$i]['blocked'])) { + continue; + } + $store['accounts'][$i]['coins'] = max(0, (int) ($store['accounts'][$i]['coins'] ?? 0)) + $coins; + if (!$noScore) { + $store['accounts'][$i]['score'] = max(0, (int) ($store['accounts'][$i]['score'] ?? 0)) + $coins; + } + if (!$noScore && $caseId !== '') { + if (!isset($store['accounts'][$i]['scoreByCase']) || !is_array($store['accounts'][$i]['scoreByCase'])) { + $store['accounts'][$i]['scoreByCase'] = []; + } + $store['accounts'][$i]['scoreByCase'][$caseId] = max(0, (int) ($store['accounts'][$i]['scoreByCase'][$caseId] ?? 0)) + $coins; + } + if ($nick !== '') { + $store['accounts'][$i]['lbName'] = $nick; + if (($store['accounts'][$i]['displayName'] ?? '') === 'Guest' || ($store['accounts'][$i]['displayName'] ?? '') === '') { + $store['accounts'][$i]['displayName'] = $nick; + } + } + $store['accounts'][$i]['updatedAt'] = gmdate('c'); + } else { + $store['accounts'][] = [ + 'id' => new_id(), + 'email' => '', + 'displayName' => ($nick !== '' ? $nick : 'Guest'), + 'loginType' => 'guest', + 'providerUserId' => $key, + 'notes' => 'auto: game-award', + 'blocked' => false, + 'coins' => $coins, + 'score' => ($noScore ? 0 : $coins), + 'scoreByCase' => ((!$noScore && $caseId !== '') ? [$caseId => $coins] : []), + 'lbName' => $nick, + 'createdAt' => gmdate('c'), + 'updatedAt' => gmdate('c'), + ]; + $byKey[$key] = count($store['accounts']) - 1; + } + $updated++; +} + +if (!write_store($store)) { + json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500); +} + +json_response(['ok' => true, 'updated' => $updated]); diff --git a/www/html/Admin/api/leaderboard-admin.php b/www/html/Admin/api/leaderboard-admin.php new file mode 100644 index 0000000..ef911a0 --- /dev/null +++ b/www/html/Admin/api/leaderboard-admin.php @@ -0,0 +1,94 @@ + (string) ($a['id'] ?? ''), + 'name' => $name, + 'score' => $score, + 'coins' => max(0, (int) ($a['coins'] ?? 0)), + 'blocked' => !empty($a['blocked']), + 'loginType' => (string) ($a['loginType'] ?? ''), + 'key' => (string) ($a['providerUserId'] ?? ''), + ]; + } + usort($rows, function ($x, $y) { + if ($y['score'] !== $x['score']) { + return $y['score'] - $x['score']; + } + return strcmp((string) $x['name'], (string) $y['name']); + }); + foreach ($rows as $i => &$r) { + $r['rank'] = $i + 1; + } + unset($r); + json_response(['ok' => true, 'rows' => $rows, 'total' => count($rows)]); +} + +if ($method === 'POST') { + $body = require_json_body(); + $action = (string) ($body['action'] ?? ''); + + if ($action === 'resetAll') { + $store = read_store(); + $n = 0; + foreach (($store['accounts'] ?? []) as $i => $a) { + if ((int) ($a['score'] ?? 0) !== 0) { + $store['accounts'][$i]['score'] = 0; + $store['accounts'][$i]['updatedAt'] = gmdate('c'); + $n++; + } + } + if (!write_store($store)) { + json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500); + } + json_response(['ok' => true, 'reset' => $n]); + } + + if ($action === 'reset') { + $id = trim((string) ($body['id'] ?? '')); + if ($id === '') { + json_response(['ok' => false, 'error' => 'ระบุ id'], 400); + } + $store = read_store(); + $found = false; + foreach (($store['accounts'] ?? []) as $i => $a) { + if (($a['id'] ?? '') === $id) { + $store['accounts'][$i]['score'] = 0; + $store['accounts'][$i]['updatedAt'] = gmdate('c'); + $found = true; + break; + } + } + if (!$found) { + json_response(['ok' => false, 'error' => 'ไม่พบบัญชี'], 404); + } + if (!write_store($store)) { + json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500); + } + json_response(['ok' => true]); + } + + json_response(['ok' => false, 'error' => 'action ไม่ถูกต้อง'], 400); +} + +json_response(['ok' => false, 'error' => 'Use GET or POST'], 405); diff --git a/www/html/Admin/api/leaderboard.php b/www/html/Admin/api/leaderboard.php new file mode 100644 index 0000000..503b879 --- /dev/null +++ b/www/html/Admin/api/leaderboard.php @@ -0,0 +1,77 @@ + + * คืน { ok, total, top: [ { rank, name, score, coins } ], me?: { rank, name, score } } + * จัดอันดับจากค่า score สะสม (ไม่ลดเมื่อใช้เหรียญ) + */ +require __DIR__ . '/_common.php'; + +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') { + json_response(['ok' => false, 'error' => 'Use GET'], 405); +} + +$limit = (int) ($_GET['limit'] ?? 50); +if ($limit < 1) $limit = 1; +if ($limit > 200) $limit = 200; + +$caseId = preg_replace('/[^0-9]/', '', (string) ($_GET['caseId'] ?? '')); + +$store = read_store(); +$accounts = (isset($store['accounts']) && is_array($store['accounts'])) ? $store['accounts'] : []; + +$rows = []; +foreach ($accounts as $a) { + if ($caseId !== '') { + $score = max(0, (int) (($a['scoreByCase'][$caseId] ?? 0))); + } else { + $score = max(0, (int) ($a['score'] ?? 0)); + } + if ($score <= 0) { + continue; + } + $name = trim((string) ($a['lbName'] ?? '')); + if ($name === '') $name = trim((string) ($a['displayName'] ?? '')); + if ($name === '' || $name === 'Guest') $name = 'ผู้เล่น'; + $rows[] = [ + 'name' => $name, + 'score' => $score, + 'coins' => max(0, (int) ($a['coins'] ?? 0)), + 'key' => (string) ($a['providerUserId'] ?? ''), + ]; +} + +usort($rows, function ($x, $y) { + if ($y['score'] !== $x['score']) { + return $y['score'] - $x['score']; + } + return strcmp((string) $x['name'], (string) $y['name']); +}); + +$total = count($rows); + +$me = null; +$myKey = trim((string) ($_GET['playerKey'] ?? '')); +if ($myKey !== '') { + for ($i = 0; $i < $total; $i++) { + if ($rows[$i]['key'] === $myKey) { + $me = ['rank' => $i + 1, 'name' => $rows[$i]['name'], 'score' => $rows[$i]['score'], 'coins' => $rows[$i]['coins']]; + break; + } + } +} + +$top = []; +$n = min($limit, $total); +for ($i = 0; $i < $n; $i++) { + $top[] = [ + 'rank' => $i + 1, + 'name' => $rows[$i]['name'], + 'score' => $rows[$i]['score'], + 'coins' => $rows[$i]['coins'], + ]; +} + +json_response(['ok' => true, 'total' => $total, 'top' => $top, 'me' => $me]); diff --git a/www/html/Admin/index.html b/www/html/Admin/index.html index 939b0fc..a20962a 100644 --- a/www/html/Admin/index.html +++ b/www/html/Admin/index.html @@ -118,6 +118,7 @@ + @@ -775,6 +776,7 @@ เวลา & รูป
+
@@ -1019,6 +1021,30 @@

+ +
- + diff --git a/www/html/Admin/private/game-award-secret.txt b/www/html/Admin/private/game-award-secret.txt new file mode 100644 index 0000000..7d67f43 --- /dev/null +++ b/www/html/Admin/private/game-award-secret.txt @@ -0,0 +1 @@ +cfac0faaa01bdd06ac50fd763625e96816f032ff047d3bb2cf9697e917fe71fd diff --git a/www/html/Admin/private/store.json b/www/html/Admin/private/store.json index 9ef507a..87594b6 100644 --- a/www/html/Admin/private/store.json +++ b/www/html/Admin/private/store.json @@ -33,14 +33,14 @@ { "id": "483a104becd7a5f92c0e5cad", "email": "", - "displayName": "Guest", + "displayName": "Q", "loginType": "guest", "providerUserId": "p_1775109142385_wq7wfy1p32j", "notes": "auto: player-coins", "blocked": false, - "coins": 10, + "coins": 110, "createdAt": "2026-04-02T05:52:21+00:00", - "updatedAt": "2026-06-12T06:21:53+00:00", + "updatedAt": "2026-06-12T08:29:33+00:00", "daily": { "anchorMs": 1781197200000, "claimedDays": [ @@ -53,7 +53,14 @@ false ], "lockUntilMs": 1781283600000 - } + }, + "score": 100, + "scoreByCase": { + "1": 10, + "10": 70, + "8": 20 + }, + "lbName": "Q" }, { "id": "1d64c56fadb64a93eae68a1d", diff --git a/www/html/Game/data/game-timing.json b/www/html/Game/data/game-timing.json index 8f0af08..2507448 100644 --- a/www/html/Game/data/game-timing.json +++ b/www/html/Game/data/game-timing.json @@ -2,7 +2,12 @@ "gauntletTickMs": 220, "gauntletJumpTicks": 16, "gauntletTimeLimitSec": 0, - "gauntletLaneImageUrls": [], + "gauntletLaneImageUrls": [ + "/Game/img/gauntlet-assets/obstacles-2.png", + "/Game/img/gauntlet-assets/obstacles-3.png", + "/Game/img/gauntlet-assets/obstacles-4.png", + "/Game/img/gauntlet-assets/obstacles-5.png" + ], "gauntletLaserTopUrl": "", "gauntletLaserBottomUrl": "", "gauntletLaserLineUrl": "", diff --git a/www/html/Game/data/gauntlet-assets-meta.json b/www/html/Game/data/gauntlet-assets-meta.json index 85bb12e..5f07a83 100644 --- a/www/html/Game/data/gauntlet-assets-meta.json +++ b/www/html/Game/data/gauntlet-assets-meta.json @@ -15,10 +15,6 @@ "label": "obstacles-5", "updatedAt": 1777530889730 }, - "gauntlet-2a75a958cd5e5e8a.png": { - "label": "obstacles-1", - "updatedAt": 1777530896068 - }, "gauntlet-dd51baed17270995.png": { "label": "buttonlazer", "updatedAt": 1777569238420 diff --git a/www/html/Game/public/css/testimony-overlay.css b/www/html/Game/public/css/testimony-overlay.css index 0f6be42..92c1abf 100644 --- a/www/html/Game/public/css/testimony-overlay.css +++ b/www/html/Game/public/css/testimony-overlay.css @@ -1024,3 +1024,59 @@ transform: scale(1.02); transition: filter 0.15s ease, transform 0.1s ease; } + +/* ปุ่ม host แยก (ขวา) — ใช้รูปจริง: btn-next-suspect (ปากคำ 1-2) / btn-start-vote (ปากคำสุดท้าย) */ +#testimony-overlay .es-reveal-wrap .es-proceed { + position: absolute; + left: 1240px; + top: 951px; + width: 360px; + height: 96px; + z-index: 11; + padding: 0; + border: none; + background: none; + cursor: pointer; + transition: filter 0.15s ease, transform 0.1s ease, opacity 0.15s ease; +} + +#testimony-overlay .es-reveal-wrap .es-proceed[hidden] { + display: none; +} + +#testimony-overlay .es-reveal-wrap .es-proceed img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + transition: opacity 0.2s; +} + +/* ค่าเริ่มต้น: โชว์ "ผู้ต้องสงสัยถัดไป" — ปากคำสุดท้าย (is-last): โชว์ "เริ่มพิจารณาคดี" */ +#testimony-overlay .es-reveal-wrap .es-proceed .es-proceed-trial { + opacity: 0; +} + +#testimony-overlay .es-reveal-wrap .es-proceed.is-last .es-proceed-next { + opacity: 0; +} + +#testimony-overlay .es-reveal-wrap .es-proceed.is-last .es-proceed-trial { + opacity: 1; +} + +#testimony-overlay .es-reveal-wrap .es-proceed:not(:disabled) { + filter: drop-shadow(0 0 12px rgba(83, 224, 255, 0.5)); +} + +#testimony-overlay .es-reveal-wrap .es-proceed:disabled { + opacity: 0.45; + cursor: not-allowed; + filter: grayscale(0.5); +} + +#testimony-overlay .es-reveal-wrap .es-proceed:not(:disabled):hover { + filter: drop-shadow(0 0 18px rgba(83, 224, 255, 0.85)) brightness(1.1); + transform: scale(1.03); +} diff --git a/www/html/Game/public/img/special-quiz/light-lawyer.png b/www/html/Game/public/img/special-quiz/light-lawyer.png new file mode 100644 index 0000000..cdfbc84 Binary files /dev/null and b/www/html/Game/public/img/special-quiz/light-lawyer.png differ diff --git a/www/html/Game/public/img/special-quiz/light-police.png b/www/html/Game/public/img/special-quiz/light-police.png new file mode 100644 index 0000000..f15c8d5 Binary files /dev/null and b/www/html/Game/public/img/special-quiz/light-police.png differ diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index 50b1222..1d8dc03 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -839,6 +839,8 @@ let gauntletCrownRunwayBgSpeedPxPerSec = 48; let gauntletCrownRunwayBgScrollPx = 0; let gauntletCrownRunwayBgFinishLatched = false; + /** ส่งสัญญาณ "ถึงเส้นชัย" ให้ server แล้วหรือยัง (กันส่งซ้ำ) — server จะหยุด obstacle/collision */ + let gauntletFinishPhaseSignaled = false; /** >0 = หยุดเลื่อนแล้ว (timestamp ms) รอ POST_STOP ก่อน latch */ let gauntletCrownRunwayBgStripFreezeSinceMs = 0; /** คอลัมน์แผนที่ที่ล็อกตอน freeze — แนบตัวนำแทนขอบขวาสูงสุดของแมป */ @@ -1275,25 +1277,48 @@ return screenFrac <= gauntletCrownRunwayFinishStopViewportFracPlay() + 0.008; } - /** จัด scroll ให้จุดอ้างอิงใน FINISH ตรงคอลัมน์แผนที่ (หรือ finishStopViewportFrac บนจอ) */ - function gauntletCrownRunwaySnapScrollToFinishStopPlay() { - if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return; + /** ค่าตำแหน่ง scroll ที่ทำให้เส้น FINISH หยุดตรงจุด (คืน null ถ้าคำนวณไม่ได้) — ใช้ทั้ง snap และ clamp กันวิ่งเลยเส้นชัย */ + function gauntletCrownRunwayFinishStopScrollPxPlay() { + if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null; const g = gauntletCrownRunwayScrollGeometryPlay(); - if (!g) return; + if (!g) return null; const finishLineStripX = gauntletCrownRunwayFinishLineStripXPlay(g); - if (finishLineStripX == null) return; + if (finishLineStripX == null) return null; const alignWorldX = gauntletCrownRunwayFinishAlignMapWorldXPlay(); if (alignWorldX != null) { - gauntletCrownRunwayBgScrollPx = finishLineStripX - alignWorldX; - return; + return finishLineStripX - alignWorldX; } const stopVp = gauntletCrownRunwayFinishStopViewportFracPlay(); const targetScrollView = finishLineStripX - stopVp * g.cwWorld; const zGaRaw = computePlayCameraZDrawPlay(); const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGaRaw); - if (!gCam) return; + if (!gCam) return null; const worldMinX = gCam.px - canvas.width / (2 * zGaRaw); - gauntletCrownRunwayBgScrollPx = targetScrollView - worldMinX; + return targetScrollView - worldMinX; + } + + /** จัด scroll ให้จุดอ้างอิงใน FINISH ตรงคอลัมน์แผนที่ (หรือ finishStopViewportFrac บนจอ) */ + function gauntletCrownRunwaySnapScrollToFinishStopPlay() { + const t = gauntletCrownRunwayFinishStopScrollPxPlay(); + if (t == null) return; + gauntletCrownRunwayBgScrollPx = t; + } + + /** ลำดับการหยุดที่เส้นชัย: ล็อก align (ครั้งเดียว) → snap เป๊ะ → เริ่ม freeze → (latch+mission ทีหลังใน tick) */ + function gauntletCrownRunwayBeginFinishStopPlay() { + if (gauntletCrownRunwayFinishAlignLatchedWorldX == null) { + const leadAlign = gauntletCrownRunwayLeadingEntityAlignWorldXPlay(); + if (leadAlign != null) gauntletCrownRunwayFinishAlignLatchedWorldX = leadAlign; + } + gauntletCrownRunwaySnapScrollToFinishStopPlay(); + gauntletCrownRunwayBgStripFreezeSinceMs = Date.now(); + /* แจ้ง server ครั้งเดียว: ถึงเส้นชัยแล้ว → หยุด obstacle + collision (กัน -10 ตอน obstacle หายแล้ว) */ + if (!gauntletFinishPhaseSignaled) { + gauntletFinishPhaseSignaled = true; + try { socket.emit('gauntlet-finish-phase'); } catch (_e) { /* ignore */ } + } + try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ } + try { draw(); } catch (_d) { /* ignore */ } } /** โหมดจอเท่านั้น: กล้องเลื่อนหลัง freeze ทำให้ FINISH หลุด — รีสแนปทุกเฟรม */ @@ -1343,6 +1368,7 @@ gauntletCrownRunwayBgImgs = [null, null, null, null, null]; gauntletCrownRunwayBgScrollPx = 0; gauntletCrownRunwayBgFinishLatched = false; + gauntletFinishPhaseSignaled = false; gauntletCrownRunwayBgStripFreezeSinceMs = 0; gauntletCrownRunwayFinishAlignLatchedWorldX = null; gauntletCrownRunwayClientMissionShown = false; @@ -1382,6 +1408,8 @@ function tickGauntletCrownRunwayBgPlay(dtSec) { if (!gauntletCrownRunwayBgDrawActivePlay()) return; + /* หยุดเลื่อน BG ระหว่างคำถามพิเศษ (special-quiz freeze) — กัน BG/เส้นชัยเดินตอนค้างตอบ */ + if (specialQuizFreeze) return; /** พรีรัน (howto / countdown) — ห้ามเลื่อนก่อน GO · Mega Virus ใช้ shell เดียวกัน */ if (usesCrownLobbyShellPlay() && gauntletCrownPregamePhase !== 'live') return; if (gauntletCrownRunwayBgFinishLatched) return; @@ -1431,7 +1459,17 @@ } catch (_d) { /* ignore */ } return; } - gauntletCrownRunwayBgScrollPx += (gauntletCrownRunwayBgSpeedPxPerSec || 48) * dtSec; + const gcrInc = (gauntletCrownRunwayBgSpeedPxPerSec || 48) * dtSec; + /* ถ้าก้าวถัดไป "จะถึง/เลย" จุดหยุด FINISH → หยุดเป๊ะตรงนั้น (ไม่วิ่งเลย) แล้วเริ่มจบเกมทันที */ + const gcrLiveStopPx = gauntletCrownRunwayFinishStopScrollPxPlay(); + if (gauntletCrownRunwayBgMapEligiblePlay() && gcrLiveStopPx != null + && gcrLiveStopPx >= gauntletCrownRunwayBgScrollPx - 0.01 + && gauntletCrownRunwayBgScrollPx + gcrInc >= gcrLiveStopPx) { + gauntletCrownRunwayBgScrollPx = gcrLiveStopPx; + gauntletCrownRunwayBeginFinishStopPlay(); + return; + } + gauntletCrownRunwayBgScrollPx += gcrInc; } /** stack + แมป mnn93hpi — พื้นหลัง intro+loop เลื่อนแนวตั้ง (stackTowerBgScroll ในแมป) */ @@ -1836,9 +1874,19 @@ const CHARACTER_ANIM_FRAME_MS = 200; /** จังหวะเดินลูปคงที่ 4 เฟรม — อย่า modulo ด้วยจำนวนเฟรมที่โหลดได้แล้ว (ไม่งั้นเฟรมกลางหาย/ลูปสั้นลง) */ + /** true เมื่ออยู่ในเกมวิ่งหลบ (gauntlet runner) ช่วงเล่นจริง — ใช้เร่งจังหวะขาให้ดูเหมือน "วิ่ง" */ + function gauntletRunnerAnimActivePlay() { + try { + return isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay(); + } catch (e) { return false; } + } + function walkAnimPhaseIndex(now, isWalking) { - const t = isWalking ? (typeof now === 'number' ? now : Date.now()) : 0; - return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES; + if (!isWalking) return 0; + const t = (typeof now === 'number' ? now : Date.now()); + /* gauntlet วิ่งหลบ: ย่นเวลาเฟรม ~1.85× ให้ขาสลับเร็ว = วิ่ง (ไม่ใช่เดิน) */ + const frameMs = gauntletRunnerAnimActivePlay() ? CHARACTER_ANIM_FRAME_MS * 0.54 : CHARACTER_ANIM_FRAME_MS; + return Math.floor(t / frameMs) % CHARACTER_ANIM_FRAMES; } /** เลือกเฟรมสูงสุดที่โหลดแล้วและ <= phase (เดิมถอย) */ @@ -2681,6 +2729,20 @@ return getStoredCharacterId(); } + /** playerKey ของผู้เล่น (ผูกบัญชีเหรียญ/คะแนน) — สร้างถ้ายังไม่มี เหมือน Main-Lobby */ + function getJdPlayerKey() { + try { + let k = (localStorage.getItem('jdPlayerKey') || '').trim(); + if (!k || k.length < 8) { + k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + localStorage.setItem('jdPlayerKey', k); + } + return k; + } catch (e) { + return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + } + } + /** บอทพรีวิว: เลือกรหัสคนละตัวจาก roster (ไม่ซ้ำผู้เล่นถ้ามีตัวเลือก) */ function pickPreviewBotCharacterId(botSlotIndex) { const idx = Math.max(0, Math.floor(Number(botSlotIndex)) || 0); @@ -9038,6 +9100,43 @@ return ranks.slice(0, 5); } + /** payload สำหรับ overlay สรุปผลกลาง (gauntlet-crown-mission-overlay) — ให้ MG4 ใช้หน้าจบแบบเดียวกับเกมอื่น */ + function quizCarryBuildGcmMissionPayload(opts) { + opts = opts || {}; + const ranks = quizCarryBuildMissionRankList(); + const ranked = ranks.map(function (row, idx) { + const pos = idx + 1; + const sc = Math.max(0, Number(row.score) || 0); + return { + id: row.id, nickname: row.nickname, characterId: row.characterId, + baseScore: sc, eliminated: false, + rank: pos, rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, finalScore: sc, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.min(100, Math.max(0, Math.round(totalSum / n))); + const grade = opts.forceGradeF ? 'F' : quizCarryGradeFromTeamAverage(averageScore); + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, totalSum: totalSum, averageScore: averageScore, + grade: grade, rewardCard: rewardCard, totalParts: totalParts, + uiSkin: 'quiz_carry', survivorCount: opts.forceGradeF ? 0 : ranked.length, + participantCount: ranked.length, + }; + } + + /** ตั้งรูปพื้นหลังหน้าจบของ overlay กลางเป็นชุด quiz-carry (cut) */ + function applyQuizCarryGcmMissionBg() { + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg) { + resBg.src = BASE + '/img/quiz-carry/popup-result.png'; + resBg.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/popup-result.png'; }; + } + } + function cancelEmbedPreviewLobbyReturnTimer() { if (embedPreviewLobbyReturnTimer != null) { clearTimeout(embedPreviewLobbyReturnTimer); @@ -9410,7 +9509,8 @@ quizCarrySessionCompleteResultToSummaryT = setTimeout(function () { quizCarrySessionCompleteResultToSummaryT = null; hideQuizCarryResultEndLayer(); - showQuizCarryMissionSummaryOverlay(summaryOpts); + /* ใช้หน้าจบ overlay กลาง (เหมือนเกมอื่น) skin quiz_carry แทน overlay เฉพาะเดิม */ + showGauntletCrownMissionOverlay(quizCarryBuildGcmMissionPayload(summaryOpts)); }, QUIZ_CARRY_SESSION_END_SPLASH_MS); renderPlayQuizScoreboard(playLiveQuizScores); return; @@ -11787,6 +11887,7 @@ if (mission.uiSkin === 'violent_crime') return isSpaceShooterMissionUiMapPlay(); if (mission.uiSkin === 'mega_virus') return isMegaVirusMissionShellMapPlay(); if (mission.uiSkin === 'stack_tower') return isStackTowerMissionUiMapPlay(); + if (mission.uiSkin === 'quiz_carry') return quizCarryEmbedMissionFlowActive() || isQuizCarry(); return false; } @@ -11802,6 +11903,7 @@ if (skin === 'violent_crime') return violentCrimeAssetUrl(file); if (skin === 'mega_virus') return megaVirusAssetUrl(file); if (skin === 'stack_tower') return stackTowerAssetUrl(file); + if (skin === 'quiz_carry') return BASE + '/img/quiz-carry/' + String(file || '').replace(/^\//, ''); return questionMissionAssetUrl(file); } @@ -12455,6 +12557,16 @@ specialQuizIconImgs[key] = img; return img; } + const specialQuizGlowImgs = {}; + /** แสง glow (08-event light-*) วางหลังไอคอน — ให้ดูเป็นของเก็บเรืองแสงบนเส้นทาง */ + function specialQuizGetGlowImg(type) { + const key = type === 'police' ? 'police' : 'lawyer'; + if (specialQuizGlowImgs[key]) return specialQuizGlowImgs[key]; + const img = new Image(); + img.src = '/Game/img/special-quiz/light-' + key + '.png'; + specialQuizGlowImgs[key] = img; + return img; + } function setSpecialQuizIconFromServer(payload) { if (!payload || payload.x == null || payload.y == null) { specialQuizIcon = null; return; } specialQuizIcon = { @@ -12517,6 +12629,19 @@ const bob = Math.sin(Date.now() / 320) * size * 0.06; const cy = sy + bob; ctx.save(); + /* glow 08-event (light-*) หมุน+เต้นหลังไอคอน — collectible เรืองแสง */ + const glowImg = specialQuizGetGlowImg(specialQuizIcon.iconType); + if (glowImg && glowImg.complete && glowImg.naturalWidth > 0) { + const pulse = 0.82 + Math.sin(Date.now() / 300) * 0.18; + const gsz = size * 2.5 * pulse; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.globalAlpha = 0.9; + ctx.translate(sx, cy); + ctx.rotate(Date.now() / 2600); + ctx.drawImage(glowImg, -gsz / 2, -gsz / 2, gsz, gsz); + ctx.restore(); + } const r = size * 0.62; const grad = ctx.createRadialGradient(sx, cy, r * 0.18, sx, cy, r); grad.addColorStop(0, 'rgba(122, 162, 247, 0.5)'); @@ -12742,7 +12867,9 @@ extendCurrentMinigameTimeClient(addSec); } if (coins > 0 && card.effectKey === 'fund') { - addOwnCoinsViaApi(coins); + /* server แจกเหรียญแล้ว (game-award.php) — แค่ดึงยอดล่าสุดมาแสดง; ถ้าไม่ใช่ (เผื่อ fallback) ค่อยบวกเอง */ + if (data.coinsServerAwarded) resyncOwnCoins(); + else addOwnCoinsViaApi(coins); } /* ถ้า overlay ควิซยังเปิด (การ์ดเพิ่งได้จากควิซ) — แผง «เล่นต่อ» โชว์การ์ดอยู่แล้ว ไม่ต้องเด้งซ้ำ */ const sqOv = specialQuizOverlayEl(); @@ -12770,6 +12897,21 @@ } catch (e) { /* ignore */ } } + /** ดึงยอดเหรียญล่าสุดจาก server มาอัปเดต cache (ใช้หลัง server แจกเหรียญเอง เช่น Fund) */ + function resyncOwnCoins() { + let key = ''; + try { key = (localStorage.getItem('jdPlayerKey') || '').trim(); } catch (e) { key = ''; } + if (!key || key.length < 8) return; + const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins.php') : '/Admin/api/player-coins.php') + '?playerKey=' + encodeURIComponent(key); + try { + fetch(url, { credentials: 'omit' }).then(function (r) { return r.json(); }).then(function (d) { + if (d && d.ok && typeof d.coins === 'number') { + try { localStorage.setItem('jdCoins', String(d.coins)); } catch (e) { /* ignore */ } + } + }).catch(function () { /* ignore */ }); + } catch (e) { /* ignore */ } + } + /** +วินาที ให้มินิเกมที่กำลังเล่น (jump/shooter/quiz carry) — mng8a80o จัดการฝั่ง server */ function extendCurrentMinigameTimeClient(sec) { const ms = Math.max(0, sec * 1000); @@ -13221,7 +13363,7 @@ if (previewMode && editorEmbedReturn) { ov.classList.add('is-hidden'); if (gcmHead) gcmHead.classList.remove('sr-only'); - if (mission && (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus' || mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime')) { + if (mission && (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus' || mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'quiz_carry')) { cancelQuizCarryResultEndAfterTimeup(); hideQuizCarryTimeupOnDeskLayer(); hideQuizCarryResultEndLayer(); @@ -17780,6 +17922,7 @@ nickname: joinNick, characterId: getPlayCharacterId(), playMapId: joinPlayMapId || undefined, + playerKey: getJdPlayerKey(), }, (res) => { if (!res || !res.ok) { const errMsg = (res && res.error) || 'เข้าร่วมไม่ได้'; @@ -18299,10 +18442,47 @@ } }); + function showMinigameCoinsToast(mine) { + let el = document.getElementById('minigame-coins-toast'); + if (!el) { + el = document.createElement('div'); + el.id = 'minigame-coins-toast'; + el.style.cssText = 'position:fixed;left:50%;top:13%;transform:translateX(-50%);z-index:99999;padding:13px 26px;border-radius:14px;font-weight:700;text-align:center;pointer-events:none;box-shadow:0 6px 24px rgba(0,0,0,.45);transition:opacity .35s;opacity:0;font-size:20px;line-height:1.3;'; + document.body.appendChild(el); + } + const survived = mine.survived !== false; + const coins = Math.max(0, Number(mine.coins) || 0); + if (survived && coins > 0) { + el.style.background = 'linear-gradient(135deg,#1b9e4b,#13c06a)'; + el.style.color = '#fff'; + el.innerHTML = '🪙 +' + coins + ' เหรียญ
คะแนนสะสม +' + coins + ''; + } else if (survived) { + el.style.background = 'linear-gradient(135deg,#555,#777)'; + el.style.color = '#fff'; + el.textContent = 'รอบนี้ไม่ติดอันดับรางวัล'; + } else { + el.style.background = 'linear-gradient(135deg,#a11,#d33)'; + el.style.color = '#fff'; + el.textContent = '💀 ตายแล้ว — ไม่ได้รับเหรียญ'; + } + el.style.opacity = '1'; + if (window.__miniCoinsToastT) clearTimeout(window.__miniCoinsToastT); + window.__miniCoinsToastT = setTimeout(() => { el.style.opacity = '0'; }, 4200); + } + + socket.on('minigame-coins', (data) => { + try { + if (!data || !Array.isArray(data.results)) return; + const mine = data.results.find((r) => r.id === myId); + if (mine) showMinigameCoinsToast(mine); + } catch (e) { /* ignore */ } + }); + socket.on('gauntlet-ended', (data) => { cancelQuizCarryResultEndAfterTimeup(); gauntletEndsAtMs = null; gauntletCrownRunwayBgFinishLatched = false; + gauntletFinishPhaseSignaled = false; gauntletCrownRunwayBgStripFreezeSinceMs = 0; gauntletCrownRunwayFinishAlignLatchedWorldX = null; gauntletCrownRunwayClientMissionShown = false; @@ -19531,52 +19711,70 @@ let playPath = []; + /** เลเซอร์ = รูป cut obstacles-1.png (emitter หัว + ลำแสง + emitter ท้าย ในรูปเดียว) ยืดเต็มความสูง + ถ้ารูปยังไม่โหลด → fallback ลำแสงแดงวาดเอง */ function drawGauntletLaserColumnScreen(rx, ry, rw, rh) { + if (rh <= 1 || rw <= 0) return; + const cx = rx + rw / 2; + const spriteRec = ensureGauntletAssetImage(BASE + '/img/gauntlet-assets/obstacles-1.png'); + if (spriteRec && spriteRec.img.complete && spriteRec.img.naturalWidth > 0) { + const drawW = Math.max(14, rw * 1.5); + try { ctx.drawImage(spriteRec.img, cx - drawW / 2, ry, drawW, rh); } catch (e) { /* ignore */ } + return; + } ctx.save(); + const coreW = Math.max(2, rw * 0.30); + const glowW = Math.max(coreW + 4, rw * 0.92); + const prevComp = ctx.globalCompositeOperation; + ctx.globalCompositeOperation = 'lighter'; + /* glow รอบนอก */ + let g1 = ctx.createLinearGradient(cx - glowW / 2, 0, cx + glowW / 2, 0); + g1.addColorStop(0, 'rgba(255,40,60,0)'); + g1.addColorStop(0.5, 'rgba(255,55,75,0.5)'); + g1.addColorStop(1, 'rgba(255,40,60,0)'); + ctx.fillStyle = g1; + ctx.fillRect(cx - glowW / 2, ry, glowW, rh); + /* แกนสว่าง */ + let g2 = ctx.createLinearGradient(cx - coreW / 2, 0, cx + coreW / 2, 0); + g2.addColorStop(0, 'rgba(255,110,130,0.85)'); + g2.addColorStop(0.5, 'rgba(255,240,245,0.98)'); + g2.addColorStop(1, 'rgba(255,110,130,0.85)'); + ctx.fillStyle = g2; + ctx.fillRect(cx - coreW / 2, ry, coreW, rh); + ctx.globalCompositeOperation = prevComp; + /* หัว/ท้าย emitter — รูปจริง gauntlet-dd51 (gauntletLaserTopUrl/BottomUrl) ที่บน-ล่างสุดของลำแสง + กล่องเป็นสีเข้ม → ใส่ glow แดงข้างหลังให้เด่นบนพื้นมืด (ผนัง museum) */ const topRec = gauntletLaserTopUrl ? ensureGauntletAssetImage(gauntletLaserTopUrl) : null; const botRec = gauntletLaserBottomUrl ? ensureGauntletAssetImage(gauntletLaserBottomUrl) : null; - const lineRec = gauntletLaserLineUrl ? ensureGauntletAssetImage(gauntletLaserLineUrl) : null; - let topH = 0; - let botH = 0; + const capW = Math.max(18, rw * 2.0); + function laserEmitterGlow(gy) { + const rad = capW * 0.8; + const pc = ctx.globalCompositeOperation; + ctx.globalCompositeOperation = 'lighter'; + const gg = ctx.createRadialGradient(cx, gy, 0, cx, gy, rad); + gg.addColorStop(0, 'rgba(255,120,140,0.9)'); + gg.addColorStop(0.5, 'rgba(255,55,75,0.55)'); + gg.addColorStop(1, 'rgba(255,40,60,0)'); + ctx.fillStyle = gg; + ctx.beginPath(); + ctx.arc(cx, gy, rad, 0, Math.PI * 2); + ctx.fill(); + ctx.globalCompositeOperation = pc; + } if (topRec && topRec.img.complete && topRec.img.naturalWidth > 0) { - topH = Math.min(rh * 0.4, rw * topRec.img.naturalHeight / topRec.img.naturalWidth); + const ch = capW * topRec.img.naturalHeight / topRec.img.naturalWidth; + laserEmitterGlow(ry + ch * 0.35); + try { ctx.drawImage(topRec.img, cx - capW / 2, ry - ch * 0.1, capW, ch); } catch (e) { /* ignore */ } } if (botRec && botRec.img.complete && botRec.img.naturalWidth > 0) { - botH = Math.min(rh * 0.4, rw * botRec.img.naturalHeight / botRec.img.naturalWidth); - } - const lineReady = !!(lineRec && lineRec.img.complete && lineRec.img.naturalWidth > 0 && rh > 1); - /* ไม่เติมสีคอลัมน์ทึบเมื่อมีรูปเส้นแล้ว — กันทึบโปร่งซ้าย/ขวาเลเซอร์ดูเหมือนแถบ UI/scrollbar */ - if (!lineReady) { - ctx.fillStyle = gauntletLaserFillColor; - ctx.fillRect(rx, ry, rw, rh); - } - /* เส้นกลาง tile ทั้งความสูงคอลัมน์ แล้วค่อยวาดหัว/ท้ายทับ — ให้ลำแสงต่อเนื่องแบบสินทรัพย์รวม (รูปอ้างอิง) */ - if (lineReady) { - const iw = lineRec.img.naturalWidth; - const ih = lineRec.img.naturalHeight; - const scale = rw / iw; - const step = Math.max(1, ih * scale); - let y = ry; - while (y < ry + rh) { - const piece = Math.min(step, ry + rh - y); - const srcH = piece / scale; - try { - ctx.drawImage(lineRec.img, 0, 0, iw, srcH, rx, y, rw, piece); - } catch (e) { /* ignore */ } - y += piece; - } - } - if (topH > 0 && topRec) { - try { ctx.drawImage(topRec.img, rx, ry, rw, topH); } catch (e) { /* ignore */ } - } - if (botH > 0 && botRec) { - try { ctx.drawImage(botRec.img, rx, ry + rh - botH, rw, botH); } catch (e) { /* ignore */ } - } - const lw = Number(gauntletLaserLineWidthPx) || 0; - if (lw > 0) { - ctx.strokeStyle = gauntletLaserStrokeColor; - ctx.lineWidth = lw; - ctx.strokeRect(rx + lw / 2, ry + lw / 2, rw - lw, rh - lw); + const ch = capW * botRec.img.naturalHeight / botRec.img.naturalWidth; + laserEmitterGlow(ry + rh - ch * 0.35); + /* ท้าย — กลับหัวรูป (เครื่องยิงหันขึ้น) วางชิดล่างสุด */ + ctx.save(); + ctx.translate(cx, ry + rh - ch * 0.9); + ctx.scale(1, -1); + try { ctx.drawImage(botRec.img, -capW / 2, 0, capW, ch); } catch (e) { /* ignore */ } + ctx.restore(); } ctx.restore(); } @@ -20189,7 +20387,7 @@ } /** แถบ Cyber SCORE + สรุปผล — หน้า idle down + tint ต่อผู้เล่น */ - function setCyberHudScoreAvatarImg(avImg, row) { + function setCyberHudScoreAvatarImg(avImg, row, skipReuse) { const cid = row.characterId ? String(row.characterId) : ''; if (!cid) { avImg.src = defaultAvatarImg.src; @@ -20201,7 +20399,9 @@ avImg.src = cached; return; } - const hudSrc = findPlayCyberHudScoreAvatarSrcForPeer(row.id); + /* skipReuse: ตอน retry (เช่น crop หัวยังไม่เสร็จ) อย่าอ่าน src ปัจจุบันจาก DOM กลับมา + ไม่งั้นจะได้ "รูปเต็มตัวชั่วคราว" กลับมาแล้ว cache ค้าง (human โชว์เต็มตัวคนเดียว) */ + const hudSrc = skipReuse ? '' : findPlayCyberHudScoreAvatarSrcForPeer(row.id); if (hudSrc) { avImg.src = hudSrc; cyberHudScoreAvatarUrlCache.set(cacheKey, hudSrc); @@ -20230,8 +20430,16 @@ cyberHudScoreAvatarUrlCache.set(cacheKey, lobbyFace); return; } - /* crop ยังไม่เสร็จ (async) → ตั้งเต็มตัวชั่วคราว ไม่ cache → retry จะได้หน้า crop */ + /* crop ยังไม่เสร็จ (async) → ตั้งเต็มตัวชั่วคราว แล้ว retry จนได้หัว (กัน human โชว์เต็มตัวคนเดียว) + จำกัดจำนวน retry กัน loop ถ้า crop ล้มเหลวถาวร */ avImg.src = lobbySrc; + var hudAvTries = avImg.__hudAvHeadTries || 0; + if (hudAvTries < 12) { + avImg.__hudAvHeadTries = hudAvTries + 1; + setTimeout(function () { + if (avImg.isConnected) setCyberHudScoreAvatarImg(avImg, row, true); + }, 200); + } return; } avImg.src = lobbySrc; @@ -21630,6 +21838,7 @@ } } else if (o.kind === 'laser' && typeof o.drawX === 'number') { if (o.drawX < stx - 2 || o.drawX > enx + 2) continue; + /* ใช้โซนที่ editor กำหนด (gauntletLaserRowStart..End เช่น 4..18) — หัว/ท้าย emitter อยู่ตามนั้น */ const { y0: laserRow0, y1: laserRow1 } = gauntletLaserRowHitRange(o, h); const wx0 = o.drawX * tileSize; const wx1 = (o.drawX + 1) * tileSize; diff --git a/www/html/Game/public/js/room-lobby.js b/www/html/Game/public/js/room-lobby.js index 8c89b3e..40dacf1 100644 --- a/www/html/Game/public/js/room-lobby.js +++ b/www/html/Game/public/js/room-lobby.js @@ -1882,6 +1882,22 @@ if (Number.isNaN(idx) || idx < 0 || idx > 2) return; var img = card.querySelector(':scope > img'); if (img) img.src = getSuspectPickImageUrl(idx); + /* ปุ่มแว่นขยาย — ดูการ์ดเต็ม + ปิดได้ (ไม่ชนการเลือก/โหวต) */ + if (!card.querySelector('.suspect-zoom-btn')) { + if (getComputedStyle(card).position === 'static') card.style.position = 'relative'; + var zb = document.createElement('button'); + zb.type = 'button'; + zb.className = 'suspect-zoom-btn'; + zb.setAttribute('aria-label', 'ดูการ์ดเต็ม'); + zb.textContent = '🔍'; + zb.style.cssText = 'position:absolute;top:9px;right:9px;z-index:8;width:34px;height:34px;border:none;border-radius:50%;cursor:pointer;font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center;background:rgba(10,16,30,.74);color:#9fe9ff;box-shadow:0 2px 8px rgba(0,0,0,.45)'; + zb.addEventListener('click', function (e) { + e.stopPropagation(); + var i = parseInt(card.getAttribute('data-index'), 10); + openEvidenceCardLightbox(getSuspectPickImageUrl(i), '', 'common'); + }); + card.appendChild(zb); + } }); } if (caseMediaData) paint(); @@ -2956,6 +2972,7 @@ spaceId, nickname: getProfileDisplayName(), characterId: getStoredCharacterId(), + playerKey: ensurePlayerKey(), desiredLobbyColorThemeIndex: savedThemeIdx, desiredLobbySkinToneIndex: savedSkinIdx, }, (res) => { @@ -4340,6 +4357,26 @@ } } + /* กระดานผู้นำในเกม — คะแนนจริง "แยกต่อคดี" จาก server (ว่าง → fallback mock) */ + var lobbyRankRealLeaders = null; + function fetchLobbyRankLeaders() { + var caseId = ''; + try { caseId = String(getDetectiveCaseId() || ''); } catch (e) { caseId = ''; } + var base = (typeof appPath === 'function' ? appPath('/Admin/api/leaderboard.php') : '/Admin/api/leaderboard.php'); + var url = base + '?limit=10&caseId=' + encodeURIComponent(caseId) + '&playerKey=' + encodeURIComponent(ensurePlayerKey()); + fetch(url, { credentials: 'omit' }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d || !d.ok || !Array.isArray(d.top)) return; + lobbyRankRealLeaders = d.top.map(function (e) { + return { name: e.name || 'ผู้เล่น', score: Math.max(0, parseInt(e.score, 10) || 0) }; + }); + var ov = document.getElementById('lobby-rank-overlay'); + if (ov && !ov.classList.contains('is-hidden')) renderLobbyRankModalContent(); + }) + .catch(function () { /* ออฟไลน์ → ใช้ mock */ }); + } + function getLobbyPlayerAvatarSrc() { try { const composed = (localStorage.getItem('jdCharLobbyIdleDown') || '').trim(); @@ -4399,7 +4436,8 @@ function renderLobbyRankModalContent() { const titleEl = document.getElementById('lobby-rank-case-title'); if (titleEl) titleEl.textContent = getLobbyRankCaseTitle(); - const sorted = [...LOBBY_RANK_MOCK_LEADERS].sort((a, b) => b.score - a.score); + const rankSrc = (lobbyRankRealLeaders && lobbyRankRealLeaders.length) ? lobbyRankRealLeaders : LOBBY_RANK_MOCK_LEADERS; + const sorted = [...rankSrc].sort((a, b) => b.score - a.score); const podium = document.getElementById('lobby-rank-podium'); const list = document.getElementById('lobby-rank-list'); const selfFoot = document.getElementById('lobby-rank-self'); @@ -4479,6 +4517,7 @@ if (!ov) return; ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); + fetchLobbyRankLeaders(); syncLobbyRankScale(); renderLobbyRankModalContent(); requestAnimationFrame(() => { @@ -5138,6 +5177,11 @@ '' + '' + '' + + /* ปุ่ม host แยกต่างหาก (ขวา) — ไปปากคำถัดไป / เริ่มพิจารณาคดี */ + '' + '' + '' + '' + @@ -5195,23 +5239,10 @@ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onActionBtnTrigger(); } }); } + /* ปุ่ม READY — ทุกคนกดยืนยันของตัวเอง (ไม่ทำหน้าที่ไปต่อแล้ว) */ var readyEl = ov.querySelector('#esBtnReady'); if (readyEl) readyEl.addEventListener('click', function () { if (this.disabled) return; - var state = this.getAttribute('data-state') || 'ready'; - if (state === 'start-vote') { - /* host กดเริ่มพิจารณาคดี — ไปปากคำถัดไป/หน้าโหวต */ - this.disabled = true; - socket.emit('testimony-start-vote', {}, function (res) { - if (!(res && res.ok)) { - var rd = document.getElementById('esBtnReady'); - if (rd) rd.disabled = false; - appendLobbySystemChat('— เริ่มพิจารณาคดีไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); - } - }); - return; - } - /* state === 'ready' — กดยืนยัน READY ของตัวเอง */ this.classList.add('is-active'); this.disabled = true; tmState.iReadied = true; @@ -5224,6 +5255,19 @@ } }); }); + /* ปุ่ม host แยก — ไปปากคำถัดไป / เริ่มพิจารณาคดี (เปิดเมื่อทุกคน READY) */ + var proceedEl = ov.querySelector('#esBtnProceed'); + if (proceedEl) proceedEl.addEventListener('click', function () { + if (this.disabled || this.hidden) return; + this.disabled = true; + socket.emit('testimony-start-vote', {}, function (res) { + if (!(res && res.ok)) { + var pe = document.getElementById('esBtnProceed'); + if (pe) pe.disabled = false; + appendLobbySystemChat('— ไปต่อไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); + } + }); + }); return ov; } @@ -5597,39 +5641,40 @@ - ทุกคน READY แล้ว: host เห็น btn-start-vote.png (กดได้); คนอื่น disabled */ function esUpdateRevealActionButton() { var btn = document.getElementById('esBtnReady'); - if (!btn) return; + var proceed = document.getElementById('esBtnProceed'); var isHost = !!(tmState.hostId && tmState.hostId === socket.id); var total = (tmState.members || []).length; var readyCount = (tmState.readyIds || []).length; var allReady = total > 0 && readyCount >= total; var meReadied = !!tmState.iReadied || ((tmState.readyIds || []).indexOf(socket.id) >= 0); - btn.classList.remove('is-start-vote', 'is-active'); - btn.disabled = false; + /* ปุ่ม READY (กลาง) — แค่ยืนยันของตัวเอง */ + if (btn) { + btn.classList.remove('is-start-vote', 'is-active'); + btn.setAttribute('data-state', 'ready'); + if (meReadied) { + btn.classList.add('is-active'); + btn.disabled = true; + btn.setAttribute('aria-label', 'READY แล้ว'); + } else { + btn.disabled = false; + btn.setAttribute('aria-label', 'READY'); + } + } - if (allReady && isHost) { - /* host เห็นปุ่มเริ่มพิจารณาคดี */ - btn.setAttribute('data-state', 'start-vote'); - btn.setAttribute('aria-label', 'เริ่มพิจารณาคดี'); - btn.classList.add('is-start-vote'); - btn.disabled = false; - } else if (allReady && !isHost) { - /* non-host: รอ host */ - btn.setAttribute('data-state', 'ready'); - btn.setAttribute('aria-label', 'รอ host เริ่มพิจารณาคดี'); - btn.classList.add('is-active'); - btn.disabled = true; - } else if (meReadied) { - /* ยังรอคนอื่น */ - btn.setAttribute('data-state', 'ready'); - btn.setAttribute('aria-label', 'รอผู้เล่นอื่น'); - btn.classList.add('is-active'); - btn.disabled = true; - } else { - /* ยังไม่ได้กด */ - btn.setAttribute('data-state', 'ready'); - btn.setAttribute('aria-label', 'READY'); - btn.disabled = false; + /* ปุ่มไปต่อ (ขวา) — host เท่านั้น, เปิดเมื่อทุกคน READY; ป้ายเปลี่ยนตามปากคำสุดท้าย */ + if (proceed) { + var isLast = (Number(tmState.round) || 0) >= 2; + proceed.classList.toggle('is-last', isLast); + proceed.setAttribute('aria-label', isLast ? 'เริ่มพิจารณาคดี' : 'ผู้ต้องสงสัยถัดไป'); + if (isHost) { + proceed.hidden = false; + proceed.disabled = !allReady; + proceed.classList.toggle('is-enabled', allReady); + } else { + proceed.hidden = true; + proceed.disabled = true; + } } } diff --git a/www/html/Game/public/play.html b/www/html/Game/public/play.html index 795d673..df98d78 100644 --- a/www/html/Game/public/play.html +++ b/www/html/Game/public/play.html @@ -4906,7 +4906,7 @@

ประลองความรู้ คำศัพท์ในกระบวนการยุติธรรม — How to play

- HOW TO PLAY — ประลองความรู้ คำศัพท์ในกระบวนการยุติธรรม + HOW TO PLAY — ประลองความรู้ คำศัพท์ในกระบวนการยุติธรรม