update Endflow
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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">บัญชี & 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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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%;
|
||||
|
||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
})();
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 ใบ/ปากคำ ก่อนโหวต) ===== */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||