update Endflow

This commit is contained in:
2026-06-17 08:15:22 +00:00
parent 2f15ac301c
commit e416f0f9b5
50 changed files with 1190 additions and 96 deletions
+214
View File
@@ -4688,6 +4688,7 @@
if (name === 'game-timing') loadGameTimingPanel();
if (name === 'stack-game') loadStackGamePanel();
if (name === 'highscore') loadHighscore();
if (name === 'achievements') loadAchievementsPanel();
if (name === 'test-mode') { loadForcedMinigamePanel(); loadSpecialCardByMapPanel(); loadTroublesomeForcePanel(); }
}
@@ -5187,6 +5188,219 @@
});
})();
/* ===== Achievements admin ===== */
var achvCatalog = [];
var achvCatalogById = {};
var achvCurrentPlayer = null;
function achvGroupLabel(g) {
var names = { 1: '1 · Analyst', 2: '2 · Explorer', 3: '3 · Risk', 4: '4 · Support', 5: '5 · Elite' };
return names[g] || String(g);
}
function renderAchvCatalog() {
var tb = el('table-achv-catalog');
if (!tb) return;
tb = tb.querySelector('tbody');
tb.innerHTML = '';
achvCatalog.slice().sort(function (a, b) { return (a.g - b.g) || 0; }).forEach(function (a) {
var tr = document.createElement('tr');
tr.setAttribute('data-achv-id', a.id);
var tdG = document.createElement('td');
var sel = document.createElement('select');
sel.className = 'achv-f-g';
for (var g = 1; g <= 5; g++) {
var op = document.createElement('option');
op.value = String(g); op.textContent = achvGroupLabel(g);
if (g === (parseInt(a.g, 10) || 1)) op.selected = true;
sel.appendChild(op);
}
tdG.appendChild(sel);
var tdT = document.createElement('td');
var inT = document.createElement('input');
inT.type = 'text'; inT.className = 'achv-f-title'; inT.value = a.title || ''; inT.style.width = '100%';
tdT.appendChild(inT);
var tdD = document.createElement('td');
var inD = document.createElement('input');
inD.type = 'text'; inD.className = 'achv-f-desc'; inD.value = a.desc || ''; inD.style.width = '100%';
tdD.appendChild(inD);
var tdTar = document.createElement('td');
var inTar = document.createElement('input');
inTar.type = 'number'; inTar.min = '1'; inTar.step = '1'; inTar.className = 'achv-f-target';
inTar.value = String(Math.max(1, parseInt(a.target, 10) || 1)); inTar.style.width = '72px';
tdTar.appendChild(inTar);
var tdE = document.createElement('td');
var inE = document.createElement('input');
inE.type = 'checkbox'; inE.className = 'achv-f-enabled'; inE.checked = a.enabled !== false;
tdE.appendChild(inE);
tr.appendChild(tdG); tr.appendChild(tdT); tr.appendChild(tdD); tr.appendChild(tdTar); tr.appendChild(tdE);
tb.appendChild(tr);
});
}
function collectAchvCatalogFromTable() {
var rows = document.querySelectorAll('#table-achv-catalog tbody tr');
var out = [];
rows.forEach(function (tr) {
out.push({
id: tr.getAttribute('data-achv-id'),
g: parseInt(tr.querySelector('.achv-f-g').value, 10) || 1,
title: tr.querySelector('.achv-f-title').value,
desc: tr.querySelector('.achv-f-desc').value,
target: Math.max(1, parseInt(tr.querySelector('.achv-f-target').value, 10) || 1),
enabled: tr.querySelector('.achv-f-enabled').checked,
});
});
return out;
}
function loadAchvCatalog() {
return api('achievements.php?action=catalog').then(function (r) {
achvCatalog = (r && r.catalog) || [];
achvCatalogById = {};
achvCatalog.forEach(function (a) { achvCatalogById[a.id] = a; });
renderAchvCatalog();
setMsg('achv-catalog-msg', 'โหลดแคตตาล็อก ' + achvCatalog.length + ' รายการ', '');
}).catch(function (err) { setMsg('achv-catalog-msg', err.message, 'error'); });
}
function renderAchvPlayers(rows) {
var tb = el('table-achv-players');
if (!tb) return;
tb = tb.querySelector('tbody');
tb.innerHTML = '';
(rows || []).forEach(function (p) {
var tr = document.createElement('tr');
var c1 = document.createElement('td'); c1.textContent = p.displayName || 'Guest';
var c2 = document.createElement('td'); c2.textContent = p.playerKey; c2.style.fontSize = '12px'; c2.style.opacity = '0.8';
var c3 = document.createElement('td'); c3.textContent = (p.unlocked || 0) + ' / ' + (p.total || 0);
var c4 = document.createElement('td'); c4.textContent = String(p.coins || 0);
var c5 = document.createElement('td');
var btn = document.createElement('button');
btn.type = 'button'; btn.className = 'btn btn-ghost'; btn.textContent = 'จัดการ';
btn.addEventListener('click', function () { openAchvPlayerDetail(p.playerKey, p.displayName); });
c5.appendChild(btn);
tr.appendChild(c1); tr.appendChild(c2); tr.appendChild(c3); tr.appendChild(c4); tr.appendChild(c5);
tb.appendChild(tr);
});
}
function loadAchvPlayers() {
var q = (el('achv-player-search') && el('achv-player-search').value || '').trim().toLowerCase();
return api('achievements.php?action=players').then(function (r) {
var rows = (r && r.players) || [];
if (q) {
rows = rows.filter(function (p) {
return (p.displayName || '').toLowerCase().indexOf(q) >= 0 || (p.playerKey || '').toLowerCase().indexOf(q) >= 0;
});
}
renderAchvPlayers(rows);
setMsg('achv-players-msg', 'ผู้เล่น ' + rows.length + ' คน', '');
}).catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); });
}
function openAchvPlayerDetail(key, name) {
achvCurrentPlayer = key;
var box = el('achv-player-detail');
if (box) box.hidden = false;
if (el('achv-player-detail-name')) el('achv-player-detail-name').textContent = (name || 'Guest') + ' (' + key + ')';
api('achievements.php?action=player&playerKey=' + encodeURIComponent(key)).then(function (r) {
var prog = (r && r.progress) || {};
var tb = el('table-achv-player-detail').querySelector('tbody');
tb.innerHTML = '';
achvCatalog.slice().sort(function (a, b) { return (a.g - b.g) || 0; }).forEach(function (a) {
var target = Math.max(1, parseInt(a.target, 10) || 1);
var cur = Math.max(0, parseInt(prog[a.id], 10) || 0);
var tr = document.createElement('tr');
var c1 = document.createElement('td');
c1.innerHTML = '<strong>' + achvGroupLabel(a.g).charAt(0) + '</strong> · ' + (a.title || a.id);
var c2 = document.createElement('td');
var inp = document.createElement('input');
inp.type = 'number'; inp.min = '0'; inp.max = String(target); inp.step = '1'; inp.value = String(Math.min(cur, target));
inp.style.width = '70px';
c2.appendChild(inp);
c2.appendChild(document.createTextNode(' / ' + target + (cur >= target ? ' ✔' : '')));
var c3 = document.createElement('td');
var setBtn = document.createElement('button');
setBtn.type = 'button'; setBtn.className = 'btn btn-ghost'; setBtn.textContent = 'ตั้งค่า';
setBtn.style.marginRight = '6px';
setBtn.addEventListener('click', function () {
api('achievements.php', { method: 'POST', body: { action: 'setProgress', playerKey: key, id: a.id, value: parseInt(inp.value, 10) || 0 } })
.then(function () { setMsg('achv-players-msg', 'บันทึก ' + a.id, 'ok'); openAchvPlayerDetail(key, name); })
.catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); });
});
var unBtn = document.createElement('button');
unBtn.type = 'button'; unBtn.className = 'btn btn-primary'; unBtn.textContent = 'ปลดล็อก';
unBtn.addEventListener('click', function () {
api('achievements.php', { method: 'POST', body: { action: 'unlock', playerKey: key, id: a.id } })
.then(function () { setMsg('achv-players-msg', 'ปลดล็อก ' + a.id, 'ok'); openAchvPlayerDetail(key, name); })
.catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); });
});
c3.appendChild(setBtn); c3.appendChild(unBtn);
tr.appendChild(c1); tr.appendChild(c2); tr.appendChild(c3);
tb.appendChild(tr);
});
}).catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); });
}
function showAchvSubtab(which) {
var isCat = which !== 'players';
if (el('achv-panel-catalog')) el('achv-panel-catalog').hidden = !isCat;
if (el('achv-panel-players')) el('achv-panel-players').hidden = isCat;
if (el('achv-subtab-catalog')) el('achv-subtab-catalog').classList.toggle('is-active', isCat);
if (el('achv-subtab-players')) el('achv-subtab-players').classList.toggle('is-active', !isCat);
if (!isCat) loadAchvPlayers();
}
var achvPanelInited = false;
function loadAchievementsPanel() {
if (!achvPanelInited) {
achvPanelInited = true;
var sc = el('achv-subtab-catalog'); if (sc) sc.addEventListener('click', function () { showAchvSubtab('catalog'); });
var sp = el('achv-subtab-players'); if (sp) sp.addEventListener('click', function () { showAchvSubtab('players'); });
var save = el('btn-achv-save');
if (save) save.addEventListener('click', function () {
api('achievements.php', { method: 'POST', body: { action: 'saveCatalog', catalog: collectAchvCatalogFromTable() } })
.then(function (r) {
achvCatalog = (r && r.catalog) || achvCatalog;
achvCatalogById = {}; achvCatalog.forEach(function (a) { achvCatalogById[a.id] = a; });
renderAchvCatalog();
setMsg('achv-catalog-msg', 'บันทึกแคตตาล็อกแล้ว', 'ok');
})
.catch(function (err) { setMsg('achv-catalog-msg', err.message, 'error'); });
});
var reload = el('btn-achv-reload');
if (reload) reload.addEventListener('click', function () { loadAchvCatalog(); });
var resetDef = el('btn-achv-reset-default');
if (resetDef) resetDef.addEventListener('click', function () {
if (!confirm('คืนค่าแคตตาล็อกกลับเป็นค่าเริ่มต้น 25 รายการ? (ค่าที่แก้ไว้จะหาย)')) return;
api('achievements.php', { method: 'POST', body: { action: 'resetCatalog' } })
.then(function () { setMsg('achv-catalog-msg', 'คืนค่าเริ่มต้นแล้ว', 'ok'); return loadAchvCatalog(); })
.catch(function (err) { setMsg('achv-catalog-msg', err.message, 'error'); });
});
var pr = el('btn-achv-players-refresh'); if (pr) pr.addEventListener('click', function () { loadAchvPlayers(); });
var ps = el('achv-player-search'); if (ps) ps.addEventListener('input', function () { loadAchvPlayers(); });
var rpb = el('btn-achv-player-reset');
if (rpb) rpb.addEventListener('click', function () {
if (!achvCurrentPlayer) return;
if (!confirm('รีเซ็ต achievement ทั้งหมดของผู้เล่นนี้?')) return;
api('achievements.php', { method: 'POST', body: { action: 'resetPlayer', playerKey: achvCurrentPlayer } })
.then(function () { setMsg('achv-players-msg', 'รีเซ็ตแล้ว', 'ok'); openAchvPlayerDetail(achvCurrentPlayer, el('achv-player-detail-name') ? el('achv-player-detail-name').textContent : ''); loadAchvPlayers(); })
.catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); });
});
}
return loadAchvCatalog();
}
el('form-account-add').addEventListener('submit', function (e) {
e.preventDefault();
var fd = new FormData(e.target);
+345
View File
@@ -0,0 +1,345 @@
<?php
declare(strict_types=1);
/**
* Achievements — แคตตาล็อก + progress รายผู้เล่น
*
* แคตตาล็อก override : Admin/private/achievements.json (ถ้าไม่มี ใช้ default ในไฟล์นี้)
* progress รายผู้เล่น : เก็บใน store.json -> accounts[].achievements { id: count }
*
* Public (ไม่ต้องล็อกอิน):
* GET ?action=state&playerKey=KEY -> { ok, catalog:[...], progress:{id:count} }
* GET ?action=catalog -> { ok, catalog:[...] }
*
* Server-only (มี secret game-award-secret.txt) — สำหรับ auto-track ภายหลัง:
* POST {action:'progress', secret, playerKey, id, inc?, set?}
*
* Admin (ต้องล็อกอิน session):
* POST {action:'saveCatalog', catalog:[...]}
* GET ?action=players -> รายชื่อผู้เล่น + จำนวนที่ปลดล็อก
* GET ?action=player&playerKey=KEY -> progress ของผู้เล่นคนเดียว
* POST {action:'setProgress', playerKey, id, value}
* POST {action:'unlock', playerKey, id} -> set = target
* POST {action:'resetPlayer', playerKey} -> ล้าง progress ทั้งหมด
*/
require __DIR__ . '/_common.php';
date_default_timezone_set('Asia/Bangkok');
define('ACHV_CATALOG_FILE', ADMIN_PRIVATE_DIR . '/achievements.json');
function achv_default_catalog(): array
{
return [
['id' => 'a1_first_deduction', 'g' => 1, 'title' => 'First Deduction', 'desc' => 'โหวตถูกตัวคนร้ายเป็นครั้งแรก', 'target' => 1, 'enabled' => true],
['id' => 'a2_sharp_eye', 'g' => 1, 'title' => 'Sharp Eye', 'desc' => 'สะสมหลักฐานระดับมีน้ำหนัก (Silver) ครบ 10 ใบ', 'target' => 10, 'enabled' => true],
['id' => 'a3_mind_architect', 'g' => 1, 'title' => 'Mind Architect', 'desc' => 'สะสมหลักฐานครบทุกระดับ (ทั่วไป, มีน้ำหนัก, ชี้ชัด) ใน 1 คดี', 'target' => 1, 'enabled' => true],
['id' => 'a4_logic_over_luck', 'g' => 1, 'title' => 'Logic Over Luck', 'desc' => 'โหวตถูกโดยไม่พึ่งหลักฐานชี้ชัด (Legendary) เลย 3 ครั้ง', 'target' => 3, 'enabled' => true],
['id' => 'a5_truth_hunter', 'g' => 1, 'title' => 'Truth Hunter', 'desc' => 'จับคนร้ายถูกตัวสะสมครบ 20 คดี', 'target' => 20, 'enabled' => true],
['id' => 'a6_unbreakable_logic', 'g' => 1, 'title' => 'Unbreakable Logic', 'desc' => 'โหวตถูกตัวติดกัน 5 คดีรวด', 'target' => 5, 'enabled' => true],
['id' => 'b1_evidence_collector', 'g' => 2, 'title' => 'Evidence Collector', 'desc' => 'สะสมหลักฐานระดับทั่วไป (Common) ครบ 20 ใบ', 'target' => 20, 'enabled' => true],
['id' => 'b2_relentless_investigator', 'g' => 2, 'title' => 'Relentless Investigator', 'desc' => 'เล่น Mini Game ครบทุกรอบ ใน 1 คดี', 'target' => 1, 'enabled' => true],
['id' => 'b3_hidden_fragment', 'g' => 2, 'title' => 'Hidden Fragment', 'desc' => 'ค้นพบหลักฐานระดับชี้ชัด (Legendary/Gold) เป็นครั้งแรก', 'target' => 1, 'enabled' => true],
['id' => 'b4_data_miner', 'g' => 2, 'title' => 'Data Miner', 'desc' => 'สะสมการ์ดหลักฐานรวมครบ 100 ใบ', 'target' => 100, 'enabled' => true],
['id' => 'b5_deep_scanner', 'g' => 2, 'title' => 'Deep Scanner', 'desc' => 'เก็บไอเทมช่วยเหลือ (ตำรวจ/ทนาย) สะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true],
['id' => 'c1_early_accusation', 'g' => 3, 'title' => 'Early Accusation', 'desc' => 'ชี้ตัวคนร้ายก่อนที่จะเปิดหลักฐานครบ', 'target' => 1, 'enabled' => true],
['id' => 'c2_high_stakes', 'g' => 3, 'title' => 'High Stakes', 'desc' => 'ชี้ตัวคนร้ายถูกโดยมีหลักฐานในมือไม่เกิน 3 ใบ', 'target' => 1, 'enabled' => true],
['id' => 'c3_quick_draw', 'g' => 3, 'title' => 'Quick Draw', 'desc' => 'ชี้ตัวคนร้ายเร็วที่สุดในทีมสะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true],
['id' => 'c4_clutch_mind', 'g' => 3, 'title' => 'Clutch Mind', 'desc' => 'โหวตถูกในช่วง 10 วินาทีสุดท้ายก่อนหมดเวลา', 'target' => 1, 'enabled' => true],
['id' => 'c5_lone_wolf', 'g' => 3, 'title' => 'Lone Wolf', 'desc' => 'โหวตสวนทางกับเสียงส่วนใหญ่ของทีม (คุณถูกคนเดียว)', 'target' => 1, 'enabled' => true],
['id' => 'd1_minigame_solver', 'g' => 4, 'title' => 'Minigame Solver', 'desc' => 'เอาชีวิตรอด / เล่นมินิเกมสำเร็จ 20 ครั้ง', 'target' => 20, 'enabled' => true],
['id' => 'd2_silent_guardian', 'g' => 4, 'title' => 'Silent Guardian', 'desc' => 'ไม่โดนโหวตใช้การ์ด Event พิเศษ เลยตลอด 5 คดี', 'target' => 5, 'enabled' => true],
['id' => 'd3_the_backbone', 'g' => 4, 'title' => 'The Backbone', 'desc' => 'ส่งมอบหลักฐานให้เพื่อนวิเคราะห์ครบ 30 ใบ', 'target' => 30, 'enabled' => true],
['id' => 'd4_flawless_diver', 'g' => 4, 'title' => 'Flawless Diver', 'desc' => 'ไม่โหวตจับผิดตัวเลยตลอด 15 คดี', 'target' => 15, 'enabled' => true],
['id' => 'e1_the_observer', 'g' => 5, 'title' => 'The Observer', 'desc' => 'เล่นจบ 15 คดีโดยไม่เคยสัมผัสหลักฐานชี้ชัด (Legendary) เลยสักครั้ง', 'target' => 15, 'enabled' => true],
['id' => 'e2_the_impostor', 'g' => 5, 'title' => 'The Impostor', 'desc' => 'รับบทเป็น "ตัวป่วน" ครั้งแรก', 'target' => 1, 'enabled' => true],
['id' => 'e3_master_of_doubt', 'g' => 5, 'title' => 'Master of Doubt', 'desc' => 'เอาชนะคดีในฐานะตัวป่วนได้สำเร็จ', 'target' => 1, 'enabled' => true],
['id' => 'e4_agent_of_chaos', 'g' => 5, 'title' => 'Agent of Chaos', 'desc' => 'รับบทเป็นตัวป่วนสะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true],
['id' => 'e5_slippery_eel', 'g' => 5, 'title' => 'Slippery Eel', 'desc' => 'เป็นตัวป่วนแต่รอดพ้นจากการถูกจับได้ (ไม่ถูกโหวตออก) จนจบเกม 5 ครั้ง', 'target' => 5, 'enabled' => true],
];
}
function achv_sanitize_catalog(array $arr): array
{
$out = [];
$seen = [];
foreach ($arr as $row) {
if (!is_array($row)) continue;
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($row['id'] ?? ''));
if ($id === '' || isset($seen[$id])) continue;
$seen[$id] = true;
$g = (int) ($row['g'] ?? 1);
if ($g < 1 || $g > 5) $g = 1;
$title = trim((string) ($row['title'] ?? ''));
$desc = trim((string) ($row['desc'] ?? ''));
if (function_exists('mb_substr')) {
$title = mb_substr($title, 0, 60);
$desc = mb_substr($desc, 0, 200);
}
$target = (int) ($row['target'] ?? 1);
if ($target < 1) $target = 1;
if ($target > 100000) $target = 100000;
$out[] = [
'id' => $id,
'g' => $g,
'title' => $title !== '' ? $title : $id,
'desc' => $desc,
'target' => $target,
'enabled' => !isset($row['enabled']) || !empty($row['enabled']),
];
}
return $out;
}
function achv_load_catalog(): array
{
if (is_file(ACHV_CATALOG_FILE)) {
$raw = @file_get_contents(ACHV_CATALOG_FILE);
$j = json_decode($raw ?: '[]', true);
if (is_array($j) && $j) {
$c = achv_sanitize_catalog($j);
if ($c) return $c;
}
}
return achv_default_catalog();
}
function achv_save_catalog(array $catalog): bool
{
if (!is_dir(ADMIN_PRIVATE_DIR)) {
if (!@mkdir(ADMIN_PRIVATE_DIR, 0750, true)) return false;
}
$tmp = ACHV_CATALOG_FILE . '.tmp.' . bin2hex(random_bytes(4));
$json = json_encode($catalog, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
if ($json === false) return false;
if (file_put_contents($tmp, $json, LOCK_EX) === false) return false;
if (!rename($tmp, ACHV_CATALOG_FILE)) { @unlink($tmp); return false; }
return true;
}
function achv_valid_key(string $key): bool
{
return (bool) preg_match('/^[a-zA-Z0-9_-]{8,128}$/', $key);
}
/* หา index บัญชี guest ตาม playerKey (สร้างใหม่ถ้าไม่มี) */
function achv_find_or_create(array &$store, string $key): int
{
foreach ($store['accounts'] as $i => $a) {
if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
return $i;
}
}
$store['accounts'][] = [
'id' => new_id(),
'email' => '',
'displayName' => 'Guest',
'loginType' => 'guest',
'providerUserId' => $key,
'notes' => 'auto: achievements',
'blocked' => false,
'coins' => 0,
'achievements' => new \stdClass(),
'createdAt' => gmdate('c'),
'updatedAt' => gmdate('c'),
];
return count($store['accounts']) - 1;
}
function achv_progress_of(array $account): array
{
$p = $account['achievements'] ?? [];
if (!is_array($p)) return [];
$out = [];
foreach ($p as $k => $v) {
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $k);
if ($id === '') continue;
$out[$id] = max(0, (int) $v);
}
return $out;
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$action = (string) ($_GET['action'] ?? '');
if ($method === 'POST') {
$body = require_json_body();
if (!$action) $action = (string) ($body['action'] ?? '');
} else {
$body = [];
}
/* ---------- Public ---------- */
if ($action === 'catalog' && $method === 'GET') {
json_response(['ok' => true, 'catalog' => achv_load_catalog()]);
}
if ($action === 'state' && $method === 'GET') {
$key = trim((string) ($_GET['playerKey'] ?? ''));
if (!achv_valid_key($key)) {
json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400);
}
$catalog = achv_load_catalog();
$store = read_store();
$progress = [];
foreach ($store['accounts'] as $a) {
if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
$progress = achv_progress_of($a);
break;
}
}
json_response(['ok' => true, 'catalog' => $catalog, 'progress' => (object) $progress]);
}
/* ---------- Server-only (secret) : auto-track ภายหลัง ---------- */
if ($action === 'progress' && $method === 'POST') {
$secretFile = ADMIN_PRIVATE_DIR . '/game-award-secret.txt';
$expected = is_file($secretFile) ? trim((string) @file_get_contents($secretFile)) : '';
$secret = (string) ($body['secret'] ?? '');
if ($expected === '' || strlen($secret) < 16 || !hash_equals($expected, $secret)) {
json_response(['ok' => false, 'error' => 'unauthorized'], 403);
}
$key = trim((string) ($body['playerKey'] ?? ''));
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
if (!achv_valid_key($key) || $id === '') {
json_response(['ok' => false, 'error' => 'bad params'], 400);
}
$catalog = achv_load_catalog();
$target = 0;
foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
$store = read_store();
$i = achv_find_or_create($store, $key);
$cur = achv_progress_of($store['accounts'][$i]);
if (isset($body['set'])) {
$val = max(0, min($target, (int) $body['set']));
} else {
$inc = (int) ($body['inc'] ?? 1);
$val = max(0, min($target, ($cur[$id] ?? 0) + $inc));
}
$cur[$id] = $val;
$store['accounts'][$i]['achievements'] = (object) $cur;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true, 'id' => $id, 'value' => $val, 'unlocked' => $val >= $target]);
}
/* ---------- Admin ---------- */
require_login();
if ($action === 'saveCatalog' && $method === 'POST') {
$catIn = (isset($body['catalog']) && is_array($body['catalog'])) ? $body['catalog'] : null;
if ($catIn === null) json_response(['ok' => false, 'error' => 'ไม่มี catalog'], 400);
$clean = achv_sanitize_catalog($catIn);
if (!$clean) json_response(['ok' => false, 'error' => 'catalog ว่าง'], 400);
if (!achv_save_catalog($clean)) {
json_response(['ok' => false, 'error' => 'บันทึก achievements.json ไม่สำเร็จ (chown ให้ user เว็บ)'], 500);
}
json_response(['ok' => true, 'catalog' => $clean]);
}
if ($action === 'resetCatalog' && $method === 'POST') {
$def = achv_default_catalog();
if (!achv_save_catalog($def)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'catalog' => $def]);
}
if ($action === 'players' && $method === 'GET') {
$catalog = achv_load_catalog();
$byId = [];
foreach ($catalog as $c) $byId[$c['id']] = (int) $c['target'];
$store = read_store();
$rows = [];
foreach ($store['accounts'] as $a) {
if (($a['loginType'] ?? '') !== 'guest') continue;
$key = (string) ($a['providerUserId'] ?? '');
if ($key === '') continue;
$prog = achv_progress_of($a);
$unlocked = 0;
foreach ($prog as $id => $v) {
if (isset($byId[$id]) && $v >= $byId[$id]) $unlocked++;
}
$rows[] = [
'playerKey' => $key,
'displayName' => (string) ($a['displayName'] ?? ($a['lbName'] ?? 'Guest')),
'coins' => max(0, (int) ($a['coins'] ?? 0)),
'blocked' => !empty($a['blocked']),
'unlocked' => $unlocked,
'total' => count($byId),
'updatedAt' => (string) ($a['updatedAt'] ?? ''),
];
}
json_response(['ok' => true, 'players' => $rows, 'total' => count($byId)]);
}
if ($action === 'player' && $method === 'GET') {
$key = trim((string) ($_GET['playerKey'] ?? ''));
if (!achv_valid_key($key)) json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400);
$store = read_store();
foreach ($store['accounts'] as $a) {
if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
json_response(['ok' => true, 'playerKey' => $key, 'displayName' => (string) ($a['displayName'] ?? 'Guest'), 'progress' => (object) achv_progress_of($a)]);
}
}
json_response(['ok' => true, 'playerKey' => $key, 'displayName' => 'Guest', 'progress' => (object) []]);
}
if ($action === 'setProgress' && $method === 'POST') {
$key = trim((string) ($body['playerKey'] ?? ''));
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
if (!achv_valid_key($key) || $id === '') json_response(['ok' => false, 'error' => 'bad params'], 400);
$catalog = achv_load_catalog();
$target = 0;
foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
$value = max(0, min($target, (int) ($body['value'] ?? 0)));
$store = read_store();
$i = achv_find_or_create($store, $key);
$cur = achv_progress_of($store['accounts'][$i]);
$cur[$id] = $value;
$store['accounts'][$i]['achievements'] = (object) $cur;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true, 'id' => $id, 'value' => $value, 'unlocked' => $value >= $target]);
}
if ($action === 'unlock' && $method === 'POST') {
$key = trim((string) ($body['playerKey'] ?? ''));
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
if (!achv_valid_key($key) || $id === '') json_response(['ok' => false, 'error' => 'bad params'], 400);
$catalog = achv_load_catalog();
$target = 0;
foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
$store = read_store();
$i = achv_find_or_create($store, $key);
$cur = achv_progress_of($store['accounts'][$i]);
$cur[$id] = $target;
$store['accounts'][$i]['achievements'] = (object) $cur;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true, 'id' => $id, 'value' => $target, 'unlocked' => true]);
}
if ($action === 'resetPlayer' && $method === 'POST') {
$key = trim((string) ($body['playerKey'] ?? ''));
if (!achv_valid_key($key)) json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400);
$store = read_store();
foreach ($store['accounts'] as $idx => $a) {
if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
$store['accounts'][$idx]['achievements'] = new \stdClass();
$store['accounts'][$idx]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true]);
}
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'unknown action'], 400);
+78 -1
View File
@@ -119,6 +119,7 @@
<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="achievements" role="tab" id="tab-achievements" aria-controls="tab-panel-achievements"><span class="tab-label">Achievements</span><span class="tab-desc">รางวัล · ความคืบหน้ารายผู้เล่น</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>
@@ -1057,6 +1058,79 @@
<p id="highscore-msg" class="msg" role="status"></p>
</section>
<section id="tab-panel-achievements" class="tab-panel card" hidden role="tabpanel" aria-labelledby="tab-achievements">
<h2>Achievements</h2>
<p class="muted">จัดการ <strong>แคตตาล็อก</strong> รางวัล (ชื่อ/คำอธิบาย/เป้าหมาย/เปิด-ปิด) และ <strong>ความคืบหน้ารายผู้เล่น</strong> · แสดงผลในหน้าโปรไฟล์ผู้เล่น 5 หมวด</p>
<div class="achv-subtabs" style="display:flex; gap:8px; margin:6px 0 14px; flex-wrap:wrap;">
<button type="button" class="btn btn-ghost is-active" id="achv-subtab-catalog" data-achv-subtab="catalog">แคตตาล็อก</button>
<button type="button" class="btn btn-ghost" id="achv-subtab-players" data-achv-subtab="players">รายผู้เล่น</button>
</div>
<div id="achv-panel-catalog">
<div class="form-inline" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px;">
<button type="button" class="btn btn-primary" id="btn-achv-save">บันทึกแคตตาล็อก</button>
<button type="button" class="btn btn-ghost" id="btn-achv-reload">โหลดใหม่</button>
<button type="button" class="btn btn-danger" id="btn-achv-reset-default">คืนค่าเริ่มต้น</button>
</div>
<div class="table-wrap">
<table class="data-table" id="table-achv-catalog">
<thead>
<tr>
<th style="width:60px;">หมวด</th>
<th style="width:200px;">ชื่อ (EN)</th>
<th>คำอธิบาย (TH)</th>
<th style="width:90px;">เป้าหมาย</th>
<th style="width:60px;">เปิด</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<p id="achv-catalog-msg" class="msg" role="status"></p>
</div>
<div id="achv-panel-players" hidden>
<div class="form-inline" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px;">
<input type="text" id="achv-player-search" placeholder="ค้นหาชื่อ / playerKey" style="min-width:240px;">
<button type="button" class="btn btn-ghost" id="btn-achv-players-refresh">รีเฟรช</button>
</div>
<div class="table-wrap">
<table class="data-table" id="table-achv-players">
<thead>
<tr>
<th>ชื่อผู้เล่น</th>
<th>playerKey</th>
<th style="width:90px;">ปลดล็อก</th>
<th style="width:80px;">COINS</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div id="achv-player-detail" hidden style="margin-top:16px; border-top:1px solid rgba(255,255,255,0.12); padding-top:14px;">
<h3 style="margin:0 0 4px;">ความคืบหน้า: <span id="achv-player-detail-name"></span></h3>
<div class="form-inline" style="display:flex; gap:8px; margin-bottom:10px;">
<button type="button" class="btn btn-danger" id="btn-achv-player-reset">รีเซ็ตของผู้เล่นนี้</button>
</div>
<div class="table-wrap">
<table class="data-table" id="table-achv-player-detail">
<thead>
<tr>
<th>รางวัล</th>
<th style="width:160px;">ความคืบหน้า</th>
<th style="width:200px;"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<p id="achv-players-msg" class="msg" role="status"></p>
</div>
</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>
@@ -1124,6 +1198,9 @@
<li><kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>1</kbd> / <kbd>2</kbd> / <kbd>3</kbd> — ที่หน้าห้อง LobbyB: พรีวิว "หน้าผลตัดสินคดี" โดยไม่ต้องเล่นจบ<br>
<span class="muted" style="font-size:13px;"><kbd>1</kbd> = โหวตถูก (เรือนจำ → โพเดียมผู้ชนะ) · <kbd>2</kbd> = โหวตผิด (ตัวป่วนชนะ) · <kbd>3</kbd> = สลับ มี/ไม่มีตัวป่วน (ดูสถานะในแชต) · ตัวป่วนจำลองใช้ผู้เล่น/บอทคนแรก</span>
</li>
<li><kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>W</kbd> — ที่หน้าห้อง LobbyB ก่อนโหวตชี้คนร้าย: บังคับให้รอบโหวตถัดไป "นับเป็นโหวตผิด"<br>
<span class="muted" style="font-size:13px;">ถ้ามี <strong>การ์ด 5 (จับผิดตัว)</strong> ค้างอยู่ → จะทริกให้ <strong>โหวตใหม่ 1 ครั้ง</strong> ทันที — ใช้เทสต์การ์ด 5</span>
</li>
</ul>
<p id="test-mode-msg" class="msg" role="status" style="margin-top:14px;"></p>
@@ -1190,6 +1267,6 @@
</div>
</main>
</div>
<script src="admin.js?v=83"></script>
<script src="admin.js?v=84"></script>
</body>
</html>
+11 -8
View File
@@ -38,31 +38,34 @@
"providerUserId": "p_1775109142385_wq7wfy1p32j",
"notes": "auto: player-coins",
"blocked": false,
"coins": 330,
"coins": 545,
"createdAt": "2026-04-02T05:52:21+00:00",
"updatedAt": "2026-06-17T03:36:39+00:00",
"updatedAt": "2026-06-17T07:52:36+00:00",
"daily": {
"anchorMs": 1781197200000,
"claimedDays": [
true,
false,
true,
false,
false,
false,
false,
false
],
"lockUntilMs": 0
"lockUntilMs": 1781715600000
},
"score": 280,
"score": 470,
"scoreByCase": {
"1": 10,
"10": 70,
"8": 110,
"10": 120,
"8": 170,
"11": 20,
"13": 20,
"14": 20,
"9": 30
"9": 40,
"12": 40,
"15": 20,
"4": 10
},
"lbName": "Q"
},
+5 -4
View File
@@ -89,9 +89,9 @@
"balloonBossPlayerBalloonFallbackUrl": "/Game/img/MegaVirus/Artboard%209.png",
"balloonBossBalloonsPerPlayer": 3,
"forcedMinigameKeys": [
"quiz",
"gauntlet",
"stack"
"jump_survive",
"space_shooter",
"balloon_boss"
],
"testSpecialCardByMap": {
"mng8a80o": 1,
@@ -102,5 +102,6 @@
"mnpz6rkp": 6,
"mnq1eml7": 7
},
"troublesomeForceOffer": false
"troublesomeForceOffer": false,
"specialQuizIconExpireSec": 20
}
@@ -1 +1 @@
{"qbroom1":[{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781171902962},{"name":"ผู้เล่น8916","score":4,"characterId":"char-1777017632279","ts":1781074083031},{"name":"ผู้เล่น2821","score":4,"characterId":"char-1777017632279","ts":1781079179108}],"qbroom2":[{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781175468983},{"name":"ผู้เล่น2612","score":1,"characterId":"char-1777017632279","ts":1781079252376}],"qbroom3":[{"name":"Q","score":9,"characterId":"char-1777017632279","ts":1781175654893}]}
{"qbroom1":[{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781171902962},{"name":"ผู้เล่น8916","score":4,"characterId":"char-1777017632279","ts":1781074083031},{"name":"ผู้เล่น2821","score":4,"characterId":"char-1777017632279","ts":1781079179108}],"qbroom2":[{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781175468983},{"name":"ผู้เล่น2612","score":1,"characterId":"char-1777017632279","ts":1781079252376}],"qbroom3":[{"name":"Q","score":9,"characterId":"char-1777017632279","ts":1781175654893}],"qbroom5":[{"name":"Q","score":6,"characterId":"char-1777017632279","ts":1781675299850}],"qbroom6":[{"name":"Q","score":3,"characterId":"char-1777017632279","ts":1781675383175}]}
@@ -421,6 +421,55 @@
display: block;
margin-bottom: -3%;
}
/* ===== Achievements แบบข้อความ (แทนรูปชื่อ archiv-name-N.png) ===== */
.room-lobby-profile-archiv-item.rlpa-text-item {
min-height: calc(118px * var(--rp-scale));
}
.rlpa-title-col {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: calc(4px * var(--rp-scale));
padding: calc(2px * var(--rp-scale)) 0;
}
.rlpa-title {
font-family: 'Kanit', 'NotoSansThai', system-ui, sans-serif;
font-weight: 800;
font-size: calc(34px * var(--rp-scale));
line-height: 1.05;
color: #eafcff;
text-shadow: 0 calc(2px * var(--rp-scale)) calc(6px * var(--rp-scale)) rgba(0, 0, 0, 0.55);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rlpa-title .rlpa-check {
color: #5dffa0;
font-size: 0.82em;
margin-left: 0.2em;
}
.rlpa-desc {
font-family: 'NotoSansThai', 'Kanit', system-ui, sans-serif;
font-weight: 500;
font-size: calc(21px * var(--rp-scale));
line-height: 1.25;
color: #b9c6e6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rlpa-text-item.is-unlocked .rlpa-title {
color: #ffe95a;
text-shadow: 0 0 calc(12px * var(--rp-scale)) rgba(255, 220, 90, 0.5);
}
.rlpa-text-item.is-unlocked .room-lobby-profile-archiv-count {
color: #5dffa0;
}
.rlpa-text-item:not(.is-unlocked) .room-lobby-profile-archiv-count {
color: #ffe95a;
}
.room-lobby-profile-archiv-scroll {
position: absolute;
top: 10%;
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+170
View File
@@ -0,0 +1,170 @@
/* ===== Justice — ระบบ Achievements (แคตตาล็อก + แสดงผลในโปรไฟล์) =====
* โมดูลกลาง ใช้ร่วมทั้ง room-lobby (room-lobby.js) และ main-lobby (profile-popup.js)
* - มีแคตตาล็อก 25 รางวัล 5 หมวดฝังไว้ (default) ทำงานได้แม้ backend ล่ม
* - ดึง override แคตตาล็อก + progress รายผู้เล่นจาก /Admin/api/achievements.php
* - เปิดให้ controller เรียก window.jdAchievements.renderGroup(listEl, groupIndex)
* Auto-track ค่อยมาเพิ่มภายหลัง (ฝั่ง server เรียก action=progress พร้อม secret)
*/
(function () {
'use strict';
var script = document.currentScript;
var rawAssetBase = (script && script.getAttribute('data-asset-base')) || 'img/03-6-Profile';
function resolveAssetBase(raw) {
var base = String(raw || 'img/03-6-Profile').replace(/\/$/, '');
if (/^\/(Game|img)\//i.test(base)) {
return typeof window.appPath === 'function' ? window.appPath(base) : base;
}
try {
return new URL(base + '/', window.location.href).pathname.replace(/\/$/, '');
} catch (e) {
return base;
}
}
var ASSET_BASE = resolveAssetBase(rawAssetBase);
/* แคตตาล็อกตั้งต้น — id ต้องคงที่ (ใช้เป็นคีย์เก็บ progress) */
var DEFAULT_CATALOG = [
/* หมวด 1 : สายวิเคราะห์ (Analyst) */
{ id: 'a1_first_deduction', g: 1, title: 'First Deduction', desc: 'โหวตถูกตัวคนร้ายเป็นครั้งแรก', target: 1 },
{ id: 'a2_sharp_eye', g: 1, title: 'Sharp Eye', desc: 'สะสมหลักฐานระดับมีน้ำหนัก (Silver) ครบ 10 ใบ', target: 10 },
{ id: 'a3_mind_architect', g: 1, title: 'Mind Architect', desc: 'สะสมหลักฐานครบทุกระดับ (ทั่วไป, มีน้ำหนัก, ชี้ชัด) ใน 1 คดี', target: 1 },
{ id: 'a4_logic_over_luck', g: 1, title: 'Logic Over Luck', desc: 'โหวตถูกโดยไม่พึ่งหลักฐานชี้ชัด (Legendary) เลย 3 ครั้ง', target: 3 },
{ id: 'a5_truth_hunter', g: 1, title: 'Truth Hunter', desc: 'จับคนร้ายถูกตัวสะสมครบ 20 คดี', target: 20 },
{ id: 'a6_unbreakable_logic', g: 1, title: 'Unbreakable Logic', desc: 'โหวตถูกตัวติดกัน 5 คดีรวด', target: 5 },
/* หมวด 2 : สายสำรวจ (Explorer) */
{ id: 'b1_evidence_collector', g: 2, title: 'Evidence Collector', desc: 'สะสมหลักฐานระดับทั่วไป (Common) ครบ 20 ใบ', target: 20 },
{ id: 'b2_relentless_investigator', g: 2, title: 'Relentless Investigator', desc: 'เล่น Mini Game ครบทุกรอบ ใน 1 คดี', target: 1 },
{ id: 'b3_hidden_fragment', g: 2, title: 'Hidden Fragment', desc: 'ค้นพบหลักฐานระดับชี้ชัด (Legendary/Gold) เป็นครั้งแรก', target: 1 },
{ id: 'b4_data_miner', g: 2, title: 'Data Miner', desc: 'สะสมการ์ดหลักฐานรวมครบ 100 ใบ', target: 100 },
{ id: 'b5_deep_scanner', g: 2, title: 'Deep Scanner', desc: 'เก็บไอเทมช่วยเหลือ (ตำรวจ/ทนาย) สะสมครบ 10 ครั้ง', target: 10 },
/* หมวด 3 : สายเสี่ยง (Risk Taker) */
{ id: 'c1_early_accusation', g: 3, title: 'Early Accusation', desc: 'ชี้ตัวคนร้ายก่อนที่จะเปิดหลักฐานครบ', target: 1 },
{ id: 'c2_high_stakes', g: 3, title: 'High Stakes', desc: 'ชี้ตัวคนร้ายถูกโดยมีหลักฐานในมือไม่เกิน 3 ใบ', target: 1 },
{ id: 'c3_quick_draw', g: 3, title: 'Quick Draw', desc: 'ชี้ตัวคนร้ายเร็วที่สุดในทีมสะสมครบ 10 ครั้ง', target: 10 },
{ id: 'c4_clutch_mind', g: 3, title: 'Clutch Mind', desc: 'โหวตถูกในช่วง 10 วินาทีสุดท้ายก่อนหมดเวลา', target: 1 },
{ id: 'c5_lone_wolf', g: 3, title: 'Lone Wolf', desc: 'โหวตสวนทางกับเสียงส่วนใหญ่ของทีม (คุณถูกคนเดียว)', target: 1 },
/* หมวด 4 : สายสนับสนุน (Support) */
{ id: 'd1_minigame_solver', g: 4, title: 'Minigame Solver', desc: 'เอาชีวิตรอด / เล่นมินิเกมสำเร็จ 20 ครั้ง', target: 20 },
{ id: 'd2_silent_guardian', g: 4, title: 'Silent Guardian', desc: 'ไม่โดนโหวตใช้การ์ด Event พิเศษ เลยตลอด 5 คดี', target: 5 },
{ id: 'd3_the_backbone', g: 4, title: 'The Backbone', desc: 'ส่งมอบหลักฐานให้เพื่อนวิเคราะห์ครบ 30 ใบ', target: 30 },
{ id: 'd4_flawless_diver', g: 4, title: 'Flawless Diver', desc: 'ไม่โหวตจับผิดตัวเลยตลอด 15 คดี', target: 15 },
/* หมวด 5 : Elite / Hidden (สายลับ & ตัวป่วน) */
{ id: 'e1_the_observer', g: 5, title: 'The Observer', desc: 'เล่นจบ 15 คดีโดยไม่เคยสัมผัสหลักฐานชี้ชัด (Legendary) เลยสักครั้ง', target: 15 },
{ id: 'e2_the_impostor', g: 5, title: 'The Impostor', desc: 'รับบทเป็น "ตัวป่วน" ครั้งแรก', target: 1 },
{ id: 'e3_master_of_doubt', g: 5, title: 'Master of Doubt', desc: 'เอาชนะคดีในฐานะตัวป่วนได้สำเร็จ', target: 1 },
{ id: 'e4_agent_of_chaos', g: 5, title: 'Agent of Chaos', desc: 'รับบทเป็นตัวป่วนสะสมครบ 10 ครั้ง', target: 10 },
{ id: 'e5_slippery_eel', g: 5, title: 'Slippery Eel', desc: 'เป็นตัวป่วนแต่รอดพ้นจากการถูกจับได้ (ไม่ถูกโหวตออก) จนจบเกม 5 ครั้ง', target: 5 }
];
var state = {
catalog: DEFAULT_CATALOG.slice(),
progress: {}, /* id -> count */
loaded: false,
loadingKey: null
};
function appRel(path) {
if (typeof window.appPath === 'function') return window.appPath(path);
return path;
}
function asset(name) { return ASSET_BASE + '/' + name; }
function readPlayerKey() {
try {
var k = localStorage.getItem('jdPlayerKey');
if (k && /^[A-Za-z0-9_-]{8,128}$/.test(k)) return k;
} catch (e) { /* ignore */ }
return null;
}
function clamp(n, lo, hi) { n = parseInt(n, 10); if (!isFinite(n)) n = 0; return Math.max(lo, Math.min(hi, n)); }
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function catalogForGroup(groupIndex) {
var g = clamp(groupIndex, 1, 5);
return state.catalog.filter(function (a) { return (parseInt(a.g, 10) || 1) === g && a.enabled !== false; });
}
/* สร้าง <li> หนึ่งรายการ (ข้อความ ไม่ใช่รูปชื่อ) */
function buildItemHtml(def) {
var target = Math.max(1, parseInt(def.target, 10) || 1);
var cur = clamp(state.progress[def.id] || 0, 0, target);
var pct = Math.round((cur / target) * 100);
var unlocked = cur >= target;
var iconSrc = def.icon ? (/^\//.test(def.icon) ? appRel(def.icon) : def.icon) : appRel('/Game/img/achievements/' + def.id + '.png');
var fallbackIcon = asset('archiv-icon-bg.png');
return '' +
'<li class="room-lobby-profile-archiv-item rlpa-text-item' + (unlocked ? ' is-unlocked' : '') + '" data-achv-id="' + escapeHtml(def.id) + '">' +
'<img class="room-lobby-profile-archiv-icon-bg" src="' + iconSrc + '" alt="" onerror="this.onerror=null;this.src=\'' + fallbackIcon + '\'">' +
'<div class="room-lobby-profile-archiv-main">' +
'<div class="room-lobby-profile-archiv-title-row">' +
'<div class="rlpa-title-col">' +
'<div class="rlpa-title">' + escapeHtml(def.title) + (unlocked ? ' <span class="rlpa-check">✔</span>' : '') + '</div>' +
'<div class="rlpa-desc">' + escapeHtml(def.desc) + '</div>' +
'</div>' +
'<strong class="room-lobby-profile-archiv-count">' + cur + '/' + target + '</strong>' +
'</div>' +
'<div class="room-lobby-profile-archiv-progress-wrap">' +
'<img class="room-lobby-profile-archiv-progress-bg" src="' + asset('archiv-progress-bg.png') + '" alt="">' +
'<img class="room-lobby-profile-archiv-progress" src="' + asset('archiv-progress.png') + '" alt="" style="--archiv-progress:' + pct + '%">' +
'</div>' +
'<img class="room-lobby-profile-archiv-line" src="' + asset('archiv-line.png') + '" alt="">' +
'</div>' +
'</li>';
}
function renderGroup(listEl, groupIndex) {
if (!listEl) return;
var defs = catalogForGroup(groupIndex);
if (!defs.length) { listEl.innerHTML = ''; return; }
listEl.innerHTML = defs.map(buildItemHtml).join('');
listEl.setAttribute('data-achv-group', String(clamp(groupIndex, 1, 5)));
}
/* render ซ้ำหมวดที่กำลังโชว์ (หลังโหลด progress เสร็จ) */
function rerenderActive() {
var lists = document.querySelectorAll('.room-lobby-profile-achievements-list');
lists.forEach(function (el) {
var g = parseInt(el.getAttribute('data-achv-group'), 10) || 1;
renderGroup(el, g);
});
}
/* ดึงแคตตาล็อก override + progress รายผู้เล่น */
function load(playerKey) {
var key = playerKey || readPlayerKey();
if (!key) { return Promise.resolve(state); }
if (state.loaded && state.loadingKey === key) return Promise.resolve(state);
state.loadingKey = key;
var url = appRel('/Admin/api/achievements.php') + '?action=state&playerKey=' + encodeURIComponent(key);
return fetch(url, { credentials: 'omit' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.ok) {
if (Array.isArray(d.catalog) && d.catalog.length) state.catalog = d.catalog;
state.progress = (d.progress && typeof d.progress === 'object') ? d.progress : {};
state.loaded = true;
rerenderActive();
}
return state;
})
.catch(function () { return state; });
}
window.jdAchievements = {
CATALOG: DEFAULT_CATALOG,
state: state,
renderGroup: renderGroup,
rerenderActive: rerenderActive,
load: load,
readPlayerKey: readPlayerKey
};
})();
+196 -26
View File
@@ -2180,8 +2180,9 @@
if (!isPreviewBotId(peerId)) return null;
const idx = parseInt(String(peerId).slice(PREVIEW_BOT_PREFIX.length), 10);
if (!Number.isFinite(idx) || idx < 0) return null;
const slot = playLobbyBotThemes[idx];
if (!slot) return null;
/* server () "" ( hash )
bot เดยวก: bot N ((N % 8)+1), สก ((N % 3)+1) */
const slot = playLobbyBotThemes[idx] || { themeIndex: (idx % 8) + 1, skinToneIndex: (idx % 3) + 1 };
const tint = playTintFromLobbyThemeIndices(slot.themeIndex, slot.skinToneIndex);
if (!tint) return null;
try {
@@ -2250,6 +2251,49 @@
}
}
/** แปลง hex/rgb → {r,g,b} (กันค่าเพี้ยน) */
function hexOrRgbToRgbPlay(v) {
const s0 = String(v || '').trim();
const m = s0.match(/rgba?\(\s*(\d+)\D+(\d+)\D+(\d+)/i);
if (m) return { r: +m[1], g: +m[2], b: +m[3] };
let s = s0.replace('#', '');
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
if (!/^[0-9a-fA-F]{6}$/.test(s)) return null;
const n = parseInt(s, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function nearestIndexFromHexPlay(value, palette) {
const asIdx = Math.floor(Number(value));
if (asIdx >= 1 && asIdx <= palette.length) return asIdx; /* เก็บเป็น index อยู่แล้ว */
const t = hexOrRgbToRgbPlay(value);
if (!t) return null;
let best = -1; let bestD = Infinity;
for (let i = 0; i < palette.length; i++) {
const c = hexOrRgbToRgbPlay(palette[i]);
if (!c) continue;
const d = (c.r - t.r) * (c.r - t.r) + (c.g - t.g) * (c.g - t.g) + (c.b - t.b) * (c.b - t.b);
if (d < bestD) { bestD = d; best = i; }
}
return best >= 0 ? best + 1 : null;
}
/** theme/skin index ที่ผู้เล่นเลือกไว้ (จาก localStorage) — ส่งให้ server เพื่อกันบอทใช้สีเดียวกับเรา */
function getMyChosenLobbyColorIndicesPlay() {
let body = '';
let skin = '';
try {
const raw = localStorage.getItem(LOBBY_PLAYER_TINT_KEY);
if (raw) { const j = JSON.parse(raw); if (j) { body = j.body || j.themeRgb || ''; skin = j.skinRgb || j.skin || ''; } }
} catch (e) { /* ignore */ }
try {
if (!body) body = localStorage.getItem('lobbyThemeColor') || '';
if (!skin) skin = localStorage.getItem('lobbySkinTone') || '';
} catch (e2) { /* ignore */ }
return {
themeIndex: nearestIndexFromHexPlay(body, PLAY_LOBBY_THEME_HEX),
skinIndex: nearestIndexFromHexPlay(skin, PLAY_LOBBY_SKIN_HEX),
};
}
function loadPlayTintForBotPeerId(peerId) {
const sid = String(peerId || '');
let botKey = sid;
@@ -2271,6 +2315,11 @@
/** สีจากล็อบบี้ (ผู้เล่น + บอท) — fallback hash ตาม peer id */
function resolvePlayTintForPeer(peerId, peerRow) {
const sid = String(peerId || '');
/* ตัวเราเอง: ยึด "สีที่เลือกไว้" (localStorage) ก่อนทุกอย่าง — กัน peerRow/theme-index จาก server มาทับ (สีกระพริบ) */
if (myId != null && sid === String(myId)) {
const meChosen = loadPlayTintFromLobbyPlayerStorage();
if (meChosen) return meChosen;
}
const fromRow = peerRow ? playTintFromJoinPeer(peerRow) : null;
if (fromRow) return fromRow;
const previewTint = playTintForPreviewBotId(sid);
@@ -2286,13 +2335,18 @@
}
function applyPlayTintFromLobbyIndices(peerId, themeIndex, skinIndex) {
const tint = playTintFromLobbyThemeIndices(themeIndex, skinIndex);
if (!tint) return;
const sid = peerId != null ? String(peerId) : '';
if (myId != null && sid === String(myId)) {
me.playTint = tint;
/* : "" (localStorage) theme-index server
(สาเหตกระพร/สล: server sync index ไมตรงสเลอก แลวท me.playTint กคร) */
const meChosen = loadPlayTintFromLobbyPlayerStorage();
if (meChosen) { me.playTint = meChosen; return; }
const tintMe = playTintFromLobbyThemeIndices(themeIndex, skinIndex);
if (tintMe) me.playTint = tintMe;
return;
}
const tint = playTintFromLobbyThemeIndices(themeIndex, skinIndex);
if (!tint) return;
const o = others.get(peerId) || others.get(sid);
if (o) o.playTint = tint;
if (isPreviewBotId(sid)) playTintForPreviewBotId(sid);
@@ -12755,6 +12809,26 @@
try { return localStorage.getItem('justiceTestMode') === '1'; } catch (e) { return false; }
}
let specialQuizIcon = null; // { iconType, x, y } (พิกัด tile-center)
let specialQuizArmSent = false; // ส่งสัญญาณ "เกม live แล้ว" ให้ server ปล่อยไอคอนหรือยัง (รอบนี้)
/** เกมมินิ (mission shell) เข้าสู่สถานะ "เล่นจริง" แล้วหรือยัง — ใช้ตัดสินว่าจะให้ไอคอนคำถามพิเศษโผล่ได้ */
function detectiveMinigameLiveNowPlay() {
if (!isMissionMockHudPlay()) return false;
if (isGauntletCrownHeistMapPlay()) return gauntletCrownPregamePhase === 'live';
if (isQuizQuestionMissionUiMapPlay()) return quizQuestionMissionPhase === 'live';
if (isStackTowerMissionUiMapPlay()) return stackTowerMissionPhase === 'live';
if (isJumpSurviveMissionUiMapPlay()) return jumpSurviveMissionPhase === 'live';
if (isSpaceShooterMissionUiMapPlay()) return spaceShooterMissionPhase === 'live';
if (isMegaVirusMissionShellMapPlay()) return balloonBossSessionStartMs > 0 && !balloonBossGameEnded;
if (isQuizCarry() && quizCarryEmbedMissionFlowActive()) return !quizCarryPregameActive;
return false;
}
/** ส่งสัญญาณให้ server ปล่อยไอคอนคำถามพิเศษ (ครั้งเดียว) เมื่อเกม live จริง — กันไอคอนโผล่ตอน howto/นับถอยหลัง */
function maybeArmSpecialQuizPlay() {
if (specialQuizArmSent) return;
if (!detectiveMinigameLiveNowPlay()) return;
specialQuizArmSent = true;
try { if (socket && socket.connected) socket.emit('special-quiz-arm'); } catch (e) { /* ignore */ }
}
let specialQuizIconScreenRect = null; // { x, y, r } พิกัดบนจอ (ไว้คลิก/แตะไอคอน)
let specialQuizCollideSent = false;
let specialQuizCollideCooldownUntil = 0;
@@ -12843,6 +12917,7 @@
iconType: payload.iconType === 'police' ? 'police' : 'lawyer',
x: Number(payload.x),
y: Number(payload.y),
expiresAt: Number(payload.expiresAt) || 0,
};
specialQuizCollideSent = false;
specialQuizCollideCooldownUntil = 0;
@@ -12933,6 +13008,26 @@
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('?', sx + size * 0.4, cy - size * 0.38);
/* countdown บนไอคอน — เหลือกี่วินาทีก่อนหาย (badge เหนือไอคอน) */
if (specialQuizIcon.expiresAt > 0) {
const remain = Math.max(0, Math.ceil((specialQuizIcon.expiresAt - Date.now()) / 1000));
const bx = sx;
const by = cy - size * 0.6;
const br = size * 0.27;
const low = remain <= 3;
ctx.beginPath();
ctx.arc(bx, by, br, 0, Math.PI * 2);
ctx.fillStyle = low ? 'rgba(247,118,142,0.95)' : 'rgba(10,16,30,0.85)';
ctx.fill();
ctx.lineWidth = Math.max(2, size * 0.045);
ctx.strokeStyle = low ? '#ffd6e0' : '#7aa2f7';
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.font = '800 ' + Math.round(size * 0.36) + 'px system-ui, "Kanit", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(remain), bx, by + size * 0.012);
}
ctx.restore();
}
@@ -13008,6 +13103,34 @@
if (ans && !row.classList.contains('sq-ov-member--correct') && !row.classList.contains('sq-ov-member--wrong')) ans.textContent = '✓';
}
/* ===== mock 09-Quiz: avatar ผู้เล่นใต้คำตอบที่เลือก (แทนชิปสมาชิก) ===== */
var SPECIAL_QUIZ_AV_COLORS = ['#5eeaff', '#ff7ab2', '#9b8cff', '#7cff6b', '#ffd166', '#ff8c5a', '#6ad4ff', '#c792ff'];
function specialQuizMemberIndexById(id) {
for (var i = 0; i < specialQuizRoster.length; i++) {
if (String(specialQuizRoster[i].id) === String(id)) return i;
}
return -1;
}
function addSpecialQuizChoiceAvatar(choiceIdx, id) {
if (id == null || !(choiceIdx >= 0)) return;
const choicesEl = document.getElementById('sq-ov-choices');
if (!choicesEl) return;
const btn = choicesEl.querySelector('.sq-ov-choice[data-idx="' + choiceIdx + '"]');
if (!btn) return;
const boxAv = btn.querySelector('.sq-ov-choice-avatars');
if (!boxAv) return;
const safe = (window.CSS && CSS.escape) ? CSS.escape(String(id)) : String(id);
if (boxAv.querySelector('[data-mid="' + safe + '"]')) return; /* กันซ้ำ */
const idx = specialQuizMemberIndexById(id);
const isMe = String(id) === String(specialQuizSelfId());
const el = document.createElement('div');
el.className = 'sq-ov-av' + (isMe ? ' sq-ov-av--me' : '');
el.dataset.mid = String(id);
el.style.background = SPECIAL_QUIZ_AV_COLORS[(idx >= 0 ? idx : 0) % SPECIAL_QUIZ_AV_COLORS.length];
el.textContent = idx >= 0 ? String(idx + 1) : '?';
boxAv.appendChild(el);
}
function openSpecialQuizQuestion(q) {
if (!q) return;
clearSpecialQuizIcon();
@@ -13049,16 +13172,24 @@
b.dataset.idx = String(i);
const k = document.createElement('span');
k.className = 'sq-ov-choice-key';
k.textContent = SPECIAL_QUIZ_CHOICE_LABELS[i] || String(i + 1);
k.textContent = (SPECIAL_QUIZ_CHOICE_LABELS[i] || String(i + 1)) + ' :';
const t = document.createElement('span');
t.className = 'sq-ov-choice-text';
t.textContent = c;
const av = document.createElement('div');
av.className = 'sq-ov-choice-avatars';
av.dataset.idx = String(i);
b.appendChild(k);
b.appendChild(t);
b.appendChild(av);
b.onclick = function () { submitSpecialQuizAnswer(i); };
choicesEl.appendChild(b);
});
}
/* mock 09-Quiz: ซ่อนปุ่มถัดไป + รีเซ็ตสถานะตอบ (avatar ใต้คำตอบ) */
ov.classList.remove('sq-answered');
const nextBtn0 = document.getElementById('sq-ov-next');
if (nextBtn0) nextBtn0.classList.add('is-hidden');
renderSpecialQuizMembers();
ov.classList.remove('is-hidden');
startSpecialQuizTimerLoop();
@@ -13076,7 +13207,10 @@
}
const status = document.getElementById('sq-ov-status');
if (status) status.textContent = 'ส่งคำตอบแล้ว — รอผู้เล่นคนอื่น...';
if (specialQuizSelfId()) markSpecialQuizMemberAnswered(specialQuizSelfId());
if (specialQuizSelfId()) {
markSpecialQuizMemberAnswered(specialQuizSelfId());
addSpecialQuizChoiceAvatar(i, specialQuizSelfId()); /* mock: avatar ตัวเองใต้คำตอบที่เลือก */
}
try { socket.emit('special-quiz-answer', { choiceIndex: i }, function () {}); } catch (e) { /* ignore */ }
}
@@ -13109,6 +13243,13 @@
}
/* เฉลย — เปิดเผยว่าแต่ละคนตอบอะไร ถูก/ผิด */
const answers = (r.answers && typeof r.answers === 'object') ? r.answers : {};
/* mock 09-Quiz: วาง avatar ของทุกคนใต้คำตอบที่เขาเลือก + ซ่อนวงเวลา */
const sqOvAns = specialQuizOverlayEl();
if (sqOvAns) sqOvAns.classList.add('sq-answered');
Object.keys(answers).forEach(function (mid) {
const ai = answers[mid];
if (Number.isInteger(ai)) addSpecialQuizChoiceAvatar(ai, mid);
});
const labels = SPECIAL_QUIZ_CHOICE_LABELS;
const box = document.getElementById('sq-ov-members');
if (box) {
@@ -13528,8 +13669,9 @@
const missionOk = gLetter !== 'F';
const hasSpecialCard = !!(mission && mission.specialCardAwarded);
let bonusCardHost = bonusEl;
/* mg2 summary: โชว์ block "โบนัสพิเศษ" เสมอ (missionOk) — ได้การ์ด=โชว์การ์ด + complete-select, ไม่ได้=complete ธรรมดา + การ์ดเปล่า placeholder */
if (mg2Summary && missionOk) {
/* mg2 summary: block "" =, = + ""
(การดพเศษไดจากคำถามพเศษระหวางเล ไมนกบเกรด แมเกรด F โชวสถานะการดได) */
if (mg2Summary) {
const bonusWrap = document.createElement('div');
bonusWrap.className = 'gcm-bonus-mg2 sm-bonus';
const bonusLeft = document.createElement('div');
@@ -13537,17 +13679,20 @@
const bonusHead = document.createElement('h3');
bonusHead.className = 'gcm-h gcm-bonus-mg2-head head';
bonusHead.textContent = 'โบนัสพิเศษ';
const completeImg = document.createElement('img');
completeImg.className = 'gcm-bonus-mg2-complete complete';
completeImg.src = gcmMissionMockAssetUrl(mission, hasSpecialCard ? 'mission-complete-select.png' : 'mission-complete.png');
completeImg.alt = 'ภารกิจสำเร็จ';
completeImg.decoding = 'async';
completeImg.onerror = function () {
this.onerror = null;
this.src = gcmMissionMockAssetUrl(mission, 'mission-complete-select.png');
};
bonusLeft.appendChild(bonusHead);
bonusLeft.appendChild(completeImg);
{
/* โชว์รูปสถานะการ์ดเสมอ (ไม่ขึ้นกับเกรด) — ได้การ์ด=mission-complete-select.png, ไม่ได้=mission-complete.png */
const completeImg = document.createElement('img');
completeImg.className = 'gcm-bonus-mg2-complete complete';
completeImg.src = gcmMissionMockAssetUrl(mission, hasSpecialCard ? 'mission-complete-select.png' : 'mission-complete.png');
completeImg.alt = hasSpecialCard ? 'ได้รับการ์ด' : 'ไม่ได้รับการ์ด';
completeImg.decoding = 'async';
completeImg.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/Jumper/' + (hasSpecialCard ? 'mission-complete-select.png' : 'mission-complete.png');
};
bonusLeft.appendChild(completeImg);
}
const bonusRight = document.createElement('div');
bonusRight.className = 'gcm-bonus-mg2-right sm-bonus-right';
bonusWrap.appendChild(bonusLeft);
@@ -13555,10 +13700,10 @@
bonusEl.appendChild(bonusWrap);
bonusCardHost = bonusRight;
if (!hasSpecialCard) {
/* ไม่ได้การ์ดพิเศษ → โชว์การ์ดพื้นหลังเปล่า (popup-result-card-bg.png) ตามดีไซน์ */
/* ไม่ได้การ์ดพิเศษ → การ์ดพื้นหลังเปล่า (popup-result-card-bg.png) + ข้อความ "ไม่ได้รับการ์ด" */
const bgCard = document.createElement('img');
bgCard.className = 'bail-card gcm-special-card-img';
bgCard.src = gcmMissionMockAssetUrl(mission, 'popup-result-card-bg.png');
bgCard.src = BASE + '/img/Jumper/popup-result-card-bg.png';
bgCard.alt = '';
bgCard.decoding = 'async';
bonusRight.appendChild(bgCard);
@@ -16102,7 +16247,13 @@
}
const remSec = balloonBossRemainingSecPlay();
if (remSec != null && remSec <= 0) {
endBalloonBossGame('time');
if (teamBossDmg >= maxHp) { endBalloonBossGame('victory'); return; }
/* หมดเวลาแต่บอสยังไม่ตาย → ลูกโป่งของคนที่ยังเหลือ "แตกหมด" ตายทั้งหมด (แพ้) */
buildBalloonBossParticipantRefsPlay().forEach((e) => {
if (e.ref && !e.ref.balloonBossEliminated) { e.ref.balloonBossBalloons = 0; e.ref.balloonBossEliminated = true; }
});
try { if (socket && myId != null) socket.emit('move', { x: me.x, y: me.y, direction: me.direction, balloonBossBalloons: 0, balloonBossEliminated: true }); } catch (eT) { /* ignore */ }
endBalloonBossGame('all_dead');
return;
}
if (teamBossDmg >= maxHp) {
@@ -16221,6 +16372,18 @@
}
}
if (pHit) continue;
/* MG7: ยิงโดนไอคอนคำถามพิเศษ = ทริกได้เลย (ไม่ต้องเดินไปแตะ) */
if (specialQuizIcon && !specialQuizCollideSent && !specialQuizActiveQuestion) {
const iwx = specialQuizIcon.x * tileSize;
const iwy = specialQuizIcon.y * tileSize;
const idx = b.x - iwx, idy = b.y - iwy;
const ir = tileSize * 0.95;
if (idx * idx + idy * idy <= ir * ir) {
sendSpecialQuizCollide();
balloonBossPlayerBullets.splice(i, 1);
continue;
}
}
const dx = b.x - bossC.cx, dy = b.y - bossC.cy;
if (dx * dx + dy * dy <= bossR * bossR) {
balloonBossHitFx.push({ x: bossC.cx, y: bossC.cy, t: 0.22 });
@@ -17901,7 +18064,8 @@
gauntletObsBlendT0 = 0;
if (Array.isArray(res.lobbyBotThemes)) playLobbyBotThemes = res.lobbyBotThemes;
const myPeerJoin = (Array.isArray(plist) ? plist : []).find((q) => q && q.id === myId);
me.playTint = playTintFromJoinPeer(myPeerJoin) || resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')), myPeerJoin);
/* ตัวเราเอง: ใช้สีที่เลือกไว้ใน Main-Lobby (localStorage) ก่อน แล้วค่อย fallback ไป theme-index ของ server (กันสีกระพริบ) */
me.playTint = loadPlayTintFromLobbyPlayerStorage() || playTintFromJoinPeer(myPeerJoin) || resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')), myPeerJoin);
if (myPeerJoin && myPeerJoin.lobbyColorThemeIndex) {
try {
const ti = parsePlayLobbyThemeIndex(myPeerJoin.lobbyColorThemeIndex);
@@ -18215,6 +18379,7 @@
const joinNick = (typeof window.jdReadDisplayName === 'function' ? window.jdReadDisplayName() : null)
|| readStoredDisplayName()
|| nick;
const __chosenColors = getMyChosenLobbyColorIndicesPlay();
socket.emit('join-space', {
spaceId,
nickname: joinNick,
@@ -18222,6 +18387,9 @@
playMapId: joinPlayMapId || undefined,
playerKey: getJdPlayerKey(),
bannedSpectator: playBannedSpectator || undefined,
/* ส่งสีที่เลือก → server กันบอทไม่ให้ใช้ธีมเดียวกับเรา (กันสีตัวละครซ้ำ) */
desiredLobbyColorThemeIndex: __chosenColors.themeIndex || undefined,
desiredLobbySkinToneIndex: __chosenColors.skinIndex || undefined,
}, (res) => {
if (!res || !res.ok) {
const errMsg = (res && res.error) || 'เข้าร่วมไม่ได้';
@@ -18868,6 +19036,7 @@
const applySnap = (md) => {
mapData = md;
playEmbedUserZoomMul = 1;
specialQuizArmSent = false; /* รอบมินิเกมใหม่ — รีเซ็ตให้ส่งสัญญาณ arm ไอคอนคำถามพิเศษได้อีกครั้ง */
if (ev.mapId != null && String(ev.mapId).trim() !== '') {
playSessionMapId = String(ev.mapId).trim();
}
@@ -22435,6 +22604,7 @@
const botOut = isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated;
const peerName = botOut ? (o.nickname + ' (ตกรอบ)') : o.nickname;
if (shouldGauntletCrownHeistSkipAvatarDrawPlay(o, false, off.ax, off.ay)) return;
if (isJumpSurvive() && o.jumpSurviveEliminated) return; /* MG5 ตายแล้ว → ตัวละครหายไป */
const axO = safeX(o.x) + off.ax;
const ayO = safeY(o.y) + off.ay;
/* ผีต้องโชว์เสมอ แม้ทับโซนคำตอบ (เดินไปไหนก็ได้) */
@@ -22455,12 +22625,11 @@
if (botOut) ctx.restore();
} else {
if (shouldGauntletCrownHeistSkipAvatarDrawPlay(me, true, 0, 0)) return;
if (isJumpSurvive() && jumpSurviveEliminated) return; /* MG5 ตายแล้ว → ตัวละครหายไป */
const axMe = safeX(me.x);
const ayMe = safeY(me.y);
const ghostActiveMe = isQuizWrongGhostActiveForEnt(myId);
if (!ghostActiveMe && playQuizAvatarHideWhileOverlappingAnswerZone(me, myId, axMe, ayMe)) return;
if (isJumpSurvive() && jumpSurviveEliminated) ctx.save();
if (isJumpSurvive() && jumpSurviveEliminated) ctx.globalAlpha = 0.4;
const faceDirMe = isGauntletFaceRightMapMno9kb07() ? 'right' : me.direction;
if (ghostActiveMe) {
const gam = _quizGhostScreenAnchorPlay(axMe, ayMe, worldToScreen, zDraw);
@@ -22471,12 +22640,13 @@
quizCarrySignForEntity(me),
(isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (me.gauntletCrownPenaltyFxUntil || 0) : 0);
}
if (isJumpSurvive() && jumpSurviveEliminated) ctx.restore();
}
});
/* Minigame-2: อัปเดต DOM overlay (ชื่อ/คะแนน/-10) ให้ตรงตำแหน่งตัวละครบน canvas */
try { syncGauntletNameDom(worldToScreen, zDraw, tileSize); } catch (eGntDom) { /* ignore */ }
}
/* ส่งสัญญาณให้ server ปล่อยไอคอนคำถามพิเศษเมื่อเกม live จริง (ครั้งเดียว/รอบ) */
maybeArmSpecialQuizPlay();
/* ไอคอนคำถามพิเศษในฉาก (วาดทับ map/ผู้เล่น) + ตรวจการเดินชน */
if (specialQuizIcon) {
drawSpecialQuizIconWorld(worldToScreen, zDraw);
+5
View File
@@ -173,6 +173,9 @@
var img = btn.querySelector('img');
if (img) img.src = active ? asset('archiv-group-0' + id + '-a.png') : asset('archiv-group-0' + id + '.png');
});
if (window.jdAchievements && refs.archivListEl) {
window.jdAchievements.renderGroup(refs.archivListEl, index);
}
syncArchivScroll();
}
@@ -198,6 +201,7 @@
function openPopup(data) {
if (!refs.overlay) return;
if (window.jdAchievements) window.jdAchievements.load();
setProfileData(data);
refs.overlay.classList.remove('is-hidden');
refs.overlay.setAttribute('aria-hidden', 'false');
@@ -295,6 +299,7 @@
bindEvents();
syncProfileScale();
setGroup(1);
if (window.jdAchievements) window.jdAchievements.load();
window.openProfilePopup = openPopup;
window.closeProfilePopup = closePopup;
window.roomLobbyProfileOverlay = { open: openPopup, close: closePopup };
+17 -10
View File
@@ -2561,6 +2561,7 @@
this._applySwitchVisual(this.sfxBtn, false);
this._setArchivGroup(1);
this._syncCoinsFromServer();
if (window.jdAchievements) window.jdAchievements.load();
}
_bindEvents() {
@@ -2649,6 +2650,9 @@
? 'img/03-6-Profile/archiv-group-0' + id + '-a.png'
: 'img/03-6-Profile/archiv-group-0' + id + '.png';
});
if (window.jdAchievements && this.archivListEl) {
window.jdAchievements.renderGroup(this.archivListEl, idx);
}
this._syncArchivScroll();
}
@@ -2725,6 +2729,7 @@
if (!this.overlay) return;
this.setProfileData(data);
this._syncCoinsFromServer();
if (window.jdAchievements) window.jdAchievements.load();
this.overlay.classList.remove('is-hidden');
this.overlay.setAttribute('aria-hidden', 'false');
this._syncProfileFrameScale();
@@ -5036,8 +5041,8 @@
pod.innerHTML = '';
var left = document.createElement('div'); left.className = 'tf-pod-side tf-pod-left';
var right = document.createElement('div'); right.className = 'tf-pod-side tf-pod-right';
var ids = (Array.isArray(memberIds) && memberIds.length) ? memberIds.slice(0, 6)
: ((_lastTrialWinners && _lastTrialWinners.length) ? _lastTrialWinners.slice(0, 6) : []);
var ids = (Array.isArray(memberIds) && memberIds.length) ? memberIds.slice(0, 8)
: ((_lastTrialWinners && _lastTrialWinners.length) ? _lastTrialWinners.slice(0, 8) : []);
if (!ids.length && socket && socket.id) ids = [socket.id];
ids.forEach(function (id, i) {
var info = resolveOccupantCharInfo(id);
@@ -5144,20 +5149,16 @@
}
if (txtEl) txtEl.style.display = 'none'; /* หน้า1 ไม่มีข้อความ congrats (อยู่หน้า2) */
if (homeEl) homeEl.style.display = 'none'; /* หน้า1 ยังกลับไม่ได้ — auto เข้าหน้า2 */
/* มีตัวป่วน → โพเดียมโชว์ "ทุกคนยกเว้นตัวป่วน" · ไม่มีตัวป่วน → โชว์ทุกคน */
var podMembers = _lastHasDisruptor
? allOccupantIds().filter(function (id) { return id !== _lastDisruptorId; })
: null;
: allOccupantIds();
winPodiumTimer = setTimeout(function () { renderWinPodium(podMembers); }, 3500);
} else if (anyVotes && _lastHasDisruptor && _lastDisruptorId) {
/* โหวตผิด + มีตัวป่วน → "ตัวป่วนชนะ" โชว์เฉพาะตัวป่วน */
/* โหวตผิด + มีตัวป่วน → "ตัวป่วนชนะ" โชว์เฉพาะตัวป่วน บน troll-win-bg */
renderTrollWinDisruptor(_lastDisruptorId);
} else if (anyVotes) {
/* จับแพะ — โหวตผิดคน (ไม่มีตัวป่วน): "ตัวป่วนชนะ ความจริงถูกบิดเบือน!" (troll-win เปล่า) */
ov.style.backgroundImage = 'url(' + R + 'troll-win-bg.png)';
if (txtEl) { txtEl.src = R + 'txt-troll-win.png'; txtEl.className = ''; txtEl.style.display = 'block'; }
if (culpEl) culpEl.style.display = 'none';
} else {
/* ไม่มีใครโหวต / ภารกิจล้มเหลว */
/* โหวตผิด (ไม่มีตัวป่วน) หรือ ไม่มีใครโหวต ภารกิจล้มเหลว: lose-bg + txt-your-lose */
ov.style.backgroundImage = 'url(' + R + 'lose-bg.png)';
if (txtEl) { txtEl.src = R + 'txt-your-lose.png'; txtEl.className = ''; txtEl.style.display = 'block'; }
if (culpEl) culpEl.style.display = 'none';
@@ -5187,6 +5188,12 @@
_testDisruptorOn = !_testDisruptorOn;
try { appendLobbySystemChat('— [Test] ตัวป่วน: ' + (_testDisruptorOn ? 'มี' : 'ไม่มี') + ' (Ctrl+Alt+1 ถูก / Ctrl+Alt+2 ผิด)'); } catch (eC) { /* ignore */ }
}
else if (e.code === 'KeyW') {
/* บังคับให้รอบโหวตชี้คนร้ายถัดไป "นับเป็นโหวตผิด" → ทริกการ์ด 5 (จับผิดตัว/โหวตใหม่) */
e.preventDefault();
try { socket.emit('test-force-wrong-vote'); } catch (eW) { /* ignore */ }
try { appendLobbySystemChat('— [Test] บังคับโหวตรอบถัดไป = ผิด (ถ้ามีการ์ด 5 จะได้โหวตใหม่)'); } catch (eW2) { /* ignore */ }
}
});
/* ===== ห้องสรุปหลักฐาน — การไต่สวน (เลือกหลักฐาน 2 ใบ/ปากคำ ก่อนโหวต) ===== */
+41 -24
View File
@@ -5080,27 +5080,43 @@
#special-quiz-overlay { position: fixed; inset: 0; z-index: 1200; display: flex; align-items: center; justify-content: center; padding: 24px; }
#special-quiz-overlay.is-hidden { display: none; }
#special-quiz-overlay .sq-ov-backdrop { position: absolute; inset: 0; background: radial-gradient(ellipse at center, rgba(16,22,46,0.82), rgba(8,10,22,0.94)); backdrop-filter: blur(3px); }
#special-quiz-overlay .sq-ov-panel { position: relative; width: min(720px, 94vw); max-height: 92vh; overflow: auto; background: linear-gradient(180deg, #141a33, #0e1226); border: 1px solid rgba(122,162,247,0.45); border-radius: 18px; box-shadow: 0 24px 70px rgba(0,0,0,0.55), 0 0 0 4px rgba(122,162,247,0.08); padding: 22px 24px 24px; color: #e7ecff; }
#special-quiz-overlay .sq-ov-head { display: flex; align-items: center; gap: 14px; }
#special-quiz-overlay .sq-ov-icon { width: 56px; height: 56px; object-fit: contain; filter: drop-shadow(0 4px 10px rgba(122,162,247,0.5)); }
#special-quiz-overlay .sq-ov-head-text { flex: 1; min-width: 0; }
#special-quiz-overlay .sq-ov-kicker { font-size: 13px; letter-spacing: .08em; text-transform: uppercase; color: #7aa2f7; font-weight: 700; }
#special-quiz-overlay .sq-ov-progress { font-size: 15px; color: #c0caf5; margin-top: 2px; }
#special-quiz-overlay .sq-ov-timer { flex: none; width: 54px; height: 54px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 22px; font-weight: 800; color: #9ece6a; border: 3px solid rgba(158,206,106,0.55); background: rgba(158,206,106,0.08); }
#special-quiz-overlay .sq-ov-timer.sq-ov-timer--low { color: #f7768e; border-color: rgba(247,118,142,0.6); background: rgba(247,118,142,0.1); }
#special-quiz-overlay .sq-ov-question { margin: 18px 2px 16px; font-size: 21px; line-height: 1.45; font-weight: 700; }
#special-quiz-overlay .sq-ov-choices { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 560px) { #special-quiz-overlay .sq-ov-choices { grid-template-columns: 1fr; } }
#special-quiz-overlay .sq-ov-choice { display: flex; align-items: center; gap: 12px; text-align: left; padding: 14px 16px; border-radius: 12px; border: 1px solid rgba(122,162,247,0.35); background: rgba(122,162,247,0.07); color: #e7ecff; font-size: 17px; cursor: pointer; transition: transform .08s, background .12s, border-color .12s; }
#special-quiz-overlay .sq-ov-choice:hover:not(:disabled) { background: rgba(122,162,247,0.16); transform: translateY(-1px); }
#special-quiz-overlay .sq-ov-choice:disabled { cursor: default; opacity: .92; }
#special-quiz-overlay .sq-ov-choice-key { flex: none; width: 30px; height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-weight: 800; background: rgba(122,162,247,0.25); color: #cdd6ff; }
#special-quiz-overlay .sq-ov-choice-text { flex: 1; }
#special-quiz-overlay .sq-ov-choice--picked { border-color: #7aa2f7; background: rgba(122,162,247,0.28); }
#special-quiz-overlay .sq-ov-choice--correct { border-color: #9ece6a; background: rgba(158,206,106,0.22); }
#special-quiz-overlay .sq-ov-choice--correct .sq-ov-choice-key { background: rgba(158,206,106,0.5); color: #0e1226; }
#special-quiz-overlay .sq-ov-choice--wrong { border-color: #f7768e; background: rgba(247,118,142,0.18); }
#special-quiz-overlay .sq-ov-status { margin-top: 14px; font-size: 14px; color: #a9b1d6; min-height: 18px; }
/* ===== Special Quiz — ดีไซน์ตาม mock 09-Quiz (กรอบ neon + ตัวเลือกเต็มแถว) ===== */
#special-quiz-overlay .sq-ov-panel { position: relative; width: min(760px, 94vw); min-height: min(560px, 90vh); max-height: 96vh; overflow: auto; box-sizing: border-box; background: url('/Game/img/special-quiz/mock/special-quiz.png') center/100% 100% no-repeat; border: none; border-radius: 0; box-shadow: none; padding: clamp(78px, 14%, 140px) 10% clamp(70px, 11%, 120px); color: #e7ecff; }
/* หัวเก่า (ไอคอน/kicker/ข้อ) ซ่อน — ชื่อ "Special Quiz" อยู่ในรูปกรอบแล้ว */
#special-quiz-overlay .sq-ov-head { display: block; }
#special-quiz-overlay .sq-ov-icon,
#special-quiz-overlay .sq-ov-head-text { display: none; }
/* นาฬิกานับถอยหลัง — วงกลมกลางล่างของกรอบ */
#special-quiz-overlay .sq-ov-timer { position: absolute; left: 50%; bottom: 3.5%; transform: translateX(-50%); width: clamp(44px, 8%, 70px); aspect-ratio: 1; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: clamp(18px, 2.4vw, 26px); font-weight: 800; color: #eafcff; border: 3px solid rgba(94,234,255,0.85); background: rgba(8,16,30,0.85); box-shadow: 0 0 14px rgba(94,234,255,0.4); z-index: 3; }
#special-quiz-overlay .sq-ov-timer.sq-ov-timer--low { color: #ffd6e0; border-color: rgba(247,118,142,0.9); box-shadow: 0 0 14px rgba(247,118,142,0.5); }
#special-quiz-overlay .sq-ov-question { margin: 0 2px 16px; font-size: clamp(17px, 2.2vw, 24px); line-height: 1.4; font-weight: 800; text-align: center; color: #ffffff; text-shadow: 0 1px 6px rgba(0,0,0,0.5); }
#special-quiz-overlay .sq-ov-choices { display: flex; flex-direction: column; gap: clamp(14px, 2.6vh, 26px); }
#special-quiz-overlay .sq-ov-choice { position: relative; display: flex; align-items: center; justify-content: center; gap: 10px; text-align: center; padding: clamp(12px, 2vh, 20px) 44px; border: none; border-radius: 0; background: url('/Game/img/special-quiz/mock/choice-default.png') center/100% 100% no-repeat; color: #14263f; font-size: clamp(15px, 1.9vw, 21px); font-weight: 800; cursor: pointer; transition: transform .08s, filter .12s; }
#special-quiz-overlay .sq-ov-choice:hover:not(:disabled) { filter: brightness(1.12); transform: translateY(-1px); }
#special-quiz-overlay .sq-ov-choice:disabled { cursor: default; }
#special-quiz-overlay .sq-ov-choice-key { flex: none; font-weight: 800; color: inherit; }
#special-quiz-overlay .sq-ov-choice-text { flex: 0 1 auto; }
#special-quiz-overlay .sq-ov-choice--picked { background-image: url('/Game/img/special-quiz/mock/choice-select.png'); }
#special-quiz-overlay .sq-ov-choice--correct { background-image: url('/Game/img/special-quiz/mock/choice-correct.png'); color: #0d3a18; }
#special-quiz-overlay .sq-ov-choice--correct::after { content: ''; position: absolute; right: 14px; top: 50%; transform: translateY(-50%); width: clamp(26px, 4vw, 40px); height: clamp(26px, 4vw, 40px); background: url('/Game/img/special-quiz/mock/correct.png') center/contain no-repeat; }
#special-quiz-overlay .sq-ov-choice--correct .sq-ov-choice-key { color: #0e2a12; background: transparent; }
#special-quiz-overlay .sq-ov-choice--wrong { background-image: url('/Game/img/special-quiz/mock/choice-wrong.png'); color: #7a1222; }
#special-quiz-overlay .sq-ov-choice--wrong::after { content: ''; position: absolute; right: 14px; top: 50%; transform: translateY(-50%); width: clamp(26px, 4vw, 40px); height: clamp(26px, 4vw, 40px); background: url('/Game/img/special-quiz/mock/wrong.png') center/contain no-repeat; }
/* mock 09-Quiz: ไม่มีชิป "สมาชิกในรอบนี้" / สถานะ — โชว์ avatar ใต้คำตอบที่เลือกแทน */
#special-quiz-overlay .sq-ov-status,
#special-quiz-overlay .sq-ov-members-head,
#special-quiz-overlay .sq-ov-members { display: none !important; }
/* แถว avatar ผู้เล่นที่เลือกคำตอบนี้ — เกาะขอบล่างของแถบ */
#special-quiz-overlay .sq-ov-choice-avatars { position: absolute; left: 50%; bottom: 0; transform: translate(-50%, 52%); display: flex; align-items: center; justify-content: center; gap: 3px; pointer-events: none; z-index: 4; min-height: 1px; }
#special-quiz-overlay .sq-ov-choice-avatars:empty { display: none; }
#special-quiz-overlay .sq-ov-av { width: clamp(26px, 3.4vw, 38px); height: clamp(26px, 3.4vw, 38px); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: clamp(12px, 1.5vw, 16px); font-weight: 800; color: #fff; border: 2px solid #eafcff; box-shadow: 0 2px 6px rgba(0,0,0,0.5); background: #5eeaff; }
#special-quiz-overlay .sq-ov-av--me { border-color: #ffe95a; box-shadow: 0 0 8px rgba(255,233,90,0.7); }
/* ปุ่ม "ถัดไป" — โผล่ตอนเฉลย แทนวงเวลา */
#special-quiz-overlay .sq-ov-next-btn { position: absolute; left: 50%; bottom: 0; transform: translate(-50%, 40%); width: clamp(150px, 22%, 210px); aspect-ratio: 354 / 132; border: none; background: url('/Game/img/special-quiz/mock/btn-next_1.png') center/100% 100% no-repeat; cursor: pointer; z-index: 5; transition: transform .08s, filter .12s; }
#special-quiz-overlay .sq-ov-next-btn:hover { filter: brightness(1.1); transform: translate(-50%, 40%) scale(1.04); }
#special-quiz-overlay .sq-ov-next-btn.is-hidden { display: none; }
#special-quiz-overlay.sq-answered .sq-ov-timer { display: none; }
#special-quiz-overlay .sq-ov-status-orig { margin-top: 14px; font-size: 14px; color: #a9b1d6; min-height: 18px; }
#special-quiz-overlay .sq-ov-result { margin-top: 12px; font-size: 18px; font-weight: 800; text-align: center; padding: 10px; border-radius: 10px; }
#special-quiz-overlay .sq-ov-result.is-hidden { display: none; }
#special-quiz-overlay .sq-ov-result--ok { color: #0e1226; background: #9ece6a; }
@@ -5114,8 +5130,8 @@
@keyframes sqAwardPop { from { opacity: 0; transform: translateY(22px) scale(.82) rotate(-5deg); } to { opacity: 1; transform: none; } }
#special-quiz-overlay .sq-ov-award-name { font-size: 18px; font-weight: 800; color: #fff; }
#special-quiz-overlay .sq-ov-award-effect { font-size: 14px; color: #c0caf5; max-width: 90%; line-height: 1.4; }
#special-quiz-overlay .sq-ov-award-btn { margin-top: 6px; cursor: pointer; border: none; border-radius: 999px; padding: 13px 40px; font-size: 17px; font-weight: 800; color: #0e1226; background: linear-gradient(180deg, #ffd666, #f0b429); box-shadow: 0 10px 28px rgba(240,180,41,0.45); transition: filter .12s, transform .08s; }
#special-quiz-overlay .sq-ov-award-btn:hover { filter: brightness(1.06); transform: translateY(-1px); }
#special-quiz-overlay .sq-ov-award-btn { margin-top: 10px; cursor: pointer; border: 2px solid rgba(94,234,255,0.9); border-radius: 14px; padding: 13px 44px; font-size: 18px; font-weight: 800; color: #eafcff; background: rgba(20,38,63,0.92); box-shadow: 0 0 18px rgba(94,234,255,0.45), inset 0 0 12px rgba(94,234,255,0.18); transition: filter .12s, transform .08s; }
#special-quiz-overlay .sq-ov-award-btn:hover { filter: brightness(1.12); transform: translateY(-1px); }
#special-quiz-overlay .sq-ov-members-head { margin-top: 16px; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: #7aa2f7; font-weight: 700; }
#special-quiz-overlay .sq-ov-members { margin-top: 8px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
@media (max-width: 560px) { #special-quiz-overlay .sq-ov-members { grid-template-columns: 1fr; } }
@@ -5167,6 +5183,7 @@
<div class="sq-ov-members-head">สมาชิกในรอบนี้ — ต้องตอบถูกครบทุกคน</div>
<div id="sq-ov-members" class="sq-ov-members"></div>
<div id="sq-ov-result" class="sq-ov-result is-hidden"></div>
<button id="sq-ov-next" type="button" class="sq-ov-next-btn is-hidden" aria-label="ถัดไป"></button>
</div>
<div id="sq-ov-award" class="sq-ov-award is-hidden">
<div id="sq-ov-award-title" class="sq-ov-award-title">ได้รับการ์ดพิเศษ!</div>
@@ -5180,7 +5197,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.006131822"></script>
<script src="js/play.js?v=0.006131831"></script>
<div class="version-tag">v —</div>
<style id="qc-howto-mg2-fix">
/* HOW TO PLAY ของ quiz_carry (Minigame-4) -> ให้เหมือน Minigame-2 (gch-mg2-mock)
+5 -3
View File
@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/room-lobby.css?v=0.0175">
<link rel="stylesheet" href="css/profile-popup.css?v=6">
<link rel="stylesheet" href="css/profile-popup.css?v=7">
<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=17">
@@ -719,7 +719,8 @@
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
/* safe center: จัดกึ่งกลางเมื่อการ์ดพอดี แต่ชิดซ้าย (ไม่ตัดใบแรก) เมื่อการ์ดล้น เช่นโฟลเดอร์ที่มี >3 คดี */
justify-content: safe center;
align-items: flex-start;
gap: max(calc(10px * var(--lobby-preplay-case-scale, 1)), min(1.5vw, var(--lobby-preplay-case-row-gap, 24px)));
width: min(96vw, var(--lobby-preplay-case-row-width));
@@ -1615,7 +1616,8 @@
<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.0283"></script>
<script src="js/achievements.js?v=0.002" data-asset-base="img/03-6-Profile"></script>
<script src="js/room-lobby.js?v=0.0286"></script>
<div class="version-tag">v —</div>
</body>
</html>
+50 -17
View File
@@ -795,8 +795,8 @@ function clearSpecialQuizTimers(space) {
/** payload ไอคอนสำหรับ client (null = ไม่มี/ถูกทริกแล้ว) */
function specialQuizIconPayload(space) {
const sq = space && space.specialQuiz;
if (!sq || sq.triggered || sq.done) return null;
return { iconType: sq.iconType, x: sq.x, y: sq.y };
if (!sq || sq.triggered || sq.done || !sq.appeared) return null;
return { iconType: sq.iconType, x: sq.x, y: sq.y, expiresAt: sq.expiresAt || 0 };
}
/** เริ่มมินิเกมสืบสวน: สุ่ม 1/3 ว่ารอบนี้จะมีไอคอนพิเศษไหม + เลือกตำแหน่ง */
@@ -857,22 +857,33 @@ function emitSpecialQuizIcon(sid, space) {
if (payload) io.to(sid).emit('special-quiz-icon', payload);
}
/** ตั้งเวลาให้ไอคอนคำถามพิเศษ "หายไป" หลัง N วินาที ถ้ายังไม่มีใครเก็บ (ตั้งค่าใน admin) */
function scheduleSpecialQuizIconExpiry(sid, space) {
/** หน่วงไอคอน 2 วิ ค่อยโผล่ → emit ให้ทั้งห้อง + ตั้งเวลาให้ "หายไป" หลัง N วินาที (ถ้ายังไม่มีใครเก็บ) */
const SPECIAL_QUIZ_ICON_DELAY_MS = 2000;
function scheduleSpecialQuizIconAppearAndExpiry(sid, space) {
const sq = space && space.specialQuiz;
if (!sq || sq.triggered || sq.done) return;
const sec = readSpecialQuizIconExpireSec();
if (!(sec > 0)) return; /* 0 = ไม่หาย */
const t = setTimeout(() => {
const tAppear = setTimeout(() => {
const sp = spaces.get(sid);
if (!sp || !sp.specialQuiz) return;
if (sp.specialQuiz.triggered || sp.specialQuiz.done) return; /* ถูกเก็บ/ทริกแล้ว */
sp.specialQuiz = null;
io.to(sid).emit('special-quiz-icon-clear', {});
console.log('[special-quiz] icon EXPIRED after', sec + 's');
}, sec * 1000);
if (!sp || !sp.specialQuiz || sp.specialQuiz.triggered || sp.specialQuiz.done) return;
const cur = sp.specialQuiz;
const sec = readSpecialQuizIconExpireSec();
cur.appeared = true;
cur.expiresAt = (sec > 0) ? (Date.now() + sec * 1000) : 0;
emitSpecialQuizIcon(sid, sp);
if (sec > 0) {
const tExpire = setTimeout(() => {
const sp2 = spaces.get(sid);
if (!sp2 || !sp2.specialQuiz || sp2.specialQuiz.triggered || sp2.specialQuiz.done) return;
sp2.specialQuiz = null;
io.to(sid).emit('special-quiz-icon-clear', {});
console.log('[special-quiz] icon EXPIRED after', sec + 's');
}, sec * 1000);
if (!Array.isArray(sp.specialQuizTimers)) sp.specialQuizTimers = [];
sp.specialQuizTimers.push(tExpire);
}
}, SPECIAL_QUIZ_ICON_DELAY_MS);
if (!Array.isArray(space.specialQuizTimers)) space.specialQuizTimers = [];
space.specialQuizTimers.push(t);
space.specialQuizTimers.push(tAppear);
}
/** roster ของเซสชัน: ผู้เล่นจริง + บอทเติมช่อง (บอทตอบถูกเสมอ) — โชว์ในแผง "ใครตอบอะไร" */
@@ -3382,8 +3393,7 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
space.mapData = md;
/* สุ่มไอคอนคำถามพิเศษสำหรับรอบนี้ (1 ใน 3) — ส่งจริงผ่าน joinCb เมื่อ client เข้า play.html */
maybeSpawnSpecialQuizForRun(space);
emitSpecialQuizIcon(sid, space);
scheduleSpecialQuizIconExpiry(sid, space);
/* ไอคอนยังไม่โผล่ตอนนี้ — รอ client ส่ง 'special-quiz-arm' เมื่อเกม "live จริง" (พ้น howto/นับถอยหลัง) แล้วค่อยหน่วง 2 วิโผล่ */
let si = 0;
space.peers.forEach((p) => {
@@ -3571,11 +3581,14 @@ function computeAndEmitTrialResult(sid, space) {
const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : 0;
/* Card 5 Bail Coin — จับผิดตัวให้โหวตใหม่ได้ 1 ครั้ง */
{
/* โหมดเทสต์ (Ctrl+Alt+W): บังคับให้รอบนี้ "นับเป็นโหวตผิด" ครั้งเดียว เพื่อทริกการ์ด 5 */
const forceWrong = !!space.testForceWrongVote;
space.testForceWrongVote = false;
const c2 = [0, 0, 0];
Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) c2[v]++; });
let mostVoted = 0; for (let k = 1; k <= 2; k++) { if (c2[k] > c2[mostVoted]) mostVoted = k; }
const anyVotes = (c2[0] + c2[1] + c2[2]) > 0;
const wrong = anyVotes && mostVoted !== culprit;
const wrong = forceWrong ? anyVotes : (anyVotes && mostVoted !== culprit);
const pend = space.pendingSpecialCard;
if (wrong && pend && Number(pend.cardId) === 5 && !pend.consumed && !space.trialBailUsed) {
pend.consumed = true;
@@ -7466,6 +7479,26 @@ io.on('connection', (socket) => {
socket.data.testMode = !!(data && data.on);
});
/** Test Mode (Ctrl+Alt+W): บังคับให้รอบโหวตชี้คนร้ายถัดไป "นับเป็นโหวตผิด" 1 ครั้ง → ใช้เทสต์การ์ด 5 (จับผิดตัว/โหวตใหม่) */
socket.on('test-force-wrong-vote', () => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !serverMapIsPostCaseLobbyB(space) || !space.peers.has(socket.id)) return;
space.testForceWrongVote = true;
console.log('[test] force-wrong-vote armed by', socket.id);
});
/** client แจ้งว่าเกม "live จริง" แล้ว (พ้น howto/นับถอยหลัง) → ปล่อยไอคอนคำถามพิเศษ (หน่วง 2 วิ + นับถอยหลัง) ครั้งเดียว/รอบ */
socket.on('special-quiz-arm', () => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return;
const sq = space.specialQuiz;
if (!sq || sq.triggered || sq.done || sq.appeared || sq.armScheduled) return;
sq.armScheduled = true;
scheduleSpecialQuizIconAppearAndExpiry(sid, space);
});
socket.on('special-quiz-collide', (_data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
+3 -2
View File
@@ -10,7 +10,7 @@
<link rel="stylesheet" href="style.css?v=0.0189">
<link rel="stylesheet" href="daily-popup.css?v=8">
<link rel="stylesheet" href="../Game/css/customize-popup.css?v=42">
<link rel="stylesheet" href="../Game/css/profile-popup.css?v=5">
<link rel="stylesheet" href="../Game/css/profile-popup.css?v=7">
</head>
<body class="lobby-page">
<div class="lobby-bg" role="img" aria-label="พื้นหลัง">
@@ -229,7 +229,8 @@
<script src="../Game/js/display-name.js?v=2"></script>
<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="../Game/js/achievements.js?v=0.002" data-asset-base="/Game/img/03-6-Profile"></script>
<script src="../Game/js/profile-popup.js?v=7" data-profile-trigger="#btn-myprofile" data-profile-asset-base="/Game/img/03-6-Profile"></script>
<script src="lobby.js?v=0.0191"></script>
</body>
</html>