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 bufferdisplay วย 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 86 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 ตอนเปลยนหน lobbyBplay.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 + (tt₀)·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