update 7 minigame 1.1

This commit is contained in:
2026-06-12 16:49:01 +00:00
parent 0c2f5119e1
commit d1dba92310
22 changed files with 1038 additions and 126 deletions
+4 -2
View File
@@ -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;
+75
View File
@@ -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 =
'<td>' + medal + '</td>' +
'<td>' + escapeHtml(r.name || 'ผู้เล่น') + (r.blocked ? ' <span class="msg error">บล็อก</span>' : '') + '</td>' +
'<td class="td-coins">' +
'<input type="number" class="input-score" min="0" step="1" value="' + score + '" data-id="' + escapeAttr(r.id) + '" aria-label="คะแนน">' +
'<button type="button" class="btn btn-primary btn-score-save" data-id="' + escapeAttr(r.id) + '">บันทึก</button>' +
'</td>' +
'<td>' + coins + '</td>' +
'<td><button type="button" class="btn btn-ghost btn-score-reset" data-id="' + escapeAttr(r.id) + '">รีเซ็ต 0</button></td>';
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);
+4
View File
@@ -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']);
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
/**
* ภายในเซิร์ฟเวอร์เท่านั้น — มอบเหรียญ + คะแนนสะสม (high score) ให้ผู้เล่นหลังจบมินิเกม
* เรียกจาก Game server (Node) เท่านั้น โดยตรวจ secret ร่วม (Admin/private/game-award-secret.txt)
*
* POST JSON: {
* "secret": "<shared secret>",
* "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]);
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/**
* แอดมินเท่านั้น — จัดการกระดานผู้นำ (High Score)
* GET → รายชื่อบัญชีเรียงตามคะแนนสะสม (มาก→น้อย)
* POST { action: 'resetAll' } → ล้างคะแนนทุกคนเป็น 0
* POST { action: 'reset', id } → ล้างคะแนนคนเดียวเป็น 0
* (การตั้งคะแนนรายคน ใช้ accounts.php PATCH { id, score })
*/
require __DIR__ . '/_common.php';
require_login();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
$store = read_store();
$rows = [];
foreach (($store['accounts'] ?? []) as $a) {
$score = max(0, (int) ($a['score'] ?? 0));
$name = trim((string) ($a['lbName'] ?? ''));
if ($name === '') $name = trim((string) ($a['displayName'] ?? ''));
if ($name === '') $name = 'ผู้เล่น';
$rows[] = [
'id' => (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);
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/**
* สาธารณะ (อ่านอย่างเดียว) — กระดานผู้นำ High Score
* GET ?limit=50&playerKey=<optional>
* คืน { 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]);
+27 -1
View File
@@ -118,6 +118,7 @@
<div class="admin-tab-sep" aria-hidden="true"><span>ระบบ &amp; บัญชี</span></div>
<button type="button" class="tab" data-tab="characters" role="tab" id="tab-characters" aria-controls="tab-panel-characters"><span class="tab-label">ตัวละคร</span><span class="tab-desc">อัปโหลด · เลือกใช้ในเกม</span></button>
<button type="button" class="tab" data-tab="accounts" role="tab" id="tab-accounts" aria-controls="tab-panel-accounts"><span class="tab-label">ผู้ใช้</span><span class="tab-desc">บัญชี &amp; COINS</span></button>
<button type="button" class="tab" data-tab="highscore" role="tab" id="tab-highscore" aria-controls="tab-panel-highscore"><span class="tab-label">กระดานผู้นำ</span><span class="tab-desc">High Score · คะแนนสะสม</span></button>
<button type="button" class="tab" data-tab="admins" role="tab" id="tab-admins" aria-controls="tab-panel-admins"><span class="tab-label">แอดมิน</span><span class="tab-desc">สิทธิ์ระบบ</span></button>
<button type="button" class="tab" data-tab="test-mode" role="tab" id="tab-test-mode" aria-controls="tab-panel-test-mode"><span class="tab-label">Test Mode</span><span class="tab-desc">เปิด hotkeys ทดสอบเกม</span></button>
</nav>
@@ -775,6 +776,7 @@
<legend>เวลา &amp; รูป</legend>
<div class="form-grid form-inline quiz-timing-grid">
<label title="0 = ใช้ 120 วิเมื่อแมปไม่กำหนด · ตั้งอย่างน้อย 10 ถ้าต้องการกำหนดเอง">เวลารอบ (วินาที) <input type="number" id="mega-virus-mission-sec" min="0" max="7200" step="5" value="0"></label>
<label title="จำนวนลูกโป่ง (ชีวิต) ต่อผู้เล่น · 0 = ใช้ค่าจากแมป หรือ default 3 · แมปกำหนดทับค่านี้">ลูกโป่งต่อคน <input type="number" id="mega-virus-balloons-per-player" min="0" max="12" step="1" value="0"></label>
</div>
<div class="form-grid form-inline" style="margin-top:0.75rem;align-items:flex-end;gap:0.75rem;flex-wrap:wrap">
<label class="space-shooter-ship-url-label" style="flex:1;min-width:14rem">รูปบอส (URL) <input type="text" id="mega-virus-boss-url" maxlength="500" spellcheck="false" placeholder="/Game/img/MegaVirus/boss.png" autocomplete="off"></label>
@@ -1019,6 +1021,30 @@
<p id="accounts-msg" class="msg" role="status"></p>
</section>
<section id="tab-panel-highscore" class="tab-panel card" hidden role="tabpanel" aria-labelledby="tab-highscore">
<h2>กระดานผู้นำ — High Score</h2>
<p class="muted">อันดับจาก <strong>คะแนนสะสม</strong> ที่ผู้เล่นได้จากการติดอันดับในมินิเกม (สะสมถาวร ไม่ลดเมื่อใช้เหรียญ) · แก้ไข/รีเซ็ตได้</p>
<div class="form-inline" style="margin-bottom:10px; display:flex; gap:8px; flex-wrap:wrap;">
<button type="button" class="btn btn-ghost" id="btn-highscore-refresh">รีเฟรช</button>
<button type="button" class="btn btn-danger" id="btn-highscore-reset-all">รีเซ็ตคะแนนทั้งหมด</button>
</div>
<div class="table-wrap">
<table class="data-table" id="table-highscore">
<thead>
<tr>
<th>อันดับ</th>
<th>ชื่อผู้เล่น</th>
<th>คะแนนสะสม</th>
<th>COINS</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<p id="highscore-msg" class="msg" role="status"></p>
</section>
<section id="tab-panel-admins" class="tab-panel card" hidden role="tabpanel" aria-labelledby="tab-admins">
<h2>บัญชีแอดมิน</h2>
<p class="muted">เฉพาะ <strong>super admin</strong> เท่านั้นที่เพิ่ม/ลบแอดมินได้</p>
@@ -1091,6 +1117,6 @@
</div>
</main>
</div>
<script src="admin.js?v=81"></script>
<script src="admin.js?v=82"></script>
</body>
</html>
@@ -0,0 +1 @@
cfac0faaa01bdd06ac50fd763625e96816f032ff047d3bb2cf9697e917fe71fd
+11 -4
View File
@@ -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",
+6 -1
View File
@@ -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": "",
@@ -15,10 +15,6 @@
"label": "obstacles-5",
"updatedAt": 1777530889730
},
"gauntlet-2a75a958cd5e5e8a.png": {
"label": "obstacles-1",
"updatedAt": 1777530896068
},
"gauntlet-dd51baed17270995.png": {
"label": "buttonlazer",
"updatedAt": 1777569238420
@@ -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);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+266 -57
View File
@@ -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 + ' เหรียญ<br><span style="font-size:.72em;font-weight:500;opacity:.92;">คะแนนสะสม +' + coins + '</span>';
} 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;
+86 -41
View File
@@ -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 @@
'<img class="ready-active" src="' + SC + 'btn-ready-active.png" alt="">' +
'<img class="ready-start-vote" src="' + SC + 'btn-start-vote.png" alt="">' +
'</button>' +
/* ปุ่ม host แยกต่างหาก (ขวา) — ไปปากคำถัดไป / เริ่มพิจารณาคดี */
'<button type="button" class="layer es-proceed btn" id="esBtnProceed" hidden disabled aria-label="ไปต่อ">' +
'<img class="es-proceed-next" src="' + SC + 'btn-next-suspect.png" alt="ผู้ต้องสงสัยถัดไป">' +
'<img class="es-proceed-trial" src="' + SC + 'btn-start-vote.png" alt="เริ่มพิจารณาคดี">' +
'</button>' +
'</div>' +
'<button type="button" class="es3-mic btn" id="es3-mic" aria-label="ไมค์">🎤</button>' +
'</div>' +
@@ -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;
}
}
}
+2 -2
View File
@@ -4906,7 +4906,7 @@
<div class="quiz-carry-pregame-card">
<h2 id="quiz-carry-pregame-sr-title" class="quiz-carry-pregame-sr-only">ประลองความรู้ คำศัพท์ในกระบวนการยุติธรรม — How to play</h2>
<div class="quiz-carry-pregame-art">
<img id="quiz-carry-pregame-howto-img" class="quiz-carry-pregame-howto-img" src="/Game/img/quiz-carry/howto.png" width="900" height="520" alt="HOW TO PLAY — ประลองความรู้ คำศัพท์ในกระบวนการยุติธรรม" loading="lazy" decoding="async" />
<img id="quiz-carry-pregame-howto-img" class="quiz-carry-pregame-howto-img" src="/Game/img/quiz-carry/popup-Howto.png" width="1454" height="854" alt="HOW TO PLAY — ประลองความรู้ คำศัพท์ในกระบวนการยุติธรรม" loading="lazy" decoding="async" />
</div>
<div class="quiz-carry-pregame-footer">
<p id="quiz-carry-pregame-status" class="quiz-carry-pregame-status" aria-live="polite"></p>
@@ -5152,7 +5152,7 @@
<script src="/app-base.js?v=2"></script>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0306"></script>
<script src="js/play.js?v=0.00610200038"></script>
<script src="js/play.js?v=0.00610200052"></script>
<div class="version-tag">v —</div>
</body>
</html>
+2 -2
View File
@@ -11,7 +11,7 @@
<link rel="stylesheet" href="css/profile-popup.css?v=6">
<link rel="stylesheet" href="css/customize-popup.css?v=42">
<link rel="stylesheet" href="css/leaderboard-popup.css?v=2">
<link rel="stylesheet" href="css/testimony-overlay.css?v=15">
<link rel="stylesheet" href="css/testimony-overlay.css?v=17">
<link rel="stylesheet" href="css/evidence-view-overlay.css?v=13">
<style>
html, body.room-lobby-page { margin: 0; padding: 0; overflow: hidden; width: 100%; height: 100%; min-height: 100%; }
@@ -1613,7 +1613,7 @@
<script src="js/display-name.js?v=2"></script>
<script src="js/version.js?v=0.0122"></script>
<script src="js/customize-popup.js?v=31" data-customize-triggers="" data-customize-asset-base="img/03-5-Customize"></script>
<script src="js/room-lobby.js?v=0.0271"></script>
<script src="js/room-lobby.js?v=0.0275"></script>
<div class="version-tag">v —</div>
</body>
</html>
+161 -11
View File
@@ -509,7 +509,7 @@ function getSpecialQuizSet(settings, level, caseId) {
/* ===== Special Quiz ในเกม — ไอคอนสุ่มในฉาก + เซสชันถาม-ตอบ multiplayer (ทุกคนต้องตอบถูก) ===== */
/** เฉพาะ 4 มินิเกมนี้ (ตอนสืบสวน) ที่ไอคอนพิเศษจะสุ่มโผล่ */
const SPECIAL_QUIZ_ELIGIBLE_MAP_IDS = ['mng8a80o', 'mnorwqx1', 'mnptfts2', 'mnpz6rkp'];
const SPECIAL_QUIZ_ELIGIBLE_MAP_IDS = ['mng8a80o', 'mnorwqx1', 'mnptfts2', 'mnpz6rkp', 'mno9kb07'];
const SPECIAL_QUIZ_ICON_TYPES = ['lawyer', 'police'];
/** โอกาสโผล่ = 1 ใน 3 ของการเล่นแต่ละรอบ */
const SPECIAL_QUIZ_SPAWN_CHANCE = 1 / 3;
@@ -861,13 +861,17 @@ function specialCardClientPayload(card) {
};
}
/** เพดานเวลาที่ Time Dilation สะสมได้ต่อรอบ (กันการ์ดซ้อนยืดเวลาเกินเหตุ) */
const TIME_DILATION_MAX_PENDING_SEC = 30;
/** ใช้การ์ดที่ทำงาน "ทันที" (minigame / now) — ส่วนที่เหลือเข้าคิวรอ flow ที่ถูก */
function applySpecialCardImmediate(sid, space, card) {
const def = specialCardDef(card && card.cardId);
if (!def) return;
if (def.when === 'minigame' && def.key === 'time_dilation') {
/* +10 วิให้รอบ quiz (mng8a80o) ตอน resume — มินิเกมอื่นฝั่ง client ขยายเอง */
space.timeDilationPendingSec = (space.timeDilationPendingSec || 0) + (def.addSec || 10);
/* +10 quiz (mng8a80o) resume client
cap นการดซอนหลายใบจนเวลายดเกนเหต */
space.timeDilationPendingSec = Math.min(TIME_DILATION_MAX_PENDING_SEC, (space.timeDilationPendingSec || 0) + (def.addSec || 10));
io.to(sid).emit('special-card-applied', {
card: specialCardClientPayload(card),
addSec: def.addSec || 10,
@@ -876,10 +880,17 @@ function applySpecialCardImmediate(sid, space, card) {
return;
}
if (def.when === 'now' && def.key === 'fund') {
/* +COINS — แต่ละ client บวกเหรียญของตัวเองผ่าน PHP (server ไม่รู้ playerKey) */
/* +COINS server game-award.php ( secret + playerKey join) client
noScore: ไมใหเหรยญ Fund ไปปนคะแนน high-score (เปนโชค ไมใช) */
const coins = def.coins || 10;
const awards = [...space.peers.values()]
.filter((p) => p.playerKey)
.map((p) => ({ playerKey: p.playerKey, nickname: (p.nickname || '').trim() || 'ผู้เล่น', coins: coins, noScore: true }));
submitGameAwards(awards);
io.to(sid).emit('special-card-applied', {
card: specialCardClientPayload(card),
coins: def.coins || 10,
coins: coins,
coinsServerAwarded: true,
});
consumePendingSpecialCard(space, card.cardId);
return;
@@ -1548,6 +1559,82 @@ function computeRunGradeForEvidence(space) {
} catch (e) { return 'C'; }
}
/* ===== แจกเหรียญตามอันดับ + คะแนนสะสม (high score) แบบ server-authoritative ===== */
const GAME_AWARD_URL = process.env.GAME_AWARD_URL || 'https://srv1361159.hstgr.cloud/Admin/api/game-award.php';
const GAME_AWARD_SECRET_PATH = path.join(__dirname, '..', 'Admin', 'private', 'game-award-secret.txt');
let __gameAwardSecret = null;
function gameAwardSecret() {
if (__gameAwardSecret != null) return __gameAwardSecret;
try { __gameAwardSecret = String(fs.readFileSync(GAME_AWARD_SECRET_PATH, 'utf8')).trim(); }
catch (e) { __gameAwardSecret = ''; }
return __gameAwardSecret;
}
/* เหรียญตามอันดับผู้รอดชีวิต: อันดับ 1→10, 2→8, 3→6, 4→4, 5→2, 6+→0 */
const MINIGAME_RANK_COINS = [10, 8, 6, 4, 2, 0];
/* คะแนน + สถานะรอด/ตาย ของผู้เล่นต่อ gameType ตอนจบมินิเกม */
function minigamePlayerScoreSurvived(space, p, gameType) {
switch (gameType) {
case 'quiz': {
const st = space.quizSession && space.quizSession.players ? space.quizSession.players[p.id] : null;
return { score: st ? Math.max(0, Number(st.score) | 0) : 0, survived: st ? !st.eliminated : true };
}
case 'gauntlet':
case 'jump_survive':
return { score: Math.max(0, p.gauntletScore | 0), survived: !p.gauntletEliminated };
case 'space_shooter':
return { score: Math.max(0, p.spaceShooterScore | 0), survived: true };
case 'balloon_boss':
return { score: Math.max(0, p.balloonBossScore | 0), survived: !p.balloonBossEliminated };
default: /* stack, quiz_carry — client รายงานคะแนน/รอดเอง ตอนจบ */
return { score: Math.max(0, p.reportedMiniScore | 0), survived: p.reportedMiniSurvived !== false };
}
}
/* ยิงไป PHP (มี secret) เพื่อบวกเหรียญ+คะแนนเข้าบัญชีผู้เล่นจริง (client ปลอมไม่ได้) */
function submitGameAwards(awards) {
const secret = gameAwardSecret();
if (!secret || !Array.isArray(awards) || !awards.length) return;
try {
fetch(GAME_AWARD_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret, awards }),
}).then((r) => r.json()).then((d) => {
if (!d || !d.ok) console.error('[game-award] failed', d);
}).catch((e) => console.error('[game-award] error', e && e.message));
} catch (e) { console.error('[game-award] throw', e && e.message); }
}
/* แจกเหรียญตามอันดับคะแนน (เฉพาะผู้รอดชีวิต ผู้ตาย = 0) + คะแนนสะสมเข้า leaderboard — ครั้งเดียวต่อรอบ */
function awardMinigameRankCoins(sid, space) {
if (!space || space.coinAwardDoneForRun) return;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
const gameType = md && md.gameType;
if (!gameType || gameType === 'zep' || gameType === 'quiz_battle' || serverMapIsPostCaseLobbyB(space)) return;
const peers = [...space.peers.values()];
if (!peers.length) return;
space.coinAwardDoneForRun = true;
const rows = peers.map((p) => {
const ss = minigamePlayerScoreSurvived(space, p, gameType);
return { id: p.id, playerKey: p.playerKey || '', nickname: (p.nickname || '').trim() || 'ผู้เล่น', score: ss.score, survived: !!ss.survived, coins: 0 };
});
/* ผู้รอดชีวิตเรียงคะแนนมาก→น้อย แล้วให้เหรียญตามอันดับ */
const survivors = rows.filter((r) => r.survived).sort((a, b) => b.score - a.score);
survivors.forEach((r, i) => { r.coins = i < MINIGAME_RANK_COINS.length ? MINIGAME_RANK_COINS[i] : 0; });
const caseId = String(space.caseId || space.detectiveLobbyCaseId || '');
const awards = rows.filter((r) => r.coins > 0 && r.playerKey).map((r) => ({ playerKey: r.playerKey, nickname: r.nickname, coins: r.coins, caseId: caseId }));
submitGameAwards(awards);
io.to(sid).emit('minigame-coins', {
gameType,
coinsTable: MINIGAME_RANK_COINS,
results: rows.map((r) => ({ id: r.id, nickname: r.nickname, score: r.score, survived: r.survived, coins: r.coins })),
});
}
/* card object ที่เก็บในแฟ้มหลักฐาน */
function sanitizeStoredEvidenceCard(c) {
if (!c || typeof c !== 'object') return null;
@@ -1716,6 +1803,8 @@ function defaultGameTiming() {
balloonBossPlayerBalloonImageUrls: ['', '', '', '', '', ''],
/** กรอบฟองรอบผู้เล่น / ลูกโป่ง fallback เริ่มต้น — Artboard 9 (วง cyan +หาง) */
balloonBossPlayerBalloonFallbackUrl: '/Game/img/MegaVirus/Artboard 9.png',
/** จำนวนลูกโป่งต่อคน (0 = ใช้ค่าจากแมป หรือ default 3) */
balloonBossBalloonsPerPlayer: 0,
};
}
@@ -2097,6 +2186,9 @@ function loadGameTiming() {
balloonBossPlayerBalloonFallbackUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossPlayerBalloonFallbackUrl')
? sanitizeGauntletAssetUrl(j.balloonBossPlayerBalloonFallbackUrl)
: '',
balloonBossBalloonsPerPlayer: Object.prototype.hasOwnProperty.call(j, 'balloonBossBalloonsPerPlayer')
? Math.max(0, Math.min(12, Math.floor(Number(j.balloonBossBalloonsPerPlayer)) || 0))
: 0,
};
}
} catch (e) { console.error('loadGameTiming', e.message); }
@@ -2105,6 +2197,13 @@ function loadGameTiming() {
let runtimeGameTiming = loadGameTiming();
/** จำนวนลูกโป่งต่อคน Mega Virus — แมปกำหนดก่อน, ไม่งั้นใช้ค่ากลางจาก admin, สุดท้าย default 3 (clamp 1-12) */
function balloonBossBalloonsForMap(md) {
const fromMap = Math.floor(Number(md && md.balloonBossBalloonsPerPlayer)) || 0;
const fromCfg = Math.floor(Number(runtimeGameTiming && runtimeGameTiming.balloonBossBalloonsPerPlayer)) || 0;
return Math.max(1, Math.min(12, fromMap || fromCfg || 3));
}
function getGauntletTickMs() {
return runtimeGameTiming.gauntletTickMs;
}
@@ -2225,6 +2324,13 @@ function saveGameTimingToFile(d) {
const balloonBossPlayerBalloonFallbackUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonFallbackUrl')
? sanitizeGauntletAssetUrl(d.balloonBossPlayerBalloonFallbackUrl)
: prevBbFb;
/* จำนวนลูกโป่งต่อคน (0 = ใช้ค่าจากแมป หรือ default 3) */
const prevBbCount = Object.prototype.hasOwnProperty.call(prev, 'balloonBossBalloonsPerPlayer')
? Math.max(0, Math.min(12, Math.floor(Number(prev.balloonBossBalloonsPerPlayer)) || 0))
: 0;
const balloonBossBalloonsPerPlayer = Object.prototype.hasOwnProperty.call(d, 'balloonBossBalloonsPerPlayer')
? Math.max(0, Math.min(12, Math.floor(Number(d.balloonBossBalloonsPerPlayer)) || 0))
: prevBbCount;
const prevStackTowerSec = Object.prototype.hasOwnProperty.call(prev, 'stackTowerMissionTimeSec')
? prev.stackTowerMissionTimeSec
: 90;
@@ -2287,6 +2393,7 @@ function saveGameTimingToFile(d) {
balloonBossBossImageUrl,
balloonBossPlayerBalloonImageUrls,
balloonBossPlayerBalloonFallbackUrl,
balloonBossBalloonsPerPlayer,
};
fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8');
runtimeGameTiming = out;
@@ -3018,6 +3125,9 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
space.suspectPhaseActive = false;
space.detectiveMinigameActive = true;
space.detectiveAwardDoneForRun = false;
space.coinAwardDoneForRun = false;
/* รีเซ็ตคะแนนที่ client รายงาน (stack/quiz_carry) ของรอบก่อน */
space.peers.forEach((p) => { p.reportedMiniScore = 0; p.reportedMiniSurvived = true; });
/* Card 3 Ban — ผู้ที่ถูกแบนไม่ได้เล่นมินิเกมรอบนี้ (1 รอบ) */
space.bannedThisRunPlayerId = space.bannedPlayerId || null;
space.bannedPlayerId = null;
@@ -3121,6 +3231,8 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) {
ensurePostCaseLobbyMapLoaded();
/* แจกเหรียญตามอันดับ + คะแนนสะสม ก่อนล้างสถานะมินิเกม (คะแนน/ผู้รอดยังครบ) */
awardMinigameRankCoins(sid, space);
clearSpaceQuizTimers(space);
clearSpecialQuizTimers(space);
space.specialQuiz = null;
@@ -5862,6 +5974,7 @@ function newGauntletRunState(space) {
spawnAcc: 0,
nextSpawnIn: 3,
crownRunHeld: crown,
finishPhase: false,
};
}
@@ -6049,12 +6162,26 @@ function runGauntletTick(sid, space) {
const h = md.height || 15;
const gr = space.gauntletRun;
if (!gr) return;
/* หยุด run ระหว่างตอบคำถามพิเศษ (special-quiz) — เลื่อน endsAt ไปด้วยให้เวลาไม่เดิน, ไม่ไหล obstacle/ชน */
if (space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)) {
if (gr.endsAt != null) gr.endsAt += getGauntletTickMs();
emitGauntletSync(sid, space);
return;
}
ensureGauntletEndsAtIfNeeded(space);
if (gr.endsAt != null && Date.now() >= gr.endsAt) {
endGauntletGame(sid, space, 'time');
return;
}
/* ถึงเส้นชัยแล้ว (client แจ้ง) — หยุดสร้าง/ชน obstacle ให้ทุกคนเดินเข้าเส้นชัยได้ ไม่มี -10 อีก */
if (gr.finishPhase) {
if (gr.obstacles.length) { gr.obstacles = []; }
space.peers.forEach((p) => { if ((p.gauntletJumpTicks || 0) > 0) p.gauntletJumpTicks--; });
emitGauntletSync(sid, space);
return;
}
if (gr.crownRunHeld) {
space.peers.forEach((p) => {
p.gauntletJumpPending = false;
@@ -6800,7 +6927,7 @@ io.on('connection', (socket) => {
if (qbAllAnswered(room)) qbReveal(room);
});
socket.on('join-space', ({ spaceId, nickname, characterId, playMapId, desiredLobbyColorThemeIndex, desiredLobbySkinToneIndex }, cb) => {
socket.on('join-space', ({ spaceId, nickname, characterId, playMapId, playerKey, desiredLobbyColorThemeIndex, desiredLobbySkinToneIndex }, cb) => {
const space = spaces.get(spaceId);
if (!space || !space.mapData) return cb && cb({ ok: false, error: 'ไม่พบห้อง' });
const maxPlayers = space.maxPlayers || 10;
@@ -6834,7 +6961,7 @@ io.on('connection', (socket) => {
const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData;
const spawnJoinOrder = space.peers.size;
const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder);
const bbStartBalloons = Math.max(1, Math.min(12, Math.floor(Number(mdJoin.balloonBossBalloonsPerPlayer)) || 3));
const bbStartBalloons = balloonBossBalloonsForMap(mdJoin);
/* ใช้สีที่ client เลือกไว้ใน Main-Lobby — คนจริงสำคัญกว่าบอท */
let chosenThemeIdx = parseInt(desiredLobbyColorThemeIndex, 10);
if (chosenThemeIdx >= 1 && chosenThemeIdx <= LOBBY_THEME_COUNT) {
@@ -6867,6 +6994,8 @@ io.on('connection', (socket) => {
lobbySkinToneIndex: chosenSkinIdx,
gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, gauntletEliminated: false, spaceShooterScore: 0,
balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossBalloons: mdJoin.gameType === 'balloon_boss' ? bbStartBalloons : 5, balloonBossEliminated: false,
playerKey: (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : '',
reportedMiniScore: 0, reportedMiniSurvived: true,
};
if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) {
const sn = quizBattleSpawnWorldFromJoinOrderServer(mdJoin, spawnJoinOrder);
@@ -7209,13 +7338,19 @@ io.on('connection', (socket) => {
reply(started);
});
socket.on('detective-minigame-finished', (_data, cb) => {
socket.on('detective-minigame-finished', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) {
return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
}
/* client รายงานคะแนน/รอด ของตัวเอง (ใช้กับเกมที่ server ไม่ได้นับคะแนน เช่น stack/quiz_carry) */
const finPeer = space.peers.get(socket.id);
if (finPeer && data && typeof data === 'object') {
if (Number.isFinite(Number(data.score))) finPeer.reportedMiniScore = Math.max(0, Math.floor(Number(data.score)));
if (data.survived === false) finPeer.reportedMiniSurvived = false;
}
if (!space.detectiveMinigameActive) {
if (serverMapIsPostCaseLobbyB(space)) {
const grant = grantDetectiveEvidenceForCurrentRun(sid, space, [socket.id]);
@@ -7522,7 +7657,8 @@ io.on('connection', (socket) => {
if (detectiveB && lobbyLevel && caseId && !isLobbyADetectiveStart) {
return reply({ ok: false, error: 'ย้ายไป LobbyB ไม่ได้ — ต้องอยู่ฉาก LobbyA (หรือล็อบบี้ชื่อ LobbyA) และเซิร์ฟต้องมีไฟล์แผนที่ LobbyB' });
}
if (!lobbyHostStandingInStartArea(space, socket.id)) {
/* detective LobbyA→LobbyB: ไม่บังคับยืนโซนส้ม (หน้าจอเลือกคดีบังแผนที่ ขยับตัวไม่ได้) */
if (!isLobbyADetectiveStart && !lobbyHostStandingInStartArea(space, socket.id)) {
return reply({ ok: false, error: 'ยืนในพื้นที่เริ่มเกม (สีส้มในเอดิเตอร์) ก่อนกดเริ่ม' });
}
if (isLobbyADetectiveStart) {
@@ -7708,6 +7844,20 @@ io.on('connection', (socket) => {
}
});
/* client แจ้งว่าเส้นชัยมาถึงแล้ว (BG ถึง finish) → เข้าโหมดจบ: หยุด obstacle + collision */
socket.on('gauntlet-finish-phase', () => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'gauntlet') return;
if (space.gauntletRun) {
space.gauntletRun.finishPhase = true;
space.gauntletRun.obstacles = [];
emitGauntletSync(sid, space);
}
});
socket.on('gauntlet-jump', () => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
@@ -7922,7 +8072,7 @@ io.on('connection', (socket) => {
}
}
if (p && md && md.gameType === 'balloon_boss' && data) {
const bbDefaultBalloons = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 3));
const bbDefaultBalloons = balloonBossBalloonsForMap(md);
if (data.balloonBossScore != null) {
const ns = Math.floor(Number(data.balloonBossScore));
if (Number.isFinite(ns) && ns >= 0) {
@@ -7960,7 +8110,7 @@ io.on('connection', (socket) => {
const out = { id: socket.id, x: nx, y: ny, direction: p.direction, characterId: p.characterId };
if (md && md.gameType === 'space_shooter') out.spaceShooterScore = Math.max(0, p.spaceShooterScore | 0);
if (md && md.gameType === 'balloon_boss') {
const bbDef = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 3));
const bbDef = balloonBossBalloonsForMap(md);
out.balloonBossScore = Math.max(0, p.balloonBossScore | 0);
out.balloonBossBossDmg = Math.max(0, p.balloonBossBossDmg | 0);
out.balloonBossBalloons = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons)
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+1 -1
View File
@@ -230,6 +230,6 @@
<script src="daily-popup.js?v=26" data-daily-trigger="#btn-daily" data-daily-asset-base="IMAGE/Daily" data-daily-test-reset-seconds="0"></script>
<script src="../Game/js/customize-popup.js?v=31" data-customize-triggers="#btn-cloth" data-customize-asset-base="/Game/img/03-5-Customize"></script>
<script src="../Game/js/profile-popup.js?v=6" data-profile-trigger="#btn-myprofile" data-profile-asset-base="/Game/img/03-6-Profile"></script>
<script src="lobby.js?v=0.0190"></script>
<script src="lobby.js?v=0.0191"></script>
</body>
</html>
+46
View File
@@ -252,10 +252,56 @@
.catch(function () { /* offline */ });
}
function lbEscapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
/** วาดกระดานผู้นำจากข้อมูลจริง (top = [{rank,name,score,coins}]) */
function renderLeaderboardRows(top) {
var listEl = document.querySelector('.lobby-leaderboard-list');
if (!listEl || !Array.isArray(top) || !top.length) return; /* ว่าง → คงตัวอย่างเดิมไว้ */
var html = '';
for (var i = 0; i < top.length; i++) {
var r = top[i] || {};
var rank = parseInt(r.rank, 10) || (i + 1);
var name = lbEscapeHtml(r.name || 'ผู้เล่น');
var score = Math.max(0, parseInt(r.score, 10) || 0);
var coins = Math.max(0, parseInt(r.coins, 10) || 0);
var head = rank <= 3
? '<img src="IMAGE/leaderboard-' + rank + '.png" alt="" class="lobby-rank-icon" role="presentation" decoding="async">'
: '<span class="lobby-rank-num">' + rank + '</span>';
html += '<li>' + head +
'<div class="lobby-rank-info">' +
'<span class="lobby-rank-name">' + name + '</span>' +
'<span class="lobby-rank-case">(เหรียญ : ' + coins + ')</span>' +
'</div>' +
'<span class="lobby-rank-score">' + score + '</span>' +
'</li>';
}
listEl.innerHTML = html;
scheduleLeaderboardPlacement();
}
/** ดึงกระดานผู้นำจากเซิร์ฟเวอร์ (อันดับตามคะแนนสะสม high score) */
function fetchAndRenderLeaderboard() {
var base = (typeof appPath === 'function' ? appPath('/Admin/api/leaderboard.php') : '/Admin/api/leaderboard.php');
var url = base + '?limit=50&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;
renderLeaderboardRows(d.top);
})
.catch(function () { /* ออฟไลน์ → คงตัวอย่างเดิม */ });
}
/** รีเฟรชหลังเลือกตัวละคร / สลับแท็บ / ย้อนกลับบน tablet (bfcache) */
function syncMainLobbyCharacterUi() {
applyProfileTexts();
syncCoinsFromServer();
fetchAndRenderLeaderboard();
function applyCharAssets(urlOrNull) {
applyProfileAvatar(urlOrNull);
applyCenterCharacterResolved(urlOrNull);