update bug design and disconnect

This commit is contained in:
2026-07-22 12:29:30 +00:00
parent 28050a7873
commit 6dcac08598
243 changed files with 4910 additions and 278 deletions
+78 -1
View File
@@ -4607,7 +4607,9 @@
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('รูปรายละเอียดคดี (แบบรูปเดียวจบ — ใช้เมื่อไม่ได้ตั้ง "รูปคดี" ด้านล่าง)', 'case', c.detail, function (v) { c.detail = v; }));
/* [2026-07-21] ตั้ง "รูปคดี" = สลับหน้ารายละเอียดไปโหมดประกอบเอง (กรอบ + รูป + ข้อความด้านล่าง) */
grid.appendChild(caseMediaImagePicker('รูปคดี (โหมดใหม่ — ตั้งแล้วหน้าจะประกอบเอง)', 'case', c.pic || '', function (v) { c.pic = 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);
@@ -4623,6 +4625,50 @@
});
root.appendChild(susGrid);
/* [2026-07-21] ไอคอนผู้ต้องสงสัย (สี่เหลี่ยมเล็ก) — โชว์เฉพาะหัวหน้าไต่สวน (es3) เท่านั้น
ไม่ตั้ง = ใช้รูปเต็มตัวด้านบน */
var icoHead = document.createElement('h3');
icoHead.className = 'cm-section-title';
icoHead.textContent = 'ไอคอนผู้ต้องสงสัย (3 คน) — หัวหน้าไต่สวน (ไม่ตั้ง = ใช้รูปเต็มตัวด้านบน)';
root.appendChild(icoHead);
var icoGrid = document.createElement('div');
icoGrid.className = 'cm-grid';
if (!Array.isArray(c.icons)) c.icons = (c.suspects || []).slice();
[0, 1, 2].forEach(function (si) {
icoGrid.appendChild(caseMediaImagePicker('ไอคอน ' + (si + 1), 'suspect-pick', c.icons[si], function (v) { c.icons[si] = v; }));
});
root.appendChild(icoGrid);
/* [2026-07-21] ข้อความหน้ารายละเอียดคดี (โหมดใหม่) — แสดงเมื่อตั้ง "รูปคดี" ไว้เท่านั้น
เว้นว่าง = ไม่แสดงบรรทัดนั้นบนหน้าเกม (ค่อยๆ กรอกทีละคดีได้) */
var txtHead = document.createElement('h3');
txtHead.className = 'cm-section-title';
txtHead.textContent = 'ข้อความรายละเอียดคดี (โหมดใหม่ — ใช้เมื่อตั้ง "รูปคดี" แล้ว · เว้นว่าง = ไม่แสดงบรรทัดนั้น)';
root.appendChild(txtHead);
if (!Array.isArray(c.txtSuspects)) c.txtSuspects = ['', '', ''];
var caseTextField = function (label, get, set, opts) {
opts = opts || {};
var lb = document.createElement('label');
lb.className = 'admin-field';
lb.textContent = label;
var el = document.createElement(opts.multiline ? 'textarea' : 'input');
if (!opts.multiline) el.type = 'text';
else el.rows = 4;
el.maxLength = opts.max || 300;
el.value = get() || '';
el.addEventListener('input', function () { set(el.value); });
lb.appendChild(el);
return lb;
};
root.appendChild(caseTextField('ข้อหา', function () { return c.txtCharge; }, function (v) { c.txtCharge = v; }));
root.appendChild(caseTextField('สถานที่เกิดเหตุ', function () { return c.txtPlace; }, function (v) { c.txtPlace = v; }));
root.appendChild(caseTextField('ผู้เสียหาย', function () { return c.txtVictim; }, function (v) { c.txtVictim = v; }));
[0, 1, 2].forEach(function (si) {
root.appendChild(caseTextField('ผู้ต้องสงสัย ' + (si + 1) + ' (ข้อความ)',
function () { return c.txtSuspects[si]; }, function (v) { c.txtSuspects[si] = v; }, { max: 240 }));
});
root.appendChild(caseTextField('บันทึกเหตุการณ์', function () { return c.txtNote; }, function (v) { c.txtNote = v; }, { multiline: true, max: 1500 }));
var culHead = document.createElement('h3');
culHead.className = 'cm-section-title';
culHead.textContent = 'ส่งผู้ร้ายเข้าคุก (3 คน) — หน้าผลพิจารณาคดี';
@@ -4712,6 +4758,7 @@
if (name === 'highscore') loadHighscore();
if (name === 'achievements') loadAchievementsPanel();
if (name === 'vote-timing') loadVoteTimingPanel();
if (name === 'postcase') loadPostCasePanel();
if (name === 'troublesome') loadTroublesomePanel();
if (name === 'test-mode') { loadForcedMinigamePanel(); loadSpecialCardByMapPanel(); loadTroublesomeForcePanel(); }
if (name === 'sound') loadSoundPanel();
@@ -5268,6 +5315,36 @@
});
})();
/* ===== จบคดี (post-case destination) — Main-Lobby หรือ LobbyA ===== */
function loadPostCasePanel() {
gameTimingFetch('GET')
.then(function (data) {
var d = (data && data.postCaseDestination === 'lobbya') ? 'lobbya' : 'mainlobby';
if (el('postcase-dest')) el('postcase-dest').value = d;
setMsg('postcase-msg', '', '');
})
.catch(function (e) { setMsg('postcase-msg', (e.message || 'โหลดไม่ได้') + ' — เช็ค /Game/api/game-timing', 'error'); });
}
(function bindPostCase() {
var btn = el('btn-postcase-save');
if (!btn) return;
btn.addEventListener('click', function () {
var d = ((el('postcase-dest') || {}).value === 'lobbya') ? 'lobbya' : 'mainlobby';
btn.disabled = true;
gameTimingFetch('GET')
.then(function (data) { data.postCaseDestination = d; return gameTimingFetch('PUT', data); })
.then(function () { return gameTimingFetch('GET'); })
.then(function (fresh) {
var v = (fresh && fresh.postCaseDestination === 'lobbya') ? 'lobbya' : 'mainlobby';
if (el('postcase-dest')) el('postcase-dest').value = v;
setMsg('postcase-msg', 'บันทึกแล้ว — เมื่อจบคดีไป ' + (v === 'lobbya' ? 'LobbyA (ห้องเดิม เริ่มคดีใหม่)' : 'Main-Lobby (เดิม)') + ' · ผู้เล่นต้องรีเฟรช/เข้าห้องใหม่', 'ok');
})
.catch(function (e) { setMsg('postcase-msg', (e.message || 'บันทึกไม่ได้') + ' — เช็ค Node + /Game/api/game-timing', 'error'); })
.then(function () { btn.disabled = false; });
});
})();
/* ===== เสียง (sound settings) — master/music/sfx volume 0..1 ===== */
function soundSettingsFetch(method, body) {
var m = method || 'GET';
+17 -1
View File
@@ -119,6 +119,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="vote-timing" role="tab" id="tab-vote-timing" aria-controls="tab-panel-vote-timing"><span class="tab-label">เวลาโหวต</span><span class="tab-desc">Silence/Ban · ชี้ตัวคนร้าย</span></button>
<button type="button" class="tab" data-tab="postcase" role="tab" id="tab-postcase" aria-controls="tab-panel-postcase"><span class="tab-label">จบคดี</span><span class="tab-desc">Main-Lobby หรือ LobbyA</span></button>
<button type="button" class="tab" data-tab="troublesome" role="tab" id="tab-troublesome" aria-controls="tab-panel-troublesome"><span class="tab-label">บทตัวป่วน</span><span class="tab-desc">เวลาเสนอ · เวลารับ</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>
@@ -1184,6 +1185,21 @@
<p id="vote-timing-msg" class="msg" role="status"></p>
</section>
<section id="tab-panel-postcase" class="tab-panel card" hidden role="tabpanel" aria-labelledby="tab-postcase">
<h2>จบคดี</h2>
<p class="muted">เลือกว่าเมื่อจบคดี (หลังโชว์ผล + หน้าให้ความรู้ "รู้ทันภัยไซเบอร์") จะพาผู้เล่นไปไหนต่อ · มีผลเมื่อผู้เล่นรีเฟรช/เข้าห้องใหม่</p>
<form id="form-postcase" class="form-grid form-inline" autocomplete="off">
<label>เมื่อจบคดี ไปที่
<select id="postcase-dest" name="postCaseDestination">
<option value="mainlobby">กลับ Main-Lobby (เดิม)</option>
<option value="lobbya">เล่นต่อที่ LobbyA (ห้องเดิม เริ่มคดีใหม่ทันที)</option>
</select>
</label>
<button type="button" class="btn btn-primary" id="btn-postcase-save">บันทึก</button>
</form>
<p id="postcase-msg" class="msg" role="status"></p>
</section>
<section id="tab-panel-troublesome" class="tab-panel card" hidden role="tabpanel" aria-labelledby="tab-troublesome">
<h2>บทตัวป่วน</h2>
<p class="muted">ตั้งเวลาเกี่ยวกับการเสนอ/รับ "บทตัวป่วน" หลังจบคดี · มีผลเมื่อผู้เล่นรีเฟรช/เข้าห้องใหม่ · ปุ่ม <strong>บังคับข้ามเงื่อนไข ≥4 คน</strong> อยู่แท็บ <strong>Test Mode</strong></p>
@@ -1358,7 +1374,7 @@
</div>
</main>
</div>
<script src="admin.js?v=95"></script>
<script src="admin.js?v=97"></script>
<script>
/* toggle: เปิด/ปิด HUD โหลด LobbyA (Iron Man) ให้ "ผู้เล่นทั่วไป" เห็น (เก็บใน game-timing.json: lobbyDebugHud)
— dev เปิดเครื่องตัวเองได้เสมอด้วย ?dbg=1 หรือ localStorage.lobbyDbg='1' โดยไม่ต้องใช้ปุ่มนี้ */
+484 -20
View File
@@ -178,23 +178,26 @@
"providerUserId": "p_1777015659667_xqfjh247bgp",
"notes": "auto: player-coins",
"blocked": false,
"coins": 382,
"coins": 402,
"createdAt": "2026-04-24T07:28:02+00:00",
"updatedAt": "2026-07-04T10:39:15+00:00",
"score": 382,
"updatedAt": "2026-07-12T04:28:47+00:00",
"score": 402,
"scoreByCase": {
"8": 220,
"5": 30,
"7": 60,
"2": 10,
"2": 30,
"9": 10,
"10": 52
},
"lbName": "Q",
"achievements": {
"d1_minigame_solver": 8,
"d1_minigame_solver": 10,
"a1_first_deduction": 1,
"a5_truth_hunter": 1
"a5_truth_hunter": 1,
"b5_deep_scanner": 4,
"b4_data_miner": 2,
"a2_sharp_eye": 2
},
"lobbyColorThemeIndex": 6,
"lobbySkinToneIndex": 1
@@ -1506,34 +1509,34 @@
"providerUserId": "p_1783488401360_la99satzc7m",
"notes": "auto: player-coins",
"blocked": false,
"coins": 65,
"coins": 95,
"createdAt": "2026-07-08T05:26:41+00:00",
"updatedAt": "2026-07-10T01:19:29+00:00",
"updatedAt": "2026-07-12T07:34:23+00:00",
"daily": {
"anchorMs": 1783530000000,
"claimedDays": [
true,
true,
false,
true,
false,
false,
false,
false
],
"lockUntilMs": 1783702800000
"lockUntilMs": 1783875600000
},
"lobbyColorThemeIndex": 5,
"lobbySkinToneIndex": 1,
"score": 40,
"score": 50,
"scoreByCase": {
"1": 40
"1": 50
},
"lbName": "ผู้เล่น",
"achievements": {
"d1_minigame_solver": 3,
"b4_data_miner": 3,
"d1_minigame_solver": 4,
"b4_data_miner": 5,
"b3_hidden_fragment": 1,
"b1_evidence_collector": 2,
"b1_evidence_collector": 4,
"b5_deep_scanner": 1,
"d3_the_backbone": 4,
"a5_truth_hunter": 1,
@@ -1559,7 +1562,7 @@
"blocked": false,
"coins": 10,
"createdAt": "2026-07-08T06:02:42+00:00",
"updatedAt": "2026-07-08T11:51:25+00:00",
"updatedAt": "2026-07-17T04:33:03+00:00",
"daily": {
"anchorMs": 1783443600000,
"claimedDays": [
@@ -1571,7 +1574,7 @@
false,
false
],
"lockUntilMs": 1783530000000
"lockUntilMs": 0
},
"lobbyColorThemeIndex": 7,
"lobbySkinToneIndex": 3,
@@ -1928,14 +1931,24 @@
{
"id": "dd9797847d294993e18052db",
"email": "",
"displayName": "Guest",
"displayName": "สุดซอยแจ้งวัฒนะ",
"loginType": "guest",
"providerUserId": "p_1783664321058_93tru4qj20l",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"coins": 10,
"createdAt": "2026-07-10T06:18:11+00:00",
"updatedAt": "2026-07-10T06:18:11+00:00"
"updatedAt": "2026-07-16T03:28:23+00:00",
"score": 10,
"scoreByCase": {
"1": 10
},
"lbName": "สุดซอยแจ้งวัฒนะ",
"achievements": {
"d1_minigame_solver": 1,
"b4_data_miner": 1,
"b1_evidence_collector": 1
}
},
{
"id": "1773d48f698d144883a71544",
@@ -2086,6 +2099,457 @@
"coins": 0,
"createdAt": "2026-07-12T03:29:32+00:00",
"updatedAt": "2026-07-12T03:29:32+00:00"
},
{
"id": "94106078d9e8eca25f4c8368",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1783842244060_7zzgy7u3guy",
"notes": "auto: player-coins",
"blocked": false,
"coins": 10,
"createdAt": "2026-07-12T07:44:04+00:00",
"updatedAt": "2026-07-12T07:44:40+00:00",
"daily": {
"anchorMs": 1783789200000,
"claimedDays": [
true,
false,
false,
false,
false,
false,
false
],
"lockUntilMs": 1783875600000
}
},
{
"id": "c9c24e6c4cc6eb9fa2c4b875",
"email": "",
"displayName": "A0",
"loginType": "guest",
"providerUserId": "all0-uvomjt2",
"notes": "auto: achievements",
"blocked": false,
"coins": 10,
"achievements": {
"b5_deep_scanner": 1,
"b4_data_miner": 1,
"d1_minigame_solver": 1
},
"createdAt": "2026-07-15T18:55:17+00:00",
"updatedAt": "2026-07-15T18:56:41+00:00",
"score": 10,
"scoreByCase": {
"1": 10
},
"lbName": "A0"
},
{
"id": "681aadf6c488dfb5a36a5ab7",
"email": "",
"displayName": "A3",
"loginType": "guest",
"providerUserId": "all3-6sd88t6",
"notes": "auto: achievements",
"blocked": false,
"coins": 4,
"achievements": {
"d1_minigame_solver": 1,
"b4_data_miner": 1,
"b1_evidence_collector": 1
},
"createdAt": "2026-07-15T18:56:41+00:00",
"updatedAt": "2026-07-15T18:56:41+00:00",
"score": 4,
"scoreByCase": {
"1": 4
},
"lbName": "A3"
},
{
"id": "282c2fc8b84213908452edf1",
"email": "",
"displayName": "A2",
"loginType": "guest",
"providerUserId": "all2-h0453xy",
"notes": "auto: achievements",
"blocked": false,
"coins": 6,
"achievements": {
"d1_minigame_solver": 1,
"a2_sharp_eye": 1,
"b4_data_miner": 1
},
"createdAt": "2026-07-15T18:56:41+00:00",
"updatedAt": "2026-07-15T18:56:41+00:00",
"score": 6,
"scoreByCase": {
"1": 6
},
"lbName": "A2"
},
{
"id": "897638f7ab8b897472a24211",
"email": "",
"displayName": "A1",
"loginType": "guest",
"providerUserId": "all1-g1qpelc",
"notes": "auto: achievements",
"blocked": false,
"coins": 8,
"achievements": {
"d1_minigame_solver": 1,
"b4_data_miner": 1
},
"createdAt": "2026-07-15T18:56:41+00:00",
"updatedAt": "2026-07-15T18:56:41+00:00",
"score": 8,
"scoreByCase": {
"1": 8
},
"lbName": "A1"
},
{
"id": "2320bae00840629acc14230c",
"email": "",
"displayName": "A0",
"loginType": "guest",
"providerUserId": "all0-3imequc",
"notes": "auto: achievements",
"blocked": false,
"coins": 10,
"achievements": {
"b5_deep_scanner": 1,
"b4_data_miner": 1,
"a2_sharp_eye": 1,
"d1_minigame_solver": 1
},
"createdAt": "2026-07-15T18:59:48+00:00",
"updatedAt": "2026-07-15T19:01:22+00:00",
"score": 10,
"scoreByCase": {
"1": 10
},
"lbName": "A0"
},
{
"id": "c9501abea414b56bed19b3e9",
"email": "",
"displayName": "A1",
"loginType": "guest",
"providerUserId": "all1-5e9uhba",
"notes": "auto: game-award",
"blocked": false,
"coins": 8,
"score": 8,
"scoreByCase": {
"1": 8
},
"lbName": "A1",
"createdAt": "2026-07-15T19:01:22+00:00",
"updatedAt": "2026-07-15T19:01:22+00:00",
"achievements": {
"b4_data_miner": 1,
"a2_sharp_eye": 1
}
},
{
"id": "6e7f934d9d8af5c04dda7f06",
"email": "",
"displayName": "A2",
"loginType": "guest",
"providerUserId": "all2-gvlx7qp",
"notes": "auto: game-award",
"blocked": false,
"coins": 6,
"score": 6,
"scoreByCase": {
"1": 6
},
"lbName": "A2",
"createdAt": "2026-07-15T19:01:22+00:00",
"updatedAt": "2026-07-15T19:01:22+00:00",
"achievements": {
"d1_minigame_solver": 1,
"a2_sharp_eye": 1,
"b4_data_miner": 1
}
},
{
"id": "e6188cabb616fd819db9c758",
"email": "",
"displayName": "A3",
"loginType": "guest",
"providerUserId": "all3-yjx37f7",
"notes": "auto: game-award",
"blocked": false,
"coins": 4,
"score": 4,
"scoreByCase": {
"1": 4
},
"lbName": "A3",
"createdAt": "2026-07-15T19:01:22+00:00",
"updatedAt": "2026-07-15T19:01:22+00:00",
"achievements": {
"d1_minigame_solver": 1,
"b1_evidence_collector": 1,
"b4_data_miner": 1
}
},
{
"id": "789382adba98a82cfbac42cd",
"email": "",
"displayName": "แจ้ง 19",
"loginType": "guest",
"providerUserId": "p_1784169008818_pp6y5eloea",
"notes": "auto: player-coins",
"blocked": false,
"coins": 8,
"createdAt": "2026-07-16T02:29:34+00:00",
"updatedAt": "2026-07-16T03:28:23+00:00",
"score": 8,
"scoreByCase": {
"1": 8
},
"lbName": "แจ้ง 19",
"achievements": {
"d1_minigame_solver": 1,
"b4_data_miner": 1,
"a2_sharp_eye": 1
}
},
{
"id": "0109d4fc970591679f6665fd",
"email": "",
"displayName": "หมึกกรุบ",
"loginType": "guest",
"providerUserId": "p_1784172060970_br44xi8hy4f",
"notes": "auto: player-coins",
"blocked": false,
"coins": 16,
"createdAt": "2026-07-16T03:20:26+00:00",
"updatedAt": "2026-07-16T03:28:23+00:00",
"lobbyColorThemeIndex": 8,
"lobbySkinToneIndex": 3,
"daily": {
"anchorMs": 1784134800000,
"claimedDays": [
true,
false,
false,
false,
false,
false,
false
],
"lockUntilMs": 1784221200000
},
"score": 6,
"scoreByCase": {
"1": 6
},
"lbName": "หมึกกรุบ",
"achievements": {
"d1_minigame_solver": 1,
"b1_evidence_collector": 1,
"b4_data_miner": 1
}
},
{
"id": "dd534a474cefda06b03efce0",
"email": "",
"displayName": "ผู้เล่น",
"loginType": "guest",
"providerUserId": "p_1784258329613_pgvq1bxgx7h",
"notes": "auto: player-coins",
"blocked": false,
"coins": 130,
"createdAt": "2026-07-17T03:18:14+00:00",
"updatedAt": "2026-07-17T07:16:09+00:00",
"lobbyColorThemeIndex": 4,
"lobbySkinToneIndex": 3,
"achievements": {
"d1_minigame_solver": 12,
"b4_data_miner": 15,
"a2_sharp_eye": 4,
"b3_hidden_fragment": 1,
"b1_evidence_collector": 10,
"b5_deep_scanner": 5,
"d3_the_backbone": 4
},
"score": 130,
"scoreByCase": {
"2": 70,
"4": 60
},
"lbName": "ผู้เล่น"
},
{
"id": "86fa71688a15fb27f4737643",
"email": "",
"displayName": "ผู้เล่น",
"loginType": "guest",
"providerUserId": "p_1784262590069_6u8wrdhjys3",
"notes": "auto: player-coins",
"blocked": false,
"coins": 20,
"createdAt": "2026-07-17T04:29:50+00:00",
"updatedAt": "2026-07-17T05:02:11+00:00",
"score": 20,
"scoreByCase": {
"13": 10,
"3": 10
},
"lbName": "ผู้เล่น",
"achievements": {
"d1_minigame_solver": 3,
"b4_data_miner": 6,
"b1_evidence_collector": 5,
"b5_deep_scanner": 2,
"d3_the_backbone": 1
}
},
{
"id": "90a168f8eb78c7a2df6a4d96",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1784262705675_47m52w5gdfv",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-07-17T04:31:45+00:00",
"updatedAt": "2026-07-17T04:31:45+00:00"
},
{
"id": "f1a0c695466465fba8861e9d",
"email": "",
"displayName": "MONE",
"loginType": "guest",
"providerUserId": "p_1784262706934_nosb84vssxg",
"notes": "auto: player-coins",
"blocked": false,
"coins": 8,
"createdAt": "2026-07-17T04:31:47+00:00",
"updatedAt": "2026-07-17T04:45:02+00:00",
"lobbyColorThemeIndex": 7,
"lobbySkinToneIndex": 3,
"score": 8,
"scoreByCase": {
"13": 8
},
"lbName": "MONE",
"achievements": {
"d1_minigame_solver": 1,
"b4_data_miner": 1,
"b1_evidence_collector": 1
}
},
{
"id": "8c0719f45806072713364771",
"email": "",
"displayName": "MONE",
"loginType": "guest",
"providerUserId": "p_1784262928006_xol5834q9k",
"notes": "auto: player-coins",
"blocked": false,
"coins": 6,
"createdAt": "2026-07-17T04:35:31+00:00",
"updatedAt": "2026-07-17T04:45:02+00:00",
"score": 6,
"scoreByCase": {
"13": 6
},
"lbName": "MONE",
"achievements": {
"d1_minigame_solver": 1,
"b4_data_miner": 1,
"b1_evidence_collector": 1
}
},
{
"id": "d7512c9302c5e53d0b51cf59",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1784263211259_utm2j25u1uj",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-07-17T04:40:11+00:00",
"updatedAt": "2026-07-17T04:40:11+00:00"
},
{
"id": "c634f9cd3b4e5c568d7b6b7c",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1784715999915_d6j12n07wno",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-07-22T10:26:39+00:00",
"updatedAt": "2026-07-22T10:27:18+00:00",
"achievements": {
"d2_silent_guardian": 1,
"e1_the_observer": 1,
"a6_unbreakable_logic": 0
},
"achvStreak": {
"e1_the_observer": 1,
"a6_unbreakable_logic": 0
}
},
{
"id": "917ecafe4ecd733532182798",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1784716094805_36m2lx8vnu4",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-07-22T10:28:14+00:00",
"updatedAt": "2026-07-22T10:28:56+00:00",
"achievements": {
"d2_silent_guardian": 1,
"e1_the_observer": 1,
"a6_unbreakable_logic": 0,
"d4_flawless_diver": 0
},
"achvStreak": {
"e1_the_observer": 1,
"a6_unbreakable_logic": 0,
"d4_flawless_diver": 0
}
},
{
"id": "372540831b261e66854a2a2a",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1784716198055_wbuzqhjjric",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-07-22T10:29:57+00:00",
"updatedAt": "2026-07-22T10:29:57+00:00"
},
{
"id": "0020d39e187921e2069d8f16",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1784716234139_kn1dnc4gus",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-07-22T10:30:33+00:00",
"updatedAt": "2026-07-22T10:30:33+00:00"
}
]
}
+284 -59
View File
@@ -2,11 +2,11 @@
"cases": {
"1": {
"name": "คดีโจรกรรมไซเบอร์",
"art": "/Main-Lobby/IMAGE/case/case-1.png",
"art": "/Main-Lobby/IMAGE/case/Case_01.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"
"/Main-Lobby/IMAGE/cutscene/Case-01-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-1-suspect-1.png",
@@ -17,15 +17,30 @@
"/Main-Lobby/IMAGE/culprit-prison/case-1-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-1-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-1-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-1-s1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-1-s2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-1-s3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_01.png",
"txtCharge": "ถูกมิจฉาชีพปลอมแปลงเป็นเจ้าหน้าที่รัฐ โทรหลอกลวงให้กลัวความผิด และโอนเงินเข้าระบบเพื่อตรวจสอบ",
"txtPlace": "ย่านธุรกิจใจกลางเมือง — ซอยสุขุมวิท",
"txtVictim": "นภา รุ่งสมัย — พนักงานออฟฟิศทั่วไป ผู้เสียหาย",
"txtSuspects": [
"พนักงานธนาคาร — สามารถเข้าถึงระบบธนาคารภายในได้ง่าย",
"พนักงานไอที — มีทักษะทางการปลอมแปลงเอกสาร ระดับชำนาญ",
"ตัวแทนประกัน — สามารถค้นหาฐานข้อมูลส่วนตัวของเหยื่อได้ง่าย"
],
"txtNote": "ผู้เสียหายได้รับสายจากบุคคลอ้างตัวเป็นเจ้าหน้าที่รัฐ แจ้งว่าบัญชีพัวพันการฟอกเงิน จึงถูกกดดันให้โอนเงินเข้าบัญชีกลางเพื่อตรวจสอบ ก่อนพบว่าติดต่อกลับไม่ได้อีกเลย"
},
"2": {
"name": "คดีปล้นร้านอัญมณี",
"art": "/Main-Lobby/IMAGE/case/case-2.png",
"art": "/Main-Lobby/IMAGE/case/Case_02.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"
"/Main-Lobby/IMAGE/cutscene/Case-02-story-1.png",
"/Main-Lobby/IMAGE/cutscene/Case-02-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-2-suspect-1.png",
@@ -36,15 +51,30 @@
"/Main-Lobby/IMAGE/culprit-prison/case-2-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-2-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-2-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-2-s-1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-2-s-2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-2-s-3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_02.png",
"txtCharge": "",
"txtPlace": "",
"txtVictim": "",
"txtSuspects": [
"",
"",
""
],
"txtNote": ""
},
"3": {
"name": "คดีฆาตกรรมปริศนา",
"art": "/Main-Lobby/IMAGE/case/case-3.png",
"art": "/Main-Lobby/IMAGE/case/Case_03.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"
"/Main-Lobby/IMAGE/cutscene/Case-03-story-1.png",
"/Main-Lobby/IMAGE/cutscene/Case-03-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-3-suspect-1.png",
@@ -55,15 +85,30 @@
"/Main-Lobby/IMAGE/culprit-prison/case-3-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-3-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-3-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-3-s-1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-3-s-2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-3-s-3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_03.png",
"txtCharge": "",
"txtPlace": "",
"txtVictim": "",
"txtSuspects": [
"",
"",
""
],
"txtNote": ""
},
"4": {
"name": "คดีที่ 4",
"art": "/Main-Lobby/IMAGE/case/case-4.png",
"art": "/Main-Lobby/IMAGE/case/Case_04.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"
"/Main-Lobby/IMAGE/cutscene/Case-04-story-1.png",
"/Main-Lobby/IMAGE/cutscene/Case-04-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-4-suspect-1.png",
@@ -74,15 +119,30 @@
"/Main-Lobby/IMAGE/culprit-prison/case-4-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-4-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-4-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-4-s-1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-4-s-2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-4-s-3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_04.png",
"txtCharge": "",
"txtPlace": "",
"txtVictim": "",
"txtSuspects": [
"",
"",
""
],
"txtNote": ""
},
"5": {
"name": "คดีที่ 5",
"art": "/Main-Lobby/IMAGE/case/case-5.png",
"art": "/Main-Lobby/IMAGE/case/Case_05.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"
"/Main-Lobby/IMAGE/cutscene/Case-05-story-1.png",
"/Main-Lobby/IMAGE/cutscene/Case-05-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-5-suspect-1.png",
@@ -93,15 +153,30 @@
"/Main-Lobby/IMAGE/culprit-prison/case-5-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-5-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-5-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-5-s-1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-5-s-2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-5-s-3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_05.png",
"txtCharge": "",
"txtPlace": "",
"txtVictim": "",
"txtSuspects": [
"",
"",
""
],
"txtNote": ""
},
"6": {
"name": "คดีที่ 6",
"art": "/Main-Lobby/IMAGE/case/case-6.png",
"art": "/Main-Lobby/IMAGE/case/Case_06.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"
"/Main-Lobby/IMAGE/cutscene/Case-06-story-1.png",
"/Main-Lobby/IMAGE/cutscene/Case-06-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-6-suspect-1.png",
@@ -112,15 +187,30 @@
"/Main-Lobby/IMAGE/culprit-prison/case-6-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-6-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-6-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-6-s-1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-6-s-2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-6-s-3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_06.png",
"txtCharge": "",
"txtPlace": "",
"txtVictim": "",
"txtSuspects": [
"",
"",
""
],
"txtNote": ""
},
"7": {
"name": "คดีที่ 7",
"art": "/Main-Lobby/IMAGE/case/case-7.png",
"art": "/Main-Lobby/IMAGE/case/Case_07.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"
"/Main-Lobby/IMAGE/cutscene/Case-07-story-1.png",
"/Main-Lobby/IMAGE/cutscene/Case-07-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-7-suspect-1.png",
@@ -131,15 +221,30 @@
"/Main-Lobby/IMAGE/culprit-prison/case-7-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-7-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-7-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-7-s-1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-7-s-2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-7-s-3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_07.png",
"txtCharge": "",
"txtPlace": "",
"txtVictim": "",
"txtSuspects": [
"",
"",
""
],
"txtNote": ""
},
"8": {
"name": "คดีที่ 8",
"art": "/Main-Lobby/IMAGE/case/case-8.png",
"art": "/Main-Lobby/IMAGE/case/Case_08.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"
"/Main-Lobby/IMAGE/cutscene/Case-08-story-1.png",
"/Main-Lobby/IMAGE/cutscene/Case-08-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-8-suspect-1.png",
@@ -150,15 +255,30 @@
"/Main-Lobby/IMAGE/culprit-prison/case-8-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-8-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-8-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-8-s-1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-8-s-2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-8-s-3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_08.png",
"txtCharge": "",
"txtPlace": "",
"txtVictim": "",
"txtSuspects": [
"",
"",
""
],
"txtNote": ""
},
"9": {
"name": "คดีที่ 9",
"art": "/Main-Lobby/IMAGE/case/case-9.png",
"art": "/Main-Lobby/IMAGE/case/Case_09.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"
"/Main-Lobby/IMAGE/cutscene/Case-09-story-1.png",
"/Main-Lobby/IMAGE/cutscene/Case-09-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-9-suspect-1.png",
@@ -169,15 +289,30 @@
"/Main-Lobby/IMAGE/culprit-prison/case-9-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-9-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-9-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-9-s-1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-9-s-2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-9-s-3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_09.png",
"txtCharge": "",
"txtPlace": "",
"txtVictim": "",
"txtSuspects": [
"",
"",
""
],
"txtNote": ""
},
"10": {
"name": "คดีที่ 10",
"art": "/Main-Lobby/IMAGE/case/case-10.png",
"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"
"/Main-Lobby/IMAGE/cutscene/Case-10-story-1.png",
"/Main-Lobby/IMAGE/cutscene/Case-10-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-10-suspect-1.png",
@@ -188,15 +323,30 @@
"/Main-Lobby/IMAGE/culprit-prison/case-10-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-10-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-10-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-10-s-1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-10-s-2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-10-s-3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_10.png",
"txtCharge": "",
"txtPlace": "",
"txtVictim": "",
"txtSuspects": [
"",
"",
""
],
"txtNote": ""
},
"11": {
"name": "คดีที่ 11",
"art": "/Main-Lobby/IMAGE/case/case-11.png",
"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"
"/Main-Lobby/IMAGE/cutscene/Case-11-story-1.png",
"/Main-Lobby/IMAGE/cutscene/Case-11-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-11-suspect-1.png",
@@ -207,15 +357,30 @@
"/Main-Lobby/IMAGE/culprit-prison/case-11-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-11-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-11-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-11-s-1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-11-s-2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-11-s-3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_11.png",
"txtCharge": "",
"txtPlace": "",
"txtVictim": "",
"txtSuspects": [
"",
"",
""
],
"txtNote": ""
},
"12": {
"name": "คดีที่ 12",
"art": "/Main-Lobby/IMAGE/case/case-12.png",
"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"
"/Main-Lobby/IMAGE/cutscene/Case-12-story-1.png",
"/Main-Lobby/IMAGE/cutscene/Case-12-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-12-suspect-1.png",
@@ -226,15 +391,30 @@
"/Main-Lobby/IMAGE/culprit-prison/case-12-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-12-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-12-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-12-s-1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-12-s-2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-12-s-3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_12.png",
"txtCharge": "",
"txtPlace": "",
"txtVictim": "",
"txtSuspects": [
"",
"",
""
],
"txtNote": ""
},
"13": {
"name": "คดีที่ 13",
"art": "/Main-Lobby/IMAGE/case/case-13.png",
"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"
"/Main-Lobby/IMAGE/cutscene/Case-13-story-1.png",
"/Main-Lobby/IMAGE/cutscene/Case-13-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-13-suspect-1.png",
@@ -245,15 +425,30 @@
"/Main-Lobby/IMAGE/culprit-prison/case-13-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-13-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-13-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-13-s-1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-13-s-2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-13-s-3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_13.png",
"txtCharge": "",
"txtPlace": "",
"txtVictim": "",
"txtSuspects": [
"",
"",
""
],
"txtNote": ""
},
"14": {
"name": "คดีที่ 14",
"art": "/Main-Lobby/IMAGE/case/case-14.png",
"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"
"/Main-Lobby/IMAGE/cutscene/Case-14-story-1.png",
"/Main-Lobby/IMAGE/cutscene/Case-14-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-14-suspect-1.png",
@@ -264,15 +459,30 @@
"/Main-Lobby/IMAGE/culprit-prison/case-14-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-14-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-14-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-14-s-1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-14-s-2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-14-s-3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_14.png",
"txtCharge": "",
"txtPlace": "",
"txtVictim": "",
"txtSuspects": [
"",
"",
""
],
"txtNote": ""
},
"15": {
"name": "คดีที่ 15",
"art": "/Main-Lobby/IMAGE/case/case-15.png",
"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"
"/Main-Lobby/IMAGE/cutscene/Case-15-story-1.png",
"/Main-Lobby/IMAGE/cutscene/Case-15-story-2.png"
],
"suspects": [
"/Main-Lobby/IMAGE/suspect-pick/case-15-suspect-1.png",
@@ -283,7 +493,22 @@
"/Main-Lobby/IMAGE/culprit-prison/case-15-suspect-1.png",
"/Main-Lobby/IMAGE/culprit-prison/case-15-suspect-2.png",
"/Main-Lobby/IMAGE/culprit-prison/case-15-suspect-3.png"
]
],
"icons": [
"/Main-Lobby/IMAGE/suspect-pick/case-15-s-1.png",
"/Main-Lobby/IMAGE/suspect-pick/case-15-s-2.png",
"/Main-Lobby/IMAGE/suspect-pick/case-15-s-3.png"
],
"pic": "/Main-Lobby/IMAGE/case/Pic_Case_15.png",
"txtCharge": "",
"txtPlace": "",
"txtVictim": "",
"txtSuspects": [
"",
"",
""
],
"txtNote": ""
}
}
}
+10 -9
View File
@@ -20,7 +20,7 @@
"stackBlockWidthTiles": 3.2,
"stackTowerMissionTimeSec": 90,
"stackTeamMissesMax": 3,
"stackTowerProgressBlocks": 60,
"stackTowerProgressBlocks": 50,
"stackBlockNormalImageUrls": [
"/Game/img/TowerBlock/Block-Code-1-red.png",
"/Game/img/TowerBlock/Block-Code-2-orange.png",
@@ -28,8 +28,8 @@
"/Game/img/TowerBlock/Block-Code-4-Green.png",
"/Game/img/TowerBlock/Block-Code-5-Blue.png",
"/Game/img/TowerBlock/Block-Code-6-Navy.png",
"/Game/img/TowerBlock/Block-Code-7-Violet.png",
"/Game/img/TowerBlock/Block-Code-8-Pink.png"
"",
""
],
"stackBlockHeavyImageUrls": [
"/Game/img/TowerBlock/Block-Code-1-red-2.png",
@@ -38,8 +38,8 @@
"/Game/img/TowerBlock/Block-Code-4-Green-2.png",
"/Game/img/TowerBlock/Block-Code-5-Blue-2.png",
"/Game/img/TowerBlock/Block-Code-6-Navy-2.png",
"/Game/img/TowerBlock/Block-Code-7-Violet-2.png",
"/Game/img/TowerBlock/Block-Code-8-Pink-2.png"
"",
""
],
"stackHeavyBlockPercent": 35,
"jumpSurviveJumpHeightMult": 1.7,
@@ -92,15 +92,15 @@
"/Game/img/MegaVirus/balloon-5.png",
"/Game/img/MegaVirus/balloon-6.png",
"",
""
"/Game/img/MegaVirus/balloon-7.png"
],
"balloonBossPlayerBalloonFallbackUrl": "/Game/img/MegaVirus/Artboard%209.png",
"balloonBossBalloonsPerPlayer": 3,
"balloonBossDamagePerHit": 10,
"forcedMinigameKeys": [
"space_shooter",
"quiz_carry",
"balloon_boss"
"",
"",
""
],
"testSpecialCardByMap": {},
"troublesomeForceOffer": false,
@@ -109,6 +109,7 @@
"pickVoteSec": 25,
"trialVoteSec": 30,
"trialResultRevealMs": 7000,
"postCaseDestination": "lobbya",
"lobbyDebugHud": false,
"lobbyDebugHudNames": []
}
@@ -1 +1 @@
{"qbroom1":[{"name":"Nam","score":18,"characterId":"char-1777017632279","ts":1782909257585},{"name":"ผู้เล่น9514","score":17,"characterId":"char-1777017632279","ts":1781850667717},{"name":"MONE","score":10,"characterId":"char-1777017632279","ts":1781850749437},{"name":"tomato","score":9,"characterId":"char-1777017632279","ts":1781850748552},{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781171902962},{"name":"ผู้เล่น1991","score":5,"characterId":"char-1777017632279","ts":1781850950078},{"name":"ผู้เล่น8916","score":4,"characterId":"char-1777017632279","ts":1781074083031},{"name":"ผู้เล่น2821","score":4,"characterId":"char-1777017632279","ts":1781079179108},{"name":"Weenwild","score":4,"characterId":"char-1777017632279","ts":1781849863580},{"name":"ผู้เล่น2359","score":0,"characterId":"char-1777017632279","ts":1783664802662}],"qbroom2":[{"name":"ผู้เล่น4472","score":13,"characterId":"char-1777017632279","ts":1781851033914},{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781175468983},{"name":"ผู้เล่น2612","score":1,"characterId":"char-1777017632279","ts":1781079252376},{"name":"ผู้เล่น1948","score":0,"characterId":"char-1777017632279","ts":1783664954451}],"qbroom3":[{"name":"Q","score":9,"characterId":"char-1777017632279","ts":1781175654893},{"name":"Weenwild","score":2,"characterId":"char-1777017632279","ts":1781849898452}],"qbroom5":[{"name":"Q","score":6,"characterId":"char-1777017632279","ts":1781675299850}],"qbroom6":[{"name":"Q","score":3,"characterId":"char-1777017632279","ts":1781675383175}],"qbroom9":[{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781696345322}]}
{"qbroom1":[{"name":"Nam","score":18,"characterId":"char-1777017632279","ts":1782909257585},{"name":"ผู้เล่น9514","score":17,"characterId":"char-1777017632279","ts":1781850667717},{"name":"MONE","score":10,"characterId":"char-1777017632279","ts":1781850749437},{"name":"tomato","score":9,"characterId":"char-1777017632279","ts":1781850748552},{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781171902962},{"name":"ผู้เล่น1991","score":5,"characterId":"char-1777017632279","ts":1781850950078},{"name":"ผู้เล่น8916","score":4,"characterId":"char-1777017632279","ts":1781074083031},{"name":"ผู้เล่น2821","score":4,"characterId":"char-1777017632279","ts":1781079179108},{"name":"Weenwild","score":4,"characterId":"char-1777017632279","ts":1781849863580},{"name":"ผู้เล่น2359","score":0,"characterId":"char-1777017632279","ts":1783664802662},{"name":"ผู้เล่น1576","score":0,"characterId":"char-1777017632279","ts":1784258335514}],"qbroom2":[{"name":"ผู้เล่น5703","score":14,"characterId":"char-1777017632279","ts":1784258779407},{"name":"ผู้เล่น4472","score":13,"characterId":"char-1777017632279","ts":1781851033914},{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781175468983},{"name":"ผู้เล่น2612","score":1,"characterId":"char-1777017632279","ts":1781079252376},{"name":"ผู้เล่น1948","score":0,"characterId":"char-1777017632279","ts":1783664954451}],"qbroom3":[{"name":"Q","score":9,"characterId":"char-1777017632279","ts":1781175654893},{"name":"Weenwild","score":2,"characterId":"char-1777017632279","ts":1781849898452},{"name":"ผู้เล่น8622","score":0,"characterId":"char-1777017632279","ts":1783842317274}],"qbroom5":[{"name":"Q","score":6,"characterId":"char-1777017632279","ts":1781675299850}],"qbroom6":[{"name":"Q","score":3,"characterId":"char-1777017632279","ts":1781675383175},{"name":"ผู้เล่น1752","score":1,"characterId":"char-1777017632279","ts":1784273155119}],"qbroom9":[{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781696345322}],"qbroom4":[{"name":"ผู้เล่น9337","score":20,"characterId":"char-1777017632279","ts":1784270128376}],"qbroom7":[{"name":"ผู้เล่น4119","score":3,"characterId":"char-1777017632279","ts":1784270217530}]}
+5
View File
@@ -0,0 +1,5 @@
{
"masterVol": 1.0,
"musicVol": 0.35,
"sfxVol": 0.75
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -714,6 +714,31 @@
object-position: center 12%;
}
/* [2026-07-21] โหมดไอคอนผู้ต้องสงสัยแบบใหม่ — รูปเดียวมีครบ (กรอบนีออน + รูป + ชื่อจริงฝังในภาพ)
ต่างจากของเดิมที่แยก 3 ชั้น (frame / portrait / name) → ถ้าไม่ซ่อนจะได้กรอบซ้อนกรอบ + ชื่อซ้อนชื่อ.
ใส่คลาส .is-icon ที่ตัว .es3-suspect / .es-suspect เมื่อ config ตั้ง icons ไว้จริง (ดู room-lobby.js) */
#testimony-overlay .es3-suspect.is-icon .es3-suspect-frame,
#testimony-overlay .es3-suspect.is-icon .es3-suspect-name,
#testimony-overlay .es-reveal-wrap .es-suspect.is-icon .es-suspect-frame,
#testimony-overlay .es-reveal-wrap .es-suspect.is-icon .es-suspect-name {
display: none;
}
#testimony-overlay .es3-suspect.is-icon .es3-suspect-portrait,
#testimony-overlay .es-reveal-wrap .es-suspect.is-icon .es-suspect-portrait {
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: 0;
background: none;
}
/* contain = เห็นครบทั้งกรอบและแถบชื่อที่ฝังมา (cover จะครอปขอบทิ้ง) */
#testimony-overlay .es3-suspect.is-icon .es3-suspect-portrait > img,
#testimony-overlay .es-reveal-wrap .es-suspect.is-icon .es-suspect-portrait img {
object-fit: contain;
object-position: center;
}
#testimony-overlay .es-reveal-wrap .es-suspect-name {
position: absolute;
left: 20px;
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+10 -3
View File
@@ -3,7 +3,12 @@
var DISPLAY_NAME_KEY = 'roomLobbyDisplayName';
var PLAYER_NAME_KEY = 'playerName';
var MAX_LEN = 24;
/* ชื่อผู้เล่นสูงสุด (นับเป็น code unit เหมือน input maxlength) — ลดจาก 24 → 10 ตามที่ผู้ใช้ขอ 2026-07-16.
⚠️ ภาษาไทย: สระ/วรรณยุกต์เป็น code unit แยก → "10" ที่นี่ = 10 code unit ไม่ใช่ 10 ตัวที่ตาเห็น
(เช่น "สวัสดี" = 6 code unit แต่เห็น ~4 ตัว). ถ้าต้องนับตามที่ตาเห็นจริงต้องใช้ Intl.Segmenter
ทั้งสองฝั่ง — ยังไม่ทำ เพราะ maxlength ของ <input> ทำแบบนั้นไม่ได้ จะเพี้ยนกันเอง.
⚠️ ต้องตรงกับ NICKNAME_MAX_LEN ใน server.js — ฝั่ง server เป็นตัวบังคับจริง (client bypass ได้) */
var MAX_LEN = 10;
function sanitizeDisplayName(raw) {
var s = String(raw == null ? '' : raw).trim();
@@ -126,8 +131,10 @@
'<div class="jd-dne-backdrop" id="jd-dne-backdrop" aria-hidden="true"></div>' +
'<div class="jd-dne-dialog" role="dialog" aria-modal="true" aria-labelledby="jd-dne-title">' +
' <h2 class="jd-dne-title" id="jd-dne-title">แก้ไขชื่อผู้เล่น</h2>' +
' <input type="text" id="jd-dne-input" class="jd-dne-input" maxlength="24" autocomplete="nickname" aria-label="ชื่อผู้เล่น">' +
' <p class="jd-dne-hint">สูงสุด 24 ตัวอักษร — ชื่อนี้จะแสดงในเกม</p>' +
/* ผูกกับ MAX_LEN ตัวเดียว — เดิม maxlength/ข้อความ hard-code "24" ไว้คนละที่กับ MAX_LEN
ถ้าแก้ค่าที่เดียวจะเพี้ยนกันเงียบ ๆ (input ยอม 24 แต่ sanitize ตัดที่ 10) */
' <input type="text" id="jd-dne-input" class="jd-dne-input" maxlength="' + MAX_LEN + '" autocomplete="nickname" aria-label="ชื่อผู้เล่น">' +
' <p class="jd-dne-hint">สูงสุด ' + MAX_LEN + ' ตัวอักษร — ชื่อนี้จะแสดงในเกม</p>' +
' <p class="jd-dne-error" id="jd-dne-error" role="alert"></p>' +
' <div class="jd-dne-actions">' +
' <button type="button" class="jd-dne-btn jd-dne-btn--cancel" id="jd-dne-cancel">ยกเลิก</button>' +
+380 -48
View File
@@ -42,6 +42,15 @@
แต่ต่ำกว่า modal (z 10000+) เลย special quiz / สรุปผล ยังกดได้ปกติ + ป้ายบอกสถานะด้านบน */
function setupBannedSpectatorUi() {
if (!playBannedSpectator || document.getElementById('banned-spectator-shield')) return;
/* หน้า how-to/pregame = "ตัวคุมรอบ" (READY/START) ไม่ใช่การเล่น → ต้องอยู่เหนือ shield เสมอ
บั๊ก: how-to ของ MG2 (คลาส gch-mg2-mock) มี z-index แค่ 56 — ต่ำกว่า shield (200) → โดนคลุมทั้งแผง
→ host ที่โดนแบนกด START ไม่ได้ และ START เป็นสิทธิ์ host เท่านั้น = ทั้งห้องค้างถาวร
(เลย์เอาต์อื่นรอดเพราะ z สูงอยู่แล้ว: crown legacy 10140 / quiz-carry 10078)
ปล่อยให้กดได้ปลอดภัย: server กัน non-host กด START และไม่นับ ready ของคนถูกแบนอยู่แล้ว */
const zfix = document.createElement('style');
zfix.id = 'banned-spectator-zfix';
zfix.textContent = '#gauntlet-crown-howto-overlay,#quiz-carry-pregame-overlay{z-index:260!important}';
document.head.appendChild(zfix);
const shield = document.createElement('div');
shield.id = 'banned-spectator-shield';
shield.style.cssText = 'position:fixed;inset:0;z-index:200;background:transparent;pointer-events:auto;cursor:not-allowed;touch-action:none;-webkit-tap-highlight-color:transparent';
@@ -88,6 +97,19 @@
try { const pe = document.getElementById('play-loading-overlay'); if (pe && pe.parentNode) pe.parentNode.removeChild(pe); } catch (_e) {}
return;
}
/* [2026-07-17] เสียง warp "ตอนวาร์ปเข้ามินิเกม" — เล่นพร้อม "คลิป/จอโหลด" ตั้งแต่เข้า play.html
(user request: เดิมอยู่ตอนฉากโหลดเสร็จ = หลังคลิป). play.html เป็นมินิเกมล้วน (LobbyB อยู่ที่
room-lobby.html เสมอ — room-lobby.js:7876) → ปลอดภัยที่จะเล่นตอน page load. gate กันเคส lobby
map (mn8nx46h) หลุดมา + ต้องมี map param จริง. warp เป็นเสียง root (ROOT_SFX) resolve ได้โดยไม่พึ่ง
mapData ที่ยังไม่มาตอนนี้. autoplay: engagement สูงพอเล่นได้ (เดิมที่ playClientReadyEmitted ก็เล่นได้
โดยไม่มี gesture ใหม่บนหน้านี้) */
try {
var _warpMap = '';
try { _warpMap = String(new URLSearchParams(location.search).get('map') || '').trim(); } catch (_e) { _warpMap = ''; }
if (window.jdSound && _warpMap && _warpMap !== 'mn8nx46h') {
try { window.jdSound.playSfx('warp'); } catch (_e) { /* ignore */ }
}
} catch (_e) { /* ignore */ }
try {
if (!document.body) return;
/* overlay เป็น static ใน play.html แล้ว — ถ้าไม่มี (html เก่า cache) ค่อย inject เสริม */
@@ -700,6 +722,10 @@
/* server-auth swing: extrapolate phase จาก sample ล่าสุดของ server (กัน drift → บล็อกวาปตอนปล่อย) */
let stackSrvPhaseAnchor = 0;
let stackSrvPhaseAnchorAt = 0;
/* เฟสของบล็อกที่ "วาดออกจอจริง" เฟรมล่าสุด = สิ่งที่ผู้เล่นเห็นและเล็ง — ใช้ส่งตอนกดวาง
(stackMini.phase อาจเพิ่ง SNAP ไปแล้ว: dPh>0.9 → วาปทันที ระหว่างเฟรมที่เห็นกับตอน handler ทำงาน
→ ส่งเฟสหลังวาป = server คิดว่าเยื้องสุดแกว่ง → "วางกลางพอดีแต่ลงเยื้อง/ร่วง") */
let stackRenderedPhase = null;
/* freeze เฟสที่เห็น ณ ตอนกดวาง ไว้จน echo ดรอปกลับมา → บล็อกไม่แกว่งต่อแล้วเด้งกลับช่วงรอ RTT */
let stackLocalSwingFrozen = false;
let stackLocalSwingFrozenAt = 0;
@@ -3708,7 +3734,7 @@
/** ดาเมจต่อการยิงโดนบอส (ลบเลือดบอส/นัด) — Admin Minigame-7 · แยกจากคะแนน · default 10 */
let playBalloonBossDamagePerHit = 10;
let playBalloonBossBossImageUrl = '';
let playBalloonBossPlayerBalloonImageUrls = ['', '', '', '', '', ''];
let playBalloonBossPlayerBalloonImageUrls = ['', '', '', '', '', '', '', '']; /* [2026-07-17] 8 ช่อง (theme 8 สี) — เดิม 6 ตัด balloon-7 (ชมพู) ทิ้ง */
let playBalloonBossPlayerBalloonFallbackUrl = '';
/** 0 = ไม่จำกัด — จาก game-timing / gauntlet-sync (พรมแดง Gauntlet เท่านั้น) */
let gauntletRuntimeTimeLimitSec = 0;
@@ -5089,7 +5115,7 @@
clearQuizMapPanelThemeInline(panel, textEl);
return;
}
const qmCenter = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
const qmCenter = isQuizQuestionMissionRenderLockPlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
const carryCam = !qmCenter && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null;
const camX = qmCenter ? qmCenter.cx : (carryCam ? carryCam.cx : me.x * tileSize);
const camY = qmCenter ? qmCenter.cy : (carryCam ? carryCam.cy : me.y * tileSize);
@@ -5997,7 +6023,14 @@
if (!o) return;
o.playTint = playTintForPreviewBotId(bid) || resolvePlayTintForPeer(bid);
if (o.botTier == null) {
const tierRoll = Math.random();
/* [2026-07-17] เดิมเป็น Math.random() = "สุ่มใหม่ทุกเครื่อง" → ป้าย (ฉลาด/กลาง/พลาดบ่อย) ของบอทตัว
เดียวกันไม่ตรงกันข้ามจอ (user เจอทั้ง MG2 และ MG7). path host-auth adopt rb.tier ทับให้ทีหลังอยู่แล้ว
แต่ path server-auth (MG5/6/7) ไม่เคยส่ง tier ลงมา → ค่าสุ่มนี้อยู่ยาวจนจบเกม.
แก้ที่ต้นทาง: derive จาก slot ใน botId (`__pv_bot_<slot>` เหมือนกันทุกเครื่อง) ด้วย "สูตรเดียวกับ
gauntletBotTierForSlot ของ server (server.js:8209)" → ทุกเครื่องได้ค่าเท่ากัน และตรงกับ tier จริง
ที่ server ใช้เดินบอท MG2 ด้วย (เดิม client สุ่มเอง = ป้ายไม่ตรงพฤติกรรมตั้งแต่แรก) */
const slot = parseInt(String(bid).slice(PREVIEW_BOT_PREFIX.length), 10);
const tierRoll = Number.isFinite(slot) ? ((slot * 73 + 17) % 100) / 100 : 0.5;
const stackPrev = mapData && mapData.gameType === 'stack';
o.botTier = stackPrev
? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak')
@@ -6052,7 +6085,12 @@
x = sp.x + 0.5 + jx;
y = sp.y + 0.5 + jy;
}
const tierRoll = Math.random();
/* [2026-07-17] ห้าม Math.random() — นี่คือ path ที่ "สร้าง" บอทจริง (ตัว backfill ที่ 6013 แทบไม่เคย
ถูกเรียกเพราะตอนสร้างเซ็ต tier ให้เสมอ) → สุ่มตรงนี้ = ป้าย 'บอท N (ฉลาด)' ไม่ตรงกันข้ามเครื่อง.
nextSlot ข้างบน deterministic อยู่แล้ว (slot ว่างเล็กสุด 0,1,2… ทุกเครื่องได้ชุดเดียวกัน — เป็น
ข้อกำหนดเดิมอยู่แล้วเพราะ idx ต้องตรง playLobbyBotThemes ให้สีตรง lobby) → derive tier จากมัน
ด้วยสูตรเดียวกับ server gauntletBotTierForSlot (server.js:8209) */
const tierRoll = ((nextSlot * 73 + 17) % 100) / 100;
const stackPrev = mapData && mapData.gameType === 'stack';
const botTier = stackPrev
? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak')
@@ -6740,7 +6778,7 @@
→ fit ความกว้างเต็มจอ (vertical scroller เลื่อนแนวตั้งด้วย jumpSurviveCamCenterY อยู่แล้ว) */
const mwPx = w * tileSize;
if (mwPx > 0) zDraw = canvas.width / mwPx;
} else if (isQuizQuestionMissionHudActivePlay()) {
} else if (isQuizQuestionMissionRenderLockPlay()) {
const qmc = getQuizQuestionMissionMapCenterWorldPxPlay();
if (qmc) {
/* ซูมออกเล็กน้อยให้เห็นพื้นหลัง/โซน SAFE-SCAM ครบแบบ mock (0.96 เคย crop ขอบล่างบนจอแคบ) */
@@ -8044,6 +8082,27 @@
hideConflictingOverlaysForGauntletCrown();
}
/* [2026-07-17] MG5 ช่วงท้ายเลื่อนเร็วขึ้น ×2 (user request) — 15 วิสุดท้าย.
⚠️ ต้องตรงกับ server เป๊ะ (server.js:jsScrollPxAt · ค่าคงที่ชุดเดียวกัน) ไม่งั้นแท่นคนละที่ = ชนไม่ตรงกัน */
const JS_ENDGAME_BOOST_SEC = 15;
const JS_ENDGAME_BOOST_MUL = 2;
function jsBoostStartSecPlay() {
const total = jumpSurviveTimeLimitSecForMission();
return Math.max(0, (Number(total) || 0) - JS_ENDGAME_BOOST_SEC);
}
function jsScrollPxAtPlay(elapsedSec, risePerSec) {
const total = jumpSurviveTimeLimitSecForMission();
const t0 = jsBoostStartSecPlay();
if (!(Number(total) > 0) || elapsedSec <= t0) return elapsedSec * risePerSec;
return (t0 * risePerSec) + ((elapsedSec - t0) * risePerSec * JS_ENDGAME_BOOST_MUL);
}
/** ความเร็วเลื่อน "ณ ตอนนี้" — ใช้กับคนที่ยืนบนแท่น (ต้องจมตามแท่น ไม่งั้นพอ boost แท่นหนีลงไปคนลอยค้าง) */
function jsRiseVelNowPlay(md) {
const v = Number(md && md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42;
if (!(jumpSurviveLiveAnchorServerMs > 0)) return v;
const el = Math.max(0, (serverNow() - jumpSurviveLiveAnchorServerMs) / 1000);
return el > jsBoostStartSecPlay() ? v * JS_ENDGAME_BOOST_MUL : v;
}
function jumpSurviveTimeLimitSecForMission() {
const m = Number(mapData && mapData.jumpSurviveTimeSec);
if (Number.isFinite(m) && m > 0 && m < 7200) return Math.floor(m);
@@ -8998,6 +9057,17 @@
function isQuizQuestionMissionHudActivePlay() {
return isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live';
}
/* [2026-07-17] เฉพาะ "การเรนเดอร์" (กล้อง+zoom) ของ MG1 — ต้องค้างเท่าตอนเล่นต่อไปแม้จบเกมแล้ว
(user: "ตอนจบเกม ฉากเกมไม่เท่าเดิมกับตอนเล่น"). ต้นเหตุ: zoom/กล้องเดิมผูกกับ
isQuizQuestionMissionHudActivePlay ที่เป็น phase==='live' → พอจบ phase='ended' branch หลุด
→ zDraw ตกไปใช้ `zoom` ดิบ + กล้องเด้งกลับไปตาม me = ฉากเปลี่ยนขนาดทันที.
เกมอื่นไม่มีอาการนี้เพราะ zoom ผูกกับ "แมป" ไม่ใช่ "เฟส".
แยกตัวนี้ออกมาเพราะ predicate เดิมคุม HUD/อินพุตอีก ~18 ที่ ซึ่ง "ต้องดับตอนจบ" ตามเดิม.
ล็อกแค่ live|ended → howto/countdown คงพฤติกรรมเดิมเป๊ะ (ไม่มีใครบ่นถึง) */
function isQuizQuestionMissionRenderLockPlay() {
return isQuizQuestionMissionUiMapPlay()
&& (quizQuestionMissionPhase === 'live' || quizQuestionMissionPhase === 'ended');
}
/** ภารกิจคำถาม mng8a80o ช่วง live: กล้องกลางแมป (world px) — ไม่ตาม me */
function getQuizQuestionMissionMapCenterWorldPxPlay() {
@@ -11622,13 +11692,19 @@
playTint: (oh && (oh.playTint || resolvePlayTintForPeer(String(entry.id)))) || resolvePlayTintForPeer(String(entry.id)),
};
}
/* ตาบอท → หน้า/สีบอท */
/* ตาบอท → หน้า/สีบอท
บอท takeover (คนหลุดช่วง how-to) ใช้ socket.id เดิมของคนนั้นเป็น botId แต่ 'user-left' ลบเขาออกจาก
others ไปแล้ว → others.get(botId) = null → เดิมได้ characterId null = อวตารเขียว (defaultAvatarImg).
ตอนนี้ server ส่งหน้า/สีเดิมมากับ order row แล้ว → fallback มาใช้ค่านั้นแทน */
const o = entry.botId ? others.get(entry.botId) : null;
const cid = (o && o.characterId != null) ? o.characterId : (entry.characterId != null ? entry.characterId : null);
return {
id: entry.botId,
characterId: o ? o.characterId : null,
characterId: cid,
isMe: false,
playTint: o ? (o.playTint || resolvePlayTintForPeer(entry.botId)) : null,
playTint: (o && (o.playTint || resolvePlayTintForPeer(entry.botId)))
|| resolvePlayTintForPeer(entry.botId, entry) /* entry มี lobbyColorThemeIndex/lobbySkinToneIndex */
|| null,
};
}
@@ -12377,6 +12453,7 @@
stackServerAuthPlay = false; /* re-arm: server จะเปิดใหม่ผ่าน stack-state(srv) เมื่อรอบใหม่เริ่ม */
stackSrvPhaseAnchor = 0;
stackSrvPhaseAnchorAt = 0;
stackRenderedPhase = null; /* รอบใหม่ → ทิ้งเฟสที่วาดไว้รอบก่อน (fallback = stackMini.phase จนกว่าจะวาดเฟรมแรก) */
stackLocalSwingFrozen = false;
stackLocalSwingFrozenAt = 0;
if (!mapData || !isStack()) return;
@@ -12811,8 +12888,12 @@
function tryHumanStackDrop() {
if (!stackHumanDropGateOkPlay()) return false;
if (stackServerAuthPlay) {
/* server-auth: ส่งเจตนากดวาง + เฟสที่เราเห็น ณ ตอนนี้ → server วางตรงจุดที่เราเห็นแกว่ง (ไม่เยื้องตาม half-RTT) */
try { socket.emit('stack-drop-req', { phase: stackMini.phase }); } catch (e) { /* ignore */ }
/* server-auth: ส่งเจตนากดวาง + เฟสที่เราเห็น ณ ตอนนี้ → server วางตรงจุดที่เราเห็นแกว่ง (ไม่เยื้องตาม half-RTT)
ใช้ "เฟสที่วาดออกจอเฟรมล่าสุด" ไม่ใช่ stackMini.phase ปัจจุบัน — กันเคส SNAP (dPh>0.9) แทรกระหว่าง
เฟรมที่ผู้เล่นเห็นกับตอน handler นี้ทำงาน แล้วส่งเฟสหลังวาปไป = เห็นตรงกลางแต่ server ตัดสินว่าเยื้อง/ร่วง */
const dropPhase = (typeof stackRenderedPhase === 'number' && Number.isFinite(stackRenderedPhase))
? stackRenderedPhase : stackMini.phase;
try { socket.emit('stack-drop-req', { phase: dropPhase }); } catch (e) { /* ignore */ }
stackMini.lastDropAt = Date.now(); /* กัน double-tap (server มี busyUntil อยู่แล้ว) */
/* freeze เฟสที่เห็น ณ ตอนกด ไว้จนดรอปกลับมา → ไม่แกว่งต่อแล้วเด้งกลับช่วงรอ echo */
stackLocalSwingFrozen = true;
@@ -13177,6 +13258,7 @@
const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize;
blockLeftWorld = swingXW - twWorld / 2;
blockTopWorld = swingDrawY;
stackRenderedPhase = m.phase; /* จำเฟสที่เพิ่งวาด = เฟสที่ผู้เล่นเห็นจริง (ใช้ตอนกดวาง) */
}
const cxMid = blockLeftWorld + twWorld / 2;
const [tcx, tcy] = worldToScreen(craneX, cableTopY);
@@ -13467,9 +13549,12 @@
}
if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossPlayerBalloonImageUrls')) {
const a = payload.balloonBossPlayerBalloonImageUrls;
const nextBb = ['', '', '', '', '', ''];
/* [2026-07-17] เก็บ 8 ช่อง (ตาม lobby theme 8 สี) — เดิม 6 ช่อง (loop si<6) ตัด index 7 (balloon-7
ชมพู theme 8) ทิ้งตั้งแต่รับ payload → theme 8 ได้ช่องว่าง → วาดสีแบนแทน PNG (user เจอ: MONE
ตัวชมพูได้ลูกโป่งสีแบน ไม่ใช่รูป balloon-7). server ส่ง 8 มาแล้ว (game-timing config) แค่ client ตัด */
const nextBb = ['', '', '', '', '', '', '', ''];
if (Array.isArray(a)) {
for (let si = 0; si < 6; si++) {
for (let si = 0; si < 8; si++) {
const u = normalizeGauntletAssetUrlForPlay(typeof a[si] === 'string' ? a[si] : '');
nextBb[si] = u;
if (u) ensureGauntletAssetImage(u);
@@ -13544,7 +13629,12 @@
เรียกทุกเฟรมจาก draw() (ส่ง worldToScreen/zDraw/tileSize เข้ามา) — container map buffer→display ด้วย transform:scale */
const gauntletDomLabelPool = new Map(); /* id -> .gnt-label (pool ไว้ reuse) */
const gauntletPenaltySeenById = new Map(); /* id -> ts penalty ล่าสุด (กัน spawn float ซ้ำ) */
function syncGauntletNameDom(worldToScreenFn, zDrawVal, tileSizePx) {
/* [2026-07-17] skipAvatarFn ต้องส่งเข้ามา ห้ามอ้างชื่อตรงๆ — shouldGauntletCrownHeistSkipAvatarDrawPlay
นิยามอยู่ "ข้างใน draw()" ส่วนตัวนี้อยู่ module scope = คนละสโคป. ของเดิมเขียน
`typeof shouldGauntletCrownHeistSkipAvatarDrawPlay === 'function'` ซึ่ง typeof บน identifier ที่ไม่มี
ในสโคป "คืน 'undefined' เงียบๆ ไม่ throw" → guard เป็น false ตลอด → ชื่อคนตาย/คนหลุดจอค้างถาวร
(เหตุผลเดียวกับที่ worldToScreen/zDraw/tileSize ถูกส่งเป็นพารามิเตอร์อยู่แล้ว) */
function syncGauntletNameDom(worldToScreenFn, zDrawVal, tileSizePx, skipAvatarFn) {
const ov = document.getElementById('gauntlet-name-overlay');
if (!ov) return;
const live = isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live';
@@ -13574,8 +13664,7 @@
const seen = new Set();
function place(id, ent, isMe) {
if (!ent) return;
if (typeof shouldGauntletCrownHeistSkipAvatarDrawPlay === 'function'
&& shouldGauntletCrownHeistSkipAvatarDrawPlay(ent, isMe, 0, 0)) return;
if (typeof skipAvatarFn === 'function' && skipAvatarFn(ent, isMe, 0, 0)) return;
seen.add(id);
const air = isMe ? (meGauntletJumpVis || 0) : ((ent.gauntletJumpVis != null ? ent.gauntletJumpVis : ent.gauntletJumpTicks) || 0);
const liftWorldY = air > 0 ? gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSizePx * gauntletRuntimeJumpHeightMult : 0;
@@ -13583,8 +13672,29 @@
const cyBottomWorld = (v(ent.y) + chv) * tileSizePx - liftWorldY;
const scr = worldToScreenFn(cxWorld, cyBottomWorld);
const sx = scr[0], sy = scr[1];
/* ชื่อ/คะแนนใต้ตัวละคร: ตัดออกตาม mock — mock โชว์ชื่อ+คะแนนเฉพาะ "แถบคะแนนบน" เท่านั้น ไม่มีใต้ตัวละคร
(กันดูเหมือน 2 layer ทับกัน) — คงไว้เฉพาะ -10 penalty float ที่ mock มี */
/* หัว = จุดบนตัวละคร (y ไม่บวก chv) → ใช้วางชื่อ "เหนือหัว" */
const cyHeadWorld = (v(ent.y)) * tileSizePx - liftWorldY;
const scrHead = worldToScreenFn(cxWorld, cyHeadWorld);
const syHead = scrHead[1];
/* [2026-07-16] ชื่อMG2 = "เหนือหัว" (user request). แก้จากเดิมที่ผมวางผิดไว้ที่ sy+6 = เท้า.
ทำที่ DOM overlay ไม่ใช่ canvas: overlay project ตาม runway camera ให้อยู่แล้ว
(canvas text เคยเพี้ยนตอน summary/howto จนต้องปิดทุกเฟส) และ **ไม่ใส่ .gnt-sc (คะแนน)**
เพราะคะแนนยังอยู่แถบบนที่เดียวตาม mock — ใส่ทั้งคู่คือสิ่งที่เคยทับกันมาก่อน */
const nm = String((ent && ent.nickname) || '').trim();
if (nm) {
let lb = gauntletDomLabelPool.get(id);
if (!lb) {
lb = document.createElement('div');
lb.className = 'gnt-label';
ov.appendChild(lb);
gauntletDomLabelPool.set(id, lb);
}
if (lb.textContent !== nm) lb.textContent = nm;
lb.classList.toggle('is-out', !!ent.gauntletEliminated);
lb.style.left = Math.round(sx) + 'px';
/* เหนือหัว: .gnt-label top-anchored (grow ลง) → ยกขึ้นเหนือ syHead ~18px ให้ลอยเหนือหัว */
lb.style.top = Math.round(syHead - 18) + 'px';
}
const pen = Number(ent.gauntletCrownPenaltyFxUntil) || 0;
/* -10 float ต้องอยู่ "ใต้แถบคะแนนบน" เท่านั้น — กันผู้เล่นที่จุดเริ่ม (project ใกล้แถบคะแนน เช่น AFK) ทำ float โผล่ทับ SCORE label */
var sbEl = document.querySelector('.play-cyber-scoreboard');
@@ -13608,6 +13718,14 @@
}
if (others && typeof others.forEach === 'function') others.forEach((o, id) => place(id, o, false));
place('__me', me, true);
/* เก็บกวาดป้ายของคนที่ไม่อยู่ในเฟรมนี้แล้ว (ออกจากห้อง / โดนข้ามด้วย shouldSkipAvatarDraw เช่น คนโดนแบน)
— `seen` ถูกเก็บไว้ตั้งแต่เดิมแต่ไม่มีใครใช้ (dead code ค้างจากตอนที่ยังมีชื่อ) ถ้าไม่ prune
ป้ายจะค้างลอยอยู่กลางจอตลอดกาล เพราะ pool reuse ตาม id */
gauntletDomLabelPool.forEach((el, id) => {
if (seen.has(id)) return;
if (el && el.parentNode) el.parentNode.removeChild(el);
gauntletDomLabelPool.delete(id);
});
}
function getGauntletObsDrawPositionsAt(nowMs) {
@@ -14085,14 +14203,25 @@
var key = ids.join('|');
if (box._gchReadyKey !== key) {
box._gchReadyKey = key;
box.innerHTML = '';
ids.forEach(function (id) {
var cell = document.createElement('div');
/* incremental diff — คง cell เดิมที่ยังอยู่ไว้ (canvas วาดแล้ว ไม่ต้อง repaint = ไม่กระพริบ/ไม่หาย)
ลบเฉพาะ id ที่หาย, เพิ่มเฉพาะ id ใหม่, จัดลำดับด้วย insertBefore (ย้าย DOM ไม่ล้าง canvas) */
var existing = {};
var kids = Array.prototype.slice.call(box.children);
for (var k = 0; k < kids.length; k++) {
var kmid = kids[k].dataset ? kids[k].dataset.mid : null;
if (kmid != null && ids.indexOf(kmid) !== -1 && !existing[kmid]) existing[kmid] = kids[k];
else box.removeChild(kids[k]); /* id หายไป หรือ cell ซ้ำ → ตัดทิ้ง */
}
for (var i = 0; i < ids.length; i++) {
var id = ids[i];
var cell = existing[id];
var isNew = !cell;
if (isNew) { cell = document.createElement('div'); cell.dataset.mid = id; }
cell.className = 'gch-ready-av' + (id === String(myId) ? ' gch-ready-av--me' : '');
cell.dataset.mid = id;
box.appendChild(cell);
renderReadyAvatarInto(cell, id);
});
var atPos = box.children[i];
if (atPos !== cell) box.insertBefore(cell, atPos || null); /* ย้ายตำแหน่ง — canvas คงอยู่ */
if (isNew) renderReadyAvatarInto(cell, id); /* วาดหลังใส่ DOM แล้ว (paint ต้อง el.isConnected) */
}
}
if (!ids.length) { box.style.display = 'none'; return; }
/* วางใต้ปุ่มจริง (viewport coords — fixed บน body ไม่โดน transform ของ howto) */
@@ -14104,6 +14233,12 @@
/** crown-lobby games (MG1,2,3,5,6,7) — ใช้ gauntletCrownLobbyReadyMap + ปุ่มของ layout ปัจจุบัน */
function updateGauntletCrownReadyAvatars() {
var btn = (typeof getGauntletCrownHowtoBtnEl === 'function') ? getGauntletCrownHowtoBtnEl() : document.getElementById('btn-gch-ready');
/* กล่องแถวหน้า ready (#gch-ready-avatars-float) ใช้ร่วมกันทุกเกม → ต้องแตะเฉพาะตอน "จอเป็นของ crown" จริง.
ตอนเล่น MG4 (ใช้ #quiz-carry-pregame-primary) ปุ่ม crown ยังอยู่ใน DOM แต่ซ่อน (offsetW=0) — ถ้าปล่อยให้
เรียกต่อ updateReadyAvatarsFloat จะเจอ visible=false แล้วสั่ง display:none ทับกล่องที่ MG4 เพิ่งโชว์
(cells/key ยังอยู่ครบ แต่มองไม่เห็น) = อาการ "MG4 กด ready แล้วหน้าตัวละครไม่ขึ้น".
ไม่ใช่เจ้าของจอ → ไม่ทำอะไรเลย (ตอนปิด how-to มี hideReadyAvatarsFloat ซ่อนให้อยู่แล้ว) */
if (!btn || (btn.offsetWidth <= 0 && btn.getClientRects().length === 0)) return;
updateReadyAvatarsFloat(gauntletCrownLobbyReadyMap, btn);
}
@@ -14183,6 +14318,11 @@
balloonBossLastTickMs = performance.now();
/* anchor = liveBeginsAt server → boss fire + timer อ้างอิงจุดเดียวกันทุกเครื่อง */
balloonBossLiveAnchorServerMs = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : serverNow();
/* [P5 v4] ล้าง anchor/นาฬิกา server ของรอบก่อน — ไม่งั้นวงลูกศรรอบใหม่คำนวณจากฐานเวลาเกมที่จบไปแล้ว
(bot-move นัดแรกของรอบใหม่จะเซ็ตให้ภายใน ~50ms) */
balloonBossRingAnchorSrvMs = 0;
balloonBossSrvClockBase = 0;
balloonBossSrvClockLocalAt = 0;
balloonBossBossShotCount = 0;
balloonBossGameEnded = false;
balloonBossPendingShots = [];
@@ -14672,9 +14812,22 @@
ยังเดินตาม serverNow และหลัง freeze จะคำนวณ backlog ยิงกระสุนรัวออกมา (กระสุนกระจาย) */
try { if (typeof jumpSurviveLiveAnchorServerMs === 'number' && jumpSurviveLiveAnchorServerMs > 0) jumpSurviveLiveAnchorServerMs += deltaMs; } catch (e) {}
try { if (typeof spaceShooterLiveAnchorServerMs === 'number' && spaceShooterLiveAnchorServerMs > 0) spaceShooterLiveAnchorServerMs += deltaMs; } catch (e) {}
try { if (typeof balloonBossLiveAnchorServerMs === 'number' && balloonBossLiveAnchorServerMs > 0) balloonBossLiveAnchorServerMs += deltaMs; } catch (e) {}
/* [v5 2026-07-17] MG7: ถ้ามี anchor จาก server แล้ว "ห้ามเลื่อนเอง" — server เลื่อนให้ตอน pause อยู่แล้ว
(ringAnchorMs) แล้วส่งมาทุก 50ms. เลื่อนซ้ำ = นับสองเด้ง + กลับไปเป็น state ต่อเครื่อง = TIME คนละเลข.
MG5/MG6 (jumpSurvive/spaceShooter) ไม่มี anchor จาก server จึงยังต้องเลื่อนเองเหมือนเดิม */
try {
if (typeof balloonBossLiveAnchorServerMs === 'number' && balloonBossLiveAnchorServerMs > 0
&& !(balloonBossRingAnchorSrvMs > 0)) balloonBossLiveAnchorServerMs += deltaMs;
} catch (e) {}
/* [mg3] MG3 stack swing phase anchor (serverNow-based) ก็ต้องเลื่อนด้วย — ไม่งั้น clock re-sync ทุก 12วิ → serverNow กระโดด → targetPhase กระโดด → ตัวห้อยกระตุก */
try { if (typeof stackSrvPhaseAnchorAt === 'number' && stackSrvPhaseAnchorAt > 0) stackSrvPhaseAnchorAt += deltaMs; } catch (e) {}
/* MG3 TIME ยึด playMissionLiveBeginsAt (serverNow − begin) → ต้องเลื่อนด้วย ไม่งั้นเวลา MG3 เดินต่อตอนคำถามพิเศษ
(MG5/6/7 ใช้ anchor ของตัวเอง = jumpSurvive/spaceShooter/balloonBossLiveAnchorServerMs ที่เลื่อนแล้ว ไม่กระทบ) */
try { if (typeof playMissionLiveBeginsAt === 'number' && playMissionLiveBeginsAt > 0) playMissionLiveBeginsAt += deltaMs; } catch (e) {}
/* [2026-07-17] MG2 ไม่ต้องเลื่อนเอง — คอมเมนต์เดิมบอกว่า server "ไม่ re-broadcast" ซึ่งไม่จริง:
runGauntletTick เรียก emitGauntletSync ทุก tick "รวมตอน pause" (server.js:10190) และ payload มี
gauntletEndsAt เสมอ → applyGauntletTimingFromServer adopt ให้อยู่แล้ว. บวก deltaMs ทับ = นับสองเด้ง
+ กลับไปเป็นเลขต่อเครื่อง. ปล่อยให้ server เป็นเจ้าของเวลาตัวเดียว = ทุกจอตรงกัน */
}
/** เรียกทุกเฟรมระหว่าง freeze — ดันเวลาไปข้างหน้าเท่าที่ผ่านไปจริง ทำให้ countdown หยุดนิ่ง */
@@ -17925,14 +18078,66 @@
return (h % 10000) / 10000 * Math.PI * 2;
}
/* [P5 v2 2026-07-16] ฐานเวลาของวงลูกศร — sync จาก "นาฬิกา host" ไม่ใช่ serverNow() ของแต่ละเครื่อง.
ปัญหาที่ user เจอ: pin แก้ตอนยิงได้ แต่ "การหมุนต่อเนื่อง" ยัง desync (วงๆ ไม่ตรงกันข้ามจอ) เพราะ
serverNow() = Date.now()+playClockOffset ต่างต่อเครื่อง. วิธีแก้: host ส่ง t=serverNow() ของตัวเองมาใน
balloon-boss-sync (~7Hz) → non-host เก็บ base+perf.now() แล้ว extrapolate ด้วยเวลา local (perf.now
เดินอัตราเดียวกับ Date.now) → ทุกวง (ของตัวเอง+peer) คำนวณจากนาฬิกา host เดียว = ตรงกับ host และตรงกันเอง
ต่อเนื่อง ไม่ drift (re-align ทุก 140ms). host ใช้ serverNow ตัวเอง (มันคือ authority). */
let balloonBossHostClockBase = 0; /* host serverNow ล่าสุด (จาก balloon-boss-sync, โหมด non-server-auth) */
let balloonBossHostClockLocalAt = 0;
let balloonBossSrvClockBase = 0; /* [P5 v3] server Date.now ล่าสุด (จาก balloon-boss-bot-move, โหมด server-auth) */
let balloonBossSrvClockLocalAt = 0;
/* [P5 v4 2026-07-17] anchor ของ server (pause-adjusted, จาก bot-move ทุก 50ms) — "ห้าม" เอาไปเลื่อนเอง
ใน shiftSpecialQuizFrozenTimers เด็ดขาด นั่นคือบั๊กที่ v2/v3 แก้ไม่หาย: ตัวที่เพี้ยนคือ anchor ไม่ใช่นาฬิกา */
let balloonBossRingAnchorSrvMs = 0;
function balloonBossRingNowMs() {
/* [P5 v3] server-auth (เกมจริง MG7): ทุกเครื่อง "รวม host" ใช้ฐานเวลา server (จาก bot-move) —
host ไม่ใช่ authority ของ ring ในโหมดนี้ (server คือ). extrapolate ด้วยเวลา local (perf.now) */
if (typeof balloonBossServerAuthActive === 'function' && balloonBossServerAuthActive() && balloonBossSrvClockBase > 0) {
return balloonBossSrvClockBase + (performance.now() - balloonBossSrvClockLocalAt);
}
/* non-server-auth: host เป็น authority (serverNow ตัวเอง); non-host ใช้ host clock (จาก sync) */
if (typeof isMePlayHost === 'function' && isMePlayHost()) return serverNow();
if (balloonBossHostClockBase > 0) return balloonBossHostClockBase + (performance.now() - balloonBossHostClockLocalAt);
return serverNow();
}
/* มุมลูกศรหมุนแบบ deterministic จากเวลา server + เฟสต่อ id → "วงๆ" หมุนตรงกันทุกเครื่อง
(เดิมสุ่ม init + บวก dt ต่อเครื่อง → คนละมุม) ความเร็ว 1.02 rad/s */
function balloonBossAutoAimRadPlay(id) {
const tMs = balloonBossLiveAnchorServerMs > 0 ? (serverNow() - balloonBossLiveAnchorServerMs) : serverNow();
const nowMs = balloonBossRingNowMs();
/* [P5 v4] server-auth (เกมจริง MG7): ใช้ "นาฬิกา server − anchor ของ server" = ค่าเดียวกันเป๊ะทุกเครื่อง.
เดิมลบด้วย balloonBossLiveAnchorServerMs ซึ่งเป็น state ต่อเครื่อง (เลื่อนเองตอนคำถามพิเศษ + fallback
เป็น serverNow() ถ้า playMissionLiveBeginsAt ยังไม่มา) → คนละมุม และไม่มีทางกลับมาตรงกัน.
นอกโหมด server-auth ยังใช้ของเดิม (host เป็น authority + adopt ผ่าน balloon-boss-sync) */
const srvAuth = typeof balloonBossServerAuthActive === 'function' && balloonBossServerAuthActive();
const anchor = (srvAuth && balloonBossRingAnchorSrvMs > 0)
? balloonBossRingAnchorSrvMs
: balloonBossLiveAnchorServerMs;
const tMs = anchor > 0 ? (nowMs - anchor) : nowMs;
const a = (tMs * 0.00102) + balloonBossAimPhaseForIdPlay(id);
return a % (Math.PI * 2);
}
/* [P5 2026-07-16] แก้ "ลูกศรกับกระสุนออกไม่ตรงกัน" บนจอคนดู.
ลูกศรวาดจาก balloonBossAutoAimRadPlay = (serverNow()-anchor)*0.00102 + phase(id) — serverNow มี
playClockOffset ต่อเครื่อง; กระสุน peer มาทาง broadcast (มุมที่เจ้าของคำนวณด้วย offset ของตัวเอง)
+ กว่ากระสุนจะมาถึง (latency) ลูกศรก็หมุนต่อ → กระสุนโผล่ไม่ตรงปลายลูกศร (ยิ่ง offset/latency มาก
ยิ่งเยื้อง; 100ms ≈ 0.1rad). fix: ตอน peer เห็นเจ้าของยิง "pin" ลูกศรของ peer นั้นไปที่มุมกระสุน
สั้น ๆ (จังหวะที่ตาจับ launch จริง) → ลูกศร+กระสุนตรงกันตอนยิงบนทุกจอ. ระหว่างยิงลูกศรหมุนอิสระเหมือนเดิม.
pin ~220ms > 1 เฟรมการมองเห็น แต่สั้นพอไม่สะดุด (ที่ 1.02rad/s = ค้าง ~13°) */
const balloonBossAimPinById = new Map(); /* id -> { rad, until } (until = serverNow ms) */
function balloonBossDisplayAimRadPlay(id) {
const pin = balloonBossAimPinById.get(String(id));
if (pin && serverNow() < pin.until) return pin.rad;
return balloonBossAutoAimRadPlay(id);
}
function balloonBossPinAimForShot(id, rad) {
if (id == null || !Number.isFinite(rad)) return;
balloonBossAimPinById.set(String(id), { rad: ((rad % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2), until: serverNow() + 220 });
}
function getBalloonBossBossLiveCenterPlay(md, ts) {
if (!md || md.gameType !== 'balloon_boss') return getBalloonBossBossWorldCenterPlay(md, ts);
const base = getBalloonBossBossWorldCenterPlay(md, ts);
@@ -18294,7 +18499,7 @@
/* host เป็นผู้จำลองบอท → broadcast กระสุนบอทให้ client อื่นเห็นด้วย */
try {
if (socket && myId != null) {
socket.emit('balloon-boss-shot', { sx: botSx, sy: botSy, vx: bvx, vy: bvy, ownerId: bid, t0: serverNow() });
socket.emit('balloon-boss-shot', { sx: botSx, sy: botSy, vx: bvx, vy: bvy, ownerId: bid, t0: serverNow(), aim });
}
} catch (eB) { /* ignore */ }
/* สเปก MG7: บอทยิง 1 ครั้ง = −5 คะแนน เหมือนผู้เล่น (ต่ำสุด 0) */
@@ -18618,7 +18823,11 @@
aliveRefs.forEach((e) => {
const r = e.ref;
if (!r || r.balloonBossEliminated) return;
r.balloonBossAimRad = balloonBossAutoAimRadPlay(e.id);
/* [P5] ตัวเราเอง: ยิงตามลูกศรจริง (auto) เสมอ — ไม่ pin ตัวเอง (จอเราลูกศร+กระสุนตรงอยู่แล้ว).
peer อื่น: ใช้ display (เคารพ pin ตอนเพิ่งเห็นเขายิง) เพื่อให้ลูกศรตรงกับกระสุนที่ broadcast มา */
r.balloonBossAimRad = (myId != null && String(e.id) === String(myId))
? balloonBossAutoAimRadPlay(e.id)
: balloonBossDisplayAimRadPlay(e.id);
});
balloonBossPlayerFireCd -= dt;
@@ -18649,7 +18858,7 @@
/* broadcast กระสุนให้ทุกคนเห็นตรงกัน (host เห็นยังไง ทุกคนเห็นแบบนั้น) */
try {
if (socket && myId != null) {
socket.emit('balloon-boss-shot', { sx: shotSx, sy: shotSy, vx, vy, ownerId: myId, t0: serverNow() });
socket.emit('balloon-boss-shot', { sx: shotSx, sy: shotSy, vx, vy, ownerId: myId, t0: serverNow(), aim });
if (window.jdSound) window.jdSound.playSfx('laser', 'mg7');
}
} catch (eS) { /* ignore */ }
@@ -18680,6 +18889,8 @@
/* anchor เวลาเริ่มเล่น (server ms) — clients adopt เพื่อให้มุม "ลูกศรวงกลม" หมุนตรงกันทุกเครื่อง
(เดิม anchor fallback = serverNow() ตอนเข้าเล่น ต่างกันต่อเครื่อง → วงๆ ไม่ sync) */
anchor: balloonBossLiveAnchorServerMs > 0 ? balloonBossLiveAnchorServerMs : 0,
/* [P5 v2] นาฬิกา host ณ ตอนส่ง → non-host ใช้เป็นฐานเวลาวงลูกศร (แทน serverNow ตัวเอง) = วงหมุน sync ต่อเนื่อง */
t: serverNow(),
players: syncRefs.map((e) => ({
id: e.id,
d: Math.max(0, (e.ref && (e.ref.balloonBossBossDmg | 0)) || 0),
@@ -18946,11 +19157,15 @@
}
/** ลูกโป่งด้านบน +50% — กระจุกเอียงซ้าย/กลางตรง/ขวา (tilt) + เชือกบางไปจุดผูบนวง · Bouquet tilt like mock */
function drawBalloonsCluster(sx, sy, count, col, z, slot1to6, playerRingR) {
const slotIdx = Math.max(0, Math.min(5, (Math.floor(Number(slot1to6)) || 1) - 1));
function drawBalloonsCluster(sx, sy, count, col, z, slot1to8, playerRingR) {
/* [2026-07-17] slot1to8 = theme index (1-8). ถ้า config slot ของ theme นั้น "ว่าง" (= theme ที่ไม่มี
PNG เป็นของตัวเอง เช่น theme 7 ม่วง) → วาดสีแบนตาม col (สีถูกต้องตรงตัวละคร) แทน fallback PNG
สีกลาง. clamp 0-7 (config มี 8 slot ไม่ใช่ 6 — เดิม clamp 5 ทำให้ theme 7,8 ชี้ผิด slot) */
const slotIdx = Math.max(0, Math.min(7, (Math.floor(Number(slot1to8)) || 1) - 1));
const perSeat = String((playBalloonBossPlayerBalloonImageUrls[slotIdx] || '')).trim();
const fallback = String(playBalloonBossPlayerBalloonFallbackUrl || '').trim();
const bRec = resolveBalloonBossBalloonSpriteRec(perSeat, fallback);
const useFlatCol = !perSeat; /* theme นี้ไม่มี PNG → สีแบน */
const fallback = useFlatCol ? '' : String(playBalloonBossPlayerBalloonFallbackUrl || '').trim();
const bRec = useFlatCol ? null : resolveBalloonBossBalloonSpriteRec(perSeat, fallback);
const n = Math.max(0, Math.min(8, count | 0));
const ringRpx = playerRingR != null && playerRingR > 0 ? playerRingR : 22 * z;
const sz = 1.5;
@@ -19033,14 +19248,35 @@
if (ent.balloonBossCx == null || ent.balloonBossCy == null) return;
if (ent.balloonBossEliminated) return;
const [sx, sy] = worldToScreen(ent.balloonBossCx, ent.balloonBossCy);
const col = BALLOON_BOSS_PLAYER_COLORS[idx % BALLOON_BOSS_PLAYER_COLORS.length];
/* [P1 2026-07-16, fix v2] สีลูกโป่งเหนือหัว = สีตัวละครที่ผู้เล่นเลือก (เดิมวนตามลำดับ idx → สีไม่ตรงตัว).
⚠️ v1 ใช้ resolvePlayTintForPeer(id, ent) → ถ้า ent.playTint ยังไม่มา (join race) มัน fall ไป
playTintFromPeerId = hash ของ socket.id ซึ่ง "ต่างทุกเครื่อง" → ลูกโป่งคนเดียวกันคนละสีข้ามจอ
(user เจอจริง: MONE เขียว/น้ำเงินคนละจอ).
fix: ใช้ tint "ตัวเดียวกับที่ตัวละครวาด" เป๊ะ = ent.playTint || resolvePlayTintForPeer(entry.id)
(บรรทัด ~19208) → ลูกโป่งกับตัวละครใช้แหล่งเดียวกัน สีตรงกันเสมอทุกจอ (ent.playTint ตั้งจาก
lobbyColorThemeIndex ที่ server ส่ง = authoritative). แล้ว map hex → theme index (nearestIndex).
2 ปลายทางคนละช่วง: col ใช้ hex เต็ม 8 สี; PNG 6 slot (clamp 1..6) → theme 7,8 map ((t-1)%6)+1 */
const bbTint = ent.playTint || ((typeof resolvePlayTintForPeer === 'function') ? resolvePlayTintForPeer(entry.id) : null);
const bbThemeIdx = (bbTint && bbTint.body) ? nearestIndexFromHexPlay(bbTint.body, PLAY_LOBBY_THEME_HEX) : null;
const z = zDraw;
const skinSlot = bbSlotCycle.length ? bbSlotCycle[idx % bbSlotCycle.length] : ((idx % 6) + 1);
/* col: สี fallback ตาม theme เต็ม 8 สี — ถ้าหา tint ไม่เจอค่อย fall กลับไปวนตามลำดับ (พฤติกรรมเดิม) */
const col = (bbThemeIdx && PLAY_LOBBY_THEME_HEX[bbThemeIdx - 1])
? PLAY_LOBBY_THEME_HEX[bbThemeIdx - 1]
: BALLOON_BOSS_PLAYER_COLORS[idx % BALLOON_BOSS_PLAYER_COLORS.length];
/* [2026-07-17] skinSlot: รูปลูกโป่งตาม theme — map ตรง 1:1 (theme N → balloon slot N). ⚠️ เดิม
`((bbThemeIdx-1)%6)+1` wrap 8→6 → theme 8 (ชมพู) ได้ slot 2 (ส้ม) = MONE ตัวชมพูได้ลูกโป่งส้ม.
มี PNG จริง 7 อัน (balloon-1..7 = แดง/ส้ม/เหลือง/เขียว/ฟ้า/น้ำเงิน/ชมพู). config 8 slot:
index 0-5 = balloon-1..6 (theme 1-6), index 6 = ว่าง (theme 7 ม่วง = ไม่มี PNG → สีแบน),
index 7 = balloon-7 (theme 8 ชมพู). drawBalloonsCluster ใช้สีแบนเองถ้า config slot ว่าง */
const skinSlot = bbThemeIdx
? bbThemeIdx
: (bbSlotCycle.length ? bbSlotCycle[idx % bbSlotCycle.length] : ((idx % 6) + 1));
const ringR = 25 * z;
/** จุดกลางฟองบนจอ — ยกจากจุด world เล็กน้อยให้ตัวอยู่กลางวง ลูกโป่งชิดขอบบน (ตาม mock) */
const bubbleCy = sy - ringR * 0.42;
/** มุมลูกศร Artboard 9 — หมุนอัตโนมัติ; Mega Virus ยิงตามมุมนี้ ณ ตอนกด Space */
const angRing = balloonBossAutoAimRadPlay(entry.id);
/** มุมลูกศร Artboard 9 — หมุนอัตโนมัติ; Mega Virus ยิงตามมุมนี้ ณ ตอนกด Space
[P5] ใช้ display helper (เคารพ pin ตอน peer ยิง) → ลูกศร+กระสุนตรงกันบนจอคนดู */
const angRing = balloonBossDisplayAimRadPlay(entry.id);
/** หันหน้าเข้ากล้อง (mock) — ไม่ใช้ทิศออกจากบอสในเลเยอร์นี้ */
const faceDir = 'down';
const slotIdxRing = Math.max(0, Math.min(5, skinSlot - 1));
@@ -19414,7 +19650,9 @@
onGround = false;
} else if (wasOnGround) {
/* ยืนอยู่ → ติดไปกับแท่นที่เลื่อนลง (กันลอยเหนือแท่นตอน fps สูง: การจม ∝dt² < ระยะ scroll ∝dt → onGround หลุด กระโดดไม่ได้/วิ่งค้าง) */
const platformRise = (Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42) * dt;
/* [2026-07-17] ใช้ความเร็ว "ณ ตอนนี้" (เร่ง ×2 ช่วงท้าย) — ค่าคงที่เดิมจะทำให้พอเข้าช่วง boost
แท่นหนีลงเร็วกว่าคน = คนลอยค้างเหนือแท่น onGround หลุด กระโดดไม่ได้ */
const platformRise = jsRiseVelNowPlay(md) * dt;
feetY += platformRise;
}
vy += g;
@@ -19883,6 +20121,8 @@
function jumpSurviveTickFrame() {
if (!mapData || !isJumpSurvive()) return;
/* คำถามพิเศษ → หยุด physics MG5 (reset lastTick กัน dt สะสม) — scroll/timer หยุดผ่าน anchor shift อยู่แล้ว */
if (specialQuizFreeze) { jumpSurviveLastTickMs = performance.now(); return; }
if (isJumpSurviveMissionUiMapPlay()) {
if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return;
const remEnd = jumpSurviveRemainingSecMission();
@@ -19916,7 +20156,7 @@
if (jumpSurviveLiveAnchorServerMs > 0) {
/* scroll = ฟังก์ชันของเวลา server (ไม่สะสม dt local) → ทุกเครื่องเลื่อนเท่ากันที่เวลาเดียวกัน */
const scrollSec = Math.max(0, (serverNow() - jumpSurviveLiveAnchorServerMs) / 1000);
jumpSurvivePlatformScrollPx = scrollSec * risePerSec;
jumpSurvivePlatformScrollPx = jsScrollPxAtPlay(scrollSec, risePerSec);
} else {
jumpSurvivePlatformScrollPx += risePerSec * dt;
}
@@ -21082,8 +21322,19 @@
const raw = d.held;
const held = (raw == null) ? null : Math.floor(Number(raw));
const val = (held == null || !Number.isFinite(held)) ? null : held;
if (String(d.id) === String(myId)) me.quizCarryHeld = val;
else { const o = others.get(d.id) || others.get(String(d.id)); if (o) o.quizCarryHeld = val; }
const isMine = String(d.id) === String(myId);
const ent = isMine ? me : (others.get(d.id) || others.get(String(d.id)));
/* [2026-07-17] เสียง "หยิบคำตอบ" — ทุกคนต้องได้ยิน ไม่ใช่แค่คนหยิบ (user request).
แขวนตรงนี้จุดเดียวพอ เพราะ server ยิง quiz-carry-held ด้วย io.to(sid) = ถึงทุกคน "รวมคนหยิบเอง"
(server.js:5660) — เหมือน pattern peer laser/jump ที่เล่นเสียงใน handler ของ state ที่ broadcast มา.
⚠️ เล่นเฉพาะตอนเปลี่ยน "ไม่ถือ → ถือ": server.js:13339 ยิง held ซ้ำให้คนเพิ่ง join/reconnect
(io.to(socket.id)) ถ้าไม่กัน จะมีเสียงหยิบหลอนตอนเข้าห้อง และตอนวาง (held=null) ก็ต้องเงียบ */
const wasHeld = ent ? ent.quizCarryHeld : null;
if (isMine) me.quizCarryHeld = val;
else if (ent) ent.quizCarryHeld = val;
if (ent && wasHeld == null && val != null && window.jdSound) {
try { window.jdSound.playSfx('pick', 'mg4'); } catch (e) { /* ignore */ }
}
try { syncQuizCarryGrabButton(); } catch (e) { /* ignore */ }
});
/* จบรอบ: server แจ้งผล (ใครถูก/correctIndex/คะแนน) → merge + เคลียร์ held */
@@ -21328,7 +21579,16 @@
if (rb.bbDmg != null) o.balloonBossBossDmg = rb.bbDmg;
if (rb.bbBalloons != null) o.balloonBossBalloons = rb.bbBalloons;
if (rb.bbElim != null) o.balloonBossEliminated = !!rb.bbElim;
if (rb.qcHeld !== undefined && !(isQuizCarry() && quizCarryServerAuthActive())) o.quizCarryHeld = rb.qcHeld;
if (rb.qcHeld !== undefined && !(isQuizCarry() && quizCarryServerAuthActive())) {
/* [2026-07-17] บอทหยิบก็ต้องมีเสียง (user request) — บอทมาคนละทางกับคนจริง (bot-move row
ไม่ใช่ quiz-carry-held) จึงต้องแขวนซ้ำที่นี่. กัน "ไม่ถือ → ถือ" เหมือนกัน เพราะ row นี้
ส่งทุก tick = ค่าเดิมซ้ำๆ ถ้าไม่กันจะรัวเป็นเสียงกรอบแกรบ */
const wasQc = o.quizCarryHeld;
o.quizCarryHeld = rb.qcHeld;
if (wasQc == null && rb.qcHeld != null && isQuizCarry() && window.jdSound) {
try { window.jdSound.playSfx('pick', 'mg4'); } catch (e) { /* ignore */ }
}
}
/* MG1 quiz: รับสถานะล็อก + คะแนนบอทจาก host (single source of truth) */
if (rb.qct != null) o.quizCannotTrue = !!rb.qct;
if (rb.qcf != null) o.quizCannotFalse = !!rb.qcf;
@@ -21424,6 +21684,13 @@
id: e.id != null ? String(e.id) : null,
botId: e.botId != null ? String(e.botId) : null,
theme: parsePlayLobbyThemeIndex(e.theme) || null,
/* หน้า/สี/ชื่อ ของบอท takeover (คนหลุดช่วง how-to) — ต้องเก็บต่อ ไม่งั้น mg3CurrentSeatRowPlay
หา others.get(botId) ไม่เจอ (user-left ลบไปแล้ว) → characterId null → อวตารเขียว */
characterId: e.characterId != null ? e.characterId : null,
lobbyColorThemeIndex: e.lobbyColorThemeIndex != null ? e.lobbyColorThemeIndex : null,
lobbySkinToneIndex: e.lobbySkinToneIndex != null ? e.lobbySkinToneIndex : null,
nickname: e.nickname || '',
takeover: !!e.takeover,
}));
}
if (stackPreviewTurnOrder && stackPreviewTurnOrder.length && Number.isFinite(Number(d.idx))) {
@@ -21480,6 +21747,10 @@
const t0 = Number(d.t0);
const fwd = Number.isFinite(t0) ? Math.max(0, Math.min(2.5, (serverNow() - t0) / 1000)) : 0;
balloonBossPlayerBullets.push({ x: sx + vx * fwd, y: sy + vy * fwd, vx, vy, ownerId: d.ownerId });
/* [P5] pin ลูกศรของเจ้าของกระสุนไปที่ "มุมที่ยิงจริง" สั้น ๆ → ลูกศร+กระสุนตรงกันบนจอเรา
(ใช้ d.aim ถ้ามี; ไม่มีก็ atan2(vy,vx) = ทิศเดียวกัน — รองรับ client เก่าที่ยังไม่ส่ง aim) */
const shotAim = Number.isFinite(Number(d.aim)) ? Number(d.aim) : Math.atan2(vy, vx);
balloonBossPinAimForShot(d.ownerId, shotAim);
if (window.jdSound) { try { window.jdSound.playSfx('laser', 'mg7'); } catch (e) { /* ignore */ } } /* peer/bot ยิง = ทุกคนได้ยินเสียง (ยิงของตัวเอง return ไปข้างบนแล้ว) */
});
@@ -21511,6 +21782,9 @@
if (Number.isFinite(anc) && anc > 0 && Math.abs((balloonBossLiveAnchorServerMs || 0) - anc) > 1) {
balloonBossLiveAnchorServerMs = anc;
}
/* [P5 v2] adopt นาฬิกา host → วงลูกศรใช้ฐานเวลาเดียวกับ host (แทน serverNow ตัวเอง) = หมุน sync ต่อเนื่อง */
const ht = Number(d.t);
if (Number.isFinite(ht) && ht > 0) { balloonBossHostClockBase = ht; balloonBossHostClockLocalAt = performance.now(); }
if (Array.isArray(d.players)) {
d.players.forEach((p) => {
if (!p || p.id == null) return;
@@ -21781,6 +22055,11 @@
/* MG3 stack — ผู้เล่นหลุดกลางรอบ → server เปลี่ยน seat เป็นบอท (เก็บ avatar เดิม) → mark + ติดป้าย */
socket.on('stack-player-to-bot', (d) => {
if (!d || d.id == null) return;
/* หลุดช่วง how-to: 'user-left' ลบ peer ออกจาก others ไปก่อนแล้ว (play.js ~21769) → o = null →
เดิมทั้ง handler นี้กลายเป็น no-op เงียบ ๆ = "ไม่มีอะไรบอกว่าเขาออกไปแล้ว" ทั้งที่ server ยิง event
มาแล้วจริง ๆ. ให้แจ้งเสมอ ไม่ผูกกับการมี entry ค้างอยู่ใน others */
const nick = String((d.nickname || '')).trim() || 'ผู้เล่น';
try { showStackTakeoverNotice(nick); } catch (e) { /* ignore */ }
const o = others.get(d.id) || others.get(String(d.id));
if (o) {
o.isStackTakeoverBot = true;
@@ -21891,6 +22170,19 @@
socket.on('balloon-boss-bot-move', (d) => {
if (!d || !Array.isArray(d.bots) || !isBalloonBoss()) return;
balloonBossServerAuthPlay = true; /* สัญญาณ: server เป็นเจ้าของบอท/รอบ/HP บอสรอบนี้ */
/* [P5 v3] adopt นาฬิกา server → ฐานเวลา "วงลูกศรหมุน" ตรงกันทุกเครื่อง (แทน serverNow ตัวเอง) */
const st = Number(d.t);
if (Number.isFinite(st) && st > 0) { balloonBossSrvClockBase = st; balloonBossSrvClockLocalAt = performance.now(); }
/* [P5 v4] adopt anchor ของ server (pause-adjusted) → มุมวงลูกศร = f(นาฬิกา server, anchor server) ล้วน */
const sa = Number(d.anchor);
if (Number.isFinite(sa) && sa > 0) {
balloonBossRingAnchorSrvMs = sa;
/* [v5 2026-07-17] anchor "หลัก" ต้อง adopt ด้วย — v4 แก้แค่วงลูกศร แต่ balloonBossLiveAnchorServerMs
ยังเป็นค่าต่อเครื่อง ทั้งที่มีคนใช้อีก 4 ที่: TIME (18250), tSec (18093), จังหวะบอสยิง (18560),
และ seed ของ spawn (18566) → user เจอ "MG7 เวลาไม่ sync กัน" = TIME คนละเลข.
(seed ที่ต่างกันแปลว่า env spawn ก็คนละชุดข้ามจอมาตลอดด้วย — หายไปพร้อมกัน) */
balloonBossLiveAnchorServerMs = sa;
}
d.bots.forEach((rb) => {
if (!rb || typeof rb.id !== 'string') return;
const o = ensureServerAuthBotEntityPlay(rb.id, 'down'); /* สร้าง entity ถ้า client นี้ยังไม่มี (กันบอทค้าง) */
@@ -21949,6 +22241,30 @@
});
});
/* MG3: "X ไม่กลับมาแล้ว → บอทเล่นแทน" — ป้ายค้าง 6 วิ ใต้ป้าย grace (คนละใบ ไม่ทับกัน)
จำเป็นเพราะ grace banner บอกแค่ "กำลังรอ" แล้วหายไป: พอครบเวลาจริง ๆ ผู้เล่นที่เหลือ (ยังอยู่หน้า
how-to) ไม่เคยได้ยินว่าเขาไม่กลับแล้ว — เห็นแค่แถว SCORE หายไปหนึ่งคน. z สูงกว่า how-to (10140). */
let _stackTakeoverNoticeTimer = null;
function showStackTakeoverNotice(nickname) {
let el = document.getElementById('play-stack-takeover-notice');
if (!el) {
el = document.createElement('div');
el.id = 'play-stack-takeover-notice';
el.style.cssText = 'position:fixed;left:50%;top:58px;transform:translateX(-50%);z-index:10500;'
+ 'padding:9px 18px;border-radius:12px;background:rgba(38,60,120,.94);color:#fff;'
+ 'font:600 15px system-ui,"Kanit",sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.42);pointer-events:none;letter-spacing:.2px;';
document.body.appendChild(el);
}
el.textContent = '🤖 ' + nickname + ' ออกจากเกม — บอทเล่นแทนในตาของเขา';
el.style.display = 'block';
if (_stackTakeoverNoticeTimer) clearTimeout(_stackTakeoverNoticeTimer);
_stackTakeoverNoticeTimer = setTimeout(function () {
_stackTakeoverNoticeTimer = null;
const e2 = document.getElementById('play-stack-takeover-notice');
if (e2) e2.style.display = 'none';
}, 6000);
}
/* ===== Disconnect grace + bot takeover (10 วิ รอ reconnect) ===== */
let _playGraceCountdownTimer = null;
function showDisconnectGraceBanner(nickname, seconds) {
@@ -23218,7 +23534,7 @@
const r = canvas.getBoundingClientRect();
const sx = e.clientX - r.left;
const sy = e.clientY - r.top;
const qmC = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
const qmC = isQuizQuestionMissionRenderLockPlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
const carryCam = !qmC && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null;
const camX = qmC ? qmC.cx : (carryCam ? carryCam.cx : me.x * tileSize);
const camY = qmC ? qmC.cy : (carryCam ? carryCam.cy : me.y * tileSize);
@@ -26156,7 +26472,9 @@
const remain = QUIZ_CARRY_FEEDBACK_FX_MS - ageMs;
const rise = Math.min(1, ageMs / QUIZ_CARRY_FEEDBACK_FX_MS) * 14;
const rr = Math.max(11, tileSize * zDraw * 0.42);
const cy = topY - rr - 8 - rise;
/* [2026-07-17] ยกจาก -8 เป็น -18: ตั้งแต่วันนี้ MG4 มีชื่อลอยเหนือหัว (กิน topY-14..topY-4)
ค่าเดิมถูกจูนตอนที่ยังไม่มีชื่อ → badge ทับชื่อ 6px ที่เฟรมแรกของ FX ก่อนจะลอยหนีขึ้นไป */
const cy = topY - rr - 18 - rise;
const alpha = remain < 300 ? Math.max(0, remain / 300) : 1;
ctx.save();
ctx.globalAlpha = alpha;
@@ -26336,7 +26654,12 @@
const withScore = isGauntlet() && typeof gauntletScoreLabel === 'number' && gauntletScoreLabel >= 0
? (nameBase + ' · ' + gauntletScoreLabel)
: nameBase;
if (!gntDomLabel) ctx.fillText(withScore, sx, sy + 10);
/* [2026-07-17] ชื่อไว้ "เหนือหัว" สำหรับ MG5 · MG1 · MG4 (user request) — lobby ยังไว้ใต้เท้า
เหมือนเดิม (drawAvatar ตัวเดียวกันใช้ร่วมกับ LobbyA/LobbyB ด้วย). ยึด spriteTopY = ยอดสไปรต์
จริง ไม่ใช่ sy-2r เพราะสไปรต์แต่ละตัวสูงไม่เท่ากันหลัง scale (MG1 มี minPx upscale ของตัวเอง)
→ ชื่อลอยห่างหัวเท่ากันทุกตัวละคร และตอนกระโดดชื่อตามไปเอง */
const nameY = (isJumpSurvive() || isQuiz() || isQuizCarry()) ? spriteTopY - 4 : sy + 10;
if (!gntDomLabel) ctx.fillText(withScore, sx, nameY);
if (!gntDomLabel
&& isGauntletCrownHeistMapPlay()
&& gauntletCrownPregamePhase === 'live'
@@ -26395,7 +26718,11 @@
}
function shouldGauntletCrownHeistSkipAvatarDrawPlay(ent, isMeFlag, offAx, offAy) {
if (!isGauntletCrownHeistMapPlay() || gauntletCrownPregamePhase !== 'live') return false;
if (!ent || ent.gauntletEliminated) return true;
/* [2026-07-17] bannedSpectator = คนโดนแบน (Card 3) เข้ามาดูเฉยๆ ไม่ได้เล่น → ต้องไม่มีทั้งตัวละคร
และป้ายชื่อบนสนาม (user เจอ "ชื่อยังอยู่แม้คนนั้นโดน ban"). เดิมเช็คแค่ gauntletEliminated
= คนที่ "เล่นแล้วตกรอบ" ซึ่งคนละเรื่องกับคนที่ไม่ได้ลงเล่นตั้งแต่แรก.
แก้ที่นี่จุดเดียวคุมทั้งคู่ เพราะ DOM overlay ป้ายชื่อ (syncGauntletNameDom) เรียกฟังก์ชันนี้ด้วย */
if (!ent || ent.gauntletEliminated || ent.bannedSpectator) return true;
const { cw, ch } = getCharacterFootprintWH(mapData);
const ax = safeX(ent.x) + (offAx || 0);
const ay = safeY(ent.y) + (offAy || 0);
@@ -26513,7 +26840,7 @@
}
});
/* Minigame-2: อัปเดต DOM overlay (ชื่อ/คะแนน/-10) ให้ตรงตำแหน่งตัวละครบน canvas */
try { syncGauntletNameDom(worldToScreen, zDraw, tileSize); } catch (eGntDom) { /* ignore */ }
try { syncGauntletNameDom(worldToScreen, zDraw, tileSize, shouldGauntletCrownHeistSkipAvatarDrawPlay); } catch (eGntDom) { /* ignore */ }
}
/* MG4: ป้ายตัวเลือกบนโต๊ะ วาดหลัง entity → อยู่หน้าตัวละคร (คน/บอทไม่บังคำตอบที่ยังไม่หยิบ) */
if (isQuizCarry()) { try { drawQuizCarryChoiceLabels(ctx, worldToScreen, zDraw); } catch (eCl) { /* ignore */ } }
@@ -26813,7 +27140,12 @@
/* BGM ต่อมินิเกม (idempotent — playBgm return เร็วถ้า scene เดิม) */
if (window.jdSound) { try { window.jdSound.playBgm(jdPlayScene()); } catch (e) { /* ignore */ } }
/* โหลดฉากมินิเกมเสร็จ (ครั้งเดียว) → เอา badge "กำลังโหลด" ออกให้คนอื่นเห็น */
if (!playClientReadyEmitted && socket && socket.connected) { playClientReadyEmitted = true; try { socket.emit('client-ready'); } catch (e) { /* ignore */ } }
if (!playClientReadyEmitted && socket && socket.connected) {
playClientReadyEmitted = true;
try { socket.emit('client-ready'); } catch (e) { /* ignore */ }
/* [2026-07-17] เสียง warp ย้ายไป showPlayLoadingOverlay (ตอนคลิป/จอโหลดเริ่ม) แล้ว — user บอกว่า
ตรงนี้ (ฉากโหลดเสร็จ) มัน "อยู่หลังคลิป" ต้องเล่นพร้อมคลิปตั้งแต่เข้า ก่อน how-to */
}
/* dt frame (วินาที) → playDtMul = dt*60 (clamp 0.05s = step ≤0.45 tile กัน tunneling) ใช้ทำ movement time-based */
{
const _pnow = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+468 -61
View File
@@ -89,13 +89,18 @@
}
/* เวลาโชว์ผลโหวตคนร้าย (ถูก/ผิด) ก่อนตัดไปหน้าผลเต็ม — default 7000ms, admin override ผ่าน game-timing.json */
var trialResultRevealMs = 7000;
/* ปลายทางหลังจบคดี (admin game-timing.json): 'mainlobby' = กลับ Main-Lobby (เดิม) · 'lobbya' = ห้องเดิมกลับ LobbyA เล่นคดีใหม่ต่อ */
var postCaseDestination = 'mainlobby';
/* อ่าน flag จาก admin (game-timing.json) — เปิด HUD ให้ทุกคน หรือเฉพาะชื่อที่ระบุ + เวลาโชว์ผลโหวต */
try {
fetch(BASE + '/data/game-timing.json?_=' + Date.now()).then(function (r) { return r.json(); }).then(function (g) {
/* ใช้ /api/game-timing (node — คืน JSON เสมอทั้ง prod/test) แทน /data/game-timing.json ที่ dev-clone
เสิร์ฟเป็น index.html (try_files fallback) → r.json() พังเงียบ → ค่า admin ไม่เข้า (trialReveal/postCase) */
fetch(BASE + '/api/game-timing?_=' + Date.now()).then(function (r) { return r.json(); }).then(function (g) {
if (!g) return;
/* เวลาโชว์ผลโหวต (คนร้ายถูก/ผิด) ก่อนตัดไปหน้าผลเต็ม — admin ปรับได้ (default 7000ms) */
var trm = Number(g.trialResultRevealMs);
if (Number.isFinite(trm) && trm >= 1500 && trm <= 20000) trialResultRevealMs = Math.round(trm);
postCaseDestination = (g.postCaseDestination === 'lobbya') ? 'lobbya' : 'mainlobby';
if (!g.lobbyDebugHud) return;
var names = Array.isArray(g.lobbyDebugHudNames) ? g.lobbyDebugHudNames.map(function (s) { return String(s).trim().toLowerCase(); }).filter(Boolean) : [];
var me = ''; try { me = (getProfileDisplayName() || '').trim().toLowerCase(); } catch (e) { /* ignore */ }
@@ -1850,9 +1855,11 @@
}
const nameText = (p.nickname || id.slice(0, 6));
const isHost = id === hostId;
/* ชื่อ+มงกุฎลงมาใกล้หัว (เดิม 0.125 ทำให้ลอยขึ้นไปทับไมค์/บับเบิล) — ไมค์/บับเบิล/ready อยู่ด้านบน */
const nameY = labelY + (tileSize * 0.5);
const voiceIconY = labelY - tileSize * 0.12;
/* [2026-07-16] ชื่ออยู่ "เหนือหัว" ไม่ใช่ที่เท้า (user request). เดิม nameY = labelY + 0.5*ts
ดันชื่อลงมาใต้ตัว (กันทับไมค์/ready ที่อยู่บนหัว) → ตอนนี้วางชื่อเหนือหัว แล้ว "ดันไมค์/ready ขึ้น
เหนือชื่อ" แทน (baseline ชื่ออยู่เหนือ headTop เล็กน้อย, ข้อความโตขึ้นบน → ลอยเหนือหัว) */
const nameY = headTop - Math.max(2, Math.round(nameFontSize * 0.25));
const voiceIconY = nameY - nameFontSize - Math.round(tileSize * 0.16);
if (p.voiceMicOn === false) {
const iconSize = Math.max(10, Math.round(tileSize * 0.35));
ctx.font = iconSize + 'px NotoSansThai, Kanit, system-ui, sans-serif';
@@ -1964,6 +1971,34 @@
el.scrollTop = 1e9;
}
/** ป้ายแจ้งกลางบน — room-lobby ไม่เคยมี (play.js มี showDisconnectGraceBanner ของตัวเอง) ทำให้
LobbyA/LobbyB เงียบสนิททั้งตอนคนเข้าและคนหลุด. z สูงกว่า preplay wizard/overlay ทุกใบ */
const _lobbyNoticeTimers = {};
function showLobbyBanner(key, text, ms, bg) {
const id = 'lobby-notice-' + key;
let el = document.getElementById(id);
if (!el) {
el = document.createElement('div');
el.id = id;
el.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);z-index:11000;'
+ 'padding:9px 18px;border-radius:12px;color:#fff;max-width:min(92vw,640px);text-align:center;'
+ 'font:600 15px system-ui,"Kanit",sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.42);pointer-events:none;letter-spacing:.2px;';
document.body.appendChild(el);
}
el.style.top = (key === 'join' ? 12 : 58) + 'px'; /* คนละแถว ไม่ทับกันถ้าขึ้นพร้อมกัน */
el.style.background = bg || 'rgba(38,60,120,.94)';
el.textContent = text;
el.style.display = 'block';
if (_lobbyNoticeTimers[key]) clearTimeout(_lobbyNoticeTimers[key]);
_lobbyNoticeTimers[key] = setTimeout(function () {
_lobbyNoticeTimers[key] = null;
const e2 = document.getElementById(id);
if (e2) e2.style.display = 'none';
}, ms || 6000);
}
function showLobbyJoinNotice(text) { showLobbyBanner('join', text, 7000, 'rgba(38,60,120,.94)'); }
function showLobbyLeaveNotice(text, ms) { showLobbyBanner('leave', text, ms || 6000, 'rgba(176,55,40,.93)'); }
function quizCellOnLobby(grid, tx, ty) {
return !!(grid && grid[ty] && grid[ty][tx] === 1);
}
@@ -2321,6 +2356,10 @@
if (!c) return def;
var suspects = (Array.isArray(c.suspects) && c.suspects.length >= 3) ? c.suspects.slice(0, 3) : def.suspects;
var culprits = (Array.isArray(c.culprits) && c.culprits.length >= 3) ? c.culprits.slice(0, 3) : def.culprits;
/* [2026-07-21] icons (ไอคอนผู้ต้องสงสัยหัวหน้าไต่สวน) — ต้องส่งต่อด้วย ไม่งั้นถูกตัดทิ้งตรงนี้
ทั้งที่ server ส่งมาแล้ว (บั๊กคลาสเดียวกับ balloon slot ที่ client ตัด array เหลือ 6).
ไม่มี/ไม่ครบ 3 → คืน suspects เพื่อให้ hasSuspectIcon() เห็นว่า "ไม่ได้ตั้ง" แล้ว fallback กรอบเดิม */
var icons = (Array.isArray(c.icons) && c.icons.length >= 3) ? c.icons.slice(0, 3) : suspects;
return {
name: c.name || def.name,
art: c.art || def.art,
@@ -2328,15 +2367,58 @@
story: (Array.isArray(c.story) && c.story.length) ? c.story : def.story,
suspects: suspects,
culprits: culprits,
icons: icons,
/* [2026-07-21] หน้ารายละเอียดแบบใหม่ (ประกอบเอง): รูปคดี + ข้อความ — ต้องส่งต่อทุกฟิลด์
(บั๊กเดิม: object นี้ตัดฟิลด์ที่ไม่รู้จักทิ้ง ทำให้ icons ไม่เคยถึงหน้าจอ) */
pic: c.pic || '',
txtCharge: c.txtCharge || '',
txtPlace: c.txtPlace || '',
txtVictim: c.txtVictim || '',
txtSuspects: (Array.isArray(c.txtSuspects) && c.txtSuspects.length >= 3) ? c.txtSuspects.slice(0, 3) : ['', '', ''],
txtNote: c.txtNote || '',
};
}
/** escape ข้อความจาก Admin ก่อนใส่ innerHTML (กัน markup หลุด) */
function cdEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (ch) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch];
});
}
/** แถวข้อความ "หัวข้อ: เนื้อหา" — ข้ามถ้ายังไม่ได้กรอก */
function cdRow(k, v) {
if (!String(v || '').trim()) return '';
return '<p class="cd-row"><span class="cd-k">' + cdEsc(k) + '</span> ' + cdEsc(v) + '</p>';
}
function getSuspectPickImageUrl(suspectIndex) {
var i = Math.max(0, Math.min(2, Math.floor(Number(suspectIndex)) || 0));
var m = getCaseMedia(getDetectiveCaseId());
return (m.suspects && m.suspects[i]) || ('/Main-Lobby/IMAGE/Choose%20a%20suspect/suspect-' + (i + 1) + '.png');
}
/* [2026-07-21] ไอคอนผู้ต้องสงสัย (สี่เหลี่ยมเล็ก) — ใช้ "เฉพาะหัวหน้าไต่สวน es3" ตามที่ user กำหนด.
ที่อื่น (การ์ดเลือกผู้ต้องสงสัย + ซูม + popup เปิดหลักฐาน) ยังใช้รูปเต็มตัวเหมือนเดิม.
fallback เป็นรูปเต็มตัวถ้ายังไม่ได้ตั้ง icons ใน Admin */
function getSuspectIconUrl(suspectIndex) {
var i = Math.max(0, Math.min(2, Math.floor(Number(suspectIndex)) || 0));
var m = getCaseMedia(getDetectiveCaseId());
return (m.icons && m.icons[i]) || getSuspectPickImageUrl(i);
}
/** ตั้ง icons ไว้จริง (ไม่ใช่ fallback เป็นรูปเต็มตัว) → เข้าโหมด .is-icon = ซ่อนกรอบ/ชื่อเดิม
เพราะไอคอนแบบใหม่มีกรอบ+ชื่อฝังในภาพมาแล้ว */
function hasSuspectIcon(suspectIndex) {
var i = Math.max(0, Math.min(2, Math.floor(Number(suspectIndex)) || 0));
var m = getCaseMedia(getDetectiveCaseId());
var ico = m.icons && m.icons[i];
return !!(ico && ico !== (m.suspects && m.suspects[i]));
}
/** ใส่/ถอดคลาส .is-icon ให้บล็อกผู้ต้องสงสัย (รับ element ตัวนอกสุด) */
function applySuspectIconMode(rootEl, suspectIndex) {
if (!rootEl) return;
rootEl.classList.toggle('is-icon', hasSuspectIcon(suspectIndex));
}
function getCulpritPrisonImageUrl(suspectIndex) {
var i = Math.max(0, Math.min(2, Math.floor(Number(suspectIndex)) || 0));
var m = getCaseMedia(getDetectiveCaseId());
@@ -2432,26 +2514,41 @@
ov = document.createElement('div');
ov.id = 'case-cutscene-overlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:99990;overflow:hidden;background:#000';
/* [2026-07-22] กล่องข้อความ cutscene ตาม mock 05-1/05-2 — ข้อความฝังมาในไฟล์ `txt/Case-NN-M.png`
(1801×233) วางที่ 75% ของจอ กว้าง 93.75vw · ปุ่ม "ถัดไป" ยึดกับกล่อง (91.1%, 86.7% ของกล่อง)
wrapper ตั้ง aspect-ratio ไว้ → ถ้าคดีไหนไม่มีไฟล์ข้อความ (404) ซ่อนแค่รูป ปุ่มไม่เลื่อน */
ov.innerHTML =
'<img id="case-cutscene-img" alt="" decoding="async" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center;display:block">' +
'<div style="position:absolute;left:0;right:0;bottom:24px;z-index:2;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 id="case-cutscene-txtwrap" style="position:absolute;left:50%;top:75%;transform:translateX(-50%);width:93.75%;max-width:1801px;aspect-ratio:1801/233;z-index:2;pointer-events:none">' +
'<img id="case-cutscene-txt" alt="" decoding="async" style="position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:none">' +
'<button type="button" id="case-cutscene-next" aria-label="ถัดไป" style="position:absolute;left:91.1%;top:86.7%;transform:translate(-50%,-50%);width:12.8%;background:none;border:none;padding:0;cursor:pointer;pointer-events:auto">' +
'<img src="/Main-Lobby/IMAGE/cutscene/btn-next.png" alt="ถัดไป" decoding="async" style="display:block;width:100%;height:auto">' +
'</button>' +
'</div>';
document.body.appendChild(ov);
}
ov.classList.remove('is-hidden');
ov.style.cssText = 'position:fixed;inset:0;z-index:99990;overflow:hidden;background:#000;display:block';
var imgEl = document.getElementById('case-cutscene-img');
var progEl = document.getElementById('case-cutscene-progress');
var txtEl = document.getElementById('case-cutscene-txt');
var nextBtn = document.getElementById('case-cutscene-next');
if (imgEl) {
imgEl.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center;display:block';
}
/* ไฟล์ข้อความตั้งชื่อ 2 หลัก: Case-01-1.png … Case-15-2.png */
var txtCaseNo = Number(cid);
var txtPad = (txtCaseNo >= 1 && txtCaseNo <= 15) ? ('0' + txtCaseNo).slice(-2) : null;
if (txtEl) {
txtEl.onload = function () { txtEl.style.display = 'block'; };
txtEl.onerror = function () { txtEl.style.display = 'none'; }; /* คดีไหนไม่มีไฟล์ข้อความ → โชว์แต่ภาพฉาก */
}
function render() {
if (imgEl) imgEl.src = imgs[idx];
if (progEl) progEl.textContent = (idx + 1) + ' / ' + imgs.length;
if (nextBtn) nextBtn.textContent = (idx + 1 >= imgs.length) ? 'เริ่มคดี ▶' : 'ไปต่อ ▶';
if (txtEl) {
txtEl.style.display = 'none';
if (txtPad) txtEl.src = '/Main-Lobby/IMAGE/cutscene/txt/Case-' + txtPad + '-' + (idx + 1) + '.png';
else txtEl.removeAttribute('src');
}
}
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(); };
@@ -2571,8 +2668,39 @@
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;
/* [2026-07-21] 2 โหมด: ตั้ง pic (รูปคดี) ไว้ = ประกอบเองตาม mockup (bg + รูป + ข้อความ)
ยังไม่ตั้ง = ใช้รูปเดียวเดิม (case-detail-N.png) → ของเก่ายังทำงานปกติระหว่างที่ยังไม่กรอกข้อมูล */
var m = getCaseMedia(cid);
var legacy = document.getElementById('lobby-case-detail-legacy');
var comp = document.getElementById('lobby-case-detail-compose');
if (m.pic && comp) {
if (legacy) legacy.hidden = true;
comp.hidden = false;
var picEl = document.getElementById('lobby-case-detail-pic');
if (picEl) picEl.src = m.pic;
var txtEl = document.getElementById('lobby-case-detail-text');
if (txtEl) {
var sus = (m.txtSuspects || []).filter(function (s) { return String(s || '').trim(); });
txtEl.innerHTML =
'<h3 class="cd-title">คดี : ' + cdEsc(m.name || '') + '</h3>'
+ cdRow('ข้อหา:', m.txtCharge)
+ cdRow('สถานที่เกิดเหตุ:', m.txtPlace)
+ cdRow('ผู้เสียหาย:', m.txtVictim)
+ (sus.length
? '<p class="cd-row"><span class="cd-k">ผู้ต้องสงสัย:</span></p><ol class="cd-list">'
+ sus.map(function (s) { return '<li>' + cdEsc(s) + '</li>'; }).join('') + '</ol>'
: '');
}
var noteEl = document.getElementById('lobby-case-detail-note');
if (noteEl) {
noteEl.innerHTML = String(m.txtNote || '').trim()
? '<p class="cd-row"><span class="cd-k">บันทึกเหตุการณ์:</span></p><p>' + cdEsc(m.txtNote) + '</p>'
: '';
}
} else {
if (comp) comp.hidden = true;
if (legacy) { legacy.hidden = false; if (cid) legacy.src = m.detail; }
}
o.classList.remove('is-hidden');
});
@@ -3609,6 +3737,15 @@
if (res.cardMinigames) setSuspectCardMinigames(res.cardMinigames);
applyPersonalSuspectState(res);
/* [TC154] เข้าห้องมาตอน host เปิด wizard อยู่ → server ปฏิเสธ set-ready ของเรา (server.js:12391).
relay 'lobby-preplay-open' ยิงเฉพาะ "ตอน host กดเปิด/ปิด" — คนที่เข้ามาทีหลังไม่เคยได้ยิน →
เดิม preplayLockActive = false: ปุ่มพร้อมดูกดได้ปกติ แต่ server ตีกลับเงียบ ๆ = "กดไม่ติด
และไม่มีอะไรบอก". รับสถานะจาก join ack แล้วปิดปุ่ม + บอกว่ากำลังรออะไรอยู่ */
if (res.preplayOpen && hostId !== socket.id) {
preplayLockActive = true;
ensureReadyControlEnabled();
appendLobbySystemChat('— Host กำลังเลือกระดับ/คดี… รอสักครู่ (กดพร้อมได้เมื่อ Host เลือกเสร็จ)');
}
serverSuspectPhaseActive = !!res.suspectPhaseActive;
/* [DBG-SUSPECT] ทำไมหน้าเลือกผู้ต้องสงสัยไม่ขึ้น → log สถานะที่ตัดสิน auto-open */
try { lobbyLog('suspect-join', 'phaseActive=' + !!res.suspectPhaseActive + ' pickIdx=' + res.suspectPickIndex + ' postCase=' + isPostCaseLobbyRoom() + ' clientMapId=' + clientLobbyMapId + ' spaceId=' + spaceId + ' mapName=' + (mapData && mapData.name)); } catch (eDbg) { /* ignore */ }
@@ -4420,10 +4557,45 @@
updateSuspectHostUi();
updateSuspectFloatingOpenBtn();
refreshHostConsoleOverlayIfOpen();
/* [2026-07-19] host เปลี่ยน (เช่น host หลุดระหว่างเฟส "สรุปหลักฐาน/ไต่สวน" es3) → ต้องรีเฟรชปุ่ม host
ของ es3 ด้วย. บั๊ก user เจอ: host หลุด → host ใหม่กด "เปิดหลักฐาน" ไม่ได้เพราะปุ่มไม่โผล่. ต้นเหตุ:
es3UpdateActionButton / esUpdateRevealActionButton เช็ค tmState.hostId === socket.id แต่ handler นี้
เดิมอัปเดตแค่ global hostId ไม่แตะ tmState.hostId (และ testimony-status ก็ไม่ส่ง hostId มา) → host ใหม่
ไม่มีวันได้ปุ่มเปิดหลักฐาน/เริ่มพิจารณา = ค้างถาวร. sync tmState.hostId + รีเฟรชปุ่มทั้งสองเฟส (ฟังก์ชัน
null-check เอง no-op ถ้า es3 ไม่ได้เปิด) */
try {
if (typeof tmState !== 'undefined' && tmState) tmState.hostId = hostId || '';
if (typeof es3UpdateActionButton === 'function') es3UpdateActionButton();
if (typeof esUpdateRevealActionButton === 'function') esUpdateRevealActionButton();
} catch (e) { /* ignore — es3 ไม่ได้เปิด */ }
});
/* คนหลุดในสายคดี (LobbyB/มินิเกม) → server ให้เวลา reconnect DISCONNECT_GRACE_SEC แล้วค่อยให้บอทแทน
และ "ยิง peer-disconnect-grace มาอยู่แล้ว" — แต่ room-lobby.js ไม่เคยมี handler (มีแต่ play.js)
→ ยืนอยู่ LobbyB แล้วคนหาย = เงียบสนิท ไม่รู้ว่าต้องรอหรือเกิดอะไรขึ้น */
socket.off('peer-disconnect-grace');
socket.on('peer-disconnect-grace', (d) => {
if (!d) return;
const nk = String(d.nickname || '').trim() || 'ผู้เล่น';
let remain = Math.max(1, Math.floor(Number(d.seconds) || 10));
showLobbyLeaveNotice('🔌 ' + nk + ' หลุด — รอกลับมา ' + remain + ' วิ (ถ้าไม่กลับ บอทจะเล่นแทน)', (remain + 1) * 1000);
if (window.__lobbyGraceTick) clearInterval(window.__lobbyGraceTick);
window.__lobbyGraceTick = setInterval(function () {
remain -= 1;
if (remain <= 0) { clearInterval(window.__lobbyGraceTick); window.__lobbyGraceTick = null; return; }
showLobbyLeaveNotice('🔌 ' + nk + ' หลุด — รอกลับมา ' + remain + ' วิ (ถ้าไม่กลับ บอทจะเล่นแทน)', (remain + 1) * 1000);
}, 1000);
appendLobbySystemChat('— ' + nk + ' หลุดการเชื่อมต่อ · รอ ' + remain + ' วิ');
});
socket.off('user-left');
socket.on('user-left', (data) => {
/* ออกจริง (ไม่ใช่ grace) → บอกด้วย: เดิมคนหายไปเฉย ๆ ไม่มีอะไรขึ้น */
try {
const gone = peers.get(data.id);
const nk = String((gone && gone.nickname) || '').trim();
if (nk) showLobbyLeaveNotice('🚪 ' + nk + ' ออกจากห้องแล้ว');
} catch (e) { /* ignore */ }
closePeer(data.id);
peers.delete(data.id);
updatePlayersHud();
@@ -5235,6 +5407,18 @@
socket.off('user-joined');
socket.on('user-joined', async (data) => {
peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData));
/* [TC154] มีคนเข้าห้องตอน host เปิด wizard เลือกระดับ/คดีอยู่: host มองไม่เห็นเลย (wizard บังทั้งจอ)
และคนใหม่ก็กดพร้อมไม่ได้ (server ปฏิเสธระหว่าง preplayOpen) → host รอ ready ที่ไม่มีวันมา.
ปิด wizard ให้ host + บอกว่าใครเข้ามา — การปิดจะ emit lobby-preplay-open{open:false} เอง
= ปลดล็อกปุ่มพร้อมของทุกคนไปในตัว */
try {
if (hostId === socket.id && preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) {
closeLobbyPreplayWizard();
const nk = String((data && data.nickname) || '').trim() || 'ผู้เล่นใหม่';
showLobbyJoinNotice('👋 ' + nk + ' เข้าห้องมาใหม่ — ยังไม่ได้กดพร้อม (เลือกคดีใหม่อีกครั้งได้เลย)');
appendLobbySystemChat('— ' + nk + ' เข้าห้องมาใหม่ · ปิดหน้าเลือกคดีให้แล้ว รอเขากดพร้อมก่อน');
}
} catch (e) { /* ignore */ }
/* แก้สีชุด peer ใหม่ไม่ขึ้นบนเครื่องคนอื่น: resolve RGB ของธีมสีทันที
(lobby-tint-sync ถูก emit ก่อน user-joined จึงข้าม peer นี้เพราะยังไม่อยู่ใน map) */
applyPeerThemeFromServer(peers.get(data.id), data.lobbyColorThemeIndex, data.lobbySkinToneIndex, function () {
@@ -5330,9 +5514,15 @@
'#trial-vote-counter{position:absolute;right:22px;top:14px;color:#7fe9ff;font:800 24px/1 Kanit,system-ui,sans-serif;display:none;z-index:62;text-shadow:0 0 12px rgba(34,211,238,.7)}' +
'#trial-vote-counter b{color:#fff}' +
'.suspect-pick--trial #trial-vote-counter{display:block}' +
'#trial-vote-timer{position:absolute;right:22px;top:50px;color:#9ece6a;font:900 34px/1 Kanit,system-ui,sans-serif;display:none;z-index:62;text-shadow:0 0 14px rgba(158,206,106,.6)}' +
'.suspect-pick--trial:not(.trial-revealed) #trial-vote-timer{display:block}' +
'#trial-vote-timer.low{color:#f7768e;text-shadow:0 0 14px rgba(247,118,142,.7)}' +
/* mock 2026-07-22: นาฬิกาย้ายลงวงกลมกลางล่างของฉาก (roomvote-bg) — เลขล้วน ไม่มีไอคอน
โชว์ทั้งตอนโหวตและตอนเปิดผล (mock หน้า "ผลการพิจารณาคดี" ก็มีเลขนับถอยหลัง) */
/* วัดจาก mock 11-2/11-3: วงแหวนบาง โปร่งกลาง สี rgb(220,230,230) เส้น 2px เส้นผ่าศูนย์กลาง 52px
จุดกึ่งกลาง (50%, 86.6%) ของจอ = คร่อมรางไฟใน roomvote-bg — ห้ามใส่พื้นทึบ/เรืองฟ้า */
'#trial-vote-timer{position:absolute;left:50%;top:86.6%;transform:translate(-50%,-50%);width:52px;height:52px;border-radius:50%;box-sizing:border-box;background:transparent;border:2px solid rgb(220,230,230);box-shadow:none;color:rgb(220,230,230);font:700 26px/48px Kanit,system-ui,sans-serif;text-align:center;display:none;z-index:62;text-shadow:none;font-variant-numeric:tabular-nums;pointer-events:none}' +
/* นับถอยหลังโชว์เฉพาะตอน "ยังต้องเลือก" — พอยืนยันโหวตแล้ว/เปิดผลแล้ว ต้องหายไป (user 2026-07-22) */
'.suspect-pick--trial:not(.trial-revealed):not(.trial-voted) #trial-vote-timer{display:block}' +
/* เตือนเวลาใกล้หมด — เฉพาะตอนโหวต (ตอนโชว์ผลนับถอยหลังเฉยๆ ไม่ต้องแดง) */
'.suspect-pick--trial:not(.trial-revealed) #trial-vote-timer.low{color:#ff8fa3;border-color:#ff8fa3}' +
'#trial-card-notice{position:absolute;left:50%;top:9%;transform:translateX(-50%);z-index:63;background:linear-gradient(180deg,#1f2747,#141a33);border:1px solid rgba(255,214,102,.75);color:#ffe7b3;font:800 18px/1.3 Kanit;padding:10px 22px;border-radius:999px;box-shadow:0 10px 30px rgba(0,0,0,.5);display:none;text-align:center}' +
'#pick-vote-overlay{position:fixed;inset:0;z-index:120;background:rgba(6,9,20,.86);display:none;align-items:center;justify-content:center;backdrop-filter:blur(3px)}' +
'#pick-vote-overlay.is-open{display:flex}' +
@@ -5362,15 +5552,34 @@
// ตอนโหวต: ใบที่เราเลือก = ปุ่มภาพ "เลือกแล้ว" (btn-selected.png)
'.suspect-card .trial-vote-badge{position:absolute;left:50%;bottom:10px;transform:translateX(-50%);display:none;z-index:7;white-space:nowrap}' +
'.suspect-pick--trial .suspect-card--myvote .trial-vote-badge{display:block;width:204px;height:83px;max-width:80%;background:url(' + RV + 'btn-selected.png) center/contain no-repeat;color:transparent;font-size:0;padding:0;box-shadow:none}' +
// ตอนเปิดเผยผล: โชว์คะแนนโหวตทุกใบ (ป้ายฟ้า)
'.suspect-pick--trial.trial-revealed .trial-vote-badge{display:block;background:rgba(34,211,238,.97);color:#06121f;font:700 15px/1 Kanit;padding:7px 16px;border-radius:999px;box-shadow:0 2px 10px rgba(0,0,0,.45)}' +
// ตอนเปิดเผยผล: ป้าย "โหวต N" เดิมเลิกใช้ — ย้ายไปแถบล่างของกรอบการ์ดเป็น "ผลการตัดสิน : ขีด"
'.suspect-pick--trial.trial-revealed .trial-vote-badge{display:none!important}' +
/* mock 2026-07-22: หน้าโหวตไม่โชว์ "ความคืบหน้า/หลักฐาน" — แถบล่างของกรอบต้องว่างไว้ให้ผลการตัดสิน */
'.suspect-pick--trial .suspect-card-ui{display:none!important}' +
'.suspect-pick--trial #suspect-pick-hint{display:none!important}' +
/* สรุปผล "ถูก/ผิด" — ย้ายลงพื้นที่ว่างใต้การ์ด (ที่เดิมอยู่บนสุดแล้วทับหัวข้อ "พิจารณาคดี") */
'.suspect-pick--trial #trial-result-banner{top:auto!important;bottom:3.5%;left:50%;transform:translateX(-50%);font:600 17px/1.45 Kanit,system-ui,sans-serif;padding:10px 24px;border-radius:14px;max-width:72%}' +
'.suspect-pick--trial #trial-result-banner b{color:#ffd666}' +
/* แถบล่างของกรอบการ์ด (Card-ผู้ต้องสงสัย-Frame): พื้นที่ใช้งานอยู่ที่ 80.2%–94.8% ของความสูงการ์ด */
'.suspect-card .trial-verdict{position:absolute;left:0;right:0;top:87.5%;transform:translateY(-50%);display:none;align-items:center;justify-content:flex-start;padding-left:13%;gap:10px;z-index:8;pointer-events:none}' +
'.suspect-pick--trial.trial-revealed .suspect-card .trial-verdict{display:flex}' +
'.trial-verdict .tv-label{color:#eaf6ff;font:600 20px/1 Kanit,system-ui,sans-serif;text-shadow:0 1px 6px rgba(0,0,0,.65);white-space:nowrap}' +
'.trial-verdict .tv-ticks{display:inline-flex;align-items:center;gap:5px;min-height:26px}' +
'.trial-verdict .tv-ticks img{display:block;height:26px;width:auto}' +
'.suspect-card--myvote{outline:3px solid #22c55e;outline-offset:-3px;border-radius:16px}' +
/* user 2026-07-22: ต้องเฉลยว่าใบไหนคือคนร้าย (เคยตัดออกตาม mock แล้ว user บอกว่า "ส่วนเลือกผิดถูกหายไป") */
'.suspect-card--culprit{outline:4px solid #f7768e;outline-offset:-4px;border-radius:16px;box-shadow:0 0 34px rgba(247,118,142,.85)}' +
// แสตมป์ "ผู้บริสุทธิ์" ทับใบที่โหวตผิด + เทาใบนั้น
'.suspect-card .trial-stamp{position:absolute;left:50%;top:44%;transform:translate(-50%,-50%) rotate(-8deg);width:78%;max-width:300px;height:auto;z-index:9;display:none;pointer-events:none}' +
/* แสตมป์ "ผู้บริสุทธิ์" — วัดจาก mock 11-4: กว้าง 1.41 เท่าของการ์ด (ล้นออกนอกขอบทั้งสองข้าง)
กึ่งกลางแสตมป์ต้องตกที่ "กึ่งกลางการ์ด" พอดี (วัดแล้ว top:46% ให้ผลตรง — 53% ต่ำไป 7%)
ไฟล์เอียงมาในตัวอยู่แล้ว → ห้าม rotate ซ้ำ */
'.suspect-card .trial-stamp{position:absolute;left:50%;top:46%;transform:translate(-50%,-50%);width:141%;max-width:none;height:auto;z-index:9;display:none;pointer-events:none}' +
'.suspect-card--innocent{z-index:12}' + /* แสตมป์ล้นขอบ ต้องอยู่เหนือการ์ดใบข้างๆ */
'.suspect-card--innocent .trial-stamp{display:block}' +
'.suspect-card--innocent{filter:grayscale(1) brightness(.8)}' +
'#trial-lose-txt{position:absolute;left:50%;top:4%;transform:translateX(-50%);width:712px;max-width:88vw;height:auto;z-index:61;display:none}' +
/* เทาเฉพาะรูปการ์ด + แถบผลการตัดสิน — ตัวแสตมป์ต้องคงสีแดงตาม mock (filter บน parent จะลามถึงลูกทุกตัว) */
'.suspect-card--innocent > img:not(.trial-stamp),.suspect-card--innocent .trial-verdict{filter:grayscale(1) brightness(.8)}' +
/* "ไม่นะ..คุณจับแพะ!!" — วัดจาก mock 11-4: ตัวอักษรอยู่กลางแถบฟ้าของฉาก (กึ่งกลางที่ 21.3% ของจอ)
ไฟล์ 712×145 ย่อเหลือ 688px → ขอบบนของไฟล์ตกที่ 15.3% (ของเดิม 4% = ลอยเหนือแถบ ไม่ตรง mock) */
'#trial-lose-txt{position:absolute;left:50%;top:15.3%;transform:translateX(-50%);width:688px;max-width:88vw;height:auto;z-index:61;display:none}' +
'#trial-result-banner{position:absolute;left:50%;top:5%;transform:translateX(-50%);background:rgba(6,12,24,.96);border:2px solid #22d3ee;border-radius:16px;padding:14px 26px;color:#e7ecff;font:600 20px/1.5 Kanit,system-ui,sans-serif;text-align:center;z-index:60;max-width:82%;box-shadow:0 8px 40px rgba(0,0,0,.6)}' +
/* #7: reveal (host) ย้ายไปมุมขวาล่าง เลี่ยงทับปุ่มยืนยันกลางล่าง */
'#trial-reveal-btn{position:absolute;right:4%;bottom:5%;left:auto;transform:none;background:linear-gradient(180deg,#22d3ee,#0e7490);color:#06121f;border:none;border-radius:14px;padding:12px 22px;font:700 17px/1 Kanit,system-ui,sans-serif;cursor:pointer;z-index:60;box-shadow:0 6px 22px rgba(34,211,238,.5)}' +
@@ -5387,7 +5596,31 @@
'#trial-silenced-layer .tsl-char{width:auto;height:42%;max-height:340px;object-fit:contain;filter:grayscale(.6) brightness(.9) drop-shadow(0 8px 20px rgba(0,0,0,.6))}' +
'#trial-silenced-layer .tsl-msg{color:#ffd6e0;font:700 26px/1.5 Kanit,system-ui,sans-serif;text-align:center;text-shadow:0 2px 10px rgba(0,0,0,.8)}' +
'#trial-silenced-layer .tsl-msg b{color:#ff6b81;font-size:30px}' +
'#trial-silenced-layer .tsl-icon{font-size:30px}';
'#trial-silenced-layer .tsl-icon{font-size:30px}' +
/* ===== การ์ดประกันตัว (Bail Coin) — จอคั่นก่อนกลับไปโหวตใหม่ [mock 11-5-ประกันตัว]
พิกัดทุกตัววัดจาก Mockup/11-5-ประกันตัว.png (1921×1081) เทียบเป็น % ของจอ ===== */
/* ต้องสูงกว่า #suspect-pick-overlay (10045) ไม่งั้นจอโหวตทับจนมองไม่เห็น — เคยพลาดมาแล้ว */
'#trial-bail-layer{position:fixed;inset:0;z-index:10090;display:none;background:rgba(3,6,14,.82);backdrop-filter:blur(2px)}' +
'#trial-bail-layer.is-open{display:block}' +
'#trial-bail-layer .tb-icon{position:absolute;left:50%;top:26.2%;transform:translate(-50%,-50%);width:181px;height:auto}' +
'#trial-bail-layer .tb-icon-txt{position:absolute;left:50%;top:37.3%;transform:translate(-50%,-50%);width:199px;height:auto}' +
'#trial-bail-layer .tb-txt{position:absolute;left:50%;top:52.1%;transform:translate(-50%,-50%);width:740px;max-width:86vw;height:auto}' +
'#trial-bail-layer .tb-confirm{position:absolute;left:50%;top:71%;transform:translate(-50%,-50%);width:333px;max-width:60vw;height:auto;background:none;border:none;padding:0;cursor:pointer;filter:drop-shadow(0 6px 18px rgba(0,0,0,.5))}' +
'#trial-bail-layer .tb-confirm:active{transform:translate(-50%,-50%) scale(.96)}' +
'#trial-bail-layer .tb-confirm img{display:block;width:100%;height:auto}' +
/* บรรทัดล่าง: txt-time.png มีช่องว่างกลางไว้ใส่เลข — ช่องนั้นอยู่ที่ 78.82% ของความกว้างไฟล์ */
'#trial-bail-layer .tb-time{position:absolute;left:50%;top:86.4%;transform:translate(-50%,-50%);width:609px;max-width:82vw}' +
'#trial-bail-layer .tb-time > img{display:block;width:100%;height:auto}' +
'#trial-bail-layer .tb-sec{position:absolute;left:78.82%;top:50%;transform:translate(-50%,-50%);width:63px;height:64px;display:flex;align-items:center;justify-content:center;background:url(/Main-Lobby/IMAGE/RoomVote/bail/time-delay.png) center/contain no-repeat;color:#eaf6ff;font:700 26px/1 Kanit,system-ui,sans-serif;font-variant-numeric:tabular-nums}' +
/* ===== ยืนยันก่อนชี้ตัวคนร้าย [mock 11-1-1-ชี้ตัวคนร้าย] — พิกัดวัดจาก Mockup (1921×1081) ===== */
'#accuse-confirm-layer{position:fixed;inset:0;z-index:10095;display:none;background:rgba(3,6,14,.82);backdrop-filter:blur(2px)}' +
'#accuse-confirm-layer.is-open{display:block}' +
'#accuse-confirm-layer .ac-txt{position:absolute;left:50%;top:37.8%;transform:translate(-50%,-50%);width:990px;max-width:90vw;height:auto}' +
'#accuse-confirm-layer .ac-btn{position:absolute;top:68.4%;transform:translate(-50%,-50%);width:330px;max-width:34vw;height:auto;background:none;border:none;padding:0;cursor:pointer;filter:drop-shadow(0 6px 18px rgba(0,0,0,.5))}' +
'#accuse-confirm-layer .ac-btn img{display:block;width:100%;height:auto}' +
'#accuse-confirm-layer .ac-btn:active{transform:translate(-50%,-50%) scale(.96)}' +
'#accuse-confirm-layer .ac-cancel{left:40.9%}' +
'#accuse-confirm-layer .ac-confirm{left:59.2%}';
document.head.appendChild(st);
}
@@ -5401,15 +5634,42 @@
b.textContent = '';
card.appendChild(b);
}
/* mock 2026-07-22: แถบล่างกรอบการ์ด = "ผลการตัดสิน : " + ขีดนับคะแนน (selected-line.png) */
if (!card.querySelector('.trial-verdict')) {
var v = document.createElement('div');
v.className = 'trial-verdict';
var lb = document.createElement('span');
lb.className = 'tv-label';
lb.textContent = 'ผลการตัดสิน :';
var tk = document.createElement('span');
tk.className = 'tv-ticks';
v.appendChild(lb);
v.appendChild(tk);
card.appendChild(v);
}
}
}
/** วาดขีดนับคะแนนโหวตในแถบล่างของกรอบการ์ด — 1 ขีด = 1 โหวต (ไม่ใช่ตัวเลขแล้ว) */
function applyTrialBadges() {
var TICK = '/Main-Lobby/IMAGE/RoomVote/selected-line.png';
for (var i = 0; i <= 2; i++) {
var card = document.querySelector('.suspect-card[data-index="' + i + '"]');
if (!card) continue;
var b = card.querySelector('.trial-vote-badge');
if (b) b.textContent = 'โหวต ' + ((trialVoteCounts && trialVoteCounts[i]) || 0);
var n = (trialVoteCounts && trialVoteCounts[i]) || 0;
var ticks = card.querySelector('.trial-verdict .tv-ticks');
if (!ticks) continue;
ticks.innerHTML = '';
for (var k = 0; k < n; k++) {
var im = document.createElement('img');
im.src = TICK;
im.alt = '';
im.decoding = 'async';
ticks.appendChild(im);
}
/* aria: ขีดเป็นภาพล้วน — บอกจำนวนจริงให้ screen reader */
var vd = card.querySelector('.trial-verdict');
if (vd) vd.setAttribute('aria-label', 'ผลการตัดสิน ' + n + ' โหวต');
}
}
@@ -5438,7 +5698,7 @@
function setTrialCounter(voted, total) {
var el = ensureTrialCounter();
if (el) el.innerHTML = 'ลงติกแล้ว : <b>' + (voted || 0) + '/' + (total || 0) + '</b>';
if (el) el.innerHTML = 'ลงมติแล้ว : <b>' + (voted || 0) + '/' + (total || 0) + '</b>';
}
var trialVoteCountdownRaf = 0;
@@ -5451,15 +5711,18 @@
function stopTrialVoteCountdown() {
if (trialVoteCountdownRaf) { cancelAnimationFrame(trialVoteCountdownRaf); trialVoteCountdownRaf = 0; }
}
function startTrialVoteCountdown(endsAt) {
/* mock 2026-07-22: เลขล้วน (ไม่มี ⏱) ในวงกลมกลางล่าง · forMode = โหมดที่ยอมให้นับต่อ
('voting' ตอนโหวต / 'revealed' ตอนโชว์ผลก่อนไปหน้าผลเต็ม) */
function startTrialVoteCountdown(endsAt, forMode) {
stopTrialVoteCountdown();
var el = ensureTrialVoteTimerEl();
var want = forMode || 'voting';
if (!el || !endsAt) { if (el) el.textContent = ''; return; }
function tick() {
if (trialMode !== 'voting') { stopTrialVoteCountdown(); return; }
if (trialMode !== want) { stopTrialVoteCountdown(); return; }
var ms = endsAt - Date.now();
var s = Math.max(0, Math.ceil(ms / 1000));
el.textContent = '⏱ ' + s;
el.textContent = String(s);
el.classList.toggle('low', s <= 5);
if (ms > 0) trialVoteCountdownRaf = requestAnimationFrame(tick);
}
@@ -5540,6 +5803,9 @@
function confirmTrialVote() {
if (trialMode !== 'voting' || trialVoteConfirmed || myTrialVote == null) return;
trialVoteConfirmed = true;
/* ยืนยันแล้ว = เลือกเสร็จ → ซ่อนนาฬิกานับถอยหลัง (user 2026-07-22) */
var ovv = document.getElementById('suspect-pick-overlay');
if (ovv) ovv.classList.add('trial-voted');
var cb = document.getElementById('trial-confirm-btn');
if (cb) { cb.disabled = true; cb.textContent = 'รอผู้เล่นอื่นยืนยัน…'; }
document.querySelectorAll('.suspect-card').forEach(function (c) { c.style.pointerEvents = 'none'; });
@@ -5547,6 +5813,7 @@
if (!(res && res.ok)) {
/* ส่งไม่สำเร็จ → ปลดล็อกให้เลือก/ยืนยันใหม่ */
trialVoteConfirmed = false;
if (ovv) ovv.classList.remove('trial-voted'); /* ส่งไม่สำเร็จ → นาฬิกาต้องกลับมา */
document.querySelectorAll('.suspect-card').forEach(function (c) { c.style.pointerEvents = ''; });
if (cb) { cb.disabled = false; cb.textContent = 'ยืนยันโหวต ✓'; }
appendLobbySystemChat('— โหวตไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : ''));
@@ -5573,6 +5840,57 @@
}
}
/* ===== การ์ดประกันตัว (Bail Coin) — จอคั่น "ใช้สิทธิ์ประกันตัวอัตโนมัติ" ก่อนกลับไปโหวตใหม่
server ทริกด้วย trial-open ที่มี revote:true (server.js:4494 — จับผิดตัว + มีการ์ด 5)
⚠️ นาฬิกาโหวตรอบใหม่เริ่มเดินตั้งแต่ server ส่ง trial-open — จอนี้จึงกินเวลาโหวตไป TRIAL_BAIL_HOLD_SEC วิ
ถ้าจะให้ไม่กิน ต้องให้ server หน่วง openTrialVotingPhase (แก้ server = ต้อง restart) ===== */
var TRIAL_BAIL_HOLD_SEC = 5;
var trialBailTimer = 0;
var TRIAL_BAIL_BASE = '/Main-Lobby/IMAGE/RoomVote/bail/';
function hideTrialBailLayer() {
if (trialBailTimer) { clearInterval(trialBailTimer); trialBailTimer = 0; }
var el = document.getElementById('trial-bail-layer');
if (el) el.classList.remove('is-open');
}
function ensureTrialBailLayer() {
var el = document.getElementById('trial-bail-layer');
if (el) return el;
var B = TRIAL_BAIL_BASE;
el = document.createElement('div');
el.id = 'trial-bail-layer';
el.setAttribute('role', 'dialog');
el.setAttribute('aria-label', 'ใช้สิทธิ์ประกันตัวอัตโนมัติ');
el.innerHTML =
'<img class="tb-icon" src="' + B + 'item-1.png" alt="" decoding="async">' +
'<img class="tb-icon-txt" src="' + B + 'item-1-txt.png" alt="ประกันตัว" decoding="async">' +
'<img class="tb-txt" src="' + encodeURI(B + 'bail coin-txt.png') + '" alt="ใช้สิทธิ์ประกันตัวอัตโนมัติ — ใช้ไอเทมการ์ดประกันตัว เพื่อปล่อยตัวผู้บริสุทธิ์ และตัดสินคดีใหม่อีกครั้ง" decoding="async">' +
'<button type="button" class="tb-confirm" id="trial-bail-confirm" aria-label="ยืนยัน">' +
'<img src="' + B + 'btn-confirm_2.png" alt="ยืนยัน" decoding="async"></button>' +
'<div class="tb-time"><img src="' + B + 'txt-time.png" alt="กลับไปห้องพิจารณาคดีอีกครั้งใน" decoding="async">' +
'<span class="tb-sec" id="trial-bail-sec">' + TRIAL_BAIL_HOLD_SEC + '</span></div>';
document.body.appendChild(el);
var btn = el.querySelector('#trial-bail-confirm');
if (btn) btn.addEventListener('click', hideTrialBailLayer);
return el;
}
function showTrialBailLayer() {
var el = ensureTrialBailLayer();
var sec = el.querySelector('#trial-bail-sec');
var left = TRIAL_BAIL_HOLD_SEC;
if (sec) sec.textContent = String(left);
el.classList.add('is-open');
if (trialBailTimer) clearInterval(trialBailTimer);
trialBailTimer = setInterval(function () {
left--;
if (sec) sec.textContent = String(Math.max(0, left));
if (left <= 0) hideTrialBailLayer();
}, 1000);
try { lobbyLog('trial-bail', 'การ์ดประกันตัว — โชว์จอคั่น ' + TRIAL_BAIL_HOLD_SEC + ' วิ ก่อนกลับไปโหวตใหม่'); } catch (e) { /* ignore */ }
}
function openTrialVoteUI(data) {
trialMode = 'voting';
myTrialVote = null;
@@ -5583,13 +5901,14 @@
if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames);
applyPersonalSuspectState(data);
injectTrialStyle();
hideAccuseConfirm(); /* เข้าเฟสโหวตแล้ว — popup ยืนยันชี้ตัวต้องไม่ค้างทับ */
var ov = document.getElementById('suspect-pick-overlay');
if (!ov) return;
applySuspectPickImages();
bindSuspectPickScaleListeners();
suspectPickOverlayOpen = true;
serverSuspectPhaseActive = false;
ov.classList.remove('is-hidden', 'trial-revealed');
ov.classList.remove('is-hidden', 'trial-revealed', 'trial-voted');
ov.classList.add('suspect-pick--trial');
ov.setAttribute('aria-hidden', 'false');
document.querySelectorAll('.suspect-card').forEach(function (c) {
@@ -5614,10 +5933,13 @@
var loseEl = document.getElementById('trial-lose-txt'); if (loseEl) loseEl.style.display = 'none';
applySuspectProgressVisual();
ensureTrialTitle();
var ttl = document.getElementById('trial-title'); if (ttl) ttl.style.display = 'block';
var ttl = document.getElementById('trial-title'); if (ttl) { ttl.style.display = 'block'; ttl.style.visibility = 'visible'; }
setTrialSubtitle('vote');
ensureTrialBadges();
document.querySelectorAll('.trial-vote-badge').forEach(function (b) { b.textContent = ''; });
/* รอบโหวตใหม่ (Bail Coin) — ล้างขีดผลการตัดสิน + ยกเลิกจังหวะตีตราของรอบก่อน */
if (trialVerdictTimer) { clearTimeout(trialVerdictTimer); trialVerdictTimer = null; }
document.querySelectorAll('.trial-verdict .tv-ticks').forEach(function (t) { t.innerHTML = ''; });
setTrialCounter(0, (data && data.totalPlayers) || (peers ? peers.size : 0) || 1);
// กันปุ่มชี้ตัวค้าง: ใส่ is-hidden ตอนเข้าหน้าโหวต
var accuseBtn0 = document.getElementById('suspect-btn-accuse');
@@ -5633,7 +5955,7 @@
/* ตัวจับเวลาโหวต + แจ้งการ์ดพิเศษ (Extension / Bail re-vote) */
startTrialVoteCountdown((data && data.voteEndsAt) || 0);
if (data && data.revote) {
showTrialCardNotice('ใช้การ์ด Bail Coin — จับผิดตัว! โหวตใหม่อีกครั้ง');
showTrialBailLayer(); /* mock 11-5 — จอคั่น "ใช้สิทธิ์ประกันตัวอัตโนมัติ" แทนป้ายเล็กแบบเดิม */
} else if (data && data.extensionCard) {
showTrialCardNotice('ใช้การ์ด Extension — เพิ่มเวลาโหวต +' + ((data.extensionSec) || 10) + ' วินาที');
}
@@ -5686,6 +6008,7 @@
trialMode = 'revealed';
trialSilencedMe = false;
var silLayer = document.getElementById('trial-silenced-layer'); if (silLayer) silLayer.style.display = 'none';
hideTrialBailLayer(); /* ผลออกแล้ว — จอประกันตัวต้องไม่ค้างทับ */
var sov = document.getElementById('suspect-pick-overlay'); if (sov) sov.classList.remove('suspect-pick--silenced');
stopTrialVoteCountdown();
var culprit = (data && typeof data.culpritIndex === 'number') ? data.culpritIndex : 0;
@@ -5698,39 +6021,54 @@
var mostVoted = 0; for (var k = 1; k <= 2; k++) { if ((counts[k] || 0) > (counts[mostVoted] || 0)) mostVoted = k; }
var anyVotes = ((counts[0] || 0) + (counts[1] || 0) + (counts[2] || 0)) > 0;
var correct = anyVotes && (mostVoted === culprit);
/* mock 2026-07-22 — เปิดผล 2 จังหวะ:
จังหวะ 1 "[ผลการพิจารณาคดี]" โชว์ขีดคะแนนล้วน การ์ดยังไม่ตีตรา (mock หน้า 2)
จังหวะ 2 ตีตรา "ผู้บริสุทธิ์" / คนร้าย + ข้อความ "ไม่นะ..คุณจับแพะ!!" (mock หน้า 3) */
ensureTrialBadges();
document.querySelectorAll('.suspect-card').forEach(function (c) {
var ci = parseInt(c.getAttribute('data-index'), 10);
c.classList.remove('suspect-card--myvote', 'suspect-card--innocent');
c.classList.toggle('suspect-card--culprit', ci === culprit);
var b = c.querySelector('.trial-vote-badge');
if (b) b.textContent = 'โหวต ' + (counts[ci] || 0);
c.classList.remove('suspect-card--myvote', 'suspect-card--innocent', 'suspect-card--culprit');
var stamp = c.querySelector('.trial-stamp');
if (!stamp) { stamp = document.createElement('img'); stamp.className = 'trial-stamp'; stamp.src = '/Main-Lobby/IMAGE/RoomVote/stamp-innocent.png'; stamp.alt = 'ผู้บริสุทธิ์'; c.appendChild(stamp); }
// โหวตผิด: ตีตรา "ผู้บริสุทธิ์" บนใบที่ถูกโหวตมากสุด (ไม่ใช่คนร้าย)
if (!correct && anyVotes && ci === mostVoted && ci !== culprit) c.classList.add('suspect-card--innocent');
});
// โหวตผิด → โชว์ "ไม่นะ..คุณจับแพะ!!" + ซ่อนหัวข้อปกติ
applyTrialBadges();
var lose = ensureTrialLoseTxt();
if (lose) lose.style.display = 'none';
var titleEl = document.getElementById('trial-title');
if (!correct && anyVotes) {
if (lose) lose.style.display = 'block';
if (titleEl) titleEl.style.display = 'none';
} else {
if (lose) lose.style.display = 'none';
if (titleEl) titleEl.style.display = 'block';
}
if (titleEl) { titleEl.style.display = 'block'; titleEl.style.visibility = 'visible'; }
/* นับถอยหลังต้องหายไปทันทีที่เข้าโหมดเปิดผล (user 2026-07-22: "เลือกเสร็จแล้ว countdown ควรหายไป") */
stopTrialVoteCountdown();
var names = (data && data.winnerNames && data.winnerNames.length) ? data.winnerNames.join(', ') : 'ไม่มีใครตอบถูก';
_lastTrialWinners = (data && Array.isArray(data.winners)) ? data.winners.slice() : [];
_lastDisruptorId = (data && data.disruptorId) ? data.disruptorId : null;
_lastHasDisruptor = !!(data && data.hasDisruptor && data.disruptorId);
var iWon = !!(data && Array.isArray(data.winners) && data.winners.indexOf(socket.id) >= 0);
var banner = ensureTrialBanner();
if (banner) {
banner.style.top = (!correct && anyVotes) ? '17%' : '5%';
banner.innerHTML = 'คนร้ายตัวจริงคือ <b>ผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + '</b><br>' +
'ผู้ตอบถูก: ' + names + (iWon ? '<br>🎉 คุณตอบถูก! +100' : '');
banner.style.display = 'block';
}
if (banner) banner.style.display = 'none'; /* จังหวะ 1 ยังไม่เฉลย */
/* จังหวะ 2 — ให้จังหวะ 1 กินเวลา ~40% ของ trialResultRevealMs (อย่างน้อย 800ms) */
var verdictDelay = Math.max(800, Math.min(2600, Math.round(trialResultRevealMs * 0.4)));
if (trialVerdictTimer) clearTimeout(trialVerdictTimer);
trialVerdictTimer = setTimeout(function () {
if (trialMode !== 'revealed') return;
document.querySelectorAll('.suspect-card').forEach(function (c) {
var ci = parseInt(c.getAttribute('data-index'), 10);
c.classList.toggle('suspect-card--culprit', ci === culprit);
// โหวตผิด: ตีตรา "ผู้บริสุทธิ์" บนใบที่ถูกโหวตมากสุด (ไม่ใช่คนร้าย)
if (!correct && anyVotes && ci === mostVoted && ci !== culprit) c.classList.add('suspect-card--innocent');
});
/* โหวตผิด → "ไม่นะ..คุณจับแพะ!!" แทนที่หัวข้อ **ตรงตำแหน่งเดิม**
ต้องใช้ visibility ไม่ใช่ display:none — ไม่งั้นหัวข้อหลุดออกจาก flow แล้วแถวการ์ดเลื่อนขึ้นไปชนข้อความ */
if (!correct && anyVotes) {
if (lose) lose.style.display = 'block';
if (titleEl) titleEl.style.visibility = 'hidden';
}
// เฉลย "ถูก/ผิด" — วางใต้การ์ด (CSS ย้ายให้แล้ว) ไม่ทับหัวข้ออีก
if (banner) {
banner.innerHTML = (correct ? '✅ จับคนร้ายได้ถูกตัว! ' : '❌ จับผิดตัว! ') +
'คนร้ายตัวจริงคือ <b>ผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + '</b><br>' +
'ผู้ตอบถูก: ' + names + (iWon ? ' · 🎉 คุณตอบถูก! +100' : '');
banner.style.display = 'block';
}
}, verdictDelay);
var rb = document.getElementById('trial-reveal-btn');
if (rb) rb.style.display = 'none';
var cfb = document.getElementById('trial-confirm-btn'); /* #7: ซ่อนปุ่มยืนยันตอน reveal */
@@ -5746,6 +6084,7 @@
}
var trialFinalTimer = null;
var trialVerdictTimer = null; /* จังหวะ 2 ของการเปิดผล (ตีตรา/จับแพะ) */
var winPodiumTimer = null;
var _lastTrialWinners = [];
var _lastDisruptorId = null;
@@ -5989,8 +6328,15 @@
'<button type="button" id="trial-final-home" aria-label="กลับหน้าหลัก">กลับหน้าหลัก</button>';
document.body.appendChild(ov);
ov.querySelector('#trial-final-home').addEventListener('click', function () {
/* โชว์หน้าให้ความรู้ "รู้ทันภัยไซเบอร์" ก่อน แล้วค่อยกลับ Main-Lobby */
showCyberAwarenessInterstitial(function () { window.location.href = '/Main-Lobby/'; });
/* โชว์หน้าให้ความรู้ "รู้ทันภัยไซเบอร์" ก่อน แล้วค่อยไปปลายทางที่ admin กำหนด */
showCyberAwarenessInterstitial(function () {
if (postCaseDestination === 'lobbya' && spaceId) {
/* ห้องเดิมกลับ LobbyA (server รีเซ็ตห้องให้แล้วตอน trial-result) → reload เข้าห้องเดิม เล่นคดีใหม่ต่อ */
window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId);
} else {
window.location.href = '/Main-Lobby/';
}
});
});
}
var culpEl = ov.querySelector('#trial-final-culprit');
@@ -6598,7 +6944,8 @@
var sd = tmSuspectData(tmState.round);
document.getElementById('es3-title').textContent = 'การไต่สวน : ปากคำที่ ' + (tmState.round + 1) + ' - ' + (sd.linkName || ('ผู้ต้องสงสัย ' + (tmState.round + 1)));
var susImg = document.getElementById('es3-sus-img');
if (susImg) susImg.src = getSuspectPickImageUrl(tmState.round);
if (susImg) susImg.src = getSuspectIconUrl(tmState.round); /* [2026-07-21] ไอคอนแบบใหม่ (มีกรอบ+ชื่อในตัว) */
applySuspectIconMode(ov.querySelector('.es3-suspect'), tmState.round);
var susName = document.getElementById('es3-sus-name'); if (susName) susName.textContent = sd.linkName || '';
var stage = ov.querySelector('.es3-stage');
if (stage) stage.classList.remove('es3-stage--reveal');
@@ -6676,7 +7023,8 @@
var titleEl = document.getElementById('es-popup-title');
if (titleEl) titleEl.textContent = 'การไต่สวน : ปากคำที่ ' + (tmState.round + 1) + ' - ' + (sd.linkName || ('ผู้ต้องสงสัย ' + (tmState.round + 1)));
var susImg = document.getElementById('es-popup-sus-img');
if (susImg) susImg.src = getSuspectPickImageUrl(tmState.round);
if (susImg) susImg.src = getSuspectIconUrl(tmState.round); /* [2026-07-21] ไอคอนแบบใหม่เหมือน es3 */
applySuspectIconMode(document.querySelector('#testimony-overlay .es-reveal-wrap .es-suspect'), tmState.round);
var susName = document.getElementById('es-popup-sus-name');
if (susName) susName.textContent = sd.linkName || '';
var rd = document.getElementById('esBtnReady');
@@ -6933,7 +7281,16 @@
/** ชี้ตัวคนร้ายได้ต่อเมื่อสืบครบทั้ง 3 ผู้ต้องสงสัย (เล่นมินิเกมอย่างน้อยการ์ดละ 1 ครั้ง) */
function allSuspectsInvestigated() {
return [0, 1, 2].every(function (i) { return ((suspectProgress && suspectProgress[i]) || 0) >= 1; });
/* [TC179-followup] ใช้ "team progress" = ตัวเลขเดียวกับที่การ์ดโชว์ (applySuspectProgressVisual)
เดิมใช้ suspectProgress = หลักฐาน "ส่วนตัว" → คนที่หลุด/โดนบอทแทนตอนมินิเกมของผู้ต้องสงสัยคนนั้น
จะไม่ได้ใบนั้น: การ์ดขึ้น ✓ (ทีมสืบแล้ว) แต่ปุ่ม "ชี้ตัวคนร้าย" ไม่โผล่ = ตัวเลขที่เห็นไม่ตรงกับ
ตัวเลขที่ตัดสิน (ต้องกด Ctrl+3 โกงแจกหลักฐานให้ตัวเองก่อนถึงจะโผล่).
ใช้ max(team, ส่วนตัว) → ไม่บล็อกคนที่มีใบส่วนตัวแต่ตัวนับทีมพลาด */
return [0, 1, 2].every(function (i) {
var team = (suspectTeamProgress && suspectTeamProgress[i]) || 0;
var mine = (suspectProgress && suspectProgress[i]) || 0;
return Math.max(team, mine) >= 1;
});
}
function updateSuspectHostUi() {
@@ -7008,9 +7365,13 @@
if (trialMode) {
trialMode = null; myTrialVote = null; trialExcludedSuspect = null; trialSilencedMe = false; trialVoteCounts = [0, 0, 0];
var sl = document.getElementById('trial-silenced-layer'); if (sl) sl.style.display = 'none';
if (ov) ov.classList.remove('suspect-pick--trial', 'trial-revealed', 'suspect-pick--silenced');
hideTrialBailLayer();
if (ov) ov.classList.remove('suspect-pick--trial', 'trial-revealed', 'trial-voted', 'suspect-pick--silenced');
document.querySelectorAll('.suspect-card').forEach(function (c) { c.classList.remove('suspect-card--myvote', 'suspect-card--culprit', 'suspect-card--innocent', 'suspect-card--excluded'); var xb = c.querySelector('.trial-excluded-badge'); if (xb) xb.remove(); });
document.querySelectorAll('.trial-vote-badge').forEach(function (b) { b.textContent = ''; });
document.querySelectorAll('.trial-verdict .tv-ticks').forEach(function (t) { t.innerHTML = ''; });
if (trialVerdictTimer) { clearTimeout(trialVerdictTimer); trialVerdictTimer = null; }
stopTrialVoteCountdown();
var banner = document.getElementById('trial-result-banner'); if (banner) banner.style.display = 'none';
var rb = document.getElementById('trial-reveal-btn'); if (rb) rb.style.display = 'none';
var loseEl = document.getElementById('trial-lose-txt'); if (loseEl) loseEl.style.display = 'none';
@@ -7504,11 +7865,48 @@
appendLobbySystemChat('— ต้องสืบให้ครบทั้ง 3 ผู้ต้องสงสัยก่อน จึงจะชี้ตัวคนร้ายได้');
return;
}
/* mock 11-1-1: ถามยืนยันก่อน — เข้าขั้นตัดสินแล้วกลับมาหาหลักฐานเพิ่มไม่ได้ */
showAccuseConfirm();
});
/* ===== ยืนยันก่อนชี้ตัวคนร้าย (mock 11-1-1-ชี้ตัวคนร้าย) ===== */
function hideAccuseConfirm() {
const el = document.getElementById('accuse-confirm-layer');
if (el) el.classList.remove('is-open');
}
function emitAccuseOpen() {
// เข้าสู่ขั้นพิจารณาคดี/โหวต (ไม่ใช่เริ่มมินิเกม)
socket.emit('suspect-accuse-open', {}, function (res) {
if (!(res && res.ok)) appendLobbySystemChat('— เปิดพิจารณาคดีไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : ''));
});
});
}
function showAccuseConfirm() {
injectTrialStyle(); /* CSS ของ layer นี้อยู่ใน trial-style */
let el = document.getElementById('accuse-confirm-layer');
if (!el) {
const A = '/Main-Lobby/IMAGE/LobbyB/accuse/';
el = document.createElement('div');
el.id = 'accuse-confirm-layer';
el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-label', 'ต้องการชี้ตัวคนร้ายเลยหรือไม่');
el.innerHTML =
'<img class="ac-txt" src="' + A + 'txt-vote.png" alt="ต้องการชี้ตัวคนร้ายเลยหรือไม่? เมื่อเข้าสู่ขั้นตอนตัดสินคุณจะไม่สามารถกลับมาหาหลักฐานเพิ่มได้อีก" decoding="async">' +
'<button type="button" class="ac-btn ac-cancel" id="accuse-confirm-cancel" aria-label="ยกเลิก"><img src="' + A + 'btn-cancel.png" alt="ยกเลิก" decoding="async"></button>' +
'<button type="button" class="ac-btn ac-confirm" id="accuse-confirm-ok" aria-label="ยืนยัน"><img src="' + A + 'btn-confirm.png" alt="ยืนยัน" decoding="async"></button>';
document.body.appendChild(el);
el.querySelector('#accuse-confirm-cancel').addEventListener('click', hideAccuseConfirm);
el.querySelector('#accuse-confirm-ok').addEventListener('click', () => {
hideAccuseConfirm();
/* เช็คซ้ำตอนกดจริง — ระหว่างเปิด popup อาจมีคนหลุด/host เปลี่ยน */
if (!suspectPickOverlayOpen || hostId !== socket.id) return;
emitAccuseOpen();
});
}
el.classList.add('is-open');
}
/* ปุ่ม X ปิด suspect overlay — ปิดได้แล้วเปิดใหม่ผ่าน #btn-suspect-reopen
(โหมด trial: ไม่ให้ปิด — กันหลุดโฟกัสตอนโหวต) */
@@ -7804,8 +8202,17 @@
if (data && data.detectiveMinigame) q += '&detectiveReturn=1';
/* Card 3 Ban — ผู้ที่ถูกแบนรอบนี้: เข้าไปดูเพื่อนเล่นได้ แต่ควบคุมตัวละครไม่ได้ (รอบหน้าเล่นปกติ)
ถ้าถูกแบนเป็น "บอท" (__lobby_bot_N) → ส่ง banBot=N ให้ play.js กันบอทตัวนั้นไม่ให้ลงเล่น (ทุก client ที่จำลองบอท) */
if (data && data.bannedPlayerId) {
if (data.bannedPlayerId === socket.id) {
if (data && (data.bannedPlayerId || data.bannedPlayerKey)) {
/* เทียบด้วย playerKey ก่อน — socket.id เปลี่ยนทุกครั้งที่เปลี่ยนหน้า/reconnect (LobbyB ⇄ play.html)
ทำให้ bannedPlayerId (id ตอนโหวต) ไม่แมตช์ตอนเริ่มเกม → ไม่ได้ส่ง banned=1 → ตัวละครคนโดนแบน
โผล่ในเกม + server นับเขาใน gate ready จน host กด START ไม่ได้. คง id ไว้เป็น fallback */
var myBanKey = '';
try { myBanKey = ensurePlayerKey() || ''; } catch (eK) { myBanKey = ''; }
var iAmBannedThisRun = !!(
(data.bannedPlayerKey && myBanKey && String(data.bannedPlayerKey) === String(myBanKey))
|| (data.bannedPlayerId && data.bannedPlayerId === socket.id)
);
if (iAmBannedThisRun) {
q += '&banned=1';
appendLobbySystemChat('— คุณถูกแบนรอบนี้ (Ban Card) · เข้าไปดูเพื่อนเล่นได้ แต่เล่นเองไม่ได้ · รอบหน้าเล่นได้ปกติ');
} else if (String(data.bannedPlayerId).indexOf('__lobby_bot_') === 0) {
+8 -4
View File
@@ -47,12 +47,16 @@
mg1: { bgm: 1, button: 1, correct: 1, endgame: 1, time: 1, wrong: 1 },
mg2: { bgm: 1, button: 1, gameover: 1, jump: 1, time: 1, winner: 1 },
mg3: { bgm: 1, button: 1, 'drop-clear': 1, 'drop-fail': 1, 'drop-hard': 1, endgame: 1, gameover: 1, time: 1, timeup: 1 },
mg4: { bgm: 1, button: 1, correct: 1, endgame: 1, gameover: 1, time: 1, timeup: 1, wrong: 1 },
mg4: { bgm: 1, button: 1, correct: 1, endgame: 1, gameover: 1, pick: 1, time: 1, timeup: 1, wrong: 1 },
mg5: { bgm: 1, button: 1, endgame: 1, gameover: 1, jump: 1, time: 1, timeup: 1 },
mg6: { bgm: 1, bomb: 1, button: 1, crash: 1, endgame: 1, gameover: 1, laser: 1, time: 1, timeup: 1 },
mg7: { bgm: 1, 'boss-elim': 1, button: 1, endgame: 1, gameover: 1, laser: 1, time: 1 }
};
var EXT = { button: 'wav' }; /* ที่เหลือ .mp3 */
/* [2026-07-17] button ย้ายจาก .wav → .mp3 (เสียงใหม่ button-soft-tap). ไฟล์ button.wav ของเดิม
ยังอยู่บนเครื่องครบทุกฉาก ไม่ได้ลบ — ถ้าจะย้อนเสียงเดิมแค่ใส่ { button: 'wav' } กลับมาบรรทัดนี้ */
var EXT = {}; /* ทุกไฟล์เป็น .mp3 */
/* เสียงกลางที่วางไว้ที่ audio root (ใช้ได้ทุกฉาก ไม่ต้องก๊อปลงทุก scene) */
var ROOT_SFX = { button: 1, warp: 1 };
function fileFor(scene, id) {
var ext = EXT[id] || 'mp3';
return scene + '/' + id + '.' + ext;
@@ -64,8 +68,8 @@
else if (id === 'lose') { id = m.gameover ? 'gameover' : null; }
if (!id) return null;
if (m[id]) return fileFor(scene, id);
/* button ใช้ตัวกลางที่ audio root ถ้าฉากไม่มี */
if (id === 'button') return 'button.wav';
/* ไม่มีในฉาก → ใช้ตัวกลางที่ audio root (warp เรียกจากฉากไหนก็ได้ ไม่ต้องอยู่ใน MANIFEST) */
if (ROOT_SFX[id]) return id + '.' + (EXT[id] || 'mp3');
return null;
}
+26 -2
View File
@@ -5457,8 +5457,8 @@
<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/sound.js?v=1"></script>
<script src="js/play.js?v=0.006301159"></script>
<script src="js/sound.js?v=2"></script>
<script src="js/play.js?v=0.006301183"></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)
@@ -5651,5 +5651,29 @@
filter: drop-shadow(0 0 16px rgba(74, 222, 128, 0.7)) !important;
}
</style>
<!-- [minigame transition] เล่นวิดีโอ transition ตอนเข้ามินิเกม (detectiveReturn=1) — ข้ามไม่ได้ · จบวิดีโอ → เผยหน้า loading/how-to ที่โหลดอยู่ข้างล่าง -->
<div id="jd-mg-transition" style="position:fixed;inset:0;z-index:2147483000;background:#000;display:none;align-items:center;justify-content:center">
<video id="jd-mg-vid" playsinline preload="auto" style="width:100%;height:100%;object-fit:cover;background:#000"></video>
</div>
<script>
(function () {
try {
var params = new URLSearchParams(window.location.search);
var isMg = params.get('detectiveReturn') === '1';
if (!isMg) { try { isMg = sessionStorage.getItem('detectiveMinigameReturn') === '1'; } catch (e) {} }
if (!isMg) return; /* เฉพาะตอนเข้ามินิเกม (detective) เท่านั้น — ฉาก detective ปกติไม่เล่น */
var ov = document.getElementById('jd-mg-transition'), vid = document.getElementById('jd-mg-vid');
if (!ov || !vid) return;
function done() { try { vid.pause(); } catch (e) {} ov.style.display = 'none'; } /* จบวิดีโอ → เอา overlay ออก (เกม/loading ที่โหลดข้างล่างโผล่มาเอง) */
vid.src = '/Game/video/minigame-transition.mp4';
vid.onended = done; vid.onerror = done; /* วิดีโอหาย/พัง → ไม่บล็อกเกม */
ov.style.display = 'flex'; /* ปิดจอระหว่างโหลด — ข้ามไม่ได้ (ไม่มีปุ่ม skip) */
vid.muted = false;
var p = vid.play();
if (p && p.catch) p.catch(function () { vid.muted = true; vid.play().catch(done); });
} catch (e) { /* ห้ามบล็อกเกม */ }
})();
</script>
</body>
</html>
+49 -5
View File
@@ -11,7 +11,7 @@
<link rel="stylesheet" href="css/profile-popup.css?v=7">
<link rel="stylesheet" href="css/customize-popup.css?v=43">
<link rel="stylesheet" href="css/leaderboard-popup.css?v=2">
<link rel="stylesheet" href="css/testimony-overlay.css?v=18">
<link rel="stylesheet" href="css/testimony-overlay.css?v=19">
<link rel="stylesheet" href="css/evidence-view-overlay.css?v=13">
<style>
html, body.room-lobby-page { margin: 0; padding: 0; overflow: hidden; width: 100%; height: 100%; min-height: 100%; }
@@ -874,6 +874,42 @@
object-position: center;
border-radius: 8px;
}
/* [2026-07-21] โหมดประกอบเอง (ตาม case-detail-mockup.png) — ตำแหน่งวัดจาก mockup 1697x986 โดย diff
กับ case-detail-bg.png แล้วคิดเป็น % ของกรอบ จึงสเกลตามจอเองทุกขนาด (กรอบ aspect 1697/986 ล็อกอยู่แล้ว) */
.lobby-preplay-case-detail-inner > .cd-compose { position: absolute; inset: 0; }
.cd-compose[hidden] { display: none; }
.cd-compose .cd-bg {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: contain; object-position: center; display: block;
}
/* ⚠️ attribute hidden ต้องชนะ `> img { display:block }` ด้านบน ไม่งั้นรูปโหมดเก่าโผล่ทะลุข้างหลัง */
.lobby-preplay-case-detail-inner > img[hidden] { display: none !important; }
/* กล่องรูปคดี — อ่านตำแหน่งจาก mockup: อยู่ "ใต้แถบหัวข้อ" ที่ฝังใน bg (aspect ~1.06 ≈ Pic_Case 537x508) */
.cd-compose .cd-pic {
position: absolute; left: 8.8%; top: 20.8%; width: 30.2%; height: 46.2%;
object-fit: cover; object-position: center; display: block; border-radius: 6px;
}
/* ข้อความขวา — เริ่มระดับเดียวกับกล่องรูป (ต้องต่ำกว่าแถบหัวข้อ "รายละเอียดคดี" ไม่งั้นทับกัน) */
.cd-compose .cd-text {
position: absolute; left: 45%; top: 20.8%; right: 5%; bottom: 32%;
overflow: auto; color: #dbe9ff;
font-family: Kanit, system-ui, sans-serif;
font-size: clamp(11px, 1.5cqw, 24px); line-height: 1.45;
}
/* บันทึกเหตุการณ์ (ล่าง) */
.cd-compose .cd-note {
position: absolute; left: 8.8%; top: 70%; right: 5%; bottom: 5%;
overflow: auto; color: #cfe0f5;
font-family: Kanit, system-ui, sans-serif;
font-size: clamp(10px, 1.36cqw, 22px); line-height: 1.42;
}
.cd-compose .cd-title { color: #59e7ff; font-weight: 800; font-size: 1.55em; margin: 0 0 .5em; }
.cd-compose .cd-row { margin: 0 0 .55em; }
.cd-compose .cd-k { color: #ffd666; font-weight: 700; }
.cd-compose .cd-list { margin: .15em 0 0; padding-left: 1.2em; }
.cd-compose .cd-list li { margin: .12em 0; }
/* ให้ 1cqw = 1% ของความกว้างกรอบ → ตัวหนังสือสเกลตามกรอบ ไม่ใช่ตาม viewport */
.lobby-preplay-case-detail-inner { container-type: inline-size; }
#lobby-preplay-case-detail-close {
position: absolute;
top: var(--case-detail-close-top);
@@ -1294,7 +1330,15 @@
<div id="lobby-preplay-case-detail-overlay" class="is-hidden" role="dialog" aria-modal="true" aria-hidden="true">
<div class="lobby-preplay-case-detail-inner">
<button type="button" id="lobby-preplay-case-detail-close" title="ปิด" aria-label="ปิดรายละเอียด">×</button>
<img src="/Main-Lobby/IMAGE/case/case-detail.png" alt="รายละเอียดคดี" decoding="async">
<!-- [2026-07-21] โหมดเดิม: รูปเดียวจบ (case-detail-N.png) — ใช้เมื่อยังไม่ได้ตั้ง bg/pic แบบใหม่ -->
<img id="lobby-case-detail-legacy" src="/Main-Lobby/IMAGE/case/case-detail.png" alt="รายละเอียดคดี" decoding="async">
<!-- [2026-07-21] โหมดใหม่ (ตาม mockup): กรอบ + รูปคดี + ข้อความจริง วางตามสัดส่วนที่วัดจาก mockup -->
<div id="lobby-case-detail-compose" class="cd-compose" hidden>
<img class="cd-bg" src="/Main-Lobby/IMAGE/case/case-detail-bg.png" alt="" decoding="async">
<img class="cd-pic" id="lobby-case-detail-pic" alt="" decoding="async">
<div class="cd-text" id="lobby-case-detail-text"></div>
<div class="cd-note" id="lobby-case-detail-note"></div>
</div>
</div>
</div>
<div id="mic-permission-overlay" aria-label="ขอสิทธิ์ไมโครโฟน">
@@ -1643,12 +1687,12 @@
</div>
<script src="../app-base.js"></script>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/display-name.js?v=2"></script>
<script src="js/display-name.js?v=3"></script>
<script src="js/version.js?v=0.0122"></script>
<script src="js/customize-popup.js?v=40" data-customize-triggers="" data-customize-asset-base="img/03-5-Customize"></script>
<script src="js/achievements.js?v=0.002" data-asset-base="img/03-6-Profile"></script>
<script src="js/sound.js?v=1"></script>
<script src="js/room-lobby.js?v=0.0357"></script>
<script src="js/sound.js?v=2"></script>
<script src="js/room-lobby.js?v=0.0380"></script>
<div class="version-tag">v —</div>
</body>
</html>
+458 -62
View File
@@ -973,7 +973,16 @@ function maybeSpawnSpecialQuizForRun(space) {
const questions = (set && Array.isArray(set.questions) ? set.questions : [])
.map(specialQuizNormalizeQuestion)
.filter(Boolean);
/* [2026-07-17] การ์ดมาจาก "สำรับต่อคดี" (3 ใบไม่ซ้ำจาก 7) แจกตาม suspectPickIndex — ไม่ใช่
set.specialCard ใบเดียวที่ Admin ตั้ง ซึ่งทำให้ 3 เกมได้ใบเดิมเสมอ (user request).
fallback เป็นค่า Admin ถ้าสำรับหาย (คดีที่เริ่มก่อน deploy นี้ / space เก่าค้าง) */
const deckIdx = (typeof space.suspectPickIndex === 'number' && space.suspectPickIndex >= 0 && space.suspectPickIndex <= 2) ? space.suspectPickIndex : 0;
const deck = Array.isArray(space.specialCardDeck) ? space.specialCardDeck : null;
let card = set && set.specialCard;
if (deck && deck[deckIdx]) {
const dDef = specialCardDef(deck[deckIdx]) || {};
card = sanitizeSpecialCard({ cardId: Number(deck[deckIdx]), th: dDef.th });
}
/* โหมดเทสต์ (admin): บังคับการ์ดใบที่เลือกสำหรับเกมนี้ + force ขึ้นเสมอ (ไม่ต้องสุ่ม) */
const testCardId = (runtimeGameTiming.testSpecialCardByMap || {})[mapId];
const testForcedCard = (Number(testCardId) >= 1 && Number(testCardId) <= 7);
@@ -1359,6 +1368,24 @@ function isSpecialQuizPausedServer(space) {
return !!(space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done));
}
/* setTimeout ที่ "หยุดนับ" ระหว่างคำถามพิเศษ (special quiz) — ใช้กับรอบ quiz(MG1)/quiz_carry(MG4) ที่ขับด้วย
setTimeout (ไม่มี tick loop ให้ extend endsAt เหมือน MG2/6/7). Poll: ถ้ากำลัง pause → ดัน deadline ตาม
เวลาที่ freeze (คงเวลาที่เหลือ) แล้ว re-poll; ถ้าถึงเวลาแล้ว → ยิง cb. เก็บ timer ใน space.quizTimers */
function pausableSpecialQuizTimeout(sid, space, cb, ms) {
const d = { at: Date.now() + Math.max(0, ms | 0), last: Date.now() };
const push = (t) => { const sp = spaces.get(sid); if (sp && Array.isArray(sp.quizTimers)) sp.quizTimers.push(t); };
const step = () => {
const sp = spaces.get(sid);
if (!sp) return;
const now = Date.now();
if (isSpecialQuizPausedServer(sp)) d.at += (now - d.last); /* freeze → เลื่อน deadline = คงเวลาที่เหลือ */
d.last = now;
if (now >= d.at) { try { cb(); } catch (e) { /* ignore */ } return; }
push(setTimeout(step, Math.max(50, Math.min(300, d.at - now))));
};
push(setTimeout(step, Math.max(50, Math.min(300, ms | 0))));
}
function startSpecialQuizSession(sid, space) {
const sq = space.specialQuiz;
if (!sq || sq.triggered || sq.done) return false;
@@ -1769,6 +1796,9 @@ function caseMediaDefault(n) {
],
suspects,
culprits,
/* [2026-07-21] ไอคอนผู้ต้องสงสัย (สี่เหลี่ยมเล็ก) — ใช้เฉพาะหัวหน้าไต่สวน es3 เท่านั้น.
default = รูปเต็มตัว (suspects) → ถ้าไม่ได้ตั้งค่า พฤติกรรมเหมือนเดิมทุกประการ */
icons: suspects.slice(),
};
}
@@ -1787,6 +1817,17 @@ function sanitizeCaseMediaUrl(v, fallback) {
return s;
}
/** [2026-07-21] ข้อความในหน้ารายละเอียดคดี (กรอกจาก Admin) — ตัดแท็ก/อักขระอันตราย + จำกัดความยาว
ไม่ใช่ URL จึงใช้ตัวนี้แทน sanitizeCaseMediaUrl */
function sanitizeCaseText(v, max) {
if (typeof v !== 'string') return '';
return v.replace(/[<>]/g, '').replace(/\r/g, '').trim().slice(0, max || 400);
}
function sanitizeCaseTextList(arr) {
const src = Array.isArray(arr) ? arr : [];
return [0, 1, 2].map((i) => sanitizeCaseText(src[i], 240));
}
function sanitizeCaseMedia(obj) {
const src = (obj && typeof obj === 'object' && obj.cases && typeof obj.cases === 'object') ? obj.cases : {};
const cases = {};
@@ -1801,6 +1842,17 @@ function sanitizeCaseMedia(obj) {
story: [sanitizeCaseMediaUrl(story[0], d.story[0]), sanitizeCaseMediaUrl(story[1], d.story[1])],
suspects: sanitizeCaseMediaUrlList(c.suspects, d.suspects),
culprits: sanitizeCaseMediaUrlList(c.culprits, d.culprits),
/* [2026-07-21] ไอคอน es3 — fallback เป็น suspects ที่ผ่าน sanitize แล้ว (ไม่ใช่ default ดิบ)
เพื่อให้ "ไม่ตั้ง icons" = ใช้รูปเต็มตัวชุดเดียวกับที่ตั้งไว้จริง */
icons: sanitizeCaseMediaUrlList(c.icons, sanitizeCaseMediaUrlList(c.suspects, d.suspects)),
/* [2026-07-21] หน้ารายละเอียดแบบใหม่ (ประกอบเอง): ตั้ง pic แล้วหน้าจะสลับไปโหมดใหม่อัตโนมัติ
ยังไม่ตั้ง (ค่าว่าง) = ใช้รูปเดียวเดิม detail (case-detail-N.png) ต่อไป */
pic: sanitizeCaseMediaUrl(c.pic, ''),
txtCharge: sanitizeCaseText(c.txtCharge, 300),
txtPlace: sanitizeCaseText(c.txtPlace, 300),
txtVictim: sanitizeCaseText(c.txtVictim, 300),
txtSuspects: sanitizeCaseTextList(c.txtSuspects),
txtNote: sanitizeCaseText(c.txtNote, 1500),
};
}
return { cases };
@@ -2318,6 +2370,9 @@ function defaultGameTiming() {
trialVoteSec: 30,
/** เวลาโชว์ผลโหวตคนร้าย (ถูก/ผิด) ก่อนตัดไปหน้าผลเต็ม — มิลลิวินาที */
trialResultRevealMs: 7000,
/** ปลายทางหลังจบคดี (โชว์ผล+หน้าความรู้แล้ว): 'mainlobby' = กลับ Main-Lobby (เดิม) ·
'lobbya' = รีเซ็ตห้องเดิมกลับ LobbyA ให้เล่นคดีใหม่ต่อได้เลย */
postCaseDestination: 'mainlobby',
};
}
@@ -2625,26 +2680,44 @@ function clampGauntletLaserLineWidthPx(n) {
return Math.max(0, Math.min(24, Math.round(v)));
}
function normalizeGauntletVisualsFromRequest(d) {
/** แปลง "ภาพ/สี" ของ gauntlet จาก request — field ที่ **ไม่ได้ส่งมา** ต้องคงค่าเดิม (prev) ไว้เสมอ
ห้ามรีเซ็ตเป็น default. เดิมอ่านจาก d อย่างเดียว → PUT บางส่วน (เช่น {postCaseDestination})
ทำให้ gauntletLaneImageUrls/laser ถูกล้างทั้งชุด = อุปสรรค MG2 "texture หาย" (fallback สี่เหลี่ยมทึบ).
ส่ง [] มาตรง ๆ ยังล้างได้ตามปกติ (hasOwnProperty = ตั้งใจล้าง). */
function normalizeGauntletVisualsFromRequest(d, prev) {
const def = defaultGauntletVisuals();
if (!d || typeof d !== 'object') return { ...def };
let laneArr = d.gauntletLaneImageUrls;
if (typeof laneArr === 'string') laneArr = laneArr.split(/[\n\r]+/);
if (!Array.isArray(laneArr)) laneArr = [];
const gauntletLaneImageUrls = [];
for (let i = 0; i < laneArr.length && gauntletLaneImageUrls.length < 24; i++) {
const u = sanitizeGauntletAssetUrl(laneArr[i]);
if (u) gauntletLaneImageUrls.push(u);
}
return {
gauntletLaneImageUrls,
gauntletLaserTopUrl: sanitizeGauntletAssetUrl(d.gauntletLaserTopUrl),
gauntletLaserBottomUrl: sanitizeGauntletAssetUrl(d.gauntletLaserBottomUrl),
gauntletLaserLineUrl: sanitizeGauntletAssetUrl(d.gauntletLaserLineUrl),
gauntletLaserFillColor: clampGauntletLaserColor(d.gauntletLaserFillColor, def.gauntletLaserFillColor),
gauntletLaserStrokeColor: clampGauntletLaserColor(d.gauntletLaserStrokeColor, def.gauntletLaserStrokeColor),
gauntletLaserLineWidthPx: clampGauntletLaserLineWidthPx(d.gauntletLaserLineWidthPx),
const p = (prev && typeof prev === 'object') ? prev : {};
const keep = (k) => (p[k] !== undefined ? p[k] : def[k]);
const base = {
gauntletLaneImageUrls: Array.isArray(p.gauntletLaneImageUrls) ? p.gauntletLaneImageUrls.slice() : def.gauntletLaneImageUrls,
gauntletLaserTopUrl: keep('gauntletLaserTopUrl'),
gauntletLaserBottomUrl: keep('gauntletLaserBottomUrl'),
gauntletLaserLineUrl: keep('gauntletLaserLineUrl'),
gauntletLaserFillColor: keep('gauntletLaserFillColor'),
gauntletLaserStrokeColor: keep('gauntletLaserStrokeColor'),
gauntletLaserLineWidthPx: keep('gauntletLaserLineWidthPx'),
};
if (!d || typeof d !== 'object') return base;
const has = (k) => Object.prototype.hasOwnProperty.call(d, k);
const out = { ...base };
if (has('gauntletLaneImageUrls')) {
let laneArr = d.gauntletLaneImageUrls;
if (typeof laneArr === 'string') laneArr = laneArr.split(/[\n\r]+/);
if (!Array.isArray(laneArr)) laneArr = [];
const urls = [];
for (let i = 0; i < laneArr.length && urls.length < 24; i++) {
const u = sanitizeGauntletAssetUrl(laneArr[i]);
if (u) urls.push(u);
}
out.gauntletLaneImageUrls = urls;
}
if (has('gauntletLaserTopUrl')) out.gauntletLaserTopUrl = sanitizeGauntletAssetUrl(d.gauntletLaserTopUrl);
if (has('gauntletLaserBottomUrl')) out.gauntletLaserBottomUrl = sanitizeGauntletAssetUrl(d.gauntletLaserBottomUrl);
if (has('gauntletLaserLineUrl')) out.gauntletLaserLineUrl = sanitizeGauntletAssetUrl(d.gauntletLaserLineUrl);
if (has('gauntletLaserFillColor')) out.gauntletLaserFillColor = clampGauntletLaserColor(d.gauntletLaserFillColor, def.gauntletLaserFillColor);
if (has('gauntletLaserStrokeColor')) out.gauntletLaserStrokeColor = clampGauntletLaserColor(d.gauntletLaserStrokeColor, def.gauntletLaserStrokeColor);
if (has('gauntletLaserLineWidthPx')) out.gauntletLaserLineWidthPx = clampGauntletLaserLineWidthPx(d.gauntletLaserLineWidthPx);
return out;
}
function getGauntletVisualsForClient() {
@@ -2749,6 +2822,7 @@ function loadGameTiming() {
trialResultRevealMs: Object.prototype.hasOwnProperty.call(j, 'trialResultRevealMs')
? Math.max(1500, Math.min(20000, Math.floor(Number(j.trialResultRevealMs)) || 7000))
: 7000,
postCaseDestination: (j.postCaseDestination === 'lobbya') ? 'lobbya' : 'mainlobby',
};
}
} catch (e) { console.error('loadGameTiming', e.message); }
@@ -2819,7 +2893,7 @@ function saveGameTimingToFile(d) {
const dir = path.dirname(GAME_TIMING_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const prev = { ...runtimeGameTiming };
const vis = normalizeGauntletVisualsFromRequest(d);
const vis = normalizeGauntletVisualsFromRequest(d, prev); /* prev = คงค่าเดิมของ field ที่ไม่ได้ส่งมา */
const stackHz = Object.prototype.hasOwnProperty.call(d, 'stackSwingHz')
? clampStackSwingHz(d.stackSwingHz)
: clampStackSwingHz(prev.stackSwingHz != null ? prev.stackSwingHz : STACK_SWING_HZ_DEFAULT);
@@ -2973,10 +3047,11 @@ function saveGameTimingToFile(d) {
? clampStackHeavyBlockPercent(d.stackHeavyBlockPercent)
: prevHeavyPct;
const out = {
gauntletTickMs: clampGauntletTickMs(d.gauntletTickMs),
gauntletJumpTicks: clampGauntletJumpTicks(d.gauntletJumpTicks),
gauntletJumpHeightMult: clampGauntletJumpHeightMult(d.gauntletJumpHeightMult),
gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec),
/* เช่นเดียวกับ vis: ถ้า request ไม่ส่ง field มา ให้คงค่าเดิม (prev) — ไม่ใช่รีเซ็ตเป็น default */
gauntletTickMs: clampGauntletTickMs(Object.prototype.hasOwnProperty.call(d, 'gauntletTickMs') ? d.gauntletTickMs : prev.gauntletTickMs),
gauntletJumpTicks: clampGauntletJumpTicks(Object.prototype.hasOwnProperty.call(d, 'gauntletJumpTicks') ? d.gauntletJumpTicks : prev.gauntletJumpTicks),
gauntletJumpHeightMult: clampGauntletJumpHeightMult(Object.prototype.hasOwnProperty.call(d, 'gauntletJumpHeightMult') ? d.gauntletJumpHeightMult : prev.gauntletJumpHeightMult),
gauntletTimeLimitSec: clampGauntletTimeLimitSec(Object.prototype.hasOwnProperty.call(d, 'gauntletTimeLimitSec') ? d.gauntletTimeLimitSec : prev.gauntletTimeLimitSec),
troublesomeOfferSec: Object.prototype.hasOwnProperty.call(d, 'troublesomeOfferSec') ? clampTroublesomeOfferSec(d.troublesomeOfferSec) : ((runtimeGameTiming && runtimeGameTiming.troublesomeOfferSec) || 15),
...vis,
stackSwingHz: stackHz,
@@ -3028,6 +3103,9 @@ function saveGameTimingToFile(d) {
trialResultRevealMs: Object.prototype.hasOwnProperty.call(d, 'trialResultRevealMs')
? Math.max(1500, Math.min(20000, Math.floor(Number(d.trialResultRevealMs)) || 7000))
: (prev.trialResultRevealMs != null ? Math.max(1500, Math.min(20000, Math.floor(Number(prev.trialResultRevealMs)) || 7000)) : 7000),
postCaseDestination: Object.prototype.hasOwnProperty.call(d, 'postCaseDestination')
? (d.postCaseDestination === 'lobbya' ? 'lobbya' : 'mainlobby')
: (prev.postCaseDestination === 'lobbya' ? 'lobbya' : 'mainlobby'),
/* DEV: เปิด HUD โหลด LobbyA (Iron Man) — admin toggle. preserve ค่าเดิมถ้า request ไม่ส่งมา */
lobbyDebugHud: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHud')
? !!d.lobbyDebugHud
@@ -3555,11 +3633,27 @@ function buildPeersSnapForSpace(space, extraFields) {
เมื่อ join/reconnect ไม่ทันรอบ (race ตอนเปลี่ยนหน้า lobbyB→play.html) */
/* true ถ้า peer นี้คือผู้ถูกแบนรอบนี้ (Card 3 Ban) — เข้ามาดูเฉยๆ ไม่ต้อง Ready และไม่นับใน gate เริ่มเกม
(เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ที่ reconnect เข้า play.html) */
/** Card 3 Ban — แปลง target ตอนโหวต (socket.id) เป็น playerKey = "ตัวตนถาวร"
socket.id เปลี่ยนทุกครั้งที่เปลี่ยนหน้า (LobbyB ⇄ play.html) → ผูกกับ id อย่างเดียวจะหาคนโดนแบน
ไม่เจอตอนเริ่มเกมจริง = ตัวละครโผล่ในเกม + ถูกนับใน gate ready จน host กด START ไม่ได้.
บอท (__lobby_bot_N) ไม่มี playerKey → คืน '' แล้วใช้ id เดิมต่อ (บอท id คงที่อยู่แล้ว) */
function resolveBanTargetPlayerKey(space, targetId) {
if (!space || !targetId) return '';
if (String(targetId).indexOf('__lobby_bot_') === 0) return '';
const p = space.peers && space.peers.get(targetId);
const k = p && p.playerKey;
return (k && /^[a-zA-Z0-9_-]{8,128}$/.test(String(k))) ? String(k) : '';
}
function isBannedPeerForRun(space, id) {
if (!space) return false;
if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return true;
const pp = space.peers && space.peers.get(id);
return !!(pp && pp.bannedSpectator);
if (pp && pp.bannedSpectator) return true;
/* เทียบด้วย playerKey — ครอบเคส socket.id เปลี่ยนหลัง reconnect/เปลี่ยนหน้า (id เดิมไม่มีทางแมตช์) */
if (space.bannedThisRunPlayerKey && pp && pp.playerKey
&& String(pp.playerKey) === String(space.bannedThisRunPlayerKey)) return true;
return false;
}
/* สำเนา ready map โดยตัด id ผู้ถูกแบนออก — กัน client นับ banned เป็นผู้เล่นที่ต้องรอ Ready (host ค้าง N/N+1) */
@@ -3749,6 +3843,25 @@ function emitDetectiveLobbyBPresence(sid, space) {
try { maybeOpenPendingBanVote(sid, space); } catch (e) { /* ignore */ }
}
/* [2026-07-19] ตัดผู้เล่นออกจาก roster คดี "ทันที" + re-emit presence — ใช้ตอน host kick / คนหลุดระหว่างเฟส
เลือกผู้ต้องสงสัย. ต้นตอบั๊ก: TC144 gate (suspect-pick-start) ใช้ total = caseParticipantKeys.size ที่ล็อก
ตอนเริ่มคดี และลดได้ทางเดียว = grace-prune 22วิ ที่ต้องมี playerKey → host kick (ไม่ prune เลย) และ
disconnect ที่ playerKey ว่าง (grace ข้าม) ทำให้ gate ค้าง "รอผู้เล่น" ถาวร host กด Start ไม่ได้.
ตัดทั้ง 'k:'+playerKey และ 'n:'+nick (เหมือน grace-prune ที่ server.js:13186-13195) เพื่อครอบ roster
ทั้งสองแบบ. emitDetectiveLobbyBPresence จะ no-op เองถ้าไม่ได้อยู่ LobbyB (กันเรียกผิดจังหวะ) */
function pruneDetectiveParticipantNow(sid, space, playerKey, nickname) {
if (!space) return;
const nick = normalizeLobbyNickname(nickname || '');
try {
if (space.caseParticipantKeys) {
if (playerKey && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) space.caseParticipantKeys.delete('k:' + playerKey);
if (nick) space.caseParticipantKeys.delete('n:' + nick);
}
if (space.caseParticipantNicknames && nick) space.caseParticipantNicknames.delete(nick);
} catch (e) { /* ignore */ }
emitDetectiveLobbyBPresence(sid, space);
}
/** โหวต Ban เปิดเมื่อผู้เล่นในคดี "กลับเข้า LobbyB ครบทุกคน" เท่านั้น — กัน candidate ไม่ครบ + timer เริ่ม
* ก่อนคนเข้าครบ ("เล่น 4 คนแต่โหวตได้ 3"). safety: รอเกิน BAN_VOTE_MAX_WAIT_MS (บางคนไม่กลับ) → เปิดเลย กัน deadlock */
const BAN_VOTE_MAX_WAIT_MS = 30000;
@@ -3781,7 +3894,11 @@ function maybeOpenPendingBanVote(sid, space) {
startPlayerPickVote(sid, space, 'ban', (targetId) => {
const sp2 = spaces.get(sid);
console.log('[ban-debug] ban vote RESOLVED target=', targetId);
if (sp2) sp2.bannedPlayerId = targetId || null;
if (sp2) {
sp2.bannedPlayerId = targetId || null;
sp2.bannedPlayerKey = resolveBanTargetPlayerKey(sp2, targetId); /* ตัวตนถาวร — id จะเปลี่ยนก่อนเกมเริ่ม */
console.log('[ban-debug] ban target key=', sp2.bannedPlayerKey || '(none/bot)');
}
});
}
@@ -3863,7 +3980,12 @@ function assignDetectiveSuspectCardMinigames(space) {
(1-3; ผู้ต้องสงสัย 1 คนเล่นได้ 3 ครั้ง) → ได้การ์ดคนละ 1 ใบแน่นอน แทนสุ่ม 1/3 ทุกเกม (กัน variance) */
space.specialQuizPlayBySuspect = { 0: 1 + Math.floor(Math.random() * 3), 1: 1 + Math.floor(Math.random() * 3), 2: 1 + Math.floor(Math.random() * 3) };
space.suspectPlayCount = { 0: 0, 1: 0, 2: 0 };
console.log('[special-quiz] schedule set (per-case):', JSON.stringify(space.specialQuizPlayBySuspect));
/* [2026-07-17] สำรับการ์ดต่อคดี — สุ่ม 3 ใบ "ไม่ซ้ำกัน" จาก 7 ใบ แจกผู้ต้องสงสัยคนละใบ (user request:
"ได้การ์ดเดียวกันสามเกม จริงๆต้องไม่ตรงกัน"). เดิมทุก suspect ได้ set.specialCard ที่ Admin ตั้งไว้
"ใบเดียวต่อคดี" → 3 เกมได้ใบเดิมเสมอ. แจกตรงนี้เพราะเป็นจุดเริ่มคดี = ล้างพร้อม suspectPlayCount
ไม่ค้างข้ามคดี. ค่า specialCard ใน Admin จะไม่ถูกใช้เป็นตัวเลือกอีก (ยังใช้เป็น fallback ถ้าสำรับหาย) */
space.specialCardDeck = specialQuizShuffle([1, 2, 3, 4, 5, 6, 7]).slice(0, 3);
console.log('[special-quiz] schedule set (per-case):', JSON.stringify(space.specialQuizPlayBySuspect), 'deck=' + JSON.stringify(space.specialCardDeck));
/* #6: ความคืบหน้าการสืบต่อผู้ต้องสงสัย (shared — เท่ากันทุกคน) = จำนวนมินิเกมที่เล่นให้คนนั้น (0-3)
ใช้โชว์ช่องหลักฐานใต้การ์ด suspect แทนหลักฐานส่วนตัว (เดิมแต่ละคนเห็นไม่เท่ากัน) */
space.suspectTeamProgress = [0, 0, 0];
@@ -4105,8 +4227,10 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
space.lastMinigameOver = null;
/* Card 3 Ban — ผู้ที่ถูกแบนไม่ได้เล่นมินิเกมรอบนี้ (1 รอบ) */
space.bannedThisRunPlayerId = space.bannedPlayerId || null;
console.log('[ban-debug] startMinigame: bannedThisRunPlayerId=', space.bannedThisRunPlayerId);
space.bannedThisRunPlayerKey = space.bannedPlayerKey || ''; /* ตัวหลักที่ใช้แมตช์จริง (id เปลี่ยนได้) */
console.log('[ban-debug] startMinigame: bannedThisRunPlayerId=', space.bannedThisRunPlayerId, 'key=', space.bannedThisRunPlayerKey || '(none/bot)');
space.bannedPlayerId = null;
space.bannedPlayerKey = '';
/* จำนวนผู้เล่นที่ต้องเข้าเกม + กด Ready ให้ครบก่อน host เริ่มได้ (ไม่นับผู้ถูกแบน)
สแนปจาก peers ใน LobbyB ตอนนี้ — กัน host เริ่มก่อน client โหลด/กด Ready ครบ (เกมไม่ sync/จบไม่พร้อมกัน) */
space.minigameExpectedActiveCount = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)).length;
@@ -4169,6 +4293,7 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
detectiveMinigame: true,
minigameLabel: cardEntry.labelTh,
bannedPlayerId: space.bannedThisRunPlayerId || null,
bannedPlayerKey: space.bannedThisRunPlayerKey || '', /* client เทียบ playerKey (id เปลี่ยนหลัง reconnect) */
});
setTimeout(() => {
const spNow = spaces.get(sid);
@@ -4208,6 +4333,7 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
detectiveReturnLobbyB: true,
minigameLabel: cardEntry.labelTh,
bannedPlayerId: space.bannedThisRunPlayerId || null,
bannedPlayerKey: space.bannedThisRunPlayerKey || '', /* client เทียบ playerKey (id เปลี่ยนหลัง reconnect) */
};
if (space.jsLayout) gameStartPayload.jsLayout = space.jsLayout;
if (md.gameType === 'gauntlet' && space.gauntletRun) {
@@ -4297,6 +4423,11 @@ function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) {
space.mapId = POST_CASE_LOBBY_SPACE_ID;
space.mapData = lb;
space.suspectPhaseActive = true;
/* [TC166-fix] arm backstop ตรงนี้ = "ตอนกลับถึง LobbyB จริง" — เดิม arm ไว้ตั้งแต่ beginDetectiveSuspectMinigame
(25วิ) แต่มินิเกมยาวกว่านั้น → timer ยิงตอนยังอยู่ในมินิเกม → เจอ guard !serverMapIsPostCaseLobbyB แล้ว
return ทิ้ง → พอกลับ LobbyB ไม่เหลือ backstop เลย. ถ้า client ไหนค้างไม่ส่ง lobbyb-loaded (เช่นโดนดึงออก
กลางมินิเกม) gate จะค้าง present<total ถาวร = ทั้งห้องเล่นต่อไม่ได้ */
armLobbyBLoadedSafety(sid, space);
space.peers.forEach((p) => {
const sp = pickSpawnForJoin(lb, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0);
p.x = sp.x;
@@ -4498,6 +4629,54 @@ function computeAndEmitTrialResult(sid, space) {
} catch (e) { console.error('[achv] trial-result', e && e.message); }
space.silencedPlayerId = null;
/* item1: admin ตั้งปลายทางหลังจบคดี = 'lobbya' → รีเซ็ตห้องเดิมกลับ LobbyA (ครั้งเดียว/คดี, terminal
เท่านั้น — revote คืนก่อนถึงจุดนี้แล้ว). ทำเงียบ (ไม่ broadcast) → client โชว์ผล+หน้าความรู้ต่อได้
แล้ว reload (room-lobby.html?space=<id>) มาเจอ LobbyA สด */
try {
if (runtimeGameTiming && runtimeGameTiming.postCaseDestination === 'lobbya' && !space.postCaseLobbyAReset) {
space.postCaseLobbyAReset = true;
resetDetectiveSpaceToLobbyA(sid, space);
}
} catch (e) { console.error('[item1] post-case reset', e && e.message); }
}
/* item1: รีเซ็ตห้อง detective กลับสภาพ LobbyA สดๆ — สร้าง space object ใหม่ (คง identity/peers/host/config)
เพื่อกัน state คดีค้าง (evidence/ban/trial/suspect/timers). client reload → join-space เจอ LobbyA. */
function resetDetectiveSpaceToLobbyA(sid, space) {
if (!space) return;
const lobbyMapId = space.lobbyAMapId || space.mapId;
const lobbyMap = (lobbyMapId && maps.get(lobbyMapId)) || space.lobbyAMapData || space.mapData;
/* หยุด timer ทั้งหมดของห้องเดิม (กัน callback ค้างมายุ่งกับ object ใหม่) */
try { stopGauntletTicker(space); } catch (e) { /* ignore */ }
try { clearSpaceQuizTimers(space); } catch (e) { /* ignore */ }
try { clearTrialVoteTimer(space); } catch (e) { /* ignore */ }
['storyReadTimer', 'troublesomeDebTimer', 'troublesomeMaxTimer', 'lobbyBLoadedSafetyTimer', 'pickVoteTimer', 'banVoteTimer'].forEach((t) => {
if (space[t]) { try { clearTimeout(space[t]); } catch (e) { /* ignore */ } space[t] = null; }
});
/* คืน spawn LobbyA + ยกเลิก ready ให้ทุก peer (client reload จะสร้าง peer ใหม่อยู่แล้ว — นี่กันช่วงคาบเกี่ยว) */
space.peers.forEach((p) => {
try {
const sp = pickSpawnForJoin(lobbyMap, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0);
p.x = sp.x; p.y = sp.y; p.direction = 'down';
} catch (e) { /* ignore */ }
p.ready = false;
p.bannedSpectator = false;
});
/* สร้าง space ใหม่แบบ LobbyA สด (เท่ากับห้องที่เพิ่งสร้าง) — คง identity/peers/host/config, ทิ้ง state คดี */
const fresh = {
mapData: lobbyMap, mapId: lobbyMapId, peers: space.peers, spaceName: space.spaceName,
hostId: space.hostId, hostPlayerKey: space.hostPlayerKey, isPrivate: space.isPrivate,
maxPlayers: space.maxPlayers, createdAt: space.createdAt, roomPassword: space.roomPassword,
previewEditorTest: space.previewEditorTest, botSlotCount: space.botSlotCount,
lobbyAMapId: lobbyMapId, lobbyAMapData: lobbyMap,
suspectCardMinigames: null, suspectPhaseActive: false, suspectPickIndex: 0, detectiveMinigameActive: false,
graceReconnect: space.graceReconnect || {}, graceTimers: space.graceTimers || {},
/* กันห้องถูกลบตอน client reload กลับเข้า LobbyA (peer=0 ชั่วขณะ) — keep-alive 5 นาที ให้ทุกคน rejoin ทัน */
postCaseKeepAliveUntil: Date.now() + 300000,
};
spaces.set(sid, fresh);
try { glog(sid, fresh, 'post-case', '[item1] reset ห้องกลับ LobbyA (postCaseDestination=lobbya) — รอ client reload'); } catch (e) { /* ignore */ }
}
/* ===== ห้องสรุปหลักฐาน — การไต่สวน (ปากคำ 3 รอบ ก่อนโหวต) ===== */
@@ -4910,8 +5089,7 @@ function scheduleQuizReadPhase(sid, space, md) {
text: String(q.text || '').trim(),
endsAt: sess.phaseEndsAt,
});
const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs);
space.quizTimers.push(t);
pausableSpecialQuizTimeout(sid, space, () => scheduleQuizAnswerPhase(sid, space, md), readMs); /* หยุดนับตอนคำถามพิเศษ */
}
function scheduleQuizAnswerPhase(sid, space, md) {
@@ -4930,8 +5108,7 @@ function scheduleQuizAnswerPhase(sid, space, md) {
/* บอท: server ตัดสินคำตอบข้อนี้ + ตั้งเป้าเดินไปโซน (server tick เดินให้ในช่วง answer) */
const qCur = sess.questions[sess.qIndex];
if (qCur) quizBotsDecideAnswers(space, sess.quizMapMd || md, !!qCur.answerTrue);
const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs);
space.quizTimers.push(t);
pausableSpecialQuizTimeout(sid, space, () => resolveQuizRound(sid, space, md), answerMs); /* หยุดนับตอนคำถามพิเศษ */
}
function resolveQuizRound(sid, space, md) {
@@ -5626,8 +5803,7 @@ function scheduleQuizCarryReadPhase(sid, space) {
endsAt: s.phaseEndsAt,
});
if (readMs <= 0) { scheduleQuizCarryAnswerPhase(sid, space); return; }
const t = setTimeout(() => scheduleQuizCarryAnswerPhase(sid, space), readMs);
space.quizTimers.push(t);
pausableSpecialQuizTimeout(sid, space, () => scheduleQuizCarryAnswerPhase(sid, space), readMs); /* หยุดนับตอนคำถามพิเศษ */
}
function scheduleQuizCarryAnswerPhase(sid, space) {
@@ -5650,8 +5826,7 @@ function scheduleQuizCarryAnswerPhase(sid, space) {
optionRevealAt: s.optionRevealAt,
endsAt: s.phaseEndsAt,
});
const t = setTimeout(() => resolveQuizCarryTimeup(sid, space), ansMs);
space.quizTimers.push(t);
pausableSpecialQuizTimeout(sid, space, () => resolveQuizCarryTimeup(sid, space), ansMs); /* หยุดนับตอนคำถามพิเศษ */
}
function startQuizCarryGame(sid, space) {
@@ -8545,11 +8720,21 @@ function buildStackTurnOrderServer(space, s) {
order.push({ kind: 'human', seat, id, botId: null, theme: stackHumanThemeServer(p) });
seat++;
}
/* บอท takeover (คนหลุดช่วง how-to) ได้ seat ต่อจากคน ก่อนบอท AI — คง avatar/ตำแหน่งลำดับเดิมไว้ */
/* บอท takeover (คนหลุดช่วง how-to) ได้ seat ต่อจากคน ก่อนบอท AI — คง avatar/ตำแหน่งลำดับเดิมไว้
ส่ง characterId/สี/ชื่อ ของคนเดิมไปด้วย: client ลบ peer ทิ้งไปแล้วตอน 'user-left' (play.js:21769)
และ botId = socket.id เดิมของเขา → others.get(botId) = null → เดิมวาดเป็นอวตารเขียว + ไม่รู้ว่าใคร */
for (const [botId, b] of s.bots) {
if (!b || !b.takeover) continue;
if (seat > STACK_SEATS) break;
order.push({ kind: 'bot', seat, id: null, botId, theme: (b.theme != null ? b.theme : ((seat % 8) + 1)) });
order.push({
kind: 'bot', seat, id: null, botId,
theme: (b.theme != null ? b.theme : ((seat % 8) + 1)),
characterId: b.characterId != null ? b.characterId : null,
lobbyColorThemeIndex: b.colorThemeIdx != null ? b.colorThemeIdx : null,
lobbySkinToneIndex: b.skinToneIdx != null ? b.skinToneIdx : null,
nickname: b.nick || '',
takeover: true, /* client: บอทที่มาจากคนหลุด (ไม่ใช่บอท AI) → โชว์ชื่อเดิม + "(บอท)" */
});
seat++;
}
const botCount = Math.max(0, Math.min(STACK_SEATS - order.length, effectiveBotSlotCount(space)));
@@ -8582,7 +8767,16 @@ function emitStackStateServer(sid, space) {
const lthemes = s.layers.map((l) => (l.theme && l.theme >= 1 && l.theme <= 8 ? l.theme : 0));
io.to(sid).emit('stack-state', {
srv: true,
order: s.turnOrder.map((e) => ({ kind: e.kind, seat: e.seat, id: e.id, botId: e.botId, theme: e.theme })),
/* ต้องส่ง characterId/สี/ชื่อ ของบอท takeover ต่อด้วย — ถ้า map ทิ้งตรงนี้ ที่ใส่ไว้ใน
buildStackTurnOrderServer ก็สูญเปล่า (client ได้แต่ theme → หน้าเขียว) */
order: s.turnOrder.map((e) => ({
kind: e.kind, seat: e.seat, id: e.id, botId: e.botId, theme: e.theme,
characterId: e.characterId != null ? e.characterId : null,
lobbyColorThemeIndex: e.lobbyColorThemeIndex != null ? e.lobbyColorThemeIndex : null,
lobbySkinToneIndex: e.lobbySkinToneIndex != null ? e.lobbySkinToneIndex : null,
nickname: e.nickname || '',
takeover: !!e.takeover,
})),
idx: s.turnIndex,
hostId: null,
lthemes,
@@ -8653,6 +8847,16 @@ function runStackTick(sid, space) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'stack') { stopStackTicker(space); return; }
const now = Date.now();
/* คำถามพิเศษ → หยุดเกม: เลื่อน startMs ตาม tick (คงเวลาที่เหลือ) + ไม่แกว่ง/ไม่เช็คจบ (เหมือน MG2/6/7) */
/* [2026-07-17] เวลาจริง ไม่ใช่ก้าว STACK_TICK_MS คงที่ — ก้าวคงที่ชดเชยไม่ครบเมื่อ tick จริงเดินช้ากว่า
ที่ตั้งไว้ (บทเรียน MG5 07-16: กู้คืนได้แค่ 68%) */
if (isSpecialQuizPausedServer(space)) {
const nowS = Date.now();
s.startMs += Math.max(0, Math.min(1000, nowS - (s.lastPauseShiftAt || nowS)));
s.lastPauseShiftAt = nowS;
return;
}
s.lastPauseShiftAt = Date.now();
/* เงื่อนไขจบเกม (server ตัดสินแหล่งเดียว) */
if (s.progressPct >= 100) { stackSessionEnd(sid, space, 'decrypt_complete'); return; }
if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) { stackSessionEnd(sid, space, 'misses_exhausted'); return; }
@@ -8734,6 +8938,14 @@ function startStackTicker(sid, space) {
s3.bots.set(rec.id, {
tier: 'avg', score: 0, takeover: true, takeoverPlayerKey: rec.playerKey || '',
theme: normStackTheme8(rec.themeIdx),
/* หน้า/สี/ชื่อ ของคนที่หลุด — recordPendingMissionTakeover เก็บไว้ให้ครบแล้ว (7982-7984) แต่เดิม
ตรงนี้หยิบมาแค่ theme → order row ไม่มี characterId → client วาดหน้าไม่ได้ = "อวตารตัวเขียว"
(setCyberHudScoreAvatarImg: ไม่มี cid → defaultAvatarImg). คอมเมนต์ที่ buildStackTurnOrderServer
เขียนไว้เองว่า "คง avatar เดิมไว้" — ตอนนี้ทำจริงตามนั้น */
characterId: rec.characterId != null ? rec.characterId : null,
colorThemeIdx: rec.themeIdx != null ? rec.themeIdx : null,
skinToneIdx: rec.skinIdx != null ? rec.skinIdx : null,
nick: rec.nick || '',
});
});
}
@@ -8956,10 +9168,22 @@ function jsGridPosStandingOnPlatform(md, ts, tx, ty, scrollPx) {
const platTop = rawTop + k * period;
return { x: (tx + 0.5) - cw * 0.5, y: platTop / ts - ch };
}
/* [2026-07-17] MG5 ช่วงท้ายเลื่อนเร็วขึ้น ×2 (user request) — 15 วิสุดท้ายของรอบ.
⚠️ สูตรนี้ต้อง "เหมือนกันเป๊ะ" กับฝั่ง client (play.js:jsScrollPxAtPlay / jsRiseVelNowPlay)
ไม่งั้นแท่นจะอยู่คนละที่ระหว่าง server กับที่ผู้เล่นเห็น = ชน/ไม่ชนไม่ตรงกัน
(บทเรียนทั้งวันนี้: สูตรที่คำนวณสองฝั่งต้องแก้พร้อมกันเสมอ)
ตำแหน่งต้องต่อเนื่อง: ใช้ผลรวมของสองช่วง (t₀·v + (t−t₀)·2v) ไม่ใช่คูณทั้งก้อน ไม่งั้นฉากจะกระโดด */
const JS_ENDGAME_BOOST_SEC = 15;
const JS_ENDGAME_BOOST_MUL = 2;
function jsScrollPxAt(elapsedSec, risePerSec, totalSec) {
const t0 = Math.max(0, (Number(totalSec) || 0) - JS_ENDGAME_BOOST_SEC);
if (!(Number(totalSec) > 0) || elapsedSec <= t0) return elapsedSec * risePerSec;
return (t0 * risePerSec) + ((elapsedSec - t0) * risePerSec * JS_ENDGAME_BOOST_MUL);
}
function jsScrollPx(s, md) {
const risePerSec = Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42;
const elapsed = Math.max(0, (Date.now() - (s.liveBeginsAt || s.startMs)) / 1000);
return elapsed * risePerSec;
return jsScrollPxAt(elapsed, risePerSec, jsTimeLimitSec(md));
}
function jsTimeLimitSec(md) {
/* ตรงกับ client jumpSurviveTimeLimitSecForMission: md.jumpSurviveTimeSec → game-timing.jumpSurviveMissionTimeSec → 60 */
@@ -9068,6 +9292,21 @@ function stepJumpSurviveBots(sid, space, dt, now) {
/* ขยับบอทเฉพาะหลังนับถอยหลัง 3-2-1 จบ (liveBeginsAt) — ก่อนหน้านี้บอทเริ่มทันทีที่ session active
ทำให้บอทวิ่ง/กระโดดก่อน "1" จบ → กันด้วยการ return จนถึงเวลาจริง (เหมือน scroll ที่อิง liveBeginsAt อยู่แล้ว) */
if (now < (s.liveBeginsAt || s.startMs || 0)) return;
/* คำถามพิเศษเปิดอยู่ → หยุดบอท + เลื่อน endsAt ไม่ให้เวลาเดิน (เหมือน MG2/MG3/MG6/MG7)
pt8 (07-11) audit ข้าม MG5 ไปเกมเดียว: วันนั้นแก้แต่ฝั่ง client (jumpSurviveTickFrame early-return
ตอน specialQuizFreeze) โดยคิดว่า "scroll/timer อิง anchor อยู่แล้ว" — แต่ s.endsAt ฝั่ง server เป็น
เวลาสัมบูรณ์ ไม่มีใครเลื่อน → รอบจบ time_up ได้ทั้งที่ผู้เล่นยังตอบคำถามค้างอยู่ (วัดได้: pause 35 วิ
แต่เกมจบที่ 63 วิ = เท่า deadline ที่ไม่ pause).
⚠️ เลื่อนด้วย "เวลาจริงที่ผ่านไป" ไม่ใช่ก้าว JS_TICK_MS คงที่แบบ MG6/MG7: sweep นี้เป็น
setInterval(33ms) ตัวเดียวที่วนทุกห้อง — ของจริงเดิน ~48ms ตอนมีโหลด → บวกทีละ 33 = ชดเชยไม่ครบ
(วัดได้: บวกแบบคงที่กู้คืนได้แค่ 24.1 จาก 35.3 วิ = 68% → เกมยังจบก่อนเวลา 11 วิ).
เป็นบทเรียนเดียวกับ advanceStackSwingPhase (07-10): อย่าสมมติว่า tick มาตรงเวลา */
if (isSpecialQuizPausedServer(space)) {
if (s.endsAt > 0) s.endsAt += Math.max(0, Math.min(1000, now - (s.lastPauseShiftAt || now)));
s.lastPauseShiftAt = now;
return;
}
s.lastPauseShiftAt = now; /* ไม่ pause → คง anchor ไว้ให้สด (กันกระโดดตอนเริ่ม pause ครั้งถัดไป) */
/* จบรอบ: หมดเวลา หรือทุก entity ตาย */
if (now >= s.endsAt) { endJumpSurviveGame(sid, space, 'time_up'); return; }
const ts = jsTileSize(md);
@@ -9268,7 +9507,15 @@ function stepSpaceShooterBots(sid, space, dt, now) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'space_shooter') return;
/* คำถามพิเศษเปิดอยู่ → หยุดบอท (เดิน+ยิง) + เลื่อน endsAt ไม่ให้เวลาเดิน (เหมือน gauntlet) */
if (isSpecialQuizPausedServer(space)) { if (s.endsAt > 0) s.endsAt += SS_TICK_MS; return; }
/* [2026-07-17] เวลาจริง ไม่ใช่ก้าว SS_TICK_MS คงที่ (flaw เดียวกับ MG5 ที่ชดเชยได้แค่ 68%) */
if (isSpecialQuizPausedServer(space)) {
const nowS = Date.now();
const shift = Math.max(0, Math.min(1000, nowS - (s.lastPauseShiftAt || nowS)));
if (s.endsAt > 0) s.endsAt += shift;
s.lastPauseShiftAt = nowS;
return;
}
s.lastPauseShiftAt = Date.now();
if (s.endsAt > 0 && now >= s.endsAt) { endSpaceShooterGame(sid, space, 'time_up'); return; }
const ts = jsTileSize(md);
const mw = (md.width || 20) * ts, mh = (md.height || 15) * ts;
@@ -9302,6 +9549,16 @@ function stepSpaceShooterBots(sid, space, dt, now) {
s.lastEmit = now;
if (outBots.length) io.to(sid).emit('space-shooter-bot-move', { bots: outBots });
}
/* [2026-07-17] ตายหมด → จบรอบ — บั๊กเดียวกับ MG7 (เดิมมีแค่หมดเวลา). endSpaceShooterGame รองรับ
reason 'all_dead' อยู่แล้ว (emit kind:'all_dead') แค่ไม่เคยมีใครเรียก. เช็คทั้งสองที่เพราะตอนคนตาย
server เซ็ตคู่กัน (:13516 p.spaceShooterEliminated + s.players[id].eliminated) */
let ssAnyAlive = false;
space.peers.forEach((p, id) => {
const st = s.players[id];
if (!((st && st.eliminated) || p.spaceShooterEliminated)) ssAnyAlive = true;
});
if (!ssAnyAlive) s.bots.forEach((b) => { if (!b.eliminated) ssAnyAlive = true; });
if (!ssAnyAlive) endSpaceShooterGame(sid, space, 'all_dead');
}
/* ============================================================
@@ -9384,6 +9641,8 @@ function startBalloonBossGame(sid, space) {
active: true, ended: false, startMs: liveBeginsAt, liveBeginsAt,
endsAt: lim > 0 ? liveBeginsAt + lim * 1000 + 2000 : 0,
maxHp: bbMaxHp(md), bots: new Map(), lastEmit: 0,
/* [P5 v4] ฐานเวลาวงลูกศร — server เป็นเจ้าของ, เลื่อนตอนคำถามพิเศษ, ส่งลงไปกับ bot-move ทุก 50ms */
ringAnchorMs: liveBeginsAt, lastPauseShiftAt: 0,
};
/* คนหลุดช่วง how-to → seed บอท takeover ก่อนสร้างบอท AI */
seedBalloonBossTakeoverBots(space, md);
@@ -9441,7 +9700,23 @@ function stepBalloonBossBots(sid, space, dt, now) {
if (!md || md.gameType !== 'balloon_boss') return;
/* คำถามพิเศษเปิดอยู่ → หยุดบอท (ลอย+ยิง) + เลื่อน endsAt ไม่ให้เวลาเดิน (เหมือน gauntlet)
กันกระสุนบอทถูก emit สะสมตอน pause แล้วไหลรัวหลังตอบเสร็จ */
if (isSpecialQuizPausedServer(space)) { if (s.endsAt > 0) s.endsAt += BB_TICK_MS; return; }
if (isSpecialQuizPausedServer(space)) {
/* [P5 v4 2026-07-17] เลื่อนด้วย "เวลาจริง" ไม่ใช่ก้าว BB_TICK_MS คงที่ — บทเรียน MG5 (07-16):
ตอนมีโหลด tick จริงเดินช้ากว่าค่าที่ตั้งไว้ → ก้าวคงที่ชดเชยไม่ครบ (MG5 ได้แค่ 68% ของเวลาที่ควรคืน) */
const shift = Math.max(0, Math.min(1000, now - (s.lastPauseShiftAt || now)));
if (s.endsAt > 0) s.endsAt += shift;
s.ringAnchorMs += shift; /* วงลูกศรหยุดหมุนพร้อมกันทุกเครื่อง */
s.lastPauseShiftAt = now;
/* ยัง emit ระหว่าง pause → ทุกเครื่อง re-align anchor ต่อเนื่อง. ถ้าเงียบ client จะ extrapolate
perf.now() ของตัวเองต่อ (วงหมุนต่อคนละมุม) แล้วกระตุกตอน emit กลับมา. bots:[] = no-op ฝั่ง client
(handler แค่ forEach ไม่มี logic ลบบอท) — ตำแหน่งบอทไม่ขยับตอน pause อยู่แล้ว */
if (now - s.lastEmit >= BB_BOT_EMIT_MS) {
s.lastEmit = now;
io.to(sid).emit('balloon-boss-bot-move', { bots: [], t: Date.now(), anchor: s.ringAnchorMs });
}
return;
}
s.lastPauseShiftAt = now;
/* จบรอบ: ชนะบอส (HP→0) ก่อน, แล้วหมดเวลา */
if (bbTeamDmg(space) >= s.maxHp) { endBalloonBossGame(sid, space, 'victory'); return; }
if (s.endsAt > 0 && now >= s.endsAt) { endBalloonBossGame(sid, space, 'time'); return; }
@@ -9491,8 +9766,23 @@ function stepBalloonBossBots(sid, space, dt, now) {
alive.forEach((b) => { const cl = bbClampWorld(b.homeCx, b.homeCy, mw, mh); b.homeCx = cl.cx; b.homeCy = cl.cy; }); /* กัน home ดริฟต์หลุดขอบ */
if (now - s.lastEmit >= BB_BOT_EMIT_MS) {
s.lastEmit = now;
if (outBots.length) io.to(sid).emit('balloon-boss-bot-move', { bots: outBots });
/* [P5 v3] แนบนาฬิกา server (Date.now) → ทุก client (รวม host) ใช้เป็นฐานเวลา "วงลูกศรหมุน" แทน
serverNow() ของตัวเอง (playClockOffset ต่างต่อเครื่อง = วงไม่ sync). ในเกมจริง MG7 เป็น server-auth
(host ไม่ส่ง balloon-boss-sync) → ฐานเวลาต้องมาจาก server. bot-move ส่งทุก 50ms = re-align ถี่.
ส่ง "เสมอ" (แม้ไม่มีบอท = ห้อง 6 คนจริง) เพื่อให้ฐานเวลาไหลเสมอ — empty array 20Hz cost น้อยมาก
และอยู่ใน balloon tick อยู่แล้ว (ไม่กระทบมินิเกมอื่น) */
/* [P5 v4] anchor = ฐานเวลาวงลูกศรของ server (pause-adjusted). v2/v3 ส่งแค่ `t` (นาฬิกา) ซึ่งตรงแล้ว
แต่ client ยังลบด้วย anchor "ของตัวเอง" ที่ shiftSpecialQuizFrozenTimers เลื่อนเองทุกเฟรม = สะสม
ต่อเครื่อง และไม่มี path ไหน adopt กลับในโหมด server-auth (balloon-boss-sync ถูกปิด) → ไม่มีวัน converge */
io.to(sid).emit('balloon-boss-bot-move', { bots: outBots, t: Date.now(), anchor: s.ringAnchorMs });
}
/* [2026-07-17] ทุก entity ตายหมด → จบรอบ (user: "mg7 ตายหมดเกมไม่จบ"). เดิม MG7 มีเงื่อนไขจบแค่
victory (HP บอสหมด) กับหมดเวลา → ตายครบก็นั่งรอจนหมดเวลาเปล่าๆ. ใช้ pattern เดียวกับ MG5 (:9345)
คน: p.balloonBossEliminated (server เซ็ตจาก move payload :13564, sticky) · บอท: b.eliminated */
let bbAnyAlive = false;
space.peers.forEach((p) => { if (!p.balloonBossEliminated) bbAnyAlive = true; });
if (!bbAnyAlive) s.bots.forEach((b) => { if (!b.eliminated) bbAnyAlive = true; });
if (!bbAnyAlive) endBalloonBossGame(sid, space, 'all_dead');
}
function startGauntletTicker(sid, space) {
@@ -9759,15 +10049,17 @@ function stepLobbyBotsForSpace(sid, space, dt, nowMs) {
isWalking: !!b.isWalking,
}));
if (arr.length) io.to(sid).emit('lobby-bot-move', { bots: arr });
/* roster snapshot (L3 ฆ่าผี) — ทุก ~1.5s ส่ง id ผู้เล่นจริงทั้งหมด ให้ client ตัดตัวที่ไม่อยู่ทิ้ง */
if (!space.lobbyRosterSyncTs || nowMs - space.lobbyRosterSyncTs > 1500) {
space.lobbyRosterSyncTs = nowMs;
emitLobbyRosterSync(sid, space);
}
/* roster snapshot (L3 ฆ่าผี) — ทุก ~1.5s ส่ง id ผู้เล่นจริงทั้งหมด ให้ client ตัดตัวที่ไม่อยู่ทิ้ง
(throttle ย้ายเข้าไปใน emitLobbyRosterSync แล้ว — กันยิงซ้ำเมื่อทั้ง sweep นี้และ interval P3 เรียก) */
emitLobbyRosterSync(sid, space);
}
/* throttle ~1.5s ต่อห้อง — ปลอดภัยไม่ว่าถูกเรียกจาก bot sweep (ห้องมีบอท) หรือ interval P3 (ทุกห้อง) */
function emitLobbyRosterSync(sid, space) {
if (!space || !space.peers) return;
const nowMs = Date.now();
if (space.lobbyRosterSyncTs && nowMs - space.lobbyRosterSyncTs <= 1500) return;
space.lobbyRosterSyncTs = nowMs;
const ids = [...space.peers.keys()];
io.to(sid).emit('lobby-roster', { ids });
}
@@ -9791,11 +10083,27 @@ setInterval(() => {
for (const [sid, space] of spaces) {
if (!space || !space.peers || space.peers.size === 0) continue;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType === 'lobby') continue; /* lobby ใช้ lobby-roster แยก */
if (!md || md.gameType === 'lobby') continue; /* lobby ใช้ lobby-roster แยก (heartbeat ด้านล่าง) */
try { io.to(sid).emit('play-roster', { ids: [...space.peers.keys()] }); } catch (e) { /* กัน 1 ห้องล้ม */ }
}
}, 1500);
/* [P3 2026-07-16] roster heartbeat ของ "lobby" — ต้องยิงทุกห้อง lobby ที่มีคน "ไม่ว่ามีบอทหรือไม่".
บั๊ก: เดิม emitLobbyRosterSync ถูกเรียก "ที่เดียว" คือใน stepLobbyBotsForSpace (bot sweep) ซึ่ง
return ก่อนถ้า effectiveBotSlotCount() <= 0 — และค่านั้น = 0 เสมอเมื่อ maxPlayers >= 4 (สร้างห้อง
เลือก ≥4 คน). ผลคือห้อง 4+ คน "ไม่เคยได้ lobby-roster" → ตัวฆ่าผี L3 ไม่ทำงาน → ถ้า user-left
หลุด/มาไม่ถึงเครื่องไหน peer ผีค้างถาวร → peers.size ต่างกัน → เลข PLAYERS ของ host ไม่ตรงคนอื่น
("ในบาง case" = เฉพาะห้องที่เลือก ≥4 คน). ยืนยันจาก gamelog: ห้อง 'บอท 0' คือห้องที่มีอาการ.
แยกออกมาเป็น interval ของตัวเอง → roster ไม่ผูกกับการมีบอทอีกต่อไป */
setInterval(() => {
for (const [sid, space] of spaces) {
if (!space || !space.peers || space.peers.size === 0) continue;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'lobby') continue; /* เฉพาะโถง lobby (มินิเกมใช้ play-roster ด้านบน) */
try { emitLobbyRosterSync(sid, space); } catch (e) { /* กัน 1 ห้องล้ม */ }
}
}, 1500);
/* เดินบอท MG1 quiz บน server (time-based) → บอทเดินเองไม่พึ่ง host (host หลุดก็ไม่หยุด) */
let quizBotSweepTs = 0;
setInterval(() => {
@@ -9982,11 +10290,19 @@ function runGauntletTick(sid, space) {
const gr = space.gauntletRun;
if (!gr) return;
/* หยุด run ระหว่างตอบคำถามพิเศษ (special-quiz) — เลื่อน endsAt ไปด้วยให้เวลาไม่เดิน, ไม่ไหล obstacle/ชน */
if (space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)) {
if (gr.endsAt != null) gr.endsAt += getGauntletTickMs();
emitGauntletSync(sid, space);
if (isSpecialQuizPausedServer(space)) {
/* [2026-07-17] เดิม `gr.endsAt += getGauntletTickMs()` = ก้าวคงที่ → ชดเชยไม่ครบ (user: "เหมือนยัง
ไม่หยุดวิ่ง" — obstacle หยุดจริงเพราะ return ออกไป แต่ deadline เดินต่อ รอบเลยจบเร็วกว่าที่ควร).
บทเรียนเดียวกับ MG5 (07-16): interval ที่ตั้ง 33ms ของจริงเดิน ~48ms ตอนมีโหลด → บวกทีละ 33
กู้คืนได้แค่ ~68%. ใช้ "เวลาจริงที่ผ่านไป" clamp 1s กันกระโดดตอน tick หาย */
const nowG = Date.now();
const shift = Math.max(0, Math.min(1000, nowG - (gr.lastPauseShiftAt || nowG)));
if (gr.endsAt != null) gr.endsAt += shift;
gr.lastPauseShiftAt = nowG;
emitGauntletSync(sid, space); /* ส่ง gauntletEndsAt ใหม่ทุก tick → ทุก client ยึดเลขเดียวกับ server */
return;
}
gr.lastPauseShiftAt = Date.now(); /* ไม่ pause → คง anchor ให้สด กันกระโดดตอนเริ่ม pause รอบหน้า */
ensureGauntletEndsAtIfNeeded(space);
if (gr.endsAt != null && Date.now() >= gr.endsAt) {
endGauntletGame(sid, space, 'time');
@@ -10233,6 +10549,19 @@ function serverMapIsPostCaseLobbyB(space) {
}
/** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */
/** ชื่อผู้เล่นสูงสุด — ต้องตรงกับ MAX_LEN ใน Game/public/js/display-name.js (2026-07-16: 24 → 10).
นับเป็น code unit เหมือน <input maxlength> (ไทย: สระ/วรรณยุกต์นับแยก ไม่ใช่ 10 ตัวที่ตาเห็น). */
const NICKNAME_MAX_LEN = 10;
/** ตัดชื่อให้อยู่ในลิมิต — server เป็นตัวบังคับจริง: client แก้ maxlength อย่างเดียวไม่พอ
(join-space เดิมรับ nickname ดิบ ไม่ cap เลย → ชื่อยาวเท่าไหร่ก็เข้าได้) */
function clampNickname(n) {
let s = String(n == null ? '' : n).trim();
try { s = s.normalize('NFKC'); } catch (e) { /* ignore */ }
s = s.replace(/[\x00-\x1f\x7f]/g, '');
if (s.length > NICKNAME_MAX_LEN) s = s.slice(0, NICKNAME_MAX_LEN);
return s;
}
function normalizeLobbyNickname(nickname) {
const raw = String(nickname || '').trim();
if (!raw) return 'ผู้เล่น';
@@ -10494,6 +10823,9 @@ function attemptTroublesomeRoll(sid, force) {
const peerIds = [...space.peers.keys()];
if (peerIds.length === 0) return;
if (!force && space.troublesomeEligible.size < peerIds.length) return;
/* [TC190] ทางปกติเดินแล้ว → ปลด backstop (guard troublesomeOfferSent กันซ้ำอยู่แล้ว แต่เก็บให้สะอาด
เหมือน deb/max timer ข้างล่าง ไม่ให้ timer ค้างถือ ref ห้องไว้อีก 45 วิ) */
if (space.troublesomeDeadlockTimer) { clearTimeout(space.troublesomeDeadlockTimer); space.troublesomeDeadlockTimer = null; }
/* เสนอบทตัวป่วนเฉพาะเมื่อมี "คนจริง ≥ 4 คน" — ยกเว้น admin บังคับ (troublesomeForceOffer) */
const forceOffer = !!(runtimeGameTiming && runtimeGameTiming.troublesomeForceOffer);
if (peerIds.length < 4 && !forceOffer) {
@@ -11130,7 +11462,9 @@ io.on('connection', (socket) => {
chosenSkinIdx = pickLobbySkinIndexForSlot(spawnJoinOrder);
}
const peer = {
id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true,
/* [2026-07-16] cap ชื่อตรงนี้ด้วย — เดิมรับค่าดิบจาก client: แก้ maxlength ฝั่ง client อย่างเดียว
ไม่ได้บังคับอะไรเลย (join-space เป็นทางเข้าหลักของชื่อ ไม่ใช่หน้าแก้ชื่อ) */
id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: clampNickname(nickname) || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true,
spawnJoinOrder,
lobbyColorThemeIndex: chosenThemeIdx,
lobbySkinToneIndex: chosenSkinIdx,
@@ -11165,12 +11499,21 @@ io.on('connection', (socket) => {
emit ทุก join → บอทเปลี่ยนสีพ้นจากสี user ทันทีทุกจอ */
emitLobbyTintSync(spaceId, space);
}
/* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby) */
/* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby)
— แต่ "เฉพาะตอนไม่มีคนอื่นเล่นค้างอยู่": เดิมดึง "ทั้งห้อง" ออกจากมินิเกมเพราะคนเดียวที่ rejoin มา
ด้วย map ไม่ตรง (คนหลุดกลางเกมแล้ว reconnect เข้า room-lobby) → คนที่เหลือโดนตัดกลางเกม, client
ค้างไม่ส่ง lobbyb-loaded → gate ค้าง = ทั้งห้องเล่นต่อไม่ได้ (เคส 2026-07-15 18:48).
ถ้ายังมีคนอื่นเล่นอยู่ → ปล่อยเกมเดินต่อ, คนที่ rejoin รอจนจบรอบแล้ว return ปกติจะดึงเข้า LobbyB เอง */
if (space.detectiveMinigameActive) {
const clientMap = playMapId != null ? String(playMapId).trim() : '';
const activeMap = space.mapId != null ? String(space.mapId).trim() : '';
if (!clientMap || !activeMap || clientMap !== activeMap) {
returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB');
const othersInRun = Math.max(0, space.peers.size - 1); /* คนอื่นที่ยังอยู่ในรอบมินิเกม (ไม่นับคนที่เพิ่ง join) */
if (othersInRun === 0) {
returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB');
} else {
try { glog(spaceId, space, 'lobbyb-gate', '[TC144] rejoin map ไม่ตรง (' + (clientMap || 'none') + '≠' + activeMap + ') แต่ยังมีคนเล่นอยู่ ' + othersInRun + ' คน → ไม่ดึงทั้งห้องออก (pass)', nickname || 'ผู้เล่น'); } catch (e) { /* ignore */ }
}
}
}
if (spaceAllowsQuizCarryLobbyRelaxed(space)) {
@@ -11231,6 +11574,11 @@ io.on('connection', (socket) => {
botSlotCount: effectiveBotSlotCount(space),
lobbyBotThemes: syncLobbyBotThemeSlots(space),
joinLocked: !!space.joinLocked,
/* [TC154] host กำลังเปิด wizard เลือกระดับ/คดี = set-ready ถูก "ปฏิเสธฝั่ง server" (12391).
เดิมไม่ได้ส่งสถานะนี้ใน join ack → คนที่เพิ่งเข้ามาไม่รู้ว่าโดนล็อก: ปุ่มพร้อมดูกดได้ปกติ
แต่กดแล้ว server ตีกลับเงียบ ๆ = "กดไม่ติด ไม่มีอะไรบอก" (คนละอันกับ relay lobby-preplay-open
ที่ส่งเฉพาะตอน host กด เปิด/ปิด — คนเข้าทีหลังไม่เคยได้ยิน) */
preplayOpen: !!space.preplayOpen,
missionLiveBeginsAt: space.missionLiveBeginsAt || null,
missionLive: isMinigameLiveForRejoinResume(space), /* rejoin กลางเกม live → client ข้าม how-to เข้าเล่นต่อ */
};
@@ -11490,6 +11838,10 @@ io.on('connection', (socket) => {
socket.to(sid).emit('balloon-boss-shot', {
sx: data.sx, sy: data.sy, vx: data.vx, vy: data.vy,
ownerId: data.ownerId, t0: (typeof data.t0 === 'number' ? data.t0 : Date.now()),
/* [P5] forward the shooter's aim so the receiver can pin the arrow ring to it (fixes arrow/bullet
desync on a viewer's screen). Redundant with atan2(vy,vx) today since vx/vy = dir*spd, but
explicit = authoritative and future-proof if the velocity ever stops being a pure direction. */
aim: (typeof data.aim === 'number' && Number.isFinite(data.aim)) ? data.aim : undefined,
});
});
@@ -11516,6 +11868,7 @@ io.on('connection', (socket) => {
if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */
const bossDmg = Math.max(0, Number(data.bossDmg) || 0);
const anchor = Math.max(0, Number(data.anchor) || 0); /* forward anchor host → clients adopt (มุมลูกศรวงกลม sync) */
const hostT = (typeof data.t === 'number' && Number.isFinite(data.t) && data.t > 0) ? data.t : undefined; /* [P5 v2] นาฬิกา host → clients ใช้เป็นฐานเวลาวงลูกศร (หมุน sync ต่อเนื่อง ไม่พึ่ง serverNow ต่อเครื่อง) */
const players = Array.isArray(data.players)
? data.players.filter((p) => p && p.id != null).slice(0, 12).map((p) => ({
id: p.id,
@@ -11524,7 +11877,7 @@ io.on('connection', (socket) => {
e: !!p.e,
}))
: [];
socket.to(sid).emit('balloon-boss-sync', { bossDmg, players, anchor });
socket.to(sid).emit('balloon-boss-sync', { bossDmg, players, anchor, t: hostT });
});
/* MG3 stack (shared tower) — relay ผลการวาง (เจ้าของตา/บอทของ host) ให้ทุกคนวางบล็อกเดียวกัน + เลื่อนตาพร้อมกัน */
@@ -11726,12 +12079,10 @@ io.on('connection', (socket) => {
if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
const p = space.peers.get(socket.id);
if (!p) return reply({ ok: false, error: 'ไม่พบผู้เล่น' });
let safe = String(nickname || '').trim();
/* ใช้ clampNickname ตัวเดียวกับ join-space — เดิม cap ที่ 32 ตรงนี้ที่เดียว ไม่ตรงกับ client (24)
และ join-space ไม่ cap เลย = 3 ค่าคนละอัน */
let safe = clampNickname(nickname);
if (!safe) return reply({ ok: false, error: 'ชื่อว่าง' });
try {
safe = safe.normalize('NFKC');
} catch (e) { /* ignore */ }
if (safe.length > 32) safe = safe.slice(0, 32);
const oldNorm = normalizeLobbyNickname(p.nickname);
const newNorm = normalizeLobbyNickname(safe);
p.nickname = safe;
@@ -11875,6 +12226,7 @@ io.on('connection', (socket) => {
const sp = spaces.get(sid);
if (!sp) return;
sp.bannedPlayerId = targetId || null;
sp.bannedPlayerKey = resolveBanTargetPlayerKey(sp, targetId); /* ตัวตนถาวร — id จะเปลี่ยนก่อนเกมเริ่ม */
const res = beginDetectiveSuspectMinigame(sid, sp, cardEntry, selectedIndex);
if (!res || !res.ok) {
io.to(sid).emit('quiz-ended', { message: (res && res.error) || 'เริ่มมินิเกมไม่สำเร็จ', returnToLobbyB: true });
@@ -11987,8 +12339,14 @@ io.on('connection', (socket) => {
const space = sid ? spaces.get(sid) : null;
if (!space || !serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' });
if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host เปิดพิจารณาคดีได้' });
/* [TC179-followup] ใช้ team progress (ตัวเลขเดียวกับที่การ์ดโชว์ทุกจอ) เป็นหลัก — หลักฐานแจก "รายคน"
คนที่หลุด/โดนบอทแทนตอนมินิเกมของผู้ต้องสงสัยคนนั้นจะไม่มีใบนั้น → host เห็นการ์ด ✓ ครบแต่กด
"ชี้ตัวคนร้าย" ไม่ได้ (เดิมเช็คแต่ prog ส่วนตัว). max(ส่วนตัว, ทีม) = ไม่บล็อกทั้งสองทาง */
const prog = playerSuspectProgressFromEvidence(space, socket.id);
if (![0, 1, 2].every((i) => (prog[i] || 0) >= 1)) return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน (เก็บหลักฐานอย่างน้อย 1 ใบต่อคน)' });
const teamProg = Array.isArray(space.suspectTeamProgress) ? space.suspectTeamProgress : [0, 0, 0];
if (![0, 1, 2].every((i) => Math.max((prog[i] || 0), (teamProg[i] || 0)) >= 1)) {
return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน (เก็บหลักฐานอย่างน้อย 1 ใบต่อคน)' });
}
/* ชี้ตัวคนร้าย = เข้าพิจารณาคดี → ต้องรอผู้เล่นในคดีกลับเข้า LobbyB ครบก่อน (กันคนตกรอบโหวต) */
const accuseWait = detectiveLobbyBMissingParticipants(space);
if (accuseWait.missing > 0) {
@@ -12192,6 +12550,10 @@ io.on('connection', (socket) => {
if (!space.peers.has(tid)) {
return cb && cb({ ok: false, error: 'ไม่พบผู้เล่นในห้อง' });
}
/* [2026-07-19] เก็บ playerKey/nickname ของคนที่ถูก kick "ก่อน" ลบ peer — ใช้ prune roster คดี */
const kickedPeer = space.peers.get(tid) || {};
const kickedKey = (typeof kickedPeer.playerKey === 'string') ? kickedPeer.playerKey : '';
const kickedNick = kickedPeer.nickname || '';
const targetSock = io.sockets.sockets.get(tid);
if (targetSock) {
targetSock.emit('host-console-kicked', { message: 'คุณถูก Host นำออกจากห้อง' });
@@ -12200,6 +12562,10 @@ io.on('connection', (socket) => {
}
space.peers.delete(tid);
io.to(sid).emit('user-left', { id: tid });
/* [2026-07-19] host ดึงคนออก = ตั้งใจถอดถาวร → ตัดออกจาก roster คดีทันที + re-emit presence
ไม่งั้น TC144 gate ตอนเลือกผู้ต้องสงสัยจะค้าง "รอผู้เล่น (x/y)" ตลอด host กด Start ต่อไม่ได้
(host-kick เดิมไม่เคย prune roster / ไม่ arm grace / ไม่ re-emit presence เลย) */
pruneDetectiveParticipantNow(sid, space, kickedKey, kickedNick);
cb && cb({ ok: true });
});
@@ -12340,6 +12706,9 @@ io.on('connection', (socket) => {
return reply({ ok: false, error: 'ยืนในพื้นที่เริ่มเกม (สีส้มในเอดิเตอร์) ก่อนกดเริ่ม' });
}
if (isLobbyADetectiveStart) {
space.lobbyAMapId = space.mapId; /* item1: จำ map LobbyA เดิมไว้ (ก่อนทับด้วย LobbyB) เพื่อ restore ตอน post-case reset */
space.lobbyAMapData = space.mapData;
space.postCaseLobbyAReset = false; /* เริ่มคดีใหม่ → เปิด guard reset→LobbyA อีกครั้ง */
assignDetectiveSuspectCardMinigames(space);
space.mapId = POST_CASE_LOBBY_SPACE_ID;
space.mapData = maps.get(POST_CASE_LOBBY_SPACE_ID);
@@ -12386,6 +12755,26 @@ io.on('connection', (socket) => {
space.troublesomeDebTimer = null;
space.troublesomeMaxTimer = null;
initTroublesomeState(space);
/* [TC190] backstop กัน deadlock "หน้าเลือกผู้ต้องสงสัยไม่ขึ้น":
openSuspectPhaseForRoom เข้าถึงได้ "ทางเดียว" = client ส่ง troublesome-eligible →
scheduleTroublesomeRoll → timer. ตัว troublesomeMaxTimer (8 วิ) ที่ดูเหมือน safety net
ถูก arm อยู่ "ข้างใน" scheduleTroublesomeRoll ที่สัญญาณนั้นเรียกเอง + สองบรรทัดข้างบนเพิ่งล้าง
timer เป็น null → ถ้าไม่มีใครส่งเลย (ติด overlay how-to/mic, หรือ room-lobby.js:4543 ซ่อน
mic overlay แล้วไม่เรียก tryTroublesomeLobbyGate ซ้ำ) = ห้องค้างถาวร ไม่มีอะไรกู้
(พิสูจน์แล้ว: justice-mg-all.js --probe eligible-loss → ค้าง 40 วิ ไม่มีวันเปิด).
arm ตรงนี้ = เฟสที่ห้อง "มาถึง LobbyB" จริง (บทเรียน §4: backstop ที่ arm ผิดเฟสจะ no-op เงียบ)
— storyReadTimer ข้างบนรู้บทเรียนนี้อยู่แล้ว, gate นี้แค่ไม่เคยได้. 45 วิ = พ้น worst case
ที่เคยวัดได้ (6 คน join ใช้ 10-16 วิ) เยอะพอจะไม่แย่งทางปกติ. */
if (space.troublesomeDeadlockTimer) clearTimeout(space.troublesomeDeadlockTimer);
space.troublesomeDeadlockTimer = setTimeout(() => {
const sp = spaces.get(sid);
if (!sp || sp.troublesomeOfferSent) return; /* เปิดไปแล้วตามทางปกติ */
if (!serverMapIsPostCaseLobbyB(sp)) return; /* ไม่ได้อยู่ LobbyB แล้ว */
try { glog(sid, sp, 'suspect-gate', '[TC190] safety-timeout 45s → force เปิดเฟสเลือกผู้ต้องสงสัย (ไม่มี client ส่ง troublesome-eligible เลย) — backstop ทำงาน (pass)'); } catch (e) { /* ignore */ }
/* force=true ข้ามเงื่อนไข "ทุกคน eligible"; ถ้า pool ว่าง attemptTroublesomeRoll
fallback เป็น peerIds ทั้งหมดอยู่แล้ว (10638) จึงปลอดภัยเมื่อไม่มีใคร eligible */
attemptTroublesomeRoll(sid, true);
}, 45000);
space.peers.forEach((p) => {
const sp = pickSpawnForJoin(space.mapData, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0);
p.x = sp.x;
@@ -12740,6 +13129,7 @@ io.on('connection', (socket) => {
space.emptySince = Date.now();
/* ระหว่างมินิเกมสืบสวน / คดีล็อก — อย่าลบห้องเมื่อเปลี่ยนหน้า room-lobby → play.html (รอ join กลับ) */
const keepCaseSpace = !!(space.detectiveMinigameActive
|| (space.postCaseKeepAliveUntil && Date.now() < space.postCaseKeepAliveUntil) /* item1: ห้อง reset→LobbyA รอ client reload กลับ */
|| (space.joinLocked && space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0));
/* ห้อง Quiz Battle — เก็บไว้เมื่อว่าง ให้ refresh กลับเข้าห้องเดิมได้ (TTL prune จะเก็บกวาดเมื่อว่างเกิน 45 วิ) */
const spaceMd = (space.mapId && maps.get(space.mapId)) || space.mapData;
@@ -12829,7 +13219,13 @@ io.on('connection', (socket) => {
const inCaseFlowForBot = space.detectiveMinigameActive
|| serverMapIsPostCaseLobbyB(space)
|| (space.joinLocked && space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0);
if (inCaseFlowForBot && leftPlayerKey) {
if (space.suspectPhaseActive) {
/* [2026-07-19] หลุดระหว่างเฟส "เลือกผู้ต้องสงสัย" → ตัดออกจาก roster ทันที (user: เล่นต่อทันที)
ครอบเคส playerKey ว่างด้วย (เดิม grace block ข้ามถ้า leftPlayerKey ว่าง → gate ค้างถาวร).
เฟสนี้อยู่ใน LobbyB ยังไม่เข้าเกม จึงไม่ต้องรอ grace 22วิ/ไม่ต้องมีบอทแทน — แค่ให้ gate ลด
แล้ว host เริ่มต่อได้เลย. ช่วง "เล่นมินิเกมจริง" ยังใช้ grace+บอทเหมือนเดิม (else-if ล่าง) */
pruneDetectiveParticipantNow(sid, space, leftPlayerKey, leftNick);
} else if (inCaseFlowForBot && leftPlayerKey) {
io.to(sid).emit('peer-disconnect-grace', { id: socket.id, nickname: leftNick, seconds: DISCONNECT_GRACE_SEC });
if (!space.graceReconnect || typeof space.graceReconnect !== 'object') space.graceReconnect = {};
if (!space.graceTimers || typeof space.graceTimers !== 'object') space.graceTimers = {};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>เข้าสู่ระบบ Google — JD JUSTICE DIVERS</title>
<link rel="stylesheet" href="style.css?v=20260526">
<style>
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #060b16; color: #cfe8ff; font-family: Kanit, system-ui, sans-serif; }
.gcb-box { text-align: center; padding: 2rem 1.5rem; max-width: 420px; }
.gcb-spin { width: 48px; height: 48px; border: 4px solid rgba(120,190,255,.2); border-top-color: #5fd0ff; border-radius: 50%; animation: gcbSpin .85s linear infinite; margin: 0 auto 1rem; }
@keyframes gcbSpin { to { transform: rotate(360deg); } }
.gcb-err { color: #fecdd3; margin-top: 1rem; line-height: 1.5; }
.gcb-btn { margin-top: 1rem; padding: .55rem 1.2rem; border-radius: 8px; border: 1px solid rgba(122,162,247,.5); background: rgba(20,28,48,.9); color: #e8f2ff; cursor: pointer; font-family: inherit; }
</style>
</head>
<body>
<div class="gcb-box">
<div class="gcb-spin" id="gcb-spin"></div>
<div id="gcb-msg">กำลังเข้าสู่ระบบด้วย Google…</div>
<div class="gcb-err is-hidden" id="gcb-err"></div>
<button type="button" class="gcb-btn is-hidden" id="gcb-back">กลับหน้า Login</button>
</div>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="/js/jd-swal.js?v=1"></script>
<script src="../app-base.js"></script>
<script src="../player-log-track.js?v=0.01"></script>
<script src="google-callback.js?v=0.04"></script>
</body>
</html>
+114
View File
@@ -0,0 +1,114 @@
(function () {
var msgEl = document.getElementById('gcb-msg');
var errEl = document.getElementById('gcb-err');
var spinEl = document.getElementById('gcb-spin');
var backBtn = document.getElementById('gcb-back');
function loginHref() {
return typeof appPath === 'function' ? appPath('/Login/') : '/Login/';
}
function lobbyHref() {
return typeof appPath === 'function' ? appPath('/Main-Lobby/') : '/Main-Lobby/';
}
function apiUrl(path) {
return (typeof appPath === 'function' ? appPath(path) : path);
}
function showError(text) {
if (spinEl) spinEl.style.display = 'none';
if (msgEl) msgEl.textContent = 'เข้าสู่ระบบไม่สำเร็จ';
if (errEl) {
errEl.textContent = text || 'เกิดข้อผิดพลาด';
errEl.classList.remove('is-hidden');
}
if (backBtn) backBtn.classList.remove('is-hidden');
}
function finishLogin(data) {
try {
localStorage.setItem('isLoggedIn', 'true');
localStorage.setItem('loginType', 'google');
localStorage.setItem('jdPlayerKey', data.playerKey);
localStorage.setItem('jdGoogleSessionActive', '1');
sessionStorage.removeItem('jdGuestModeActive');
if (data.displayName) {
localStorage.setItem('roomLobbyDisplayName', data.displayName);
localStorage.setItem('playerName', data.displayName);
}
if (data.email) {
localStorage.setItem('googleEmail', data.email);
}
if (data.avatarUrl) {
localStorage.setItem('googleAvatarUrl', data.avatarUrl);
}
if (data.coins != null) {
localStorage.setItem('jdCoins', String(Math.max(0, parseInt(data.coins, 10) || 0)));
}
sessionStorage.removeItem('oauthGoogleState');
sessionStorage.removeItem('oauthGoogleMergeGuestKey');
} catch (e) { /* ignore */ }
if (typeof jdTrackPlayerLog === 'function') {
jdTrackPlayerLog('login_google', { email: data.email || '' });
}
window.location.replace(lobbyHref());
}
if (backBtn) {
backBtn.addEventListener('click', function () {
window.location.href = loginHref();
});
}
var params = new URLSearchParams(window.location.search);
var oauthErr = params.get('error');
if (oauthErr) {
showError('Google: ' + oauthErr);
return;
}
var code = params.get('code');
var state = params.get('state') || '';
if (!code) {
showError('ไม่พบ authorization code จาก Google');
return;
}
var savedState = '';
try { savedState = sessionStorage.getItem('oauthGoogleState') || ''; } catch (e) { /* ignore */ }
if (!savedState || savedState !== state) {
showError('state ไม่ตรง (อาจหมดอายุหรือเปิดลิงก์ซ้ำ) — ลอง Login ใหม่');
return;
}
var mergeGuestKey = '';
try { mergeGuestKey = sessionStorage.getItem('oauthGoogleMergeGuestKey') || ''; } catch (e2) { /* ignore */ }
var redirectUri = window.location.origin + (typeof appPath === 'function'
? appPath('/Login/google-callback.html')
: '/Login/google-callback.html');
fetch(apiUrl('/Admin/api/oauth-google-callback.php'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'omit',
body: JSON.stringify({
code: code,
redirectUri: redirectUri,
state: state,
mergeGuestKey: mergeGuestKey || undefined,
}),
})
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j || !j.ok) {
showError((j && j.error) || 'เซิร์ฟเวอร์ปฏิเสธการเข้าสู่ระบบ');
return;
}
finishLogin(j);
})
.catch(function () {
showError('เชื่อมต่อเซิร์ฟเวอร์ไม่ได้');
});
})();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 515 KiB

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

Some files were not shown because too many files have changed in this diff Show More