โชว์การ์ดพิเศษที่ได้ + ปุ่มไปต่อ

This commit is contained in:
2026-06-02 08:29:04 +00:00
parent 18cad3e9f6
commit f98cbfa9e0
69 changed files with 725 additions and 48 deletions
+30
View File
@@ -2758,6 +2758,36 @@ code {
}
/* ===== Evidence Cards admin panel ===== */
.cm-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 14px;
}
.cm-pick {
border: 1px solid rgba(124, 154, 255, 0.25);
border-radius: 12px;
padding: 12px;
background: rgba(20, 26, 48, 0.4);
display: flex;
flex-direction: column;
gap: 10px;
}
.cm-pick-label {
font-weight: 800;
font-size: 0.92rem;
color: #cdd6ff;
}
.cm-pick-prev {
width: 100%;
height: 200px;
object-fit: contain;
background: #0a0e1a;
border-radius: 8px;
border: 1px solid rgba(124, 154, 255, 0.18);
}
.cm-pick select { width: 100%; }
.ev-suspect-block {
border: 1px solid rgba(124, 154, 255, 0.25);
border-radius: 12px;
+139
View File
@@ -4184,6 +4184,144 @@
if (saveBtn) saveBtn.addEventListener('click', saveEvidenceCardsPanel);
})();
/* ===== รูปคดี & Cutscene (LobbyA → LobbyB) ===== */
var CASE_MEDIA_API = '/Game/api/case-media';
var CASE_MEDIA_IMAGES_API = '/Game/api/case-media-images';
var CASE_MEDIA_COUNT = 15;
var caseMediaData = null;
var caseMediaImages = null; // { case:[...], cutscene:[...] }
var caseMediaCurrent = '1';
function caseMediaPopulateSelect() {
var sel = el('case-media-case');
if (!sel || sel.options.length) return;
for (var i = 1; i <= CASE_MEDIA_COUNT; i++) {
var o = document.createElement('option');
o.value = String(i); o.textContent = 'คดี ' + i;
sel.appendChild(o);
}
}
function loadCaseMediaPanel() {
caseMediaPopulateSelect();
if (caseMediaImages == null) {
fetch(CASE_MEDIA_IMAGES_API + '?_=' + Date.now(), { credentials: 'include', cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (j) { caseMediaImages = (j && j.images) || { case: [], cutscene: [] }; renderCaseMedia(caseMediaCurrent); })
.catch(function () { caseMediaImages = { case: [], cutscene: [] }; });
}
fetch(CASE_MEDIA_API + '?_=' + Date.now(), { credentials: 'include', cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (j) {
caseMediaData = (j && j.cases) ? j : { cases: {} };
var sel = el('case-media-case');
if (sel) caseMediaCurrent = sel.value || '1';
renderCaseMedia(caseMediaCurrent);
})
.catch(function (e) {
setMsg('case-media-msg', (e.message || 'โหลดไม่ได้') + ' — เช็คว่า Node รัน Game/server.js และ /Game/api/case-media ไม่ 404', 'error');
});
}
function caseMediaGetCase(cid) {
if (!caseMediaData) caseMediaData = { cases: {} };
if (!caseMediaData.cases) caseMediaData.cases = {};
var n = parseInt(cid, 10) || 1;
if (!caseMediaData.cases[cid]) caseMediaData.cases[cid] = {};
var c = caseMediaData.cases[cid];
if (typeof c.name !== 'string') c.name = 'คดีที่ ' + n;
if (typeof c.art !== 'string') c.art = '/Main-Lobby/IMAGE/case/case-' + n + '.png';
if (typeof c.detail !== 'string') c.detail = '/Main-Lobby/IMAGE/case/case-detail-' + n + '.png';
if (!Array.isArray(c.story)) c.story = [];
if (typeof c.story[0] !== 'string') c.story[0] = '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-1.png';
if (typeof c.story[1] !== 'string') c.story[1] = '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-2.png';
return c;
}
/** ตัวเลือกรูป (dropdown + พรีวิว) — kind = 'case' | 'cutscene' */
function caseMediaImagePicker(label, kind, current, onChange) {
var wrap = document.createElement('div');
wrap.className = 'cm-pick';
var lab = document.createElement('div'); lab.className = 'cm-pick-label'; lab.textContent = label;
var prev = document.createElement('img'); prev.className = 'cm-pick-prev'; prev.alt = ''; prev.src = current;
prev.onerror = function () { this.style.opacity = '0.25'; };
var sel = document.createElement('select'); sel.className = 'admin-inp-num';
var list = (caseMediaImages && caseMediaImages[kind]) ? caseMediaImages[kind].slice() : [];
if (current && list.indexOf(current) < 0) list.unshift(current);
list.forEach(function (u) {
var o = document.createElement('option');
o.value = u; o.textContent = u.split('/').pop();
if (u === current) o.selected = true;
sel.appendChild(o);
});
sel.addEventListener('change', function () { prev.src = sel.value; onChange(sel.value); });
wrap.appendChild(lab); wrap.appendChild(prev); wrap.appendChild(sel);
return wrap;
}
function renderCaseMedia(cid) {
caseMediaCurrent = cid;
var tag = el('case-media-set-tag');
if (tag) tag.textContent = 'กำลังแก้: คดี ' + cid;
var root = el('case-media-root');
if (!root) return;
var c = caseMediaGetCase(cid);
root.innerHTML = '';
var nameLabel = document.createElement('label');
nameLabel.className = 'admin-field';
nameLabel.textContent = 'ชื่อคดี';
var nameInp = document.createElement('input');
nameInp.type = 'text'; nameInp.maxLength = 80; nameInp.value = c.name || '';
nameInp.addEventListener('input', function () { c.name = nameInp.value; });
nameLabel.appendChild(nameInp);
root.appendChild(nameLabel);
var grid = document.createElement('div');
grid.className = 'cm-grid';
grid.appendChild(caseMediaImagePicker('รูปเลือกคดี (การ์ด)', 'case', c.art, function (v) { c.art = v; }));
grid.appendChild(caseMediaImagePicker('รูปรายละเอียดคดี', 'case', c.detail, function (v) { c.detail = v; }));
grid.appendChild(caseMediaImagePicker('Cutscene รูปที่ 1', 'cutscene', c.story[0], function (v) { c.story[0] = v; }));
grid.appendChild(caseMediaImagePicker('Cutscene รูปที่ 2', 'cutscene', c.story[1], function (v) { c.story[1] = v; }));
root.appendChild(grid);
}
function saveCaseMediaPanel() {
if (!caseMediaData) return;
var btn = el('btn-case-media-save');
if (btn) btn.disabled = true;
setMsg('case-media-msg', 'กำลังบันทึก…', '');
fetch(CASE_MEDIA_API, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(caseMediaData),
}).then(function (r) {
return r.text().then(function (t) {
var j = {};
try { j = t ? JSON.parse(t) : {}; } catch (e) { /* ignore */ }
if (!r.ok || !j.ok) throw new Error((j && j.error) || ('HTTP ' + r.status));
return j;
});
}).then(function (j) {
if (j && j.cases) caseMediaData = { cases: j.cases };
renderCaseMedia(caseMediaCurrent);
setMsg('case-media-msg', 'บันทึกแล้ว — มีผลในเกมเมื่อรีเฟรช LobbyA/LobbyB', 'ok');
}).catch(function (e) {
setMsg('case-media-msg', e.message || 'บันทึกไม่ได้', 'error');
}).then(function () {
if (btn) btn.disabled = false;
});
}
(function bindCaseMediaPanel() {
caseMediaPopulateSelect();
var caseSel = el('case-media-case');
if (caseSel) caseSel.addEventListener('change', function () { renderCaseMedia(caseSel.value || '1'); });
var saveBtn = el('btn-case-media-save');
if (saveBtn) saveBtn.addEventListener('click', saveCaseMediaPanel);
})();
function setTab(name) {
document.querySelectorAll('.tab').forEach(function (t) {
var on = t.getAttribute('data-tab') === name;
@@ -4212,6 +4350,7 @@
if (name === 'quiz') loadQuizSettingsPanel();
if (name === 'special-quiz') loadSpecialQuizPanel();
if (name === 'evidence-cards') loadEvidenceCardsPanel();
if (name === 'case-media') loadCaseMediaPanel();
if (name === 'quiz-carry') loadQuizCarryPanel();
if (name === 'quiz-battle') loadQbBattlePanel();
if (name === 'jump-survive') loadJumpSurviveTimingPanel();
+25 -2
View File
@@ -10,7 +10,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<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="admin.css?v=36">
<link rel="stylesheet" href="admin.css?v=37">
</head>
<body>
<a class="skip-link" href="#admin-main">ข้ามไปเนื้อหา</a>
@@ -84,6 +84,7 @@
<div class="admin-tab-sep" aria-hidden="true"><span>คำถาม &amp; การ์ดพิเศษ</span></div>
<button type="button" class="tab" data-tab="special-quiz" role="tab" id="tab-special-quiz" aria-controls="tab-panel-special-quiz"><span class="tab-label">ชุดคำถามพิเศษ</span><span class="tab-desc">15 ชุด (Level × Case) · การ์ดรางวัล</span></button>
<button type="button" class="tab" data-tab="evidence-cards" role="tab" id="tab-evidence-cards" aria-controls="tab-panel-evidence-cards"><span class="tab-label">การ์ดหลักฐาน</span><span class="tab-desc">3 คดี × ผู้ต้องสงสัย × 3 ใบ · รูป/ข้อความ</span></button>
<button type="button" class="tab" data-tab="case-media" role="tab" id="tab-case-media" aria-controls="tab-panel-case-media"><span class="tab-label">รูปคดี &amp; Cutscene</span><span class="tab-desc">15 คดี · รูปเลือกคดี/รายละเอียด/เรื่องราว 2 รูป</span></button>
<div class="admin-tab-sep" aria-hidden="true"><span>ระบบ &amp; บัญชี</span></div>
<button type="button" class="tab" data-tab="characters" role="tab" id="tab-characters" aria-controls="tab-panel-characters"><span class="tab-label">ตัวละคร</span><span class="tab-desc">อัปโหลด · เลือกใช้ในเกม</span></button>
@@ -332,6 +333,28 @@
<p id="evidence-cards-msg" class="msg" role="status"></p>
</section>
<section id="tab-panel-case-media" class="tab-panel card" hidden role="tabpanel" aria-labelledby="tab-case-media">
<h2 class="sq-title">รูปคดี &amp; Cutscene — LobbyA → LobbyB</h2>
<p class="muted">มี <strong>15 คดี</strong> · แต่ละคดีตั้งได้: <strong>ชื่อคดี</strong>, <strong>รูปการ์ดเลือกคดี</strong>, <strong>รูปรายละเอียดคดี</strong>, และ <strong>Cutscene 2 รูป</strong> (โชว์ก่อนเข้า LobbyB · ต้องกด «ไปต่อ») · เลือกรูปจากที่อัปโหลดไว้ใน <code>/Main-Lobby/IMAGE/case</code> และ <code>/Main-Lobby/IMAGE/cutscene</code> · เก็บที่ <code>/Game/data/case-media.json</code></p>
<fieldset class="sq-fieldset sq-pick">
<legend>เลือกคดีที่จะแก้</legend>
<div class="sq-pick-row">
<label class="admin-field">คดี (Case)
<select id="case-media-case" class="admin-inp-num sq-select"></select>
</label>
<span id="case-media-set-tag" class="sq-set-tag">กำลังแก้: คดี 1</span>
</div>
</fieldset>
<div id="case-media-root"></div>
<div class="quiz-admin-actions">
<button type="button" class="btn btn-primary" id="btn-case-media-save">บันทึกรูปคดีทั้งหมด (15 คดี)</button>
</div>
<p id="case-media-msg" class="msg" role="status"></p>
</section>
<section id="tab-panel-quiz-carry" class="tab-panel card" hidden role="tabpanel" aria-labelledby="tab-quiz-carry">
<h2>คำถามหลายตัวเลือก (หยิบมาวาง)</h2>
<p class="muted">ใช้กับฉากประเภท <strong>ตอบคำถาม — หยิบคำตอบมาวางกลาง</strong> ใน Editor — ผู้เล่นหยิบตัวเลือกไปวางโซนกลาง · ถ้ามีรายการที่นี่ เกมจะใช้<strong>เฉพาะชุดนี้</strong> (ไม่ผสมกับคำถามถูก/ผิดแท็บคำถามเกม) · เก็บที่ <code>/Game/data/quiz-settings.json</code> ฟิลด์ <code>carryQuestions</code></p>
@@ -1036,6 +1059,6 @@
</div>
</main>
</div>
<script src="admin.js?v=77"></script>
<script src="admin.js?v=78"></script>
</body>
</html>
+139
View File
@@ -0,0 +1,139 @@
{
"cases": {
"1": {
"name": "คดีโจรกรรมไซเบอร์",
"art": "/Main-Lobby/IMAGE/case/case-1.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-1.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-1-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-1-story-2.png"
]
},
"2": {
"name": "คดีปล้นร้านอัญมณี",
"art": "/Main-Lobby/IMAGE/case/case-2.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-2.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-2-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-2-story-2.png"
]
},
"3": {
"name": "คดีฆาตกรรมปริศนา",
"art": "/Main-Lobby/IMAGE/case/case-3.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-3.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-3-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-3-story-2.png"
]
},
"4": {
"name": "คดีที่ 4",
"art": "/Main-Lobby/IMAGE/case/case-4.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-4.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-4-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-4-story-2.png"
]
},
"5": {
"name": "คดีที่ 5",
"art": "/Main-Lobby/IMAGE/case/case-5.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-5.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-5-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-5-story-2.png"
]
},
"6": {
"name": "คดีที่ 6",
"art": "/Main-Lobby/IMAGE/case/case-6.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-6.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-6-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-6-story-2.png"
]
},
"7": {
"name": "คดีที่ 7",
"art": "/Main-Lobby/IMAGE/case/case-7.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-7.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-7-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-7-story-2.png"
]
},
"8": {
"name": "คดีที่ 8",
"art": "/Main-Lobby/IMAGE/case/case-8.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-8.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-8-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-8-story-2.png"
]
},
"9": {
"name": "คดีที่ 9",
"art": "/Main-Lobby/IMAGE/case/case-9.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-9.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-9-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-9-story-2.png"
]
},
"10": {
"name": "คดีที่ 10",
"art": "/Main-Lobby/IMAGE/case/case-10.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-10.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-10-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-10-story-2.png"
]
},
"11": {
"name": "คดีที่ 11",
"art": "/Main-Lobby/IMAGE/case/case-11.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-11.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-11-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-11-story-2.png"
]
},
"12": {
"name": "คดีที่ 12",
"art": "/Main-Lobby/IMAGE/case/case-12.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-12.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-12-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-12-story-2.png"
]
},
"13": {
"name": "คดีที่ 13",
"art": "/Main-Lobby/IMAGE/case/case-13.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-13.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-13-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-13-story-2.png"
]
},
"14": {
"name": "คดีที่ 14",
"art": "/Main-Lobby/IMAGE/case/case-14.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-14.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-14-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-14-story-2.png"
]
},
"15": {
"name": "คดีที่ 15",
"art": "/Main-Lobby/IMAGE/case/case-15.png",
"detail": "/Main-Lobby/IMAGE/case/case-detail-15.png",
"story": [
"/Main-Lobby/IMAGE/cutscene/case-15-story-1.png",
"/Main-Lobby/IMAGE/cutscene/case-15-story-2.png"
]
}
}
}
+64 -19
View File
@@ -11457,6 +11457,7 @@
let specialQuizIcon = null; // { iconType, x, y } (พิกัด tile-center)
let specialQuizIconScreenRect = null; // { x, y, r } พิกัดบนจอ (ไว้คลิก/แตะไอคอน)
let specialQuizCollideSent = false;
let specialQuizCollideCooldownUntil = 0;
let specialQuizAwardedCard = null; // การ์ดที่ได้รอบนี้ (จาก special-quiz-ended)
let specialQuizActiveQuestion = null; // { index, total, choices, endsAt, iconType }
let specialQuizAnswered = false;
@@ -11468,31 +11469,49 @@
let specialQuizRoster = []; // [{ id, nickname, isBot }]
let specialQuizAnswerStatus = {}; // id -> true (ตอบแล้ว)
let specialQuizFreezeLastTickMs = 0;
function beginSpecialQuizFreeze() {
if (specialQuizFreeze) return;
specialQuizFreeze = true;
specialQuizFreezeStartMs = Date.now();
specialQuizFreezeLastTickMs = Date.now();
try { for (const k in keys) keys[k] = false; } catch (e) { /* ignore */ }
try { playPath = []; } catch (e) { /* ignore */ }
if (me) me.isWalking = false;
}
/** เลื่อน deadline ที่อิงเวลาจริง (quiz_carry / quiz HUD) ไปข้างหน้าเท่าเวลาที่หยุด */
/** deadline/anchor delta freeze
เพอให "เวลาที่แสดง" หยดน (ไมใชแคชดเชยตอนจบ) */
function shiftSpecialQuizFrozenTimers(deltaMs) {
if (!(deltaMs > 0)) return;
const shift = (v) => (typeof v === 'number' && v > 0) ? v + deltaMs : v;
try { playQuizPhaseEndsAt = shift(playQuizPhaseEndsAt); } catch (e) {}
try { quizCarryEmbedCountdownStartAt = shift(quizCarryEmbedCountdownStartAt); } catch (e) {}
try { quizCarryEmbedCountdownEndAt = shift(quizCarryEmbedCountdownEndAt); } catch (e) {}
try { quizCarryEmbedPreOptionCountdownStartAt = shift(quizCarryEmbedPreOptionCountdownStartAt); } catch (e) {}
try { quizCarryEmbedPreOptionCountdownEndAt = shift(quizCarryEmbedPreOptionCountdownEndAt); } catch (e) {}
try { quizCarryOptionRevealAt = shift(quizCarryOptionRevealAt); } catch (e) {}
try { quizCarryAnswerCloseAt = shift(quizCarryAnswerCloseAt); } catch (e) {}
try { if (typeof jumpSurviveSessionStartMs === 'number' && jumpSurviveSessionStartMs > 0) jumpSurviveSessionStartMs += deltaMs; } catch (e) {}
try { if (typeof spaceShooterSessionStartMs === 'number' && spaceShooterSessionStartMs > 0) spaceShooterSessionStartMs += deltaMs; } catch (e) {}
}
/** เรียกทุกเฟรมระหว่าง freeze — ดันเวลาไปข้างหน้าเท่าที่ผ่านไปจริง ทำให้ countdown หยุดนิ่ง */
function tickSpecialQuizFreezeShift() {
if (!specialQuizFreeze) return;
const now = Date.now();
shiftSpecialQuizFrozenTimers(now - specialQuizFreezeLastTickMs);
specialQuizFreezeLastTickMs = now;
}
function endSpecialQuizFreeze() {
if (!specialQuizFreeze) return;
const frozenMs = Date.now() - specialQuizFreezeStartMs;
/* ชดเชย delta ที่เหลือตั้งแต่เฟรมสุดท้ายจนถึงตอนนี้ */
shiftSpecialQuizFrozenTimers(Date.now() - specialQuizFreezeLastTickMs);
specialQuizFreeze = false;
specialQuizFreezeStartMs = 0;
if (frozenMs > 0) {
const shift = (v) => (typeof v === 'number' && v > 0) ? v + frozenMs : v;
try { playQuizPhaseEndsAt = shift(playQuizPhaseEndsAt); } catch (e) {}
try { quizCarryEmbedCountdownStartAt = shift(quizCarryEmbedCountdownStartAt); } catch (e) {}
try { quizCarryEmbedCountdownEndAt = shift(quizCarryEmbedCountdownEndAt); } catch (e) {}
try { quizCarryEmbedPreOptionCountdownStartAt = shift(quizCarryEmbedPreOptionCountdownStartAt); } catch (e) {}
try { quizCarryEmbedPreOptionCountdownEndAt = shift(quizCarryEmbedPreOptionCountdownEndAt); } catch (e) {}
try { quizCarryOptionRevealAt = shift(quizCarryOptionRevealAt); } catch (e) {}
try { quizCarryAnswerCloseAt = shift(quizCarryAnswerCloseAt); } catch (e) {}
}
specialQuizFreezeLastTickMs = 0;
}
function specialQuizIconUrl(type) {
@@ -11514,12 +11533,27 @@
y: Number(payload.y),
};
specialQuizCollideSent = false;
specialQuizCollideCooldownUntil = 0;
specialQuizGetIconImg(specialQuizIcon.iconType);
}
function clearSpecialQuizIcon() {
specialQuizIcon = null;
specialQuizIconScreenRect = null;
specialQuizCollideSent = false;
specialQuizCollideCooldownUntil = 0;
}
/** ส่ง collide ไป server เพื่อเปิดคำถาม — ถ้า server ปฏิเสธ (เช่น race) ให้ลองใหม่ได้หลัง cooldown */
function sendSpecialQuizCollide() {
if (specialQuizCollideSent || specialQuizActiveQuestion) return;
if (Date.now() < specialQuizCollideCooldownUntil) return;
specialQuizCollideSent = true;
specialQuizCollideCooldownUntil = Date.now() + 800;
try {
socket.emit('special-quiz-collide', {}, function (res) {
if (!(res && res.ok) && !specialQuizActiveQuestion) specialQuizCollideSent = false;
});
} catch (e) { specialQuizCollideSent = false; }
}
/** กด/แตะไอคอนควิซเพื่อเปิดคำถาม (ใช้ได้ทุกมินิเกม รวม space shooter ที่เดินไปแตะไม่ได้) */
@@ -11532,9 +11566,10 @@
const py = (clientY - r.top) * (canvas.height / r.height);
const dx = px - specialQuizIconScreenRect.x;
const dy = py - specialQuizIconScreenRect.y;
if (dx * dx + dy * dy <= specialQuizIconScreenRect.r * specialQuizIconScreenRect.r) {
specialQuizCollideSent = true;
try { socket.emit('special-quiz-collide', {}, function () {}); } catch (e) { /* ignore */ }
/* เผื่อระยะกดให้กว้างขึ้น — แตะใกล้ไอคอนก็เปิดได้ */
const hitR = specialQuizIconScreenRect.r * 1.3;
if (dx * dx + dy * dy <= hitR * hitR) {
sendSpecialQuizCollide();
return true;
}
return false;
@@ -11582,9 +11617,9 @@
if (!Number.isFinite(me.x) || !Number.isFinite(me.y)) return;
const dx = me.x - specialQuizIcon.x;
const dy = me.y - specialQuizIcon.y;
if (dx * dx + dy * dy <= 0.85 * 0.85) {
specialQuizCollideSent = true;
try { socket.emit('special-quiz-collide', {}, function () {}); } catch (e) { /* ignore */ }
/* รัศมีกว้างขึ้น (1.25 tile) — ไอคอนวาดใหญ่ 1.75 tile เดินเข้าใกล้ก็ทริกได้ ไม่ต้องทับ tile กลางพอดี */
if (dx * dx + dy * dy <= 1.25 * 1.25) {
sendSpecialQuizCollide();
}
}
@@ -11662,6 +11697,11 @@
if (qEl) qEl.textContent = q.text || '';
const result = document.getElementById('sq-ov-result');
if (result) { result.classList.add('is-hidden'); result.textContent = ''; result.className = 'sq-ov-result is-hidden'; }
/* แสดงเฉพาะ layer คำถาม/คำตอบ — ซ่อน layer ผลรางวัล */
const quizBody = document.getElementById('sq-ov-quiz-body');
if (quizBody) quizBody.classList.remove('is-hidden');
const awardLayer = document.getElementById('sq-ov-award');
if (awardLayer) awardLayer.classList.add('is-hidden');
const status = document.getElementById('sq-ov-status');
if (status) status.textContent = 'เลือกคำตอบ — ทุกคนต้องตอบถูกทุกข้อจึงได้การ์ดพิเศษ';
const choicesEl = document.getElementById('sq-ov-choices');
@@ -11897,6 +11937,9 @@
if (fxEl) fxEl.textContent = 'ตอบถูกครบทุกคนเพื่อรับการ์ดพิเศษในครั้งหน้า';
}
if (btn) btn.onclick = specialQuizContinueAndResume;
/* สลับ layer: ซ่อนคำถาม/คำตอบ แล้วโชว์ผลรางวัลเต็มหน้า */
const quizBody = document.getElementById('sq-ov-quiz-body');
if (quizBody) quizBody.classList.add('is-hidden');
award.classList.remove('is-hidden');
} else {
/* ไม่มี element สำรอง — เล่นต่อทันที */
@@ -19888,8 +19931,10 @@
let lastSend = 0;
function tick() {
if (!mapData) { requestAnimationFrame(tick); return; }
/* คำถามพิเศษเปิดอยู่ — หยุดเกมทุกอย่าง (input/sim/timer) เห็นแต่ภาพค้างไว้ */
/* (input/sim/timer)
นเวลาทกตวไปขางหนาเท delta กเฟรม countdown แสดงหยดนงจร */
if (specialQuizFreeze) {
tickSpecialQuizFreezeShift();
if (me) me.isWalking = false;
draw();
requestAnimationFrame(tick);
+176 -10
View File
@@ -1807,9 +1807,106 @@
preplayStepCase.style.setProperty('--lobby-preplay-case-scale', scale.toFixed(4));
}
/* ===== รูปคดี + Cutscene (โหลดจาก /api/case-media — แก้ใน Admin ได้) ===== */
var caseMediaData = null;
var caseMediaFetchPromise = null;
function ensureCaseMediaLoaded() {
if (caseMediaData) return Promise.resolve(caseMediaData);
if (caseMediaFetchPromise) return caseMediaFetchPromise;
caseMediaFetchPromise = fetch(SERVER + '/api/case-media', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { caseMediaData = (j && j.cases) ? j : { cases: {} }; return caseMediaData; })
.catch(function () { caseMediaFetchPromise = null; return { cases: {} }; });
return caseMediaFetchPromise;
}
function getCaseMedia(cid) {
var n = parseInt(cid, 10);
if (!(n >= 1)) n = 1;
var def = {
name: 'คดีที่ ' + n,
art: '/Main-Lobby/IMAGE/case/case-' + n + '.png',
detail: '/Main-Lobby/IMAGE/case/case-detail-' + n + '.png',
story: ['/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-1.png', '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-2.png'],
};
var c = (caseMediaData && caseMediaData.cases) ? (caseMediaData.cases[n] || caseMediaData.cases[String(n)]) : null;
if (!c) return def;
return {
name: c.name || def.name,
art: c.art || def.art,
detail: c.detail || def.detail,
story: (Array.isArray(c.story) && c.story.length) ? c.story : def.story,
};
}
/** สร้างการ์ดเลือกคดีตามระดับที่เลือก — 5 ระดับ × 3 คดี (L1=1-3, L2=4-6 … L5=13-15) */
function renderPreplayCaseCards() {
var row = preplayOverlay ? preplayOverlay.querySelector('.lobby-preplay-case-row') : null;
if (!row) return;
var lvl = parseInt(preplaySelectedLevel || (preplayOverlay && preplayOverlay.dataset.preplayLevel) || '1', 10);
if (!(lvl >= 1 && lvl <= 5)) lvl = 1;
var startCase = (lvl - 1) * 3 + 1;
var endCase = startCase + 2;
var html = '';
for (var n = startCase; n <= endCase; n++) {
var m = getCaseMedia(n);
var nm = String(m.name || ('คดีที่ ' + n)).replace(/"/g, '&quot;');
html += '<div class="lobby-case-card-wrap">' +
'<img class="lobby-case-art" src="' + m.art + '" alt="' + nm + '" decoding="async">' +
'<div class="lobby-case-actions">' +
'<button type="button" class="lobby-case-detail" data-case="' + n + '" title="รายละเอียด"><img src="/Main-Lobby/IMAGE/case/btn-detail.png" alt="รายละเอียด" decoding="async"></button>' +
'<button type="button" class="lobby-case-start" data-case="' + n + '" title="เริ่มคดี"><img src="/Main-Lobby/IMAGE/case/btn-startcase.png" alt="เริ่มคดี" decoding="async"></button>' +
'</div>' +
'</div>';
}
row.innerHTML = html;
}
/** Cutscene ก่อนเข้า LobbyB — โชว์ภาพเรื่องราว 2 รูปของคดี ต้องกด «ไปต่อ» ทีละรูป */
function showCaseCutscene(cid, onDone) {
var media = getCaseMedia(cid);
var imgs = (media.story || []).filter(function (s) { return typeof s === 'string' && s; });
var finished = false;
var done = function () { if (finished) return; finished = true; if (typeof onDone === 'function') onDone(); };
if (!imgs.length) { done(); return; }
var idx = 0;
var ov = document.getElementById('case-cutscene-overlay');
if (!ov) {
ov = document.createElement('div');
ov.id = 'case-cutscene-overlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:99990;display:flex;align-items:center;justify-content:center;background:#05070f';
ov.innerHTML =
'<img id="case-cutscene-img" alt="" decoding="async" style="max-width:100vw;max-height:100vh;width:auto;height:auto;object-fit:contain;display:block">' +
'<div style="position:absolute;left:0;right:0;bottom:24px;display:flex;align-items:center;justify-content:center;gap:16px;padding:0 24px;pointer-events:none">' +
'<span id="case-cutscene-progress" style="font:800 15px/1 Kanit,system-ui,sans-serif;color:#cdd6ff;background:rgba(8,12,26,.6);padding:8px 14px;border-radius:999px"></span>' +
'<button type="button" id="case-cutscene-next" style="pointer-events:auto;cursor:pointer;border:none;border-radius:999px;padding:13px 38px;font:800 17px/1 Kanit,system-ui,sans-serif;color:#0a0e1a;background:linear-gradient(180deg,#ffd666,#f0b429);box-shadow:0 10px 28px rgba(240,180,41,.5)">ไปต่อ ▶</button>' +
'</div>';
document.body.appendChild(ov);
}
ov.classList.remove('is-hidden');
ov.style.display = 'flex';
var imgEl = document.getElementById('case-cutscene-img');
var progEl = document.getElementById('case-cutscene-progress');
var nextBtn = document.getElementById('case-cutscene-next');
function render() {
if (imgEl) imgEl.src = imgs[idx];
if (progEl) progEl.textContent = (idx + 1) + ' / ' + imgs.length;
if (nextBtn) nextBtn.textContent = (idx + 1 >= imgs.length) ? 'เริ่มคดี ▶' : 'ไปต่อ ▶';
}
function finish() { ov.classList.add('is-hidden'); ov.style.display = 'none'; done(); }
if (nextBtn) nextBtn.onclick = function () { idx++; if (idx >= imgs.length) finish(); else render(); };
/* preload รูปถัดไป */
if (imgs[1]) { var pre = new Image(); pre.src = imgs[1]; }
render();
}
function showPreplayCaseStep() {
if (preplayStepLevel) preplayStepLevel.classList.add('is-hidden');
if (preplayStepCase) preplayStepCase.classList.remove('is-hidden');
ensureCaseMediaLoaded().then(function () {
renderPreplayCaseCards();
syncPreplayCaseStepScale();
requestAnimationFrame(syncPreplayCaseStepScale);
});
syncPreplayCaseStepScale();
requestAnimationFrame(syncPreplayCaseStepScale);
}
@@ -1906,11 +2003,16 @@
});
});
document.querySelectorAll('.lobby-case-detail').forEach(function (btn) {
btn.addEventListener('click', function () {
var o = document.getElementById('lobby-preplay-case-detail-overlay');
if (o) o.classList.remove('is-hidden');
});
/* delegation — การ์ดถูก render ใหม่ 15 ใบ; ตั้งรูปรายละเอียดตามคดีที่กด */
preplayOverlay.addEventListener('click', function (ev) {
var detailBtn = ev.target.closest('.lobby-case-detail');
if (!detailBtn || !preplayOverlay.contains(detailBtn)) return;
var cid = (detailBtn.getAttribute('data-case') || '').trim();
var o = document.getElementById('lobby-preplay-case-detail-overlay');
if (!o) return;
var img = o.querySelector('img');
if (img && cid) img.src = getCaseMedia(cid).detail;
o.classList.remove('is-hidden');
});
var detailClose = document.getElementById('lobby-preplay-case-detail-close');
@@ -3954,6 +4056,7 @@
};
fullImg.src = c.imageUrl;
card.appendChild(fullImg);
makeEvidenceCardZoomable(card, c, linkName);
return card;
}
buildEvHtmlCard(card, c, linkName, cardIdx);
@@ -4975,7 +5078,11 @@
if (res && res.ok) {
tmState.submitted = true;
if (!tmState.submittedPicksById) tmState.submittedPicksById = {};
tmState.submittedPicksById[socket.id] = tmState.picks.slice(0, req);
/* เก็บเป็น card object (สอดคล้องกับ data.picks จาก server) ไม่ใช่ index */
var ownedRow = (myPlayerEvidence[tmState.round] || []);
tmState.submittedPicksById[socket.id] = tmState.picks.slice(0, req)
.map(function (slot) { return ownedRow[slot]; })
.filter(function (c) { return c && typeof c === 'object' && c.imageUrl; });
if (tmState.submittedIds.indexOf(socket.id) < 0) tmState.submittedIds.push(socket.id);
es3SetStatus('ส่งหลักฐานแล้ว — รอผู้เล่นอื่น...');
es3RenderMembers(tmState.submittedIds, [], false);
@@ -5106,6 +5213,60 @@
card.querySelector('.sname').textContent = nm;
}
var EVIDENCE_LIGHTBOX_GLOW = { common: 'rgba(120,160,255,.55)', rare: 'rgba(150,120,255,.6)', legendary: 'rgba(255,196,90,.65)' };
/** เปิดดูการ์ดหลักฐานแบบเต็มจอ + ปุ่มปิด (คลิก backdrop / กากบาท / Esc เพื่อปิด) */
function openEvidenceCardLightbox(imageUrl, label, rarity) {
if (!imageUrl) return;
var rar = String(rarity || 'common').toLowerCase();
if (!EVIDENCE_LIGHTBOX_GLOW[rar]) rar = 'common';
var ov = document.getElementById('evidence-card-lightbox');
if (!ov) {
ov = document.createElement('div');
ov.id = 'evidence-card-lightbox';
ov.style.cssText = 'position:fixed;inset:0;z-index:100000;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(5,8,18,.86);backdrop-filter:blur(4px)';
ov.innerHTML =
'<button type="button" class="ecl-close" aria-label="ปิด" ' +
'style="position:absolute;top:22px;right:26px;width:46px;height:46px;border:none;border-radius:50%;cursor:pointer;font:900 26px/1 system-ui,sans-serif;color:#0a0e1a;background:linear-gradient(180deg,#ffd666,#f0b429);box-shadow:0 8px 22px rgba(0,0,0,.45)">&times;</button>' +
'<div class="ecl-stage" style="display:flex;flex-direction:column;align-items:center;gap:14px;max-width:92vw">' +
'<img class="ecl-img" alt="" style="display:block;width:auto;height:auto;max-width:min(86vw,520px);max-height:78vh;border-radius:16px;animation:eclPop .3s cubic-bezier(.2,.9,.3,1.35)" />' +
'<div class="ecl-cap" style="font:800 16px/1.3 Kanit,system-ui,sans-serif;color:#e7ecff;text-align:center;max-width:90vw"></div>' +
'</div>';
document.body.appendChild(ov);
if (!document.getElementById('ecl-anim-style')) {
var st = document.createElement('style');
st.id = 'ecl-anim-style';
st.textContent = '@keyframes eclPop{from{opacity:0;transform:scale(.85)}to{opacity:1;transform:none}}';
document.head.appendChild(st);
}
var closeFn = function () { ov.classList.add('is-hidden'); ov.style.display = 'none'; };
ov.addEventListener('click', function (e) { if (e.target === ov || e.target.classList.contains('ecl-close')) closeFn(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && ov.style.display !== 'none') closeFn();
});
}
var img = ov.querySelector('.ecl-img');
var cap = ov.querySelector('.ecl-cap');
if (img) {
img.style.boxShadow = '0 0 44px ' + EVIDENCE_LIGHTBOX_GLOW[rar] + ',0 22px 60px rgba(0,0,0,.6)';
img.src = imageUrl;
}
if (cap) cap.textContent = label || '';
ov.classList.remove('is-hidden');
ov.style.display = 'flex';
}
/** ทำให้การ์ด (PNG เต็มใบ) คลิกเพื่อดูใหญ่ได้ */
function makeEvidenceCardZoomable(cardEl, c, linkName) {
if (!cardEl || !c || !c.imageUrl) return;
cardEl.style.cursor = 'zoom-in';
cardEl.addEventListener('click', function () {
var label = (c.titleTh || '') + (c.titleEn ? ' · ' + c.titleEn : '');
if (!label && linkName) label = linkName;
openEvidenceCardLightbox(c.imageUrl, label, c.rarity);
});
}
function esRevealCardEl(c, linkName, cardIdx) {
var rar = String(c.rarity || 'common').toLowerCase();
if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common';
@@ -5120,6 +5281,7 @@
img.onerror = function () { this.onerror = null; buildEsRevealHtmlCard(card, c, linkName, cardIdx); };
img.src = c.imageUrl;
card.appendChild(img);
makeEvidenceCardZoomable(card, c, linkName);
return card;
}
buildEsRevealHtmlCard(card, c, linkName, cardIdx);
@@ -5136,12 +5298,11 @@
if (k === memberId || String(k) === String(memberId)) sel = picksMap[k];
});
}
if (!sel) return [];
if (!Array.isArray(sel)) return [];
/* picks = array ของ card object (server เก็บเป็น object หลัง refactor) — คืน object ตามเดิม */
var out = [];
sel.forEach(function (ci) {
var idx = Math.floor(Number(ci));
if (idx >= 0 && out.indexOf(idx) === -1) out.push(idx);
sel.forEach(function (c) {
if (c && typeof c === 'object' && c.imageUrl) out.push(c);
});
return out;
}
@@ -6019,6 +6180,11 @@
function applyRoomLobbyBTransition(data) {
const mid = (data && data.mapId) ? String(data.mapId).trim() : '';
if (!mid) return;
/* Cutscene เรื่องราวคดี ก่อนเข้า LobbyB — โหลด LobbyB ใต้ overlay พร้อมกัน */
var cutsceneCid = (data && data.caseId != null) ? data.caseId : null;
if (cutsceneCid) {
ensureCaseMediaLoaded().then(function () { showCaseCutscene(cutsceneCid, function () {}); });
}
fetch(SERVER + '/api/maps/' + encodeURIComponent(mid))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (json) {
+17 -14
View File
@@ -3899,7 +3899,8 @@
#special-quiz-overlay .sq-ov-result.is-hidden { display: none; }
#special-quiz-overlay .sq-ov-result--ok { color: #0e1226; background: #9ece6a; }
#special-quiz-overlay .sq-ov-result--fail { color: #fff; background: rgba(247,118,142,0.9); }
#special-quiz-overlay .sq-ov-award { margin-top: 16px; display: flex; flex-direction: column; align-items: center; gap: 10px; text-align: center; }
#special-quiz-overlay #sq-ov-quiz-body.is-hidden { display: none; }
#special-quiz-overlay .sq-ov-award { display: flex; flex-direction: column; align-items: center; gap: 10px; text-align: center; }
#special-quiz-overlay .sq-ov-award.is-hidden { display: none; }
#special-quiz-overlay .sq-ov-award-title { font-size: 20px; font-weight: 900; color: #ffe7a3; text-shadow: 0 0 14px rgba(255,214,102,0.55); }
#special-quiz-overlay .sq-ov-award-img { width: 170px; max-width: 60vw; height: auto; border-radius: 14px; box-shadow: 0 0 34px rgba(255,200,90,0.55), 0 14px 40px rgba(0,0,0,0.55); animation: sqAwardPop .55s cubic-bezier(.2,.9,.3,1.45); }
@@ -3945,20 +3946,22 @@
<div id="special-quiz-overlay" class="is-hidden" role="dialog" aria-modal="true" aria-labelledby="sq-overlay-title">
<div class="sq-ov-backdrop" aria-hidden="true"></div>
<div class="sq-ov-panel">
<div class="sq-ov-head">
<img id="sq-ov-icon" class="sq-ov-icon" src="/Game/img/special-quiz/icon-lawyer.png" alt="" />
<div class="sq-ov-head-text">
<div id="sq-overlay-title" class="sq-ov-kicker">คำถามพิเศษ · Special Quiz</div>
<div id="sq-ov-progress" class="sq-ov-progress">ข้อ 1 / 1</div>
<div id="sq-ov-quiz-body">
<div class="sq-ov-head">
<img id="sq-ov-icon" class="sq-ov-icon" src="/Game/img/special-quiz/icon-lawyer.png" alt="" />
<div class="sq-ov-head-text">
<div id="sq-overlay-title" class="sq-ov-kicker">คำถามพิเศษ · Special Quiz</div>
<div id="sq-ov-progress" class="sq-ov-progress">ข้อ 1 / 1</div>
</div>
<div id="sq-ov-timer" class="sq-ov-timer">20</div>
</div>
<div id="sq-ov-timer" class="sq-ov-timer">20</div>
<div id="sq-ov-question" class="sq-ov-question"></div>
<div id="sq-ov-choices" class="sq-ov-choices"></div>
<div id="sq-ov-status" class="sq-ov-status"></div>
<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>
</div>
<div id="sq-ov-question" class="sq-ov-question"></div>
<div id="sq-ov-choices" class="sq-ov-choices"></div>
<div id="sq-ov-status" class="sq-ov-status"></div>
<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>
<div id="sq-ov-award" class="sq-ov-award is-hidden">
<div id="sq-ov-award-title" class="sq-ov-award-title">ได้รับการ์ดพิเศษ!</div>
<img id="sq-ov-award-img" class="sq-ov-award-img" alt="" />
@@ -3971,7 +3974,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.0509"></script>
<script src="js/play.js?v=0.0512"></script>
<div class="version-tag">v —</div>
</body>
</html>
+1 -1
View File
@@ -1613,7 +1613,7 @@
<script src="js/display-name.js?v=2"></script>
<script src="js/version.js?v=0.0122"></script>
<script src="js/customize-popup.js?v=31" data-customize-triggers="" data-customize-asset-base="img/03-5-Customize"></script>
<script src="js/room-lobby.js?v=0.0262"></script>
<script src="js/room-lobby.js?v=0.0266"></script>
<div class="version-tag">v —</div>
</body>
</html>
+134 -2
View File
@@ -375,7 +375,9 @@ function sanitizeSpecialQuizSets(obj) {
function getSpecialQuizSet(settings, level, caseId) {
const sets = (settings && settings.specialQuizSets) || sanitizeSpecialQuizSets({});
const L = SPECIAL_QUIZ_LEVELS.indexOf(Number(level)) >= 0 ? Number(level) : 1;
const C = SPECIAL_QUIZ_CASES.indexOf(Number(caseId)) >= 0 ? Number(caseId) : 1;
/* caseId เป็นเลขคดีรวม 1-15 (5 ระดับ × 3 คดี) → แปลงเป็นคดีสัมพัทธ์ในระดับ 1-3 */
const rel = ((Math.max(1, Number(caseId) || 1) - 1) % 3) + 1;
const C = SPECIAL_QUIZ_CASES.indexOf(rel) >= 0 ? rel : 1;
return sets[specialQuizSetKey(L, C)] || sets[specialQuizSetKey(1, 1)] || { questions: [], specialCard: sanitizeSpecialCard({}) };
}
@@ -1196,6 +1198,87 @@ function mergeQuizCarryFromMirrorIfSet(base) {
const EVIDENCE_CARDS_PATH = path.join(__dirname, 'data', 'evidence-cards.json');
const EVIDENCE_RARITIES = ['common', 'rare', 'legendary'];
const EVIDENCE_CASE_COUNT = 15;
/* ===== รูปภาพคดี + Cutscene (LobbyA → LobbyB) — จัดการผ่าน Admin ===== */
const CASE_MEDIA_PATH = path.join(__dirname, 'data', 'case-media.json');
const CASE_MEDIA_COUNT = 15;
/* Main-Lobby อยู่นอกโฟลเดอร์ Game (เสิร์ฟจาก /var/www/html) — ขึ้นไป 1 ระดับ */
const MAIN_LOBBY_IMAGE_ROOT = path.join(__dirname, '..', 'Main-Lobby', 'IMAGE');
function caseMediaDefault(n) {
return {
name: 'คดีที่ ' + n,
art: '/Main-Lobby/IMAGE/case/case-' + n + '.png',
detail: '/Main-Lobby/IMAGE/case/case-detail-' + n + '.png',
story: [
'/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-1.png',
'/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-2.png',
],
};
}
/** อนุญาตเฉพาะ path รูปภายในเว็บ (กัน URL ภายนอก/สคริปต์) — ไม่ผ่าน → คืนค่า fallback */
function sanitizeCaseMediaUrl(v, fallback) {
if (typeof v !== 'string') return fallback;
const s = v.trim();
if (!s) return fallback;
if (!/^\/[\w./-]*\.(png|jpe?g|webp)$/i.test(s)) return fallback;
return s;
}
function sanitizeCaseMedia(obj) {
const src = (obj && typeof obj === 'object' && obj.cases && typeof obj.cases === 'object') ? obj.cases : {};
const cases = {};
for (let n = 1; n <= CASE_MEDIA_COUNT; n++) {
const d = caseMediaDefault(n);
const c = src[n] || src[String(n)] || {};
const story = Array.isArray(c.story) ? c.story : [];
cases[n] = {
name: (typeof c.name === 'string' && c.name.trim()) ? c.name.trim().slice(0, 80) : d.name,
art: sanitizeCaseMediaUrl(c.art, d.art),
detail: sanitizeCaseMediaUrl(c.detail, d.detail),
story: [sanitizeCaseMediaUrl(story[0], d.story[0]), sanitizeCaseMediaUrl(story[1], d.story[1])],
};
}
return { cases };
}
function loadCaseMedia() {
try {
if (fs.existsSync(CASE_MEDIA_PATH)) {
return sanitizeCaseMedia(JSON.parse(fs.readFileSync(CASE_MEDIA_PATH, 'utf8')));
}
} catch (e) { /* ignore — คืนค่า default */ }
return sanitizeCaseMedia({});
}
function saveCaseMedia(d) {
try {
const clean = sanitizeCaseMedia(d);
fs.writeFileSync(CASE_MEDIA_PATH, JSON.stringify(clean, null, 2), 'utf8');
return { ok: true, cases: clean.cases };
} catch (e) {
let err = 'บันทึกไม่ได้';
if (e && e.code === 'EACCES') err = 'ไม่มีสิทธิ์เขียนไฟล์ case-media.json (chown เป็น user ที่รันเกม)';
else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
return { ok: false, error: err };
}
}
/** รายการรูปที่มีจริงในโฟลเดอร์ case / cutscene (ไว้ให้ Admin เลือก) */
function listCaseMediaImages() {
const out = { case: [], cutscene: [] };
['case', 'cutscene'].forEach((sub) => {
try {
const p = path.join(MAIN_LOBBY_IMAGE_ROOT, sub);
out[sub] = fs.readdirSync(p)
.filter((f) => /\.(png|jpe?g|webp)$/i.test(f))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
.map((f) => '/Main-Lobby/IMAGE/' + sub + '/' + f);
} catch (e) { /* โฟลเดอร์อาจไม่มี */ }
});
return out;
}
const EVIDENCE_IMAGE_DIRS = ['suspect-1', 'suspect-2', 'suspect-3'];
/* rarity คิดจากเลขไฟล์การ์ด: 01-22 = common, 23-44 = rare, 45-54 = legendary (ชี้คนร้าย) */
const EVIDENCE_RARITY_RANGES = [
@@ -4078,6 +4161,53 @@ const server = http.createServer((req, res) => {
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const caseMediaUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (caseMediaUrlPath === BASE_PATH + '/api/case-media') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify(loadCaseMedia()));
}
if (req.method === 'PUT') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const saved = saveCaseMedia(d);
if (!saved.ok) {
res.writeHead(500);
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
}
res.writeHead(200);
return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const caseMediaImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (caseMediaImagesUrlPath === BASE_PATH + '/api/case-media-images') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify({ images: listCaseMediaImages(), count: CASE_MEDIA_COUNT }));
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const gameTimingUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (gameTimingUrlPath === BASE_PATH + '/api/game-timing') {
if (req.method === 'GET') {
@@ -6413,7 +6543,8 @@ io.on('connection', (socket) => {
let lobbyLevel = (data && data.lobbyLevel != null) ? String(data.lobbyLevel).trim() : '';
let caseId = (data && data.caseId != null) ? String(data.caseId).trim() : '';
if (lobbyLevel && !/^[1-5]$/.test(lobbyLevel)) lobbyLevel = '';
if (caseId && !/^[1-3]$/.test(caseId)) caseId = '';
/* 15 คดี (5 ระดับ × 3 คดี) — caseId 1-15 */
if (caseId && !/^(?:[1-9]|1[0-5])$/.test(caseId)) caseId = '';
ensurePostCaseLobbyMapLoaded();
const mdBefore = getLobbyLayoutMapForSpace(space);
if (mdBefore && mdBefore.gameType === 'quiz') {
@@ -6462,6 +6593,7 @@ io.on('connection', (socket) => {
space.suspectPickIndex = 0;
space.detectiveLobbyLevel = Number(lobbyLevel) || 1;
space.detectiveLobbyCaseId = Number(caseId) || 1;
space.caseId = Number(caseId) || 1;
if (space.troublesomeEligible) space.troublesomeEligible.clear();
else space.troublesomeEligible = new Set();
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB