update bug design and disconnect
@@ -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';
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
<div class="admin-tab-sep" aria-hidden="true"><span>คำถาม & การ์ดพิเศษ</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">รูปคดี & 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' โดยไม่ต้องใช้ปุ่มนี้ */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}]}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"masterVol": 1.0,
|
||||
"musicVol": 0.35,
|
||||
"sfxVol": 0.75
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 557 B |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 436 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 4.5 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 5.5 MiB |
|
After Width: | Height: | Size: 5.3 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 100 KiB |
@@ -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>' +
|
||||
|
||||
@@ -42,6 +42,15 @@
|
||||
แต่ต่ำกว่า modal (z 10000+) เลย special quiz / สรุปผล ยังกดได้ปกติ + ป้ายบอกสถานะด้านบน */
|
||||
function setupBannedSpectatorUi() {
|
||||
if (!playBannedSpectator || document.getElementById('banned-spectator-shield')) return;
|
||||
/* หน้า how-to/pregame = "ตัวคุมรอบ" (READY/START) ไม่ใช่การเล่น → ต้องอยู่เหนือ shield เสมอ
|
||||
บั๊ก: how-to ของ MG2 (คลาส gch-mg2-mock) มี z-index แค่ 56 — ต่ำกว่า shield (200) → โดนคลุมทั้งแผง
|
||||
→ host ที่โดนแบนกด START ไม่ได้ และ START เป็นสิทธิ์ host เท่านั้น = ทั้งห้องค้างถาวร
|
||||
(เลย์เอาต์อื่นรอดเพราะ z สูงอยู่แล้ว: crown legacy 10140 / quiz-carry 10078)
|
||||
ปล่อยให้กดได้ปลอดภัย: server กัน non-host กด START และไม่นับ ready ของคนถูกแบนอยู่แล้ว */
|
||||
const zfix = document.createElement('style');
|
||||
zfix.id = 'banned-spectator-zfix';
|
||||
zfix.textContent = '#gauntlet-crown-howto-overlay,#quiz-carry-pregame-overlay{z-index:260!important}';
|
||||
document.head.appendChild(zfix);
|
||||
const shield = document.createElement('div');
|
||||
shield.id = 'banned-spectator-shield';
|
||||
shield.style.cssText = 'position:fixed;inset:0;z-index:200;background:transparent;pointer-events:auto;cursor:not-allowed;touch-action:none;-webkit-tap-highlight-color:transparent';
|
||||
@@ -88,6 +97,19 @@
|
||||
try { const pe = document.getElementById('play-loading-overlay'); if (pe && pe.parentNode) pe.parentNode.removeChild(pe); } catch (_e) {}
|
||||
return;
|
||||
}
|
||||
/* [2026-07-17] เสียง warp "ตอนวาร์ปเข้ามินิเกม" — เล่นพร้อม "คลิป/จอโหลด" ตั้งแต่เข้า play.html
|
||||
(user request: เดิมอยู่ตอนฉากโหลดเสร็จ = หลังคลิป). play.html เป็นมินิเกมล้วน (LobbyB อยู่ที่
|
||||
room-lobby.html เสมอ — room-lobby.js:7876) → ปลอดภัยที่จะเล่นตอน page load. gate กันเคส lobby
|
||||
map (mn8nx46h) หลุดมา + ต้องมี map param จริง. warp เป็นเสียง root (ROOT_SFX) resolve ได้โดยไม่พึ่ง
|
||||
mapData ที่ยังไม่มาตอนนี้. autoplay: engagement สูงพอเล่นได้ (เดิมที่ playClientReadyEmitted ก็เล่นได้
|
||||
โดยไม่มี gesture ใหม่บนหน้านี้) */
|
||||
try {
|
||||
var _warpMap = '';
|
||||
try { _warpMap = String(new URLSearchParams(location.search).get('map') || '').trim(); } catch (_e) { _warpMap = ''; }
|
||||
if (window.jdSound && _warpMap && _warpMap !== 'mn8nx46h') {
|
||||
try { window.jdSound.playSfx('warp'); } catch (_e) { /* ignore */ }
|
||||
}
|
||||
} catch (_e) { /* ignore */ }
|
||||
try {
|
||||
if (!document.body) return;
|
||||
/* overlay เป็น static ใน play.html แล้ว — ถ้าไม่มี (html เก่า cache) ค่อย inject เสริม */
|
||||
@@ -700,6 +722,10 @@
|
||||
/* server-auth swing: extrapolate phase จาก sample ล่าสุดของ server (กัน drift → บล็อกวาปตอนปล่อย) */
|
||||
let stackSrvPhaseAnchor = 0;
|
||||
let stackSrvPhaseAnchorAt = 0;
|
||||
/* เฟสของบล็อกที่ "วาดออกจอจริง" เฟรมล่าสุด = สิ่งที่ผู้เล่นเห็นและเล็ง — ใช้ส่งตอนกดวาง
|
||||
(stackMini.phase อาจเพิ่ง SNAP ไปแล้ว: dPh>0.9 → วาปทันที ระหว่างเฟรมที่เห็นกับตอน handler ทำงาน
|
||||
→ ส่งเฟสหลังวาป = server คิดว่าเยื้องสุดแกว่ง → "วางกลางพอดีแต่ลงเยื้อง/ร่วง") */
|
||||
let stackRenderedPhase = null;
|
||||
/* freeze เฟสที่เห็น ณ ตอนกดวาง ไว้จน echo ดรอปกลับมา → บล็อกไม่แกว่งต่อแล้วเด้งกลับช่วงรอ RTT */
|
||||
let stackLocalSwingFrozen = false;
|
||||
let stackLocalSwingFrozenAt = 0;
|
||||
@@ -3708,7 +3734,7 @@
|
||||
/** ดาเมจต่อการยิงโดนบอส (ลบเลือดบอส/นัด) — Admin Minigame-7 · แยกจากคะแนน · default 10 */
|
||||
let playBalloonBossDamagePerHit = 10;
|
||||
let playBalloonBossBossImageUrl = '';
|
||||
let playBalloonBossPlayerBalloonImageUrls = ['', '', '', '', '', ''];
|
||||
let playBalloonBossPlayerBalloonImageUrls = ['', '', '', '', '', '', '', '']; /* [2026-07-17] 8 ช่อง (theme 8 สี) — เดิม 6 ตัด balloon-7 (ชมพู) ทิ้ง */
|
||||
let playBalloonBossPlayerBalloonFallbackUrl = '';
|
||||
/** 0 = ไม่จำกัด — จาก game-timing / gauntlet-sync (พรมแดง Gauntlet เท่านั้น) */
|
||||
let gauntletRuntimeTimeLimitSec = 0;
|
||||
@@ -5089,7 +5115,7 @@
|
||||
clearQuizMapPanelThemeInline(panel, textEl);
|
||||
return;
|
||||
}
|
||||
const qmCenter = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
|
||||
const qmCenter = isQuizQuestionMissionRenderLockPlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
|
||||
const carryCam = !qmCenter && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null;
|
||||
const camX = qmCenter ? qmCenter.cx : (carryCam ? carryCam.cx : me.x * tileSize);
|
||||
const camY = qmCenter ? qmCenter.cy : (carryCam ? carryCam.cy : me.y * tileSize);
|
||||
@@ -5997,7 +6023,14 @@
|
||||
if (!o) return;
|
||||
o.playTint = playTintForPreviewBotId(bid) || resolvePlayTintForPeer(bid);
|
||||
if (o.botTier == null) {
|
||||
const tierRoll = Math.random();
|
||||
/* [2026-07-17] เดิมเป็น Math.random() = "สุ่มใหม่ทุกเครื่อง" → ป้าย (ฉลาด/กลาง/พลาดบ่อย) ของบอทตัว
|
||||
เดียวกันไม่ตรงกันข้ามจอ (user เจอทั้ง MG2 และ MG7). path host-auth adopt rb.tier ทับให้ทีหลังอยู่แล้ว
|
||||
แต่ path server-auth (MG5/6/7) ไม่เคยส่ง tier ลงมา → ค่าสุ่มนี้อยู่ยาวจนจบเกม.
|
||||
แก้ที่ต้นทาง: derive จาก slot ใน botId (`__pv_bot_<slot>` เหมือนกันทุกเครื่อง) ด้วย "สูตรเดียวกับ
|
||||
gauntletBotTierForSlot ของ server (server.js:8209)" → ทุกเครื่องได้ค่าเท่ากัน และตรงกับ tier จริง
|
||||
ที่ server ใช้เดินบอท MG2 ด้วย (เดิม client สุ่มเอง = ป้ายไม่ตรงพฤติกรรมตั้งแต่แรก) */
|
||||
const slot = parseInt(String(bid).slice(PREVIEW_BOT_PREFIX.length), 10);
|
||||
const tierRoll = Number.isFinite(slot) ? ((slot * 73 + 17) % 100) / 100 : 0.5;
|
||||
const stackPrev = mapData && mapData.gameType === 'stack';
|
||||
o.botTier = stackPrev
|
||||
? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak')
|
||||
@@ -6052,7 +6085,12 @@
|
||||
x = sp.x + 0.5 + jx;
|
||||
y = sp.y + 0.5 + jy;
|
||||
}
|
||||
const tierRoll = Math.random();
|
||||
/* [2026-07-17] ห้าม Math.random() — นี่คือ path ที่ "สร้าง" บอทจริง (ตัว backfill ที่ 6013 แทบไม่เคย
|
||||
ถูกเรียกเพราะตอนสร้างเซ็ต tier ให้เสมอ) → สุ่มตรงนี้ = ป้าย 'บอท N (ฉลาด)' ไม่ตรงกันข้ามเครื่อง.
|
||||
nextSlot ข้างบน deterministic อยู่แล้ว (slot ว่างเล็กสุด 0,1,2… ทุกเครื่องได้ชุดเดียวกัน — เป็น
|
||||
ข้อกำหนดเดิมอยู่แล้วเพราะ idx ต้องตรง playLobbyBotThemes ให้สีตรง lobby) → derive tier จากมัน
|
||||
ด้วยสูตรเดียวกับ server gauntletBotTierForSlot (server.js:8209) */
|
||||
const tierRoll = ((nextSlot * 73 + 17) % 100) / 100;
|
||||
const stackPrev = mapData && mapData.gameType === 'stack';
|
||||
const botTier = stackPrev
|
||||
? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak')
|
||||
@@ -6740,7 +6778,7 @@
|
||||
→ fit ความกว้างเต็มจอ (vertical scroller เลื่อนแนวตั้งด้วย jumpSurviveCamCenterY อยู่แล้ว) */
|
||||
const mwPx = w * tileSize;
|
||||
if (mwPx > 0) zDraw = canvas.width / mwPx;
|
||||
} else if (isQuizQuestionMissionHudActivePlay()) {
|
||||
} else if (isQuizQuestionMissionRenderLockPlay()) {
|
||||
const qmc = getQuizQuestionMissionMapCenterWorldPxPlay();
|
||||
if (qmc) {
|
||||
/* ซูมออกเล็กน้อยให้เห็นพื้นหลัง/โซน SAFE-SCAM ครบแบบ mock (0.96 เคย crop ขอบล่างบนจอแคบ) */
|
||||
@@ -8044,6 +8082,27 @@
|
||||
hideConflictingOverlaysForGauntletCrown();
|
||||
}
|
||||
|
||||
/* [2026-07-17] MG5 ช่วงท้ายเลื่อนเร็วขึ้น ×2 (user request) — 15 วิสุดท้าย.
|
||||
⚠️ ต้องตรงกับ server เป๊ะ (server.js:jsScrollPxAt · ค่าคงที่ชุดเดียวกัน) ไม่งั้นแท่นคนละที่ = ชนไม่ตรงกัน */
|
||||
const JS_ENDGAME_BOOST_SEC = 15;
|
||||
const JS_ENDGAME_BOOST_MUL = 2;
|
||||
function jsBoostStartSecPlay() {
|
||||
const total = jumpSurviveTimeLimitSecForMission();
|
||||
return Math.max(0, (Number(total) || 0) - JS_ENDGAME_BOOST_SEC);
|
||||
}
|
||||
function jsScrollPxAtPlay(elapsedSec, risePerSec) {
|
||||
const total = jumpSurviveTimeLimitSecForMission();
|
||||
const t0 = jsBoostStartSecPlay();
|
||||
if (!(Number(total) > 0) || elapsedSec <= t0) return elapsedSec * risePerSec;
|
||||
return (t0 * risePerSec) + ((elapsedSec - t0) * risePerSec * JS_ENDGAME_BOOST_MUL);
|
||||
}
|
||||
/** ความเร็วเลื่อน "ณ ตอนนี้" — ใช้กับคนที่ยืนบนแท่น (ต้องจมตามแท่น ไม่งั้นพอ boost แท่นหนีลงไปคนลอยค้าง) */
|
||||
function jsRiseVelNowPlay(md) {
|
||||
const v = Number(md && md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42;
|
||||
if (!(jumpSurviveLiveAnchorServerMs > 0)) return v;
|
||||
const el = Math.max(0, (serverNow() - jumpSurviveLiveAnchorServerMs) / 1000);
|
||||
return el > jsBoostStartSecPlay() ? v * JS_ENDGAME_BOOST_MUL : v;
|
||||
}
|
||||
function jumpSurviveTimeLimitSecForMission() {
|
||||
const m = Number(mapData && mapData.jumpSurviveTimeSec);
|
||||
if (Number.isFinite(m) && m > 0 && m < 7200) return Math.floor(m);
|
||||
@@ -8998,6 +9057,17 @@
|
||||
function isQuizQuestionMissionHudActivePlay() {
|
||||
return isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live';
|
||||
}
|
||||
/* [2026-07-17] เฉพาะ "การเรนเดอร์" (กล้อง+zoom) ของ MG1 — ต้องค้างเท่าตอนเล่นต่อไปแม้จบเกมแล้ว
|
||||
(user: "ตอนจบเกม ฉากเกมไม่เท่าเดิมกับตอนเล่น"). ต้นเหตุ: zoom/กล้องเดิมผูกกับ
|
||||
isQuizQuestionMissionHudActivePlay ที่เป็น phase==='live' → พอจบ phase='ended' branch หลุด
|
||||
→ zDraw ตกไปใช้ `zoom` ดิบ + กล้องเด้งกลับไปตาม me = ฉากเปลี่ยนขนาดทันที.
|
||||
เกมอื่นไม่มีอาการนี้เพราะ zoom ผูกกับ "แมป" ไม่ใช่ "เฟส".
|
||||
แยกตัวนี้ออกมาเพราะ predicate เดิมคุม HUD/อินพุตอีก ~18 ที่ ซึ่ง "ต้องดับตอนจบ" ตามเดิม.
|
||||
ล็อกแค่ live|ended → howto/countdown คงพฤติกรรมเดิมเป๊ะ (ไม่มีใครบ่นถึง) */
|
||||
function isQuizQuestionMissionRenderLockPlay() {
|
||||
return isQuizQuestionMissionUiMapPlay()
|
||||
&& (quizQuestionMissionPhase === 'live' || quizQuestionMissionPhase === 'ended');
|
||||
}
|
||||
|
||||
/** ภารกิจคำถาม mng8a80o ช่วง live: กล้องกลางแมป (world px) — ไม่ตาม me */
|
||||
function getQuizQuestionMissionMapCenterWorldPxPlay() {
|
||||
@@ -11622,13 +11692,19 @@
|
||||
playTint: (oh && (oh.playTint || resolvePlayTintForPeer(String(entry.id)))) || resolvePlayTintForPeer(String(entry.id)),
|
||||
};
|
||||
}
|
||||
/* ตาบอท → หน้า/สีบอท */
|
||||
/* ตาบอท → หน้า/สีบอท
|
||||
บอท takeover (คนหลุดช่วง how-to) ใช้ socket.id เดิมของคนนั้นเป็น botId แต่ 'user-left' ลบเขาออกจาก
|
||||
others ไปแล้ว → others.get(botId) = null → เดิมได้ characterId null = อวตารเขียว (defaultAvatarImg).
|
||||
ตอนนี้ server ส่งหน้า/สีเดิมมากับ order row แล้ว → fallback มาใช้ค่านั้นแทน */
|
||||
const o = entry.botId ? others.get(entry.botId) : null;
|
||||
const cid = (o && o.characterId != null) ? o.characterId : (entry.characterId != null ? entry.characterId : null);
|
||||
return {
|
||||
id: entry.botId,
|
||||
characterId: o ? o.characterId : null,
|
||||
characterId: cid,
|
||||
isMe: false,
|
||||
playTint: o ? (o.playTint || resolvePlayTintForPeer(entry.botId)) : null,
|
||||
playTint: (o && (o.playTint || resolvePlayTintForPeer(entry.botId)))
|
||||
|| resolvePlayTintForPeer(entry.botId, entry) /* entry มี lobbyColorThemeIndex/lobbySkinToneIndex */
|
||||
|| null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12377,6 +12453,7 @@
|
||||
stackServerAuthPlay = false; /* re-arm: server จะเปิดใหม่ผ่าน stack-state(srv) เมื่อรอบใหม่เริ่ม */
|
||||
stackSrvPhaseAnchor = 0;
|
||||
stackSrvPhaseAnchorAt = 0;
|
||||
stackRenderedPhase = null; /* รอบใหม่ → ทิ้งเฟสที่วาดไว้รอบก่อน (fallback = stackMini.phase จนกว่าจะวาดเฟรมแรก) */
|
||||
stackLocalSwingFrozen = false;
|
||||
stackLocalSwingFrozenAt = 0;
|
||||
if (!mapData || !isStack()) return;
|
||||
@@ -12811,8 +12888,12 @@
|
||||
function tryHumanStackDrop() {
|
||||
if (!stackHumanDropGateOkPlay()) return false;
|
||||
if (stackServerAuthPlay) {
|
||||
/* server-auth: ส่งเจตนากดวาง + เฟสที่เราเห็น ณ ตอนนี้ → server วางตรงจุดที่เราเห็นแกว่ง (ไม่เยื้องตาม half-RTT) */
|
||||
try { socket.emit('stack-drop-req', { phase: stackMini.phase }); } catch (e) { /* ignore */ }
|
||||
/* server-auth: ส่งเจตนากดวาง + เฟสที่เราเห็น ณ ตอนนี้ → server วางตรงจุดที่เราเห็นแกว่ง (ไม่เยื้องตาม half-RTT)
|
||||
ใช้ "เฟสที่วาดออกจอเฟรมล่าสุด" ไม่ใช่ stackMini.phase ปัจจุบัน — กันเคส SNAP (dPh>0.9) แทรกระหว่าง
|
||||
เฟรมที่ผู้เล่นเห็นกับตอน handler นี้ทำงาน แล้วส่งเฟสหลังวาปไป = เห็นตรงกลางแต่ server ตัดสินว่าเยื้อง/ร่วง */
|
||||
const dropPhase = (typeof stackRenderedPhase === 'number' && Number.isFinite(stackRenderedPhase))
|
||||
? stackRenderedPhase : stackMini.phase;
|
||||
try { socket.emit('stack-drop-req', { phase: dropPhase }); } catch (e) { /* ignore */ }
|
||||
stackMini.lastDropAt = Date.now(); /* กัน double-tap (server มี busyUntil อยู่แล้ว) */
|
||||
/* freeze เฟสที่เห็น ณ ตอนกด ไว้จนดรอปกลับมา → ไม่แกว่งต่อแล้วเด้งกลับช่วงรอ echo */
|
||||
stackLocalSwingFrozen = true;
|
||||
@@ -13177,6 +13258,7 @@
|
||||
const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize;
|
||||
blockLeftWorld = swingXW - twWorld / 2;
|
||||
blockTopWorld = swingDrawY;
|
||||
stackRenderedPhase = m.phase; /* จำเฟสที่เพิ่งวาด = เฟสที่ผู้เล่นเห็นจริง (ใช้ตอนกดวาง) */
|
||||
}
|
||||
const cxMid = blockLeftWorld + twWorld / 2;
|
||||
const [tcx, tcy] = worldToScreen(craneX, cableTopY);
|
||||
@@ -13467,9 +13549,12 @@
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossPlayerBalloonImageUrls')) {
|
||||
const a = payload.balloonBossPlayerBalloonImageUrls;
|
||||
const nextBb = ['', '', '', '', '', ''];
|
||||
/* [2026-07-17] เก็บ 8 ช่อง (ตาม lobby theme 8 สี) — เดิม 6 ช่อง (loop si<6) ตัด index 7 (balloon-7
|
||||
ชมพู theme 8) ทิ้งตั้งแต่รับ payload → theme 8 ได้ช่องว่าง → วาดสีแบนแทน PNG (user เจอ: MONE
|
||||
ตัวชมพูได้ลูกโป่งสีแบน ไม่ใช่รูป balloon-7). server ส่ง 8 มาแล้ว (game-timing config) แค่ client ตัด */
|
||||
const nextBb = ['', '', '', '', '', '', '', ''];
|
||||
if (Array.isArray(a)) {
|
||||
for (let si = 0; si < 6; si++) {
|
||||
for (let si = 0; si < 8; si++) {
|
||||
const u = normalizeGauntletAssetUrlForPlay(typeof a[si] === 'string' ? a[si] : '');
|
||||
nextBb[si] = u;
|
||||
if (u) ensureGauntletAssetImage(u);
|
||||
@@ -13544,7 +13629,12 @@
|
||||
เรียกทุกเฟรมจาก draw() (ส่ง worldToScreen/zDraw/tileSize เข้ามา) — container map buffer→display ด้วย transform:scale */
|
||||
const gauntletDomLabelPool = new Map(); /* id -> .gnt-label (pool ไว้ reuse) */
|
||||
const gauntletPenaltySeenById = new Map(); /* id -> ts penalty ล่าสุด (กัน spawn float ซ้ำ) */
|
||||
function syncGauntletNameDom(worldToScreenFn, zDrawVal, tileSizePx) {
|
||||
/* [2026-07-17] skipAvatarFn ต้องส่งเข้ามา ห้ามอ้างชื่อตรงๆ — shouldGauntletCrownHeistSkipAvatarDrawPlay
|
||||
นิยามอยู่ "ข้างใน draw()" ส่วนตัวนี้อยู่ module scope = คนละสโคป. ของเดิมเขียน
|
||||
`typeof shouldGauntletCrownHeistSkipAvatarDrawPlay === 'function'` ซึ่ง typeof บน identifier ที่ไม่มี
|
||||
ในสโคป "คืน 'undefined' เงียบๆ ไม่ throw" → guard เป็น false ตลอด → ชื่อคนตาย/คนหลุดจอค้างถาวร
|
||||
(เหตุผลเดียวกับที่ worldToScreen/zDraw/tileSize ถูกส่งเป็นพารามิเตอร์อยู่แล้ว) */
|
||||
function syncGauntletNameDom(worldToScreenFn, zDrawVal, tileSizePx, skipAvatarFn) {
|
||||
const ov = document.getElementById('gauntlet-name-overlay');
|
||||
if (!ov) return;
|
||||
const live = isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live';
|
||||
@@ -13574,8 +13664,7 @@
|
||||
const seen = new Set();
|
||||
function place(id, ent, isMe) {
|
||||
if (!ent) return;
|
||||
if (typeof shouldGauntletCrownHeistSkipAvatarDrawPlay === 'function'
|
||||
&& shouldGauntletCrownHeistSkipAvatarDrawPlay(ent, isMe, 0, 0)) return;
|
||||
if (typeof skipAvatarFn === 'function' && skipAvatarFn(ent, isMe, 0, 0)) return;
|
||||
seen.add(id);
|
||||
const air = isMe ? (meGauntletJumpVis || 0) : ((ent.gauntletJumpVis != null ? ent.gauntletJumpVis : ent.gauntletJumpTicks) || 0);
|
||||
const liftWorldY = air > 0 ? gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSizePx * gauntletRuntimeJumpHeightMult : 0;
|
||||
@@ -13583,8 +13672,29 @@
|
||||
const cyBottomWorld = (v(ent.y) + chv) * tileSizePx - liftWorldY;
|
||||
const scr = worldToScreenFn(cxWorld, cyBottomWorld);
|
||||
const sx = scr[0], sy = scr[1];
|
||||
/* ชื่อ/คะแนนใต้ตัวละคร: ตัดออกตาม mock — mock โชว์ชื่อ+คะแนนเฉพาะ "แถบคะแนนบน" เท่านั้น ไม่มีใต้ตัวละคร
|
||||
(กันดูเหมือน 2 layer ทับกัน) — คงไว้เฉพาะ -10 penalty float ที่ mock มี */
|
||||
/* หัว = จุดบนตัวละคร (y ไม่บวก chv) → ใช้วางชื่อ "เหนือหัว" */
|
||||
const cyHeadWorld = (v(ent.y)) * tileSizePx - liftWorldY;
|
||||
const scrHead = worldToScreenFn(cxWorld, cyHeadWorld);
|
||||
const syHead = scrHead[1];
|
||||
/* [2026-07-16] ชื่อMG2 = "เหนือหัว" (user request). แก้จากเดิมที่ผมวางผิดไว้ที่ sy+6 = เท้า.
|
||||
ทำที่ DOM overlay ไม่ใช่ canvas: overlay project ตาม runway camera ให้อยู่แล้ว
|
||||
(canvas text เคยเพี้ยนตอน summary/howto จนต้องปิดทุกเฟส) และ **ไม่ใส่ .gnt-sc (คะแนน)**
|
||||
เพราะคะแนนยังอยู่แถบบนที่เดียวตาม mock — ใส่ทั้งคู่คือสิ่งที่เคยทับกันมาก่อน */
|
||||
const nm = String((ent && ent.nickname) || '').trim();
|
||||
if (nm) {
|
||||
let lb = gauntletDomLabelPool.get(id);
|
||||
if (!lb) {
|
||||
lb = document.createElement('div');
|
||||
lb.className = 'gnt-label';
|
||||
ov.appendChild(lb);
|
||||
gauntletDomLabelPool.set(id, lb);
|
||||
}
|
||||
if (lb.textContent !== nm) lb.textContent = nm;
|
||||
lb.classList.toggle('is-out', !!ent.gauntletEliminated);
|
||||
lb.style.left = Math.round(sx) + 'px';
|
||||
/* เหนือหัว: .gnt-label top-anchored (grow ลง) → ยกขึ้นเหนือ syHead ~18px ให้ลอยเหนือหัว */
|
||||
lb.style.top = Math.round(syHead - 18) + 'px';
|
||||
}
|
||||
const pen = Number(ent.gauntletCrownPenaltyFxUntil) || 0;
|
||||
/* -10 float ต้องอยู่ "ใต้แถบคะแนนบน" เท่านั้น — กันผู้เล่นที่จุดเริ่ม (project ใกล้แถบคะแนน เช่น AFK) ทำ float โผล่ทับ SCORE label */
|
||||
var sbEl = document.querySelector('.play-cyber-scoreboard');
|
||||
@@ -13608,6 +13718,14 @@
|
||||
}
|
||||
if (others && typeof others.forEach === 'function') others.forEach((o, id) => place(id, o, false));
|
||||
place('__me', me, true);
|
||||
/* เก็บกวาดป้ายของคนที่ไม่อยู่ในเฟรมนี้แล้ว (ออกจากห้อง / โดนข้ามด้วย shouldSkipAvatarDraw เช่น คนโดนแบน)
|
||||
— `seen` ถูกเก็บไว้ตั้งแต่เดิมแต่ไม่มีใครใช้ (dead code ค้างจากตอนที่ยังมีชื่อ) ถ้าไม่ prune
|
||||
ป้ายจะค้างลอยอยู่กลางจอตลอดกาล เพราะ pool reuse ตาม id */
|
||||
gauntletDomLabelPool.forEach((el, id) => {
|
||||
if (seen.has(id)) return;
|
||||
if (el && el.parentNode) el.parentNode.removeChild(el);
|
||||
gauntletDomLabelPool.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
function getGauntletObsDrawPositionsAt(nowMs) {
|
||||
@@ -14085,14 +14203,25 @@
|
||||
var key = ids.join('|');
|
||||
if (box._gchReadyKey !== key) {
|
||||
box._gchReadyKey = key;
|
||||
box.innerHTML = '';
|
||||
ids.forEach(function (id) {
|
||||
var cell = document.createElement('div');
|
||||
/* incremental diff — คง cell เดิมที่ยังอยู่ไว้ (canvas วาดแล้ว ไม่ต้อง repaint = ไม่กระพริบ/ไม่หาย)
|
||||
ลบเฉพาะ id ที่หาย, เพิ่มเฉพาะ id ใหม่, จัดลำดับด้วย insertBefore (ย้าย DOM ไม่ล้าง canvas) */
|
||||
var existing = {};
|
||||
var kids = Array.prototype.slice.call(box.children);
|
||||
for (var k = 0; k < kids.length; k++) {
|
||||
var kmid = kids[k].dataset ? kids[k].dataset.mid : null;
|
||||
if (kmid != null && ids.indexOf(kmid) !== -1 && !existing[kmid]) existing[kmid] = kids[k];
|
||||
else box.removeChild(kids[k]); /* id หายไป หรือ cell ซ้ำ → ตัดทิ้ง */
|
||||
}
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var id = ids[i];
|
||||
var cell = existing[id];
|
||||
var isNew = !cell;
|
||||
if (isNew) { cell = document.createElement('div'); cell.dataset.mid = id; }
|
||||
cell.className = 'gch-ready-av' + (id === String(myId) ? ' gch-ready-av--me' : '');
|
||||
cell.dataset.mid = id;
|
||||
box.appendChild(cell);
|
||||
renderReadyAvatarInto(cell, id);
|
||||
});
|
||||
var atPos = box.children[i];
|
||||
if (atPos !== cell) box.insertBefore(cell, atPos || null); /* ย้ายตำแหน่ง — canvas คงอยู่ */
|
||||
if (isNew) renderReadyAvatarInto(cell, id); /* วาดหลังใส่ DOM แล้ว (paint ต้อง el.isConnected) */
|
||||
}
|
||||
}
|
||||
if (!ids.length) { box.style.display = 'none'; return; }
|
||||
/* วางใต้ปุ่มจริง (viewport coords — fixed บน body ไม่โดน transform ของ howto) */
|
||||
@@ -14104,6 +14233,12 @@
|
||||
/** crown-lobby games (MG1,2,3,5,6,7) — ใช้ gauntletCrownLobbyReadyMap + ปุ่มของ layout ปัจจุบัน */
|
||||
function updateGauntletCrownReadyAvatars() {
|
||||
var btn = (typeof getGauntletCrownHowtoBtnEl === 'function') ? getGauntletCrownHowtoBtnEl() : document.getElementById('btn-gch-ready');
|
||||
/* กล่องแถวหน้า ready (#gch-ready-avatars-float) ใช้ร่วมกันทุกเกม → ต้องแตะเฉพาะตอน "จอเป็นของ crown" จริง.
|
||||
ตอนเล่น MG4 (ใช้ #quiz-carry-pregame-primary) ปุ่ม crown ยังอยู่ใน DOM แต่ซ่อน (offsetW=0) — ถ้าปล่อยให้
|
||||
เรียกต่อ updateReadyAvatarsFloat จะเจอ visible=false แล้วสั่ง display:none ทับกล่องที่ MG4 เพิ่งโชว์
|
||||
(cells/key ยังอยู่ครบ แต่มองไม่เห็น) = อาการ "MG4 กด ready แล้วหน้าตัวละครไม่ขึ้น".
|
||||
ไม่ใช่เจ้าของจอ → ไม่ทำอะไรเลย (ตอนปิด how-to มี hideReadyAvatarsFloat ซ่อนให้อยู่แล้ว) */
|
||||
if (!btn || (btn.offsetWidth <= 0 && btn.getClientRects().length === 0)) return;
|
||||
updateReadyAvatarsFloat(gauntletCrownLobbyReadyMap, btn);
|
||||
}
|
||||
|
||||
@@ -14183,6 +14318,11 @@
|
||||
balloonBossLastTickMs = performance.now();
|
||||
/* anchor = liveBeginsAt server → boss fire + timer อ้างอิงจุดเดียวกันทุกเครื่อง */
|
||||
balloonBossLiveAnchorServerMs = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : serverNow();
|
||||
/* [P5 v4] ล้าง anchor/นาฬิกา server ของรอบก่อน — ไม่งั้นวงลูกศรรอบใหม่คำนวณจากฐานเวลาเกมที่จบไปแล้ว
|
||||
(bot-move นัดแรกของรอบใหม่จะเซ็ตให้ภายใน ~50ms) */
|
||||
balloonBossRingAnchorSrvMs = 0;
|
||||
balloonBossSrvClockBase = 0;
|
||||
balloonBossSrvClockLocalAt = 0;
|
||||
balloonBossBossShotCount = 0;
|
||||
balloonBossGameEnded = false;
|
||||
balloonBossPendingShots = [];
|
||||
@@ -14672,9 +14812,22 @@
|
||||
ยังเดินตาม serverNow และหลัง freeze จะคำนวณ backlog ยิงกระสุนรัวออกมา (กระสุนกระจาย) */
|
||||
try { if (typeof jumpSurviveLiveAnchorServerMs === 'number' && jumpSurviveLiveAnchorServerMs > 0) jumpSurviveLiveAnchorServerMs += deltaMs; } catch (e) {}
|
||||
try { if (typeof spaceShooterLiveAnchorServerMs === 'number' && spaceShooterLiveAnchorServerMs > 0) spaceShooterLiveAnchorServerMs += deltaMs; } catch (e) {}
|
||||
try { if (typeof balloonBossLiveAnchorServerMs === 'number' && balloonBossLiveAnchorServerMs > 0) balloonBossLiveAnchorServerMs += deltaMs; } catch (e) {}
|
||||
/* [v5 2026-07-17] MG7: ถ้ามี anchor จาก server แล้ว "ห้ามเลื่อนเอง" — server เลื่อนให้ตอน pause อยู่แล้ว
|
||||
(ringAnchorMs) แล้วส่งมาทุก 50ms. เลื่อนซ้ำ = นับสองเด้ง + กลับไปเป็น state ต่อเครื่อง = TIME คนละเลข.
|
||||
MG5/MG6 (jumpSurvive/spaceShooter) ไม่มี anchor จาก server จึงยังต้องเลื่อนเองเหมือนเดิม */
|
||||
try {
|
||||
if (typeof balloonBossLiveAnchorServerMs === 'number' && balloonBossLiveAnchorServerMs > 0
|
||||
&& !(balloonBossRingAnchorSrvMs > 0)) balloonBossLiveAnchorServerMs += deltaMs;
|
||||
} catch (e) {}
|
||||
/* [mg3] MG3 stack swing phase anchor (serverNow-based) ก็ต้องเลื่อนด้วย — ไม่งั้น clock re-sync ทุก 12วิ → serverNow กระโดด → targetPhase กระโดด → ตัวห้อยกระตุก */
|
||||
try { if (typeof stackSrvPhaseAnchorAt === 'number' && stackSrvPhaseAnchorAt > 0) stackSrvPhaseAnchorAt += deltaMs; } catch (e) {}
|
||||
/* MG3 TIME ยึด playMissionLiveBeginsAt (serverNow − begin) → ต้องเลื่อนด้วย ไม่งั้นเวลา MG3 เดินต่อตอนคำถามพิเศษ
|
||||
(MG5/6/7 ใช้ anchor ของตัวเอง = jumpSurvive/spaceShooter/balloonBossLiveAnchorServerMs ที่เลื่อนแล้ว ไม่กระทบ) */
|
||||
try { if (typeof playMissionLiveBeginsAt === 'number' && playMissionLiveBeginsAt > 0) playMissionLiveBeginsAt += deltaMs; } catch (e) {}
|
||||
/* [2026-07-17] MG2 ไม่ต้องเลื่อนเอง — คอมเมนต์เดิมบอกว่า server "ไม่ re-broadcast" ซึ่งไม่จริง:
|
||||
runGauntletTick เรียก emitGauntletSync ทุก tick "รวมตอน pause" (server.js:10190) และ payload มี
|
||||
gauntletEndsAt เสมอ → applyGauntletTimingFromServer adopt ให้อยู่แล้ว. บวก deltaMs ทับ = นับสองเด้ง
|
||||
+ กลับไปเป็นเลขต่อเครื่อง. ปล่อยให้ server เป็นเจ้าของเวลาตัวเดียว = ทุกจอตรงกัน */
|
||||
}
|
||||
|
||||
/** เรียกทุกเฟรมระหว่าง freeze — ดันเวลาไปข้างหน้าเท่าที่ผ่านไปจริง ทำให้ countdown หยุดนิ่ง */
|
||||
@@ -17925,14 +18078,66 @@
|
||||
return (h % 10000) / 10000 * Math.PI * 2;
|
||||
}
|
||||
|
||||
/* [P5 v2 2026-07-16] ฐานเวลาของวงลูกศร — sync จาก "นาฬิกา host" ไม่ใช่ serverNow() ของแต่ละเครื่อง.
|
||||
ปัญหาที่ user เจอ: pin แก้ตอนยิงได้ แต่ "การหมุนต่อเนื่อง" ยัง desync (วงๆ ไม่ตรงกันข้ามจอ) เพราะ
|
||||
serverNow() = Date.now()+playClockOffset ต่างต่อเครื่อง. วิธีแก้: host ส่ง t=serverNow() ของตัวเองมาใน
|
||||
balloon-boss-sync (~7Hz) → non-host เก็บ base+perf.now() แล้ว extrapolate ด้วยเวลา local (perf.now
|
||||
เดินอัตราเดียวกับ Date.now) → ทุกวง (ของตัวเอง+peer) คำนวณจากนาฬิกา host เดียว = ตรงกับ host และตรงกันเอง
|
||||
ต่อเนื่อง ไม่ drift (re-align ทุก 140ms). host ใช้ serverNow ตัวเอง (มันคือ authority). */
|
||||
let balloonBossHostClockBase = 0; /* host serverNow ล่าสุด (จาก balloon-boss-sync, โหมด non-server-auth) */
|
||||
let balloonBossHostClockLocalAt = 0;
|
||||
let balloonBossSrvClockBase = 0; /* [P5 v3] server Date.now ล่าสุด (จาก balloon-boss-bot-move, โหมด server-auth) */
|
||||
let balloonBossSrvClockLocalAt = 0;
|
||||
/* [P5 v4 2026-07-17] anchor ของ server (pause-adjusted, จาก bot-move ทุก 50ms) — "ห้าม" เอาไปเลื่อนเอง
|
||||
ใน shiftSpecialQuizFrozenTimers เด็ดขาด นั่นคือบั๊กที่ v2/v3 แก้ไม่หาย: ตัวที่เพี้ยนคือ anchor ไม่ใช่นาฬิกา */
|
||||
let balloonBossRingAnchorSrvMs = 0;
|
||||
function balloonBossRingNowMs() {
|
||||
/* [P5 v3] server-auth (เกมจริง MG7): ทุกเครื่อง "รวม host" ใช้ฐานเวลา server (จาก bot-move) —
|
||||
host ไม่ใช่ authority ของ ring ในโหมดนี้ (server คือ). extrapolate ด้วยเวลา local (perf.now) */
|
||||
if (typeof balloonBossServerAuthActive === 'function' && balloonBossServerAuthActive() && balloonBossSrvClockBase > 0) {
|
||||
return balloonBossSrvClockBase + (performance.now() - balloonBossSrvClockLocalAt);
|
||||
}
|
||||
/* non-server-auth: host เป็น authority (serverNow ตัวเอง); non-host ใช้ host clock (จาก sync) */
|
||||
if (typeof isMePlayHost === 'function' && isMePlayHost()) return serverNow();
|
||||
if (balloonBossHostClockBase > 0) return balloonBossHostClockBase + (performance.now() - balloonBossHostClockLocalAt);
|
||||
return serverNow();
|
||||
}
|
||||
|
||||
/* มุมลูกศรหมุนแบบ deterministic จากเวลา server + เฟสต่อ id → "วงๆ" หมุนตรงกันทุกเครื่อง
|
||||
(เดิมสุ่ม init + บวก dt ต่อเครื่อง → คนละมุม) ความเร็ว 1.02 rad/s */
|
||||
function balloonBossAutoAimRadPlay(id) {
|
||||
const tMs = balloonBossLiveAnchorServerMs > 0 ? (serverNow() - balloonBossLiveAnchorServerMs) : serverNow();
|
||||
const nowMs = balloonBossRingNowMs();
|
||||
/* [P5 v4] server-auth (เกมจริง MG7): ใช้ "นาฬิกา server − anchor ของ server" = ค่าเดียวกันเป๊ะทุกเครื่อง.
|
||||
เดิมลบด้วย balloonBossLiveAnchorServerMs ซึ่งเป็น state ต่อเครื่อง (เลื่อนเองตอนคำถามพิเศษ + fallback
|
||||
เป็น serverNow() ถ้า playMissionLiveBeginsAt ยังไม่มา) → คนละมุม และไม่มีทางกลับมาตรงกัน.
|
||||
นอกโหมด server-auth ยังใช้ของเดิม (host เป็น authority + adopt ผ่าน balloon-boss-sync) */
|
||||
const srvAuth = typeof balloonBossServerAuthActive === 'function' && balloonBossServerAuthActive();
|
||||
const anchor = (srvAuth && balloonBossRingAnchorSrvMs > 0)
|
||||
? balloonBossRingAnchorSrvMs
|
||||
: balloonBossLiveAnchorServerMs;
|
||||
const tMs = anchor > 0 ? (nowMs - anchor) : nowMs;
|
||||
const a = (tMs * 0.00102) + balloonBossAimPhaseForIdPlay(id);
|
||||
return a % (Math.PI * 2);
|
||||
}
|
||||
|
||||
/* [P5 2026-07-16] แก้ "ลูกศรกับกระสุนออกไม่ตรงกัน" บนจอคนดู.
|
||||
ลูกศรวาดจาก balloonBossAutoAimRadPlay = (serverNow()-anchor)*0.00102 + phase(id) — serverNow มี
|
||||
playClockOffset ต่อเครื่อง; กระสุน peer มาทาง broadcast (มุมที่เจ้าของคำนวณด้วย offset ของตัวเอง)
|
||||
+ กว่ากระสุนจะมาถึง (latency) ลูกศรก็หมุนต่อ → กระสุนโผล่ไม่ตรงปลายลูกศร (ยิ่ง offset/latency มาก
|
||||
ยิ่งเยื้อง; 100ms ≈ 0.1rad). fix: ตอน peer เห็นเจ้าของยิง "pin" ลูกศรของ peer นั้นไปที่มุมกระสุน
|
||||
สั้น ๆ (จังหวะที่ตาจับ launch จริง) → ลูกศร+กระสุนตรงกันตอนยิงบนทุกจอ. ระหว่างยิงลูกศรหมุนอิสระเหมือนเดิม.
|
||||
pin ~220ms > 1 เฟรมการมองเห็น แต่สั้นพอไม่สะดุด (ที่ 1.02rad/s = ค้าง ~13°) */
|
||||
const balloonBossAimPinById = new Map(); /* id -> { rad, until } (until = serverNow ms) */
|
||||
function balloonBossDisplayAimRadPlay(id) {
|
||||
const pin = balloonBossAimPinById.get(String(id));
|
||||
if (pin && serverNow() < pin.until) return pin.rad;
|
||||
return balloonBossAutoAimRadPlay(id);
|
||||
}
|
||||
function balloonBossPinAimForShot(id, rad) {
|
||||
if (id == null || !Number.isFinite(rad)) return;
|
||||
balloonBossAimPinById.set(String(id), { rad: ((rad % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2), until: serverNow() + 220 });
|
||||
}
|
||||
|
||||
function getBalloonBossBossLiveCenterPlay(md, ts) {
|
||||
if (!md || md.gameType !== 'balloon_boss') return getBalloonBossBossWorldCenterPlay(md, ts);
|
||||
const base = getBalloonBossBossWorldCenterPlay(md, ts);
|
||||
@@ -18294,7 +18499,7 @@
|
||||
/* host เป็นผู้จำลองบอท → broadcast กระสุนบอทให้ client อื่นเห็นด้วย */
|
||||
try {
|
||||
if (socket && myId != null) {
|
||||
socket.emit('balloon-boss-shot', { sx: botSx, sy: botSy, vx: bvx, vy: bvy, ownerId: bid, t0: serverNow() });
|
||||
socket.emit('balloon-boss-shot', { sx: botSx, sy: botSy, vx: bvx, vy: bvy, ownerId: bid, t0: serverNow(), aim });
|
||||
}
|
||||
} catch (eB) { /* ignore */ }
|
||||
/* สเปก MG7: บอทยิง 1 ครั้ง = −5 คะแนน เหมือนผู้เล่น (ต่ำสุด 0) */
|
||||
@@ -18618,7 +18823,11 @@
|
||||
aliveRefs.forEach((e) => {
|
||||
const r = e.ref;
|
||||
if (!r || r.balloonBossEliminated) return;
|
||||
r.balloonBossAimRad = balloonBossAutoAimRadPlay(e.id);
|
||||
/* [P5] ตัวเราเอง: ยิงตามลูกศรจริง (auto) เสมอ — ไม่ pin ตัวเอง (จอเราลูกศร+กระสุนตรงอยู่แล้ว).
|
||||
peer อื่น: ใช้ display (เคารพ pin ตอนเพิ่งเห็นเขายิง) เพื่อให้ลูกศรตรงกับกระสุนที่ broadcast มา */
|
||||
r.balloonBossAimRad = (myId != null && String(e.id) === String(myId))
|
||||
? balloonBossAutoAimRadPlay(e.id)
|
||||
: balloonBossDisplayAimRadPlay(e.id);
|
||||
});
|
||||
|
||||
balloonBossPlayerFireCd -= dt;
|
||||
@@ -18649,7 +18858,7 @@
|
||||
/* broadcast กระสุนให้ทุกคนเห็นตรงกัน (host เห็นยังไง ทุกคนเห็นแบบนั้น) */
|
||||
try {
|
||||
if (socket && myId != null) {
|
||||
socket.emit('balloon-boss-shot', { sx: shotSx, sy: shotSy, vx, vy, ownerId: myId, t0: serverNow() });
|
||||
socket.emit('balloon-boss-shot', { sx: shotSx, sy: shotSy, vx, vy, ownerId: myId, t0: serverNow(), aim });
|
||||
if (window.jdSound) window.jdSound.playSfx('laser', 'mg7');
|
||||
}
|
||||
} catch (eS) { /* ignore */ }
|
||||
@@ -18680,6 +18889,8 @@
|
||||
/* anchor เวลาเริ่มเล่น (server ms) — clients adopt เพื่อให้มุม "ลูกศรวงกลม" หมุนตรงกันทุกเครื่อง
|
||||
(เดิม anchor fallback = serverNow() ตอนเข้าเล่น ต่างกันต่อเครื่อง → วงๆ ไม่ sync) */
|
||||
anchor: balloonBossLiveAnchorServerMs > 0 ? balloonBossLiveAnchorServerMs : 0,
|
||||
/* [P5 v2] นาฬิกา host ณ ตอนส่ง → non-host ใช้เป็นฐานเวลาวงลูกศร (แทน serverNow ตัวเอง) = วงหมุน sync ต่อเนื่อง */
|
||||
t: serverNow(),
|
||||
players: syncRefs.map((e) => ({
|
||||
id: e.id,
|
||||
d: Math.max(0, (e.ref && (e.ref.balloonBossBossDmg | 0)) || 0),
|
||||
@@ -18946,11 +19157,15 @@
|
||||
}
|
||||
|
||||
/** ลูกโป่งด้านบน +50% — กระจุกเอียงซ้าย/กลางตรง/ขวา (tilt) + เชือกบางไปจุดผูบนวง · Bouquet tilt like mock */
|
||||
function drawBalloonsCluster(sx, sy, count, col, z, slot1to6, playerRingR) {
|
||||
const slotIdx = Math.max(0, Math.min(5, (Math.floor(Number(slot1to6)) || 1) - 1));
|
||||
function drawBalloonsCluster(sx, sy, count, col, z, slot1to8, playerRingR) {
|
||||
/* [2026-07-17] slot1to8 = theme index (1-8). ถ้า config slot ของ theme นั้น "ว่าง" (= theme ที่ไม่มี
|
||||
PNG เป็นของตัวเอง เช่น theme 7 ม่วง) → วาดสีแบนตาม col (สีถูกต้องตรงตัวละคร) แทน fallback PNG
|
||||
สีกลาง. clamp 0-7 (config มี 8 slot ไม่ใช่ 6 — เดิม clamp 5 ทำให้ theme 7,8 ชี้ผิด slot) */
|
||||
const slotIdx = Math.max(0, Math.min(7, (Math.floor(Number(slot1to8)) || 1) - 1));
|
||||
const perSeat = String((playBalloonBossPlayerBalloonImageUrls[slotIdx] || '')).trim();
|
||||
const fallback = String(playBalloonBossPlayerBalloonFallbackUrl || '').trim();
|
||||
const bRec = resolveBalloonBossBalloonSpriteRec(perSeat, fallback);
|
||||
const useFlatCol = !perSeat; /* theme นี้ไม่มี PNG → สีแบน */
|
||||
const fallback = useFlatCol ? '' : String(playBalloonBossPlayerBalloonFallbackUrl || '').trim();
|
||||
const bRec = useFlatCol ? null : resolveBalloonBossBalloonSpriteRec(perSeat, fallback);
|
||||
const n = Math.max(0, Math.min(8, count | 0));
|
||||
const ringRpx = playerRingR != null && playerRingR > 0 ? playerRingR : 22 * z;
|
||||
const sz = 1.5;
|
||||
@@ -19033,14 +19248,35 @@
|
||||
if (ent.balloonBossCx == null || ent.balloonBossCy == null) return;
|
||||
if (ent.balloonBossEliminated) return;
|
||||
const [sx, sy] = worldToScreen(ent.balloonBossCx, ent.balloonBossCy);
|
||||
const col = BALLOON_BOSS_PLAYER_COLORS[idx % BALLOON_BOSS_PLAYER_COLORS.length];
|
||||
/* [P1 2026-07-16, fix v2] สีลูกโป่งเหนือหัว = สีตัวละครที่ผู้เล่นเลือก (เดิมวนตามลำดับ idx → สีไม่ตรงตัว).
|
||||
⚠️ v1 ใช้ resolvePlayTintForPeer(id, ent) → ถ้า ent.playTint ยังไม่มา (join race) มัน fall ไป
|
||||
playTintFromPeerId = hash ของ socket.id ซึ่ง "ต่างทุกเครื่อง" → ลูกโป่งคนเดียวกันคนละสีข้ามจอ
|
||||
(user เจอจริง: MONE เขียว/น้ำเงินคนละจอ).
|
||||
fix: ใช้ tint "ตัวเดียวกับที่ตัวละครวาด" เป๊ะ = ent.playTint || resolvePlayTintForPeer(entry.id)
|
||||
(บรรทัด ~19208) → ลูกโป่งกับตัวละครใช้แหล่งเดียวกัน สีตรงกันเสมอทุกจอ (ent.playTint ตั้งจาก
|
||||
lobbyColorThemeIndex ที่ server ส่ง = authoritative). แล้ว map hex → theme index (nearestIndex).
|
||||
2 ปลายทางคนละช่วง: col ใช้ hex เต็ม 8 สี; PNG 6 slot (clamp 1..6) → theme 7,8 map ((t-1)%6)+1 */
|
||||
const bbTint = ent.playTint || ((typeof resolvePlayTintForPeer === 'function') ? resolvePlayTintForPeer(entry.id) : null);
|
||||
const bbThemeIdx = (bbTint && bbTint.body) ? nearestIndexFromHexPlay(bbTint.body, PLAY_LOBBY_THEME_HEX) : null;
|
||||
const z = zDraw;
|
||||
const skinSlot = bbSlotCycle.length ? bbSlotCycle[idx % bbSlotCycle.length] : ((idx % 6) + 1);
|
||||
/* col: สี fallback ตาม theme เต็ม 8 สี — ถ้าหา tint ไม่เจอค่อย fall กลับไปวนตามลำดับ (พฤติกรรมเดิม) */
|
||||
const col = (bbThemeIdx && PLAY_LOBBY_THEME_HEX[bbThemeIdx - 1])
|
||||
? PLAY_LOBBY_THEME_HEX[bbThemeIdx - 1]
|
||||
: BALLOON_BOSS_PLAYER_COLORS[idx % BALLOON_BOSS_PLAYER_COLORS.length];
|
||||
/* [2026-07-17] skinSlot: รูปลูกโป่งตาม theme — map ตรง 1:1 (theme N → balloon slot N). ⚠️ เดิม
|
||||
`((bbThemeIdx-1)%6)+1` wrap 8→6 → theme 8 (ชมพู) ได้ slot 2 (ส้ม) = MONE ตัวชมพูได้ลูกโป่งส้ม.
|
||||
มี PNG จริง 7 อัน (balloon-1..7 = แดง/ส้ม/เหลือง/เขียว/ฟ้า/น้ำเงิน/ชมพู). config 8 slot:
|
||||
index 0-5 = balloon-1..6 (theme 1-6), index 6 = ว่าง (theme 7 ม่วง = ไม่มี PNG → สีแบน),
|
||||
index 7 = balloon-7 (theme 8 ชมพู). drawBalloonsCluster ใช้สีแบนเองถ้า config slot ว่าง */
|
||||
const skinSlot = bbThemeIdx
|
||||
? bbThemeIdx
|
||||
: (bbSlotCycle.length ? bbSlotCycle[idx % bbSlotCycle.length] : ((idx % 6) + 1));
|
||||
const ringR = 25 * z;
|
||||
/** จุดกลางฟองบนจอ — ยกจากจุด world เล็กน้อยให้ตัวอยู่กลางวง ลูกโป่งชิดขอบบน (ตาม mock) */
|
||||
const bubbleCy = sy - ringR * 0.42;
|
||||
/** มุมลูกศร Artboard 9 — หมุนอัตโนมัติ; Mega Virus ยิงตามมุมนี้ ณ ตอนกด Space */
|
||||
const angRing = balloonBossAutoAimRadPlay(entry.id);
|
||||
/** มุมลูกศร Artboard 9 — หมุนอัตโนมัติ; Mega Virus ยิงตามมุมนี้ ณ ตอนกด Space
|
||||
[P5] ใช้ display helper (เคารพ pin ตอน peer ยิง) → ลูกศร+กระสุนตรงกันบนจอคนดู */
|
||||
const angRing = balloonBossDisplayAimRadPlay(entry.id);
|
||||
/** หันหน้าเข้ากล้อง (mock) — ไม่ใช้ทิศออกจากบอสในเลเยอร์นี้ */
|
||||
const faceDir = 'down';
|
||||
const slotIdxRing = Math.max(0, Math.min(5, skinSlot - 1));
|
||||
@@ -19414,7 +19650,9 @@
|
||||
onGround = false;
|
||||
} else if (wasOnGround) {
|
||||
/* ยืนอยู่ → ติดไปกับแท่นที่เลื่อนลง (กันลอยเหนือแท่นตอน fps สูง: การจม ∝dt² < ระยะ scroll ∝dt → onGround หลุด กระโดดไม่ได้/วิ่งค้าง) */
|
||||
const platformRise = (Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42) * dt;
|
||||
/* [2026-07-17] ใช้ความเร็ว "ณ ตอนนี้" (เร่ง ×2 ช่วงท้าย) — ค่าคงที่เดิมจะทำให้พอเข้าช่วง boost
|
||||
แท่นหนีลงเร็วกว่าคน = คนลอยค้างเหนือแท่น onGround หลุด กระโดดไม่ได้ */
|
||||
const platformRise = jsRiseVelNowPlay(md) * dt;
|
||||
feetY += platformRise;
|
||||
}
|
||||
vy += g;
|
||||
@@ -19883,6 +20121,8 @@
|
||||
|
||||
function jumpSurviveTickFrame() {
|
||||
if (!mapData || !isJumpSurvive()) return;
|
||||
/* คำถามพิเศษ → หยุด physics MG5 (reset lastTick กัน dt สะสม) — scroll/timer หยุดผ่าน anchor shift อยู่แล้ว */
|
||||
if (specialQuizFreeze) { jumpSurviveLastTickMs = performance.now(); return; }
|
||||
if (isJumpSurviveMissionUiMapPlay()) {
|
||||
if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return;
|
||||
const remEnd = jumpSurviveRemainingSecMission();
|
||||
@@ -19916,7 +20156,7 @@
|
||||
if (jumpSurviveLiveAnchorServerMs > 0) {
|
||||
/* scroll = ฟังก์ชันของเวลา server (ไม่สะสม dt local) → ทุกเครื่องเลื่อนเท่ากันที่เวลาเดียวกัน */
|
||||
const scrollSec = Math.max(0, (serverNow() - jumpSurviveLiveAnchorServerMs) / 1000);
|
||||
jumpSurvivePlatformScrollPx = scrollSec * risePerSec;
|
||||
jumpSurvivePlatformScrollPx = jsScrollPxAtPlay(scrollSec, risePerSec);
|
||||
} else {
|
||||
jumpSurvivePlatformScrollPx += risePerSec * dt;
|
||||
}
|
||||
@@ -21082,8 +21322,19 @@
|
||||
const raw = d.held;
|
||||
const held = (raw == null) ? null : Math.floor(Number(raw));
|
||||
const val = (held == null || !Number.isFinite(held)) ? null : held;
|
||||
if (String(d.id) === String(myId)) me.quizCarryHeld = val;
|
||||
else { const o = others.get(d.id) || others.get(String(d.id)); if (o) o.quizCarryHeld = val; }
|
||||
const isMine = String(d.id) === String(myId);
|
||||
const ent = isMine ? me : (others.get(d.id) || others.get(String(d.id)));
|
||||
/* [2026-07-17] เสียง "หยิบคำตอบ" — ทุกคนต้องได้ยิน ไม่ใช่แค่คนหยิบ (user request).
|
||||
แขวนตรงนี้จุดเดียวพอ เพราะ server ยิง quiz-carry-held ด้วย io.to(sid) = ถึงทุกคน "รวมคนหยิบเอง"
|
||||
(server.js:5660) — เหมือน pattern peer laser/jump ที่เล่นเสียงใน handler ของ state ที่ broadcast มา.
|
||||
⚠️ เล่นเฉพาะตอนเปลี่ยน "ไม่ถือ → ถือ": server.js:13339 ยิง held ซ้ำให้คนเพิ่ง join/reconnect
|
||||
(io.to(socket.id)) ถ้าไม่กัน จะมีเสียงหยิบหลอนตอนเข้าห้อง และตอนวาง (held=null) ก็ต้องเงียบ */
|
||||
const wasHeld = ent ? ent.quizCarryHeld : null;
|
||||
if (isMine) me.quizCarryHeld = val;
|
||||
else if (ent) ent.quizCarryHeld = val;
|
||||
if (ent && wasHeld == null && val != null && window.jdSound) {
|
||||
try { window.jdSound.playSfx('pick', 'mg4'); } catch (e) { /* ignore */ }
|
||||
}
|
||||
try { syncQuizCarryGrabButton(); } catch (e) { /* ignore */ }
|
||||
});
|
||||
/* จบรอบ: server แจ้งผล (ใครถูก/correctIndex/คะแนน) → merge + เคลียร์ held */
|
||||
@@ -21328,7 +21579,16 @@
|
||||
if (rb.bbDmg != null) o.balloonBossBossDmg = rb.bbDmg;
|
||||
if (rb.bbBalloons != null) o.balloonBossBalloons = rb.bbBalloons;
|
||||
if (rb.bbElim != null) o.balloonBossEliminated = !!rb.bbElim;
|
||||
if (rb.qcHeld !== undefined && !(isQuizCarry() && quizCarryServerAuthActive())) o.quizCarryHeld = rb.qcHeld;
|
||||
if (rb.qcHeld !== undefined && !(isQuizCarry() && quizCarryServerAuthActive())) {
|
||||
/* [2026-07-17] บอทหยิบก็ต้องมีเสียง (user request) — บอทมาคนละทางกับคนจริง (bot-move row
|
||||
ไม่ใช่ quiz-carry-held) จึงต้องแขวนซ้ำที่นี่. กัน "ไม่ถือ → ถือ" เหมือนกัน เพราะ row นี้
|
||||
ส่งทุก tick = ค่าเดิมซ้ำๆ ถ้าไม่กันจะรัวเป็นเสียงกรอบแกรบ */
|
||||
const wasQc = o.quizCarryHeld;
|
||||
o.quizCarryHeld = rb.qcHeld;
|
||||
if (wasQc == null && rb.qcHeld != null && isQuizCarry() && window.jdSound) {
|
||||
try { window.jdSound.playSfx('pick', 'mg4'); } catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
/* MG1 quiz: รับสถานะล็อก + คะแนนบอทจาก host (single source of truth) */
|
||||
if (rb.qct != null) o.quizCannotTrue = !!rb.qct;
|
||||
if (rb.qcf != null) o.quizCannotFalse = !!rb.qcf;
|
||||
@@ -21424,6 +21684,13 @@
|
||||
id: e.id != null ? String(e.id) : null,
|
||||
botId: e.botId != null ? String(e.botId) : null,
|
||||
theme: parsePlayLobbyThemeIndex(e.theme) || null,
|
||||
/* หน้า/สี/ชื่อ ของบอท takeover (คนหลุดช่วง how-to) — ต้องเก็บต่อ ไม่งั้น mg3CurrentSeatRowPlay
|
||||
หา others.get(botId) ไม่เจอ (user-left ลบไปแล้ว) → characterId null → อวตารเขียว */
|
||||
characterId: e.characterId != null ? e.characterId : null,
|
||||
lobbyColorThemeIndex: e.lobbyColorThemeIndex != null ? e.lobbyColorThemeIndex : null,
|
||||
lobbySkinToneIndex: e.lobbySkinToneIndex != null ? e.lobbySkinToneIndex : null,
|
||||
nickname: e.nickname || '',
|
||||
takeover: !!e.takeover,
|
||||
}));
|
||||
}
|
||||
if (stackPreviewTurnOrder && stackPreviewTurnOrder.length && Number.isFinite(Number(d.idx))) {
|
||||
@@ -21480,6 +21747,10 @@
|
||||
const t0 = Number(d.t0);
|
||||
const fwd = Number.isFinite(t0) ? Math.max(0, Math.min(2.5, (serverNow() - t0) / 1000)) : 0;
|
||||
balloonBossPlayerBullets.push({ x: sx + vx * fwd, y: sy + vy * fwd, vx, vy, ownerId: d.ownerId });
|
||||
/* [P5] pin ลูกศรของเจ้าของกระสุนไปที่ "มุมที่ยิงจริง" สั้น ๆ → ลูกศร+กระสุนตรงกันบนจอเรา
|
||||
(ใช้ d.aim ถ้ามี; ไม่มีก็ atan2(vy,vx) = ทิศเดียวกัน — รองรับ client เก่าที่ยังไม่ส่ง aim) */
|
||||
const shotAim = Number.isFinite(Number(d.aim)) ? Number(d.aim) : Math.atan2(vy, vx);
|
||||
balloonBossPinAimForShot(d.ownerId, shotAim);
|
||||
if (window.jdSound) { try { window.jdSound.playSfx('laser', 'mg7'); } catch (e) { /* ignore */ } } /* peer/bot ยิง = ทุกคนได้ยินเสียง (ยิงของตัวเอง return ไปข้างบนแล้ว) */
|
||||
});
|
||||
|
||||
@@ -21511,6 +21782,9 @@
|
||||
if (Number.isFinite(anc) && anc > 0 && Math.abs((balloonBossLiveAnchorServerMs || 0) - anc) > 1) {
|
||||
balloonBossLiveAnchorServerMs = anc;
|
||||
}
|
||||
/* [P5 v2] adopt นาฬิกา host → วงลูกศรใช้ฐานเวลาเดียวกับ host (แทน serverNow ตัวเอง) = หมุน sync ต่อเนื่อง */
|
||||
const ht = Number(d.t);
|
||||
if (Number.isFinite(ht) && ht > 0) { balloonBossHostClockBase = ht; balloonBossHostClockLocalAt = performance.now(); }
|
||||
if (Array.isArray(d.players)) {
|
||||
d.players.forEach((p) => {
|
||||
if (!p || p.id == null) return;
|
||||
@@ -21781,6 +22055,11 @@
|
||||
/* MG3 stack — ผู้เล่นหลุดกลางรอบ → server เปลี่ยน seat เป็นบอท (เก็บ avatar เดิม) → mark + ติดป้าย */
|
||||
socket.on('stack-player-to-bot', (d) => {
|
||||
if (!d || d.id == null) return;
|
||||
/* หลุดช่วง how-to: 'user-left' ลบ peer ออกจาก others ไปก่อนแล้ว (play.js ~21769) → o = null →
|
||||
เดิมทั้ง handler นี้กลายเป็น no-op เงียบ ๆ = "ไม่มีอะไรบอกว่าเขาออกไปแล้ว" ทั้งที่ server ยิง event
|
||||
มาแล้วจริง ๆ. ให้แจ้งเสมอ ไม่ผูกกับการมี entry ค้างอยู่ใน others */
|
||||
const nick = String((d.nickname || '')).trim() || 'ผู้เล่น';
|
||||
try { showStackTakeoverNotice(nick); } catch (e) { /* ignore */ }
|
||||
const o = others.get(d.id) || others.get(String(d.id));
|
||||
if (o) {
|
||||
o.isStackTakeoverBot = true;
|
||||
@@ -21891,6 +22170,19 @@
|
||||
socket.on('balloon-boss-bot-move', (d) => {
|
||||
if (!d || !Array.isArray(d.bots) || !isBalloonBoss()) return;
|
||||
balloonBossServerAuthPlay = true; /* สัญญาณ: server เป็นเจ้าของบอท/รอบ/HP บอสรอบนี้ */
|
||||
/* [P5 v3] adopt นาฬิกา server → ฐานเวลา "วงลูกศรหมุน" ตรงกันทุกเครื่อง (แทน serverNow ตัวเอง) */
|
||||
const st = Number(d.t);
|
||||
if (Number.isFinite(st) && st > 0) { balloonBossSrvClockBase = st; balloonBossSrvClockLocalAt = performance.now(); }
|
||||
/* [P5 v4] adopt anchor ของ server (pause-adjusted) → มุมวงลูกศร = f(นาฬิกา server, anchor server) ล้วน */
|
||||
const sa = Number(d.anchor);
|
||||
if (Number.isFinite(sa) && sa > 0) {
|
||||
balloonBossRingAnchorSrvMs = sa;
|
||||
/* [v5 2026-07-17] anchor "หลัก" ต้อง adopt ด้วย — v4 แก้แค่วงลูกศร แต่ balloonBossLiveAnchorServerMs
|
||||
ยังเป็นค่าต่อเครื่อง ทั้งที่มีคนใช้อีก 4 ที่: TIME (18250), tSec (18093), จังหวะบอสยิง (18560),
|
||||
และ seed ของ spawn (18566) → user เจอ "MG7 เวลาไม่ sync กัน" = TIME คนละเลข.
|
||||
(seed ที่ต่างกันแปลว่า env spawn ก็คนละชุดข้ามจอมาตลอดด้วย — หายไปพร้อมกัน) */
|
||||
balloonBossLiveAnchorServerMs = sa;
|
||||
}
|
||||
d.bots.forEach((rb) => {
|
||||
if (!rb || typeof rb.id !== 'string') return;
|
||||
const o = ensureServerAuthBotEntityPlay(rb.id, 'down'); /* สร้าง entity ถ้า client นี้ยังไม่มี (กันบอทค้าง) */
|
||||
@@ -21949,6 +22241,30 @@
|
||||
});
|
||||
});
|
||||
|
||||
/* MG3: "X ไม่กลับมาแล้ว → บอทเล่นแทน" — ป้ายค้าง 6 วิ ใต้ป้าย grace (คนละใบ ไม่ทับกัน)
|
||||
จำเป็นเพราะ grace banner บอกแค่ "กำลังรอ" แล้วหายไป: พอครบเวลาจริง ๆ ผู้เล่นที่เหลือ (ยังอยู่หน้า
|
||||
how-to) ไม่เคยได้ยินว่าเขาไม่กลับแล้ว — เห็นแค่แถว SCORE หายไปหนึ่งคน. z สูงกว่า how-to (10140). */
|
||||
let _stackTakeoverNoticeTimer = null;
|
||||
function showStackTakeoverNotice(nickname) {
|
||||
let el = document.getElementById('play-stack-takeover-notice');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'play-stack-takeover-notice';
|
||||
el.style.cssText = 'position:fixed;left:50%;top:58px;transform:translateX(-50%);z-index:10500;'
|
||||
+ 'padding:9px 18px;border-radius:12px;background:rgba(38,60,120,.94);color:#fff;'
|
||||
+ 'font:600 15px system-ui,"Kanit",sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.42);pointer-events:none;letter-spacing:.2px;';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.textContent = '🤖 ' + nickname + ' ออกจากเกม — บอทเล่นแทนในตาของเขา';
|
||||
el.style.display = 'block';
|
||||
if (_stackTakeoverNoticeTimer) clearTimeout(_stackTakeoverNoticeTimer);
|
||||
_stackTakeoverNoticeTimer = setTimeout(function () {
|
||||
_stackTakeoverNoticeTimer = null;
|
||||
const e2 = document.getElementById('play-stack-takeover-notice');
|
||||
if (e2) e2.style.display = 'none';
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
/* ===== Disconnect grace + bot takeover (10 วิ รอ reconnect) ===== */
|
||||
let _playGraceCountdownTimer = null;
|
||||
function showDisconnectGraceBanner(nickname, seconds) {
|
||||
@@ -23218,7 +23534,7 @@
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const sx = e.clientX - r.left;
|
||||
const sy = e.clientY - r.top;
|
||||
const qmC = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
|
||||
const qmC = isQuizQuestionMissionRenderLockPlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
|
||||
const carryCam = !qmC && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null;
|
||||
const camX = qmC ? qmC.cx : (carryCam ? carryCam.cx : me.x * tileSize);
|
||||
const camY = qmC ? qmC.cy : (carryCam ? carryCam.cy : me.y * tileSize);
|
||||
@@ -26156,7 +26472,9 @@
|
||||
const remain = QUIZ_CARRY_FEEDBACK_FX_MS - ageMs;
|
||||
const rise = Math.min(1, ageMs / QUIZ_CARRY_FEEDBACK_FX_MS) * 14;
|
||||
const rr = Math.max(11, tileSize * zDraw * 0.42);
|
||||
const cy = topY - rr - 8 - rise;
|
||||
/* [2026-07-17] ยกจาก -8 เป็น -18: ตั้งแต่วันนี้ MG4 มีชื่อลอยเหนือหัว (กิน topY-14..topY-4)
|
||||
ค่าเดิมถูกจูนตอนที่ยังไม่มีชื่อ → badge ทับชื่อ 6px ที่เฟรมแรกของ FX ก่อนจะลอยหนีขึ้นไป */
|
||||
const cy = topY - rr - 18 - rise;
|
||||
const alpha = remain < 300 ? Math.max(0, remain / 300) : 1;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
@@ -26336,7 +26654,12 @@
|
||||
const withScore = isGauntlet() && typeof gauntletScoreLabel === 'number' && gauntletScoreLabel >= 0
|
||||
? (nameBase + ' · ' + gauntletScoreLabel)
|
||||
: nameBase;
|
||||
if (!gntDomLabel) ctx.fillText(withScore, sx, sy + 10);
|
||||
/* [2026-07-17] ชื่อไว้ "เหนือหัว" สำหรับ MG5 · MG1 · MG4 (user request) — lobby ยังไว้ใต้เท้า
|
||||
เหมือนเดิม (drawAvatar ตัวเดียวกันใช้ร่วมกับ LobbyA/LobbyB ด้วย). ยึด spriteTopY = ยอดสไปรต์
|
||||
จริง ไม่ใช่ sy-2r เพราะสไปรต์แต่ละตัวสูงไม่เท่ากันหลัง scale (MG1 มี minPx upscale ของตัวเอง)
|
||||
→ ชื่อลอยห่างหัวเท่ากันทุกตัวละคร และตอนกระโดดชื่อตามไปเอง */
|
||||
const nameY = (isJumpSurvive() || isQuiz() || isQuizCarry()) ? spriteTopY - 4 : sy + 10;
|
||||
if (!gntDomLabel) ctx.fillText(withScore, sx, nameY);
|
||||
if (!gntDomLabel
|
||||
&& isGauntletCrownHeistMapPlay()
|
||||
&& gauntletCrownPregamePhase === 'live'
|
||||
@@ -26395,7 +26718,11 @@
|
||||
}
|
||||
function shouldGauntletCrownHeistSkipAvatarDrawPlay(ent, isMeFlag, offAx, offAy) {
|
||||
if (!isGauntletCrownHeistMapPlay() || gauntletCrownPregamePhase !== 'live') return false;
|
||||
if (!ent || ent.gauntletEliminated) return true;
|
||||
/* [2026-07-17] bannedSpectator = คนโดนแบน (Card 3) เข้ามาดูเฉยๆ ไม่ได้เล่น → ต้องไม่มีทั้งตัวละคร
|
||||
และป้ายชื่อบนสนาม (user เจอ "ชื่อยังอยู่แม้คนนั้นโดน ban"). เดิมเช็คแค่ gauntletEliminated
|
||||
= คนที่ "เล่นแล้วตกรอบ" ซึ่งคนละเรื่องกับคนที่ไม่ได้ลงเล่นตั้งแต่แรก.
|
||||
แก้ที่นี่จุดเดียวคุมทั้งคู่ เพราะ DOM overlay ป้ายชื่อ (syncGauntletNameDom) เรียกฟังก์ชันนี้ด้วย */
|
||||
if (!ent || ent.gauntletEliminated || ent.bannedSpectator) return true;
|
||||
const { cw, ch } = getCharacterFootprintWH(mapData);
|
||||
const ax = safeX(ent.x) + (offAx || 0);
|
||||
const ay = safeY(ent.y) + (offAy || 0);
|
||||
@@ -26513,7 +26840,7 @@
|
||||
}
|
||||
});
|
||||
/* Minigame-2: อัปเดต DOM overlay (ชื่อ/คะแนน/-10) ให้ตรงตำแหน่งตัวละครบน canvas */
|
||||
try { syncGauntletNameDom(worldToScreen, zDraw, tileSize); } catch (eGntDom) { /* ignore */ }
|
||||
try { syncGauntletNameDom(worldToScreen, zDraw, tileSize, shouldGauntletCrownHeistSkipAvatarDrawPlay); } catch (eGntDom) { /* ignore */ }
|
||||
}
|
||||
/* MG4: ป้ายตัวเลือกบนโต๊ะ วาดหลัง entity → อยู่หน้าตัวละคร (คน/บอทไม่บังคำตอบที่ยังไม่หยิบ) */
|
||||
if (isQuizCarry()) { try { drawQuizCarryChoiceLabels(ctx, worldToScreen, zDraw); } catch (eCl) { /* ignore */ } }
|
||||
@@ -26813,7 +27140,12 @@
|
||||
/* BGM ต่อมินิเกม (idempotent — playBgm return เร็วถ้า scene เดิม) */
|
||||
if (window.jdSound) { try { window.jdSound.playBgm(jdPlayScene()); } catch (e) { /* ignore */ } }
|
||||
/* โหลดฉากมินิเกมเสร็จ (ครั้งเดียว) → เอา badge "กำลังโหลด" ออกให้คนอื่นเห็น */
|
||||
if (!playClientReadyEmitted && socket && socket.connected) { playClientReadyEmitted = true; try { socket.emit('client-ready'); } catch (e) { /* ignore */ } }
|
||||
if (!playClientReadyEmitted && socket && socket.connected) {
|
||||
playClientReadyEmitted = true;
|
||||
try { socket.emit('client-ready'); } catch (e) { /* ignore */ }
|
||||
/* [2026-07-17] เสียง warp ย้ายไป showPlayLoadingOverlay (ตอนคลิป/จอโหลดเริ่ม) แล้ว — user บอกว่า
|
||||
ตรงนี้ (ฉากโหลดเสร็จ) มัน "อยู่หลังคลิป" ต้องเล่นพร้อมคลิปตั้งแต่เข้า ก่อน how-to */
|
||||
}
|
||||
/* dt frame (วินาที) → playDtMul = dt*60 (clamp 0.05s = step ≤0.45 tile กัน tunneling) ใช้ทำ movement time-based */
|
||||
{
|
||||
const _pnow = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
||||
|
||||
@@ -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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -973,7 +973,16 @@ function maybeSpawnSpecialQuizForRun(space) {
|
||||
const questions = (set && Array.isArray(set.questions) ? set.questions : [])
|
||||
.map(specialQuizNormalizeQuestion)
|
||||
.filter(Boolean);
|
||||
/* [2026-07-17] การ์ดมาจาก "สำรับต่อคดี" (3 ใบไม่ซ้ำจาก 7) แจกตาม suspectPickIndex — ไม่ใช่
|
||||
set.specialCard ใบเดียวที่ Admin ตั้ง ซึ่งทำให้ 3 เกมได้ใบเดิมเสมอ (user request).
|
||||
fallback เป็นค่า Admin ถ้าสำรับหาย (คดีที่เริ่มก่อน deploy นี้ / space เก่าค้าง) */
|
||||
const deckIdx = (typeof space.suspectPickIndex === 'number' && space.suspectPickIndex >= 0 && space.suspectPickIndex <= 2) ? space.suspectPickIndex : 0;
|
||||
const deck = Array.isArray(space.specialCardDeck) ? space.specialCardDeck : null;
|
||||
let card = set && set.specialCard;
|
||||
if (deck && deck[deckIdx]) {
|
||||
const dDef = specialCardDef(deck[deckIdx]) || {};
|
||||
card = sanitizeSpecialCard({ cardId: Number(deck[deckIdx]), th: dDef.th });
|
||||
}
|
||||
/* โหมดเทสต์ (admin): บังคับการ์ดใบที่เลือกสำหรับเกมนี้ + force ขึ้นเสมอ (ไม่ต้องสุ่ม) */
|
||||
const testCardId = (runtimeGameTiming.testSpecialCardByMap || {})[mapId];
|
||||
const testForcedCard = (Number(testCardId) >= 1 && Number(testCardId) <= 7);
|
||||
@@ -1359,6 +1368,24 @@ function isSpecialQuizPausedServer(space) {
|
||||
return !!(space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done));
|
||||
}
|
||||
|
||||
/* setTimeout ที่ "หยุดนับ" ระหว่างคำถามพิเศษ (special quiz) — ใช้กับรอบ quiz(MG1)/quiz_carry(MG4) ที่ขับด้วย
|
||||
setTimeout (ไม่มี tick loop ให้ extend endsAt เหมือน MG2/6/7). Poll: ถ้ากำลัง pause → ดัน deadline ตาม
|
||||
เวลาที่ freeze (คงเวลาที่เหลือ) แล้ว re-poll; ถ้าถึงเวลาแล้ว → ยิง cb. เก็บ timer ใน space.quizTimers */
|
||||
function pausableSpecialQuizTimeout(sid, space, cb, ms) {
|
||||
const d = { at: Date.now() + Math.max(0, ms | 0), last: Date.now() };
|
||||
const push = (t) => { const sp = spaces.get(sid); if (sp && Array.isArray(sp.quizTimers)) sp.quizTimers.push(t); };
|
||||
const step = () => {
|
||||
const sp = spaces.get(sid);
|
||||
if (!sp) return;
|
||||
const now = Date.now();
|
||||
if (isSpecialQuizPausedServer(sp)) d.at += (now - d.last); /* freeze → เลื่อน deadline = คงเวลาที่เหลือ */
|
||||
d.last = now;
|
||||
if (now >= d.at) { try { cb(); } catch (e) { /* ignore */ } return; }
|
||||
push(setTimeout(step, Math.max(50, Math.min(300, d.at - now))));
|
||||
};
|
||||
push(setTimeout(step, Math.max(50, Math.min(300, ms | 0))));
|
||||
}
|
||||
|
||||
function startSpecialQuizSession(sid, space) {
|
||||
const sq = space.specialQuiz;
|
||||
if (!sq || sq.triggered || sq.done) return false;
|
||||
@@ -1769,6 +1796,9 @@ function caseMediaDefault(n) {
|
||||
],
|
||||
suspects,
|
||||
culprits,
|
||||
/* [2026-07-21] ไอคอนผู้ต้องสงสัย (สี่เหลี่ยมเล็ก) — ใช้เฉพาะหัวหน้าไต่สวน es3 เท่านั้น.
|
||||
default = รูปเต็มตัว (suspects) → ถ้าไม่ได้ตั้งค่า พฤติกรรมเหมือนเดิมทุกประการ */
|
||||
icons: suspects.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1787,6 +1817,17 @@ function sanitizeCaseMediaUrl(v, fallback) {
|
||||
return s;
|
||||
}
|
||||
|
||||
/** [2026-07-21] ข้อความในหน้ารายละเอียดคดี (กรอกจาก Admin) — ตัดแท็ก/อักขระอันตราย + จำกัดความยาว
|
||||
ไม่ใช่ URL จึงใช้ตัวนี้แทน sanitizeCaseMediaUrl */
|
||||
function sanitizeCaseText(v, max) {
|
||||
if (typeof v !== 'string') return '';
|
||||
return v.replace(/[<>]/g, '').replace(/\r/g, '').trim().slice(0, max || 400);
|
||||
}
|
||||
function sanitizeCaseTextList(arr) {
|
||||
const src = Array.isArray(arr) ? arr : [];
|
||||
return [0, 1, 2].map((i) => sanitizeCaseText(src[i], 240));
|
||||
}
|
||||
|
||||
function sanitizeCaseMedia(obj) {
|
||||
const src = (obj && typeof obj === 'object' && obj.cases && typeof obj.cases === 'object') ? obj.cases : {};
|
||||
const cases = {};
|
||||
@@ -1801,6 +1842,17 @@ function sanitizeCaseMedia(obj) {
|
||||
story: [sanitizeCaseMediaUrl(story[0], d.story[0]), sanitizeCaseMediaUrl(story[1], d.story[1])],
|
||||
suspects: sanitizeCaseMediaUrlList(c.suspects, d.suspects),
|
||||
culprits: sanitizeCaseMediaUrlList(c.culprits, d.culprits),
|
||||
/* [2026-07-21] ไอคอน es3 — fallback เป็น suspects ที่ผ่าน sanitize แล้ว (ไม่ใช่ default ดิบ)
|
||||
เพื่อให้ "ไม่ตั้ง icons" = ใช้รูปเต็มตัวชุดเดียวกับที่ตั้งไว้จริง */
|
||||
icons: sanitizeCaseMediaUrlList(c.icons, sanitizeCaseMediaUrlList(c.suspects, d.suspects)),
|
||||
/* [2026-07-21] หน้ารายละเอียดแบบใหม่ (ประกอบเอง): ตั้ง pic แล้วหน้าจะสลับไปโหมดใหม่อัตโนมัติ
|
||||
ยังไม่ตั้ง (ค่าว่าง) = ใช้รูปเดียวเดิม detail (case-detail-N.png) ต่อไป */
|
||||
pic: sanitizeCaseMediaUrl(c.pic, ''),
|
||||
txtCharge: sanitizeCaseText(c.txtCharge, 300),
|
||||
txtPlace: sanitizeCaseText(c.txtPlace, 300),
|
||||
txtVictim: sanitizeCaseText(c.txtVictim, 300),
|
||||
txtSuspects: sanitizeCaseTextList(c.txtSuspects),
|
||||
txtNote: sanitizeCaseText(c.txtNote, 1500),
|
||||
};
|
||||
}
|
||||
return { cases };
|
||||
@@ -2318,6 +2370,9 @@ function defaultGameTiming() {
|
||||
trialVoteSec: 30,
|
||||
/** เวลาโชว์ผลโหวตคนร้าย (ถูก/ผิด) ก่อนตัดไปหน้าผลเต็ม — มิลลิวินาที */
|
||||
trialResultRevealMs: 7000,
|
||||
/** ปลายทางหลังจบคดี (โชว์ผล+หน้าความรู้แล้ว): 'mainlobby' = กลับ Main-Lobby (เดิม) ·
|
||||
'lobbya' = รีเซ็ตห้องเดิมกลับ LobbyA ให้เล่นคดีใหม่ต่อได้เลย */
|
||||
postCaseDestination: 'mainlobby',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2625,26 +2680,44 @@ function clampGauntletLaserLineWidthPx(n) {
|
||||
return Math.max(0, Math.min(24, Math.round(v)));
|
||||
}
|
||||
|
||||
function normalizeGauntletVisualsFromRequest(d) {
|
||||
/** แปลง "ภาพ/สี" ของ gauntlet จาก request — field ที่ **ไม่ได้ส่งมา** ต้องคงค่าเดิม (prev) ไว้เสมอ
|
||||
ห้ามรีเซ็ตเป็น default. เดิมอ่านจาก d อย่างเดียว → PUT บางส่วน (เช่น {postCaseDestination})
|
||||
ทำให้ gauntletLaneImageUrls/laser ถูกล้างทั้งชุด = อุปสรรค MG2 "texture หาย" (fallback สี่เหลี่ยมทึบ).
|
||||
ส่ง [] มาตรง ๆ ยังล้างได้ตามปกติ (hasOwnProperty = ตั้งใจล้าง). */
|
||||
function normalizeGauntletVisualsFromRequest(d, prev) {
|
||||
const def = defaultGauntletVisuals();
|
||||
if (!d || typeof d !== 'object') return { ...def };
|
||||
let laneArr = d.gauntletLaneImageUrls;
|
||||
if (typeof laneArr === 'string') laneArr = laneArr.split(/[\n\r]+/);
|
||||
if (!Array.isArray(laneArr)) laneArr = [];
|
||||
const gauntletLaneImageUrls = [];
|
||||
for (let i = 0; i < laneArr.length && gauntletLaneImageUrls.length < 24; i++) {
|
||||
const u = sanitizeGauntletAssetUrl(laneArr[i]);
|
||||
if (u) gauntletLaneImageUrls.push(u);
|
||||
}
|
||||
return {
|
||||
gauntletLaneImageUrls,
|
||||
gauntletLaserTopUrl: sanitizeGauntletAssetUrl(d.gauntletLaserTopUrl),
|
||||
gauntletLaserBottomUrl: sanitizeGauntletAssetUrl(d.gauntletLaserBottomUrl),
|
||||
gauntletLaserLineUrl: sanitizeGauntletAssetUrl(d.gauntletLaserLineUrl),
|
||||
gauntletLaserFillColor: clampGauntletLaserColor(d.gauntletLaserFillColor, def.gauntletLaserFillColor),
|
||||
gauntletLaserStrokeColor: clampGauntletLaserColor(d.gauntletLaserStrokeColor, def.gauntletLaserStrokeColor),
|
||||
gauntletLaserLineWidthPx: clampGauntletLaserLineWidthPx(d.gauntletLaserLineWidthPx),
|
||||
const p = (prev && typeof prev === 'object') ? prev : {};
|
||||
const keep = (k) => (p[k] !== undefined ? p[k] : def[k]);
|
||||
const base = {
|
||||
gauntletLaneImageUrls: Array.isArray(p.gauntletLaneImageUrls) ? p.gauntletLaneImageUrls.slice() : def.gauntletLaneImageUrls,
|
||||
gauntletLaserTopUrl: keep('gauntletLaserTopUrl'),
|
||||
gauntletLaserBottomUrl: keep('gauntletLaserBottomUrl'),
|
||||
gauntletLaserLineUrl: keep('gauntletLaserLineUrl'),
|
||||
gauntletLaserFillColor: keep('gauntletLaserFillColor'),
|
||||
gauntletLaserStrokeColor: keep('gauntletLaserStrokeColor'),
|
||||
gauntletLaserLineWidthPx: keep('gauntletLaserLineWidthPx'),
|
||||
};
|
||||
if (!d || typeof d !== 'object') return base;
|
||||
const has = (k) => Object.prototype.hasOwnProperty.call(d, k);
|
||||
const out = { ...base };
|
||||
if (has('gauntletLaneImageUrls')) {
|
||||
let laneArr = d.gauntletLaneImageUrls;
|
||||
if (typeof laneArr === 'string') laneArr = laneArr.split(/[\n\r]+/);
|
||||
if (!Array.isArray(laneArr)) laneArr = [];
|
||||
const urls = [];
|
||||
for (let i = 0; i < laneArr.length && urls.length < 24; i++) {
|
||||
const u = sanitizeGauntletAssetUrl(laneArr[i]);
|
||||
if (u) urls.push(u);
|
||||
}
|
||||
out.gauntletLaneImageUrls = urls;
|
||||
}
|
||||
if (has('gauntletLaserTopUrl')) out.gauntletLaserTopUrl = sanitizeGauntletAssetUrl(d.gauntletLaserTopUrl);
|
||||
if (has('gauntletLaserBottomUrl')) out.gauntletLaserBottomUrl = sanitizeGauntletAssetUrl(d.gauntletLaserBottomUrl);
|
||||
if (has('gauntletLaserLineUrl')) out.gauntletLaserLineUrl = sanitizeGauntletAssetUrl(d.gauntletLaserLineUrl);
|
||||
if (has('gauntletLaserFillColor')) out.gauntletLaserFillColor = clampGauntletLaserColor(d.gauntletLaserFillColor, def.gauntletLaserFillColor);
|
||||
if (has('gauntletLaserStrokeColor')) out.gauntletLaserStrokeColor = clampGauntletLaserColor(d.gauntletLaserStrokeColor, def.gauntletLaserStrokeColor);
|
||||
if (has('gauntletLaserLineWidthPx')) out.gauntletLaserLineWidthPx = clampGauntletLaserLineWidthPx(d.gauntletLaserLineWidthPx);
|
||||
return out;
|
||||
}
|
||||
|
||||
function getGauntletVisualsForClient() {
|
||||
@@ -2749,6 +2822,7 @@ function loadGameTiming() {
|
||||
trialResultRevealMs: Object.prototype.hasOwnProperty.call(j, 'trialResultRevealMs')
|
||||
? Math.max(1500, Math.min(20000, Math.floor(Number(j.trialResultRevealMs)) || 7000))
|
||||
: 7000,
|
||||
postCaseDestination: (j.postCaseDestination === 'lobbya') ? 'lobbya' : 'mainlobby',
|
||||
};
|
||||
}
|
||||
} catch (e) { console.error('loadGameTiming', e.message); }
|
||||
@@ -2819,7 +2893,7 @@ function saveGameTimingToFile(d) {
|
||||
const dir = path.dirname(GAME_TIMING_PATH);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const prev = { ...runtimeGameTiming };
|
||||
const vis = normalizeGauntletVisualsFromRequest(d);
|
||||
const vis = normalizeGauntletVisualsFromRequest(d, prev); /* prev = คงค่าเดิมของ field ที่ไม่ได้ส่งมา */
|
||||
const stackHz = Object.prototype.hasOwnProperty.call(d, 'stackSwingHz')
|
||||
? clampStackSwingHz(d.stackSwingHz)
|
||||
: clampStackSwingHz(prev.stackSwingHz != null ? prev.stackSwingHz : STACK_SWING_HZ_DEFAULT);
|
||||
@@ -2973,10 +3047,11 @@ function saveGameTimingToFile(d) {
|
||||
? clampStackHeavyBlockPercent(d.stackHeavyBlockPercent)
|
||||
: prevHeavyPct;
|
||||
const out = {
|
||||
gauntletTickMs: clampGauntletTickMs(d.gauntletTickMs),
|
||||
gauntletJumpTicks: clampGauntletJumpTicks(d.gauntletJumpTicks),
|
||||
gauntletJumpHeightMult: clampGauntletJumpHeightMult(d.gauntletJumpHeightMult),
|
||||
gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec),
|
||||
/* เช่นเดียวกับ vis: ถ้า request ไม่ส่ง field มา ให้คงค่าเดิม (prev) — ไม่ใช่รีเซ็ตเป็น default */
|
||||
gauntletTickMs: clampGauntletTickMs(Object.prototype.hasOwnProperty.call(d, 'gauntletTickMs') ? d.gauntletTickMs : prev.gauntletTickMs),
|
||||
gauntletJumpTicks: clampGauntletJumpTicks(Object.prototype.hasOwnProperty.call(d, 'gauntletJumpTicks') ? d.gauntletJumpTicks : prev.gauntletJumpTicks),
|
||||
gauntletJumpHeightMult: clampGauntletJumpHeightMult(Object.prototype.hasOwnProperty.call(d, 'gauntletJumpHeightMult') ? d.gauntletJumpHeightMult : prev.gauntletJumpHeightMult),
|
||||
gauntletTimeLimitSec: clampGauntletTimeLimitSec(Object.prototype.hasOwnProperty.call(d, 'gauntletTimeLimitSec') ? d.gauntletTimeLimitSec : prev.gauntletTimeLimitSec),
|
||||
troublesomeOfferSec: Object.prototype.hasOwnProperty.call(d, 'troublesomeOfferSec') ? clampTroublesomeOfferSec(d.troublesomeOfferSec) : ((runtimeGameTiming && runtimeGameTiming.troublesomeOfferSec) || 15),
|
||||
...vis,
|
||||
stackSwingHz: stackHz,
|
||||
@@ -3028,6 +3103,9 @@ function saveGameTimingToFile(d) {
|
||||
trialResultRevealMs: Object.prototype.hasOwnProperty.call(d, 'trialResultRevealMs')
|
||||
? Math.max(1500, Math.min(20000, Math.floor(Number(d.trialResultRevealMs)) || 7000))
|
||||
: (prev.trialResultRevealMs != null ? Math.max(1500, Math.min(20000, Math.floor(Number(prev.trialResultRevealMs)) || 7000)) : 7000),
|
||||
postCaseDestination: Object.prototype.hasOwnProperty.call(d, 'postCaseDestination')
|
||||
? (d.postCaseDestination === 'lobbya' ? 'lobbya' : 'mainlobby')
|
||||
: (prev.postCaseDestination === 'lobbya' ? 'lobbya' : 'mainlobby'),
|
||||
/* DEV: เปิด HUD โหลด LobbyA (Iron Man) — admin toggle. preserve ค่าเดิมถ้า request ไม่ส่งมา */
|
||||
lobbyDebugHud: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHud')
|
||||
? !!d.lobbyDebugHud
|
||||
@@ -3555,11 +3633,27 @@ function buildPeersSnapForSpace(space, extraFields) {
|
||||
เมื่อ join/reconnect ไม่ทันรอบ (race ตอนเปลี่ยนหน้า lobbyB→play.html) */
|
||||
/* true ถ้า peer นี้คือผู้ถูกแบนรอบนี้ (Card 3 Ban) — เข้ามาดูเฉยๆ ไม่ต้อง Ready และไม่นับใน gate เริ่มเกม
|
||||
(เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ที่ reconnect เข้า play.html) */
|
||||
/** Card 3 Ban — แปลง target ตอนโหวต (socket.id) เป็น playerKey = "ตัวตนถาวร"
|
||||
socket.id เปลี่ยนทุกครั้งที่เปลี่ยนหน้า (LobbyB ⇄ play.html) → ผูกกับ id อย่างเดียวจะหาคนโดนแบน
|
||||
ไม่เจอตอนเริ่มเกมจริง = ตัวละครโผล่ในเกม + ถูกนับใน gate ready จน host กด START ไม่ได้.
|
||||
บอท (__lobby_bot_N) ไม่มี playerKey → คืน '' แล้วใช้ id เดิมต่อ (บอท id คงที่อยู่แล้ว) */
|
||||
function resolveBanTargetPlayerKey(space, targetId) {
|
||||
if (!space || !targetId) return '';
|
||||
if (String(targetId).indexOf('__lobby_bot_') === 0) return '';
|
||||
const p = space.peers && space.peers.get(targetId);
|
||||
const k = p && p.playerKey;
|
||||
return (k && /^[a-zA-Z0-9_-]{8,128}$/.test(String(k))) ? String(k) : '';
|
||||
}
|
||||
|
||||
function isBannedPeerForRun(space, id) {
|
||||
if (!space) return false;
|
||||
if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return true;
|
||||
const pp = space.peers && space.peers.get(id);
|
||||
return !!(pp && pp.bannedSpectator);
|
||||
if (pp && pp.bannedSpectator) return true;
|
||||
/* เทียบด้วย playerKey — ครอบเคส socket.id เปลี่ยนหลัง reconnect/เปลี่ยนหน้า (id เดิมไม่มีทางแมตช์) */
|
||||
if (space.bannedThisRunPlayerKey && pp && pp.playerKey
|
||||
&& String(pp.playerKey) === String(space.bannedThisRunPlayerKey)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/* สำเนา ready map โดยตัด id ผู้ถูกแบนออก — กัน client นับ banned เป็นผู้เล่นที่ต้องรอ Ready (host ค้าง N/N+1) */
|
||||
@@ -3749,6 +3843,25 @@ function emitDetectiveLobbyBPresence(sid, space) {
|
||||
try { maybeOpenPendingBanVote(sid, space); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
/* [2026-07-19] ตัดผู้เล่นออกจาก roster คดี "ทันที" + re-emit presence — ใช้ตอน host kick / คนหลุดระหว่างเฟส
|
||||
เลือกผู้ต้องสงสัย. ต้นตอบั๊ก: TC144 gate (suspect-pick-start) ใช้ total = caseParticipantKeys.size ที่ล็อก
|
||||
ตอนเริ่มคดี และลดได้ทางเดียว = grace-prune 22วิ ที่ต้องมี playerKey → host kick (ไม่ prune เลย) และ
|
||||
disconnect ที่ playerKey ว่าง (grace ข้าม) ทำให้ gate ค้าง "รอผู้เล่น" ถาวร host กด Start ไม่ได้.
|
||||
ตัดทั้ง 'k:'+playerKey และ 'n:'+nick (เหมือน grace-prune ที่ server.js:13186-13195) เพื่อครอบ roster
|
||||
ทั้งสองแบบ. emitDetectiveLobbyBPresence จะ no-op เองถ้าไม่ได้อยู่ LobbyB (กันเรียกผิดจังหวะ) */
|
||||
function pruneDetectiveParticipantNow(sid, space, playerKey, nickname) {
|
||||
if (!space) return;
|
||||
const nick = normalizeLobbyNickname(nickname || '');
|
||||
try {
|
||||
if (space.caseParticipantKeys) {
|
||||
if (playerKey && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) space.caseParticipantKeys.delete('k:' + playerKey);
|
||||
if (nick) space.caseParticipantKeys.delete('n:' + nick);
|
||||
}
|
||||
if (space.caseParticipantNicknames && nick) space.caseParticipantNicknames.delete(nick);
|
||||
} catch (e) { /* ignore */ }
|
||||
emitDetectiveLobbyBPresence(sid, space);
|
||||
}
|
||||
|
||||
/** โหวต Ban เปิดเมื่อผู้เล่นในคดี "กลับเข้า LobbyB ครบทุกคน" เท่านั้น — กัน candidate ไม่ครบ + timer เริ่ม
|
||||
* ก่อนคนเข้าครบ ("เล่น 4 คนแต่โหวตได้ 3"). safety: รอเกิน BAN_VOTE_MAX_WAIT_MS (บางคนไม่กลับ) → เปิดเลย กัน deadlock */
|
||||
const BAN_VOTE_MAX_WAIT_MS = 30000;
|
||||
@@ -3781,7 +3894,11 @@ function maybeOpenPendingBanVote(sid, space) {
|
||||
startPlayerPickVote(sid, space, 'ban', (targetId) => {
|
||||
const sp2 = spaces.get(sid);
|
||||
console.log('[ban-debug] ban vote RESOLVED target=', targetId);
|
||||
if (sp2) sp2.bannedPlayerId = targetId || null;
|
||||
if (sp2) {
|
||||
sp2.bannedPlayerId = targetId || null;
|
||||
sp2.bannedPlayerKey = resolveBanTargetPlayerKey(sp2, targetId); /* ตัวตนถาวร — id จะเปลี่ยนก่อนเกมเริ่ม */
|
||||
console.log('[ban-debug] ban target key=', sp2.bannedPlayerKey || '(none/bot)');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3863,7 +3980,12 @@ function assignDetectiveSuspectCardMinigames(space) {
|
||||
(1-3; ผู้ต้องสงสัย 1 คนเล่นได้ 3 ครั้ง) → ได้การ์ดคนละ 1 ใบแน่นอน แทนสุ่ม 1/3 ทุกเกม (กัน variance) */
|
||||
space.specialQuizPlayBySuspect = { 0: 1 + Math.floor(Math.random() * 3), 1: 1 + Math.floor(Math.random() * 3), 2: 1 + Math.floor(Math.random() * 3) };
|
||||
space.suspectPlayCount = { 0: 0, 1: 0, 2: 0 };
|
||||
console.log('[special-quiz] schedule set (per-case):', JSON.stringify(space.specialQuizPlayBySuspect));
|
||||
/* [2026-07-17] สำรับการ์ดต่อคดี — สุ่ม 3 ใบ "ไม่ซ้ำกัน" จาก 7 ใบ แจกผู้ต้องสงสัยคนละใบ (user request:
|
||||
"ได้การ์ดเดียวกันสามเกม จริงๆต้องไม่ตรงกัน"). เดิมทุก suspect ได้ set.specialCard ที่ Admin ตั้งไว้
|
||||
"ใบเดียวต่อคดี" → 3 เกมได้ใบเดิมเสมอ. แจกตรงนี้เพราะเป็นจุดเริ่มคดี = ล้างพร้อม suspectPlayCount
|
||||
ไม่ค้างข้ามคดี. ค่า specialCard ใน Admin จะไม่ถูกใช้เป็นตัวเลือกอีก (ยังใช้เป็น fallback ถ้าสำรับหาย) */
|
||||
space.specialCardDeck = specialQuizShuffle([1, 2, 3, 4, 5, 6, 7]).slice(0, 3);
|
||||
console.log('[special-quiz] schedule set (per-case):', JSON.stringify(space.specialQuizPlayBySuspect), 'deck=' + JSON.stringify(space.specialCardDeck));
|
||||
/* #6: ความคืบหน้าการสืบต่อผู้ต้องสงสัย (shared — เท่ากันทุกคน) = จำนวนมินิเกมที่เล่นให้คนนั้น (0-3)
|
||||
ใช้โชว์ช่องหลักฐานใต้การ์ด suspect แทนหลักฐานส่วนตัว (เดิมแต่ละคนเห็นไม่เท่ากัน) */
|
||||
space.suspectTeamProgress = [0, 0, 0];
|
||||
@@ -4105,8 +4227,10 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
|
||||
space.lastMinigameOver = null;
|
||||
/* Card 3 Ban — ผู้ที่ถูกแบนไม่ได้เล่นมินิเกมรอบนี้ (1 รอบ) */
|
||||
space.bannedThisRunPlayerId = space.bannedPlayerId || null;
|
||||
console.log('[ban-debug] startMinigame: bannedThisRunPlayerId=', space.bannedThisRunPlayerId);
|
||||
space.bannedThisRunPlayerKey = space.bannedPlayerKey || ''; /* ตัวหลักที่ใช้แมตช์จริง (id เปลี่ยนได้) */
|
||||
console.log('[ban-debug] startMinigame: bannedThisRunPlayerId=', space.bannedThisRunPlayerId, 'key=', space.bannedThisRunPlayerKey || '(none/bot)');
|
||||
space.bannedPlayerId = null;
|
||||
space.bannedPlayerKey = '';
|
||||
/* จำนวนผู้เล่นที่ต้องเข้าเกม + กด Ready ให้ครบก่อน host เริ่มได้ (ไม่นับผู้ถูกแบน)
|
||||
สแนปจาก peers ใน LobbyB ตอนนี้ — กัน host เริ่มก่อน client โหลด/กด Ready ครบ (เกมไม่ sync/จบไม่พร้อมกัน) */
|
||||
space.minigameExpectedActiveCount = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)).length;
|
||||
@@ -4169,6 +4293,7 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
|
||||
detectiveMinigame: true,
|
||||
minigameLabel: cardEntry.labelTh,
|
||||
bannedPlayerId: space.bannedThisRunPlayerId || null,
|
||||
bannedPlayerKey: space.bannedThisRunPlayerKey || '', /* client เทียบ playerKey (id เปลี่ยนหลัง reconnect) */
|
||||
});
|
||||
setTimeout(() => {
|
||||
const spNow = spaces.get(sid);
|
||||
@@ -4208,6 +4333,7 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
|
||||
detectiveReturnLobbyB: true,
|
||||
minigameLabel: cardEntry.labelTh,
|
||||
bannedPlayerId: space.bannedThisRunPlayerId || null,
|
||||
bannedPlayerKey: space.bannedThisRunPlayerKey || '', /* client เทียบ playerKey (id เปลี่ยนหลัง reconnect) */
|
||||
};
|
||||
if (space.jsLayout) gameStartPayload.jsLayout = space.jsLayout;
|
||||
if (md.gameType === 'gauntlet' && space.gauntletRun) {
|
||||
@@ -4297,6 +4423,11 @@ function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) {
|
||||
space.mapId = POST_CASE_LOBBY_SPACE_ID;
|
||||
space.mapData = lb;
|
||||
space.suspectPhaseActive = true;
|
||||
/* [TC166-fix] arm backstop ตรงนี้ = "ตอนกลับถึง LobbyB จริง" — เดิม arm ไว้ตั้งแต่ beginDetectiveSuspectMinigame
|
||||
(25วิ) แต่มินิเกมยาวกว่านั้น → timer ยิงตอนยังอยู่ในมินิเกม → เจอ guard !serverMapIsPostCaseLobbyB แล้ว
|
||||
return ทิ้ง → พอกลับ LobbyB ไม่เหลือ backstop เลย. ถ้า client ไหนค้างไม่ส่ง lobbyb-loaded (เช่นโดนดึงออก
|
||||
กลางมินิเกม) gate จะค้าง present<total ถาวร = ทั้งห้องเล่นต่อไม่ได้ */
|
||||
armLobbyBLoadedSafety(sid, space);
|
||||
space.peers.forEach((p) => {
|
||||
const sp = pickSpawnForJoin(lb, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0);
|
||||
p.x = sp.x;
|
||||
@@ -4498,6 +4629,54 @@ function computeAndEmitTrialResult(sid, space) {
|
||||
} catch (e) { console.error('[achv] trial-result', e && e.message); }
|
||||
|
||||
space.silencedPlayerId = null;
|
||||
|
||||
/* item1: admin ตั้งปลายทางหลังจบคดี = 'lobbya' → รีเซ็ตห้องเดิมกลับ LobbyA (ครั้งเดียว/คดี, terminal
|
||||
เท่านั้น — revote คืนก่อนถึงจุดนี้แล้ว). ทำเงียบ (ไม่ broadcast) → client โชว์ผล+หน้าความรู้ต่อได้
|
||||
แล้ว reload (room-lobby.html?space=<id>) มาเจอ LobbyA สด */
|
||||
try {
|
||||
if (runtimeGameTiming && runtimeGameTiming.postCaseDestination === 'lobbya' && !space.postCaseLobbyAReset) {
|
||||
space.postCaseLobbyAReset = true;
|
||||
resetDetectiveSpaceToLobbyA(sid, space);
|
||||
}
|
||||
} catch (e) { console.error('[item1] post-case reset', e && e.message); }
|
||||
}
|
||||
|
||||
/* item1: รีเซ็ตห้อง detective กลับสภาพ LobbyA สดๆ — สร้าง space object ใหม่ (คง identity/peers/host/config)
|
||||
เพื่อกัน state คดีค้าง (evidence/ban/trial/suspect/timers). client reload → join-space เจอ LobbyA. */
|
||||
function resetDetectiveSpaceToLobbyA(sid, space) {
|
||||
if (!space) return;
|
||||
const lobbyMapId = space.lobbyAMapId || space.mapId;
|
||||
const lobbyMap = (lobbyMapId && maps.get(lobbyMapId)) || space.lobbyAMapData || space.mapData;
|
||||
/* หยุด timer ทั้งหมดของห้องเดิม (กัน callback ค้างมายุ่งกับ object ใหม่) */
|
||||
try { stopGauntletTicker(space); } catch (e) { /* ignore */ }
|
||||
try { clearSpaceQuizTimers(space); } catch (e) { /* ignore */ }
|
||||
try { clearTrialVoteTimer(space); } catch (e) { /* ignore */ }
|
||||
['storyReadTimer', 'troublesomeDebTimer', 'troublesomeMaxTimer', 'lobbyBLoadedSafetyTimer', 'pickVoteTimer', 'banVoteTimer'].forEach((t) => {
|
||||
if (space[t]) { try { clearTimeout(space[t]); } catch (e) { /* ignore */ } space[t] = null; }
|
||||
});
|
||||
/* คืน spawn LobbyA + ยกเลิก ready ให้ทุก peer (client reload จะสร้าง peer ใหม่อยู่แล้ว — นี่กันช่วงคาบเกี่ยว) */
|
||||
space.peers.forEach((p) => {
|
||||
try {
|
||||
const sp = pickSpawnForJoin(lobbyMap, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0);
|
||||
p.x = sp.x; p.y = sp.y; p.direction = 'down';
|
||||
} catch (e) { /* ignore */ }
|
||||
p.ready = false;
|
||||
p.bannedSpectator = false;
|
||||
});
|
||||
/* สร้าง space ใหม่แบบ LobbyA สด (เท่ากับห้องที่เพิ่งสร้าง) — คง identity/peers/host/config, ทิ้ง state คดี */
|
||||
const fresh = {
|
||||
mapData: lobbyMap, mapId: lobbyMapId, peers: space.peers, spaceName: space.spaceName,
|
||||
hostId: space.hostId, hostPlayerKey: space.hostPlayerKey, isPrivate: space.isPrivate,
|
||||
maxPlayers: space.maxPlayers, createdAt: space.createdAt, roomPassword: space.roomPassword,
|
||||
previewEditorTest: space.previewEditorTest, botSlotCount: space.botSlotCount,
|
||||
lobbyAMapId: lobbyMapId, lobbyAMapData: lobbyMap,
|
||||
suspectCardMinigames: null, suspectPhaseActive: false, suspectPickIndex: 0, detectiveMinigameActive: false,
|
||||
graceReconnect: space.graceReconnect || {}, graceTimers: space.graceTimers || {},
|
||||
/* กันห้องถูกลบตอน client reload กลับเข้า LobbyA (peer=0 ชั่วขณะ) — keep-alive 5 นาที ให้ทุกคน rejoin ทัน */
|
||||
postCaseKeepAliveUntil: Date.now() + 300000,
|
||||
};
|
||||
spaces.set(sid, fresh);
|
||||
try { glog(sid, fresh, 'post-case', '[item1] reset ห้องกลับ LobbyA (postCaseDestination=lobbya) — รอ client reload'); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
/* ===== ห้องสรุปหลักฐาน — การไต่สวน (ปากคำ 3 รอบ ก่อนโหวต) ===== */
|
||||
@@ -4910,8 +5089,7 @@ function scheduleQuizReadPhase(sid, space, md) {
|
||||
text: String(q.text || '').trim(),
|
||||
endsAt: sess.phaseEndsAt,
|
||||
});
|
||||
const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs);
|
||||
space.quizTimers.push(t);
|
||||
pausableSpecialQuizTimeout(sid, space, () => scheduleQuizAnswerPhase(sid, space, md), readMs); /* หยุดนับตอนคำถามพิเศษ */
|
||||
}
|
||||
|
||||
function scheduleQuizAnswerPhase(sid, space, md) {
|
||||
@@ -4930,8 +5108,7 @@ function scheduleQuizAnswerPhase(sid, space, md) {
|
||||
/* บอท: server ตัดสินคำตอบข้อนี้ + ตั้งเป้าเดินไปโซน (server tick เดินให้ในช่วง answer) */
|
||||
const qCur = sess.questions[sess.qIndex];
|
||||
if (qCur) quizBotsDecideAnswers(space, sess.quizMapMd || md, !!qCur.answerTrue);
|
||||
const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs);
|
||||
space.quizTimers.push(t);
|
||||
pausableSpecialQuizTimeout(sid, space, () => resolveQuizRound(sid, space, md), answerMs); /* หยุดนับตอนคำถามพิเศษ */
|
||||
}
|
||||
|
||||
function resolveQuizRound(sid, space, md) {
|
||||
@@ -5626,8 +5803,7 @@ function scheduleQuizCarryReadPhase(sid, space) {
|
||||
endsAt: s.phaseEndsAt,
|
||||
});
|
||||
if (readMs <= 0) { scheduleQuizCarryAnswerPhase(sid, space); return; }
|
||||
const t = setTimeout(() => scheduleQuizCarryAnswerPhase(sid, space), readMs);
|
||||
space.quizTimers.push(t);
|
||||
pausableSpecialQuizTimeout(sid, space, () => scheduleQuizCarryAnswerPhase(sid, space), readMs); /* หยุดนับตอนคำถามพิเศษ */
|
||||
}
|
||||
|
||||
function scheduleQuizCarryAnswerPhase(sid, space) {
|
||||
@@ -5650,8 +5826,7 @@ function scheduleQuizCarryAnswerPhase(sid, space) {
|
||||
optionRevealAt: s.optionRevealAt,
|
||||
endsAt: s.phaseEndsAt,
|
||||
});
|
||||
const t = setTimeout(() => resolveQuizCarryTimeup(sid, space), ansMs);
|
||||
space.quizTimers.push(t);
|
||||
pausableSpecialQuizTimeout(sid, space, () => resolveQuizCarryTimeup(sid, space), ansMs); /* หยุดนับตอนคำถามพิเศษ */
|
||||
}
|
||||
|
||||
function startQuizCarryGame(sid, space) {
|
||||
@@ -8545,11 +8720,21 @@ function buildStackTurnOrderServer(space, s) {
|
||||
order.push({ kind: 'human', seat, id, botId: null, theme: stackHumanThemeServer(p) });
|
||||
seat++;
|
||||
}
|
||||
/* บอท takeover (คนหลุดช่วง how-to) ได้ seat ต่อจากคน ก่อนบอท AI — คง avatar/ตำแหน่งลำดับเดิมไว้ */
|
||||
/* บอท takeover (คนหลุดช่วง how-to) ได้ seat ต่อจากคน ก่อนบอท AI — คง avatar/ตำแหน่งลำดับเดิมไว้
|
||||
ส่ง characterId/สี/ชื่อ ของคนเดิมไปด้วย: client ลบ peer ทิ้งไปแล้วตอน 'user-left' (play.js:21769)
|
||||
และ botId = socket.id เดิมของเขา → others.get(botId) = null → เดิมวาดเป็นอวตารเขียว + ไม่รู้ว่าใคร */
|
||||
for (const [botId, b] of s.bots) {
|
||||
if (!b || !b.takeover) continue;
|
||||
if (seat > STACK_SEATS) break;
|
||||
order.push({ kind: 'bot', seat, id: null, botId, theme: (b.theme != null ? b.theme : ((seat % 8) + 1)) });
|
||||
order.push({
|
||||
kind: 'bot', seat, id: null, botId,
|
||||
theme: (b.theme != null ? b.theme : ((seat % 8) + 1)),
|
||||
characterId: b.characterId != null ? b.characterId : null,
|
||||
lobbyColorThemeIndex: b.colorThemeIdx != null ? b.colorThemeIdx : null,
|
||||
lobbySkinToneIndex: b.skinToneIdx != null ? b.skinToneIdx : null,
|
||||
nickname: b.nick || '',
|
||||
takeover: true, /* client: บอทที่มาจากคนหลุด (ไม่ใช่บอท AI) → โชว์ชื่อเดิม + "(บอท)" */
|
||||
});
|
||||
seat++;
|
||||
}
|
||||
const botCount = Math.max(0, Math.min(STACK_SEATS - order.length, effectiveBotSlotCount(space)));
|
||||
@@ -8582,7 +8767,16 @@ function emitStackStateServer(sid, space) {
|
||||
const lthemes = s.layers.map((l) => (l.theme && l.theme >= 1 && l.theme <= 8 ? l.theme : 0));
|
||||
io.to(sid).emit('stack-state', {
|
||||
srv: true,
|
||||
order: s.turnOrder.map((e) => ({ kind: e.kind, seat: e.seat, id: e.id, botId: e.botId, theme: e.theme })),
|
||||
/* ต้องส่ง characterId/สี/ชื่อ ของบอท takeover ต่อด้วย — ถ้า map ทิ้งตรงนี้ ที่ใส่ไว้ใน
|
||||
buildStackTurnOrderServer ก็สูญเปล่า (client ได้แต่ theme → หน้าเขียว) */
|
||||
order: s.turnOrder.map((e) => ({
|
||||
kind: e.kind, seat: e.seat, id: e.id, botId: e.botId, theme: e.theme,
|
||||
characterId: e.characterId != null ? e.characterId : null,
|
||||
lobbyColorThemeIndex: e.lobbyColorThemeIndex != null ? e.lobbyColorThemeIndex : null,
|
||||
lobbySkinToneIndex: e.lobbySkinToneIndex != null ? e.lobbySkinToneIndex : null,
|
||||
nickname: e.nickname || '',
|
||||
takeover: !!e.takeover,
|
||||
})),
|
||||
idx: s.turnIndex,
|
||||
hostId: null,
|
||||
lthemes,
|
||||
@@ -8653,6 +8847,16 @@ function runStackTick(sid, space) {
|
||||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||||
if (!md || md.gameType !== 'stack') { stopStackTicker(space); return; }
|
||||
const now = Date.now();
|
||||
/* คำถามพิเศษ → หยุดเกม: เลื่อน startMs ตาม tick (คงเวลาที่เหลือ) + ไม่แกว่ง/ไม่เช็คจบ (เหมือน MG2/6/7) */
|
||||
/* [2026-07-17] เวลาจริง ไม่ใช่ก้าว STACK_TICK_MS คงที่ — ก้าวคงที่ชดเชยไม่ครบเมื่อ tick จริงเดินช้ากว่า
|
||||
ที่ตั้งไว้ (บทเรียน MG5 07-16: กู้คืนได้แค่ 68%) */
|
||||
if (isSpecialQuizPausedServer(space)) {
|
||||
const nowS = Date.now();
|
||||
s.startMs += Math.max(0, Math.min(1000, nowS - (s.lastPauseShiftAt || nowS)));
|
||||
s.lastPauseShiftAt = nowS;
|
||||
return;
|
||||
}
|
||||
s.lastPauseShiftAt = Date.now();
|
||||
/* เงื่อนไขจบเกม (server ตัดสินแหล่งเดียว) */
|
||||
if (s.progressPct >= 100) { stackSessionEnd(sid, space, 'decrypt_complete'); return; }
|
||||
if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) { stackSessionEnd(sid, space, 'misses_exhausted'); return; }
|
||||
@@ -8734,6 +8938,14 @@ function startStackTicker(sid, space) {
|
||||
s3.bots.set(rec.id, {
|
||||
tier: 'avg', score: 0, takeover: true, takeoverPlayerKey: rec.playerKey || '',
|
||||
theme: normStackTheme8(rec.themeIdx),
|
||||
/* หน้า/สี/ชื่อ ของคนที่หลุด — recordPendingMissionTakeover เก็บไว้ให้ครบแล้ว (7982-7984) แต่เดิม
|
||||
ตรงนี้หยิบมาแค่ theme → order row ไม่มี characterId → client วาดหน้าไม่ได้ = "อวตารตัวเขียว"
|
||||
(setCyberHudScoreAvatarImg: ไม่มี cid → defaultAvatarImg). คอมเมนต์ที่ buildStackTurnOrderServer
|
||||
เขียนไว้เองว่า "คง avatar เดิมไว้" — ตอนนี้ทำจริงตามนั้น */
|
||||
characterId: rec.characterId != null ? rec.characterId : null,
|
||||
colorThemeIdx: rec.themeIdx != null ? rec.themeIdx : null,
|
||||
skinToneIdx: rec.skinIdx != null ? rec.skinIdx : null,
|
||||
nick: rec.nick || '',
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -8956,10 +9168,22 @@ function jsGridPosStandingOnPlatform(md, ts, tx, ty, scrollPx) {
|
||||
const platTop = rawTop + k * period;
|
||||
return { x: (tx + 0.5) - cw * 0.5, y: platTop / ts - ch };
|
||||
}
|
||||
/* [2026-07-17] MG5 ช่วงท้ายเลื่อนเร็วขึ้น ×2 (user request) — 15 วิสุดท้ายของรอบ.
|
||||
⚠️ สูตรนี้ต้อง "เหมือนกันเป๊ะ" กับฝั่ง client (play.js:jsScrollPxAtPlay / jsRiseVelNowPlay)
|
||||
ไม่งั้นแท่นจะอยู่คนละที่ระหว่าง server กับที่ผู้เล่นเห็น = ชน/ไม่ชนไม่ตรงกัน
|
||||
(บทเรียนทั้งวันนี้: สูตรที่คำนวณสองฝั่งต้องแก้พร้อมกันเสมอ)
|
||||
ตำแหน่งต้องต่อเนื่อง: ใช้ผลรวมของสองช่วง (t₀·v + (t−t₀)·2v) ไม่ใช่คูณทั้งก้อน ไม่งั้นฉากจะกระโดด */
|
||||
const JS_ENDGAME_BOOST_SEC = 15;
|
||||
const JS_ENDGAME_BOOST_MUL = 2;
|
||||
function jsScrollPxAt(elapsedSec, risePerSec, totalSec) {
|
||||
const t0 = Math.max(0, (Number(totalSec) || 0) - JS_ENDGAME_BOOST_SEC);
|
||||
if (!(Number(totalSec) > 0) || elapsedSec <= t0) return elapsedSec * risePerSec;
|
||||
return (t0 * risePerSec) + ((elapsedSec - t0) * risePerSec * JS_ENDGAME_BOOST_MUL);
|
||||
}
|
||||
function jsScrollPx(s, md) {
|
||||
const risePerSec = Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42;
|
||||
const elapsed = Math.max(0, (Date.now() - (s.liveBeginsAt || s.startMs)) / 1000);
|
||||
return elapsed * risePerSec;
|
||||
return jsScrollPxAt(elapsed, risePerSec, jsTimeLimitSec(md));
|
||||
}
|
||||
function jsTimeLimitSec(md) {
|
||||
/* ตรงกับ client jumpSurviveTimeLimitSecForMission: md.jumpSurviveTimeSec → game-timing.jumpSurviveMissionTimeSec → 60 */
|
||||
@@ -9068,6 +9292,21 @@ function stepJumpSurviveBots(sid, space, dt, now) {
|
||||
/* ขยับบอทเฉพาะหลังนับถอยหลัง 3-2-1 จบ (liveBeginsAt) — ก่อนหน้านี้บอทเริ่มทันทีที่ session active
|
||||
ทำให้บอทวิ่ง/กระโดดก่อน "1" จบ → กันด้วยการ return จนถึงเวลาจริง (เหมือน scroll ที่อิง liveBeginsAt อยู่แล้ว) */
|
||||
if (now < (s.liveBeginsAt || s.startMs || 0)) return;
|
||||
/* คำถามพิเศษเปิดอยู่ → หยุดบอท + เลื่อน endsAt ไม่ให้เวลาเดิน (เหมือน MG2/MG3/MG6/MG7)
|
||||
pt8 (07-11) audit ข้าม MG5 ไปเกมเดียว: วันนั้นแก้แต่ฝั่ง client (jumpSurviveTickFrame early-return
|
||||
ตอน specialQuizFreeze) โดยคิดว่า "scroll/timer อิง anchor อยู่แล้ว" — แต่ s.endsAt ฝั่ง server เป็น
|
||||
เวลาสัมบูรณ์ ไม่มีใครเลื่อน → รอบจบ time_up ได้ทั้งที่ผู้เล่นยังตอบคำถามค้างอยู่ (วัดได้: pause 35 วิ
|
||||
แต่เกมจบที่ 63 วิ = เท่า deadline ที่ไม่ pause).
|
||||
⚠️ เลื่อนด้วย "เวลาจริงที่ผ่านไป" ไม่ใช่ก้าว JS_TICK_MS คงที่แบบ MG6/MG7: sweep นี้เป็น
|
||||
setInterval(33ms) ตัวเดียวที่วนทุกห้อง — ของจริงเดิน ~48ms ตอนมีโหลด → บวกทีละ 33 = ชดเชยไม่ครบ
|
||||
(วัดได้: บวกแบบคงที่กู้คืนได้แค่ 24.1 จาก 35.3 วิ = 68% → เกมยังจบก่อนเวลา 11 วิ).
|
||||
เป็นบทเรียนเดียวกับ advanceStackSwingPhase (07-10): อย่าสมมติว่า tick มาตรงเวลา */
|
||||
if (isSpecialQuizPausedServer(space)) {
|
||||
if (s.endsAt > 0) s.endsAt += Math.max(0, Math.min(1000, now - (s.lastPauseShiftAt || now)));
|
||||
s.lastPauseShiftAt = now;
|
||||
return;
|
||||
}
|
||||
s.lastPauseShiftAt = now; /* ไม่ pause → คง anchor ไว้ให้สด (กันกระโดดตอนเริ่ม pause ครั้งถัดไป) */
|
||||
/* จบรอบ: หมดเวลา หรือทุก entity ตาย */
|
||||
if (now >= s.endsAt) { endJumpSurviveGame(sid, space, 'time_up'); return; }
|
||||
const ts = jsTileSize(md);
|
||||
@@ -9268,7 +9507,15 @@ function stepSpaceShooterBots(sid, space, dt, now) {
|
||||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||||
if (!md || md.gameType !== 'space_shooter') return;
|
||||
/* คำถามพิเศษเปิดอยู่ → หยุดบอท (เดิน+ยิง) + เลื่อน endsAt ไม่ให้เวลาเดิน (เหมือน gauntlet) */
|
||||
if (isSpecialQuizPausedServer(space)) { if (s.endsAt > 0) s.endsAt += SS_TICK_MS; return; }
|
||||
/* [2026-07-17] เวลาจริง ไม่ใช่ก้าว SS_TICK_MS คงที่ (flaw เดียวกับ MG5 ที่ชดเชยได้แค่ 68%) */
|
||||
if (isSpecialQuizPausedServer(space)) {
|
||||
const nowS = Date.now();
|
||||
const shift = Math.max(0, Math.min(1000, nowS - (s.lastPauseShiftAt || nowS)));
|
||||
if (s.endsAt > 0) s.endsAt += shift;
|
||||
s.lastPauseShiftAt = nowS;
|
||||
return;
|
||||
}
|
||||
s.lastPauseShiftAt = Date.now();
|
||||
if (s.endsAt > 0 && now >= s.endsAt) { endSpaceShooterGame(sid, space, 'time_up'); return; }
|
||||
const ts = jsTileSize(md);
|
||||
const mw = (md.width || 20) * ts, mh = (md.height || 15) * ts;
|
||||
@@ -9302,6 +9549,16 @@ function stepSpaceShooterBots(sid, space, dt, now) {
|
||||
s.lastEmit = now;
|
||||
if (outBots.length) io.to(sid).emit('space-shooter-bot-move', { bots: outBots });
|
||||
}
|
||||
/* [2026-07-17] ตายหมด → จบรอบ — บั๊กเดียวกับ MG7 (เดิมมีแค่หมดเวลา). endSpaceShooterGame รองรับ
|
||||
reason 'all_dead' อยู่แล้ว (emit kind:'all_dead') แค่ไม่เคยมีใครเรียก. เช็คทั้งสองที่เพราะตอนคนตาย
|
||||
server เซ็ตคู่กัน (:13516 p.spaceShooterEliminated + s.players[id].eliminated) */
|
||||
let ssAnyAlive = false;
|
||||
space.peers.forEach((p, id) => {
|
||||
const st = s.players[id];
|
||||
if (!((st && st.eliminated) || p.spaceShooterEliminated)) ssAnyAlive = true;
|
||||
});
|
||||
if (!ssAnyAlive) s.bots.forEach((b) => { if (!b.eliminated) ssAnyAlive = true; });
|
||||
if (!ssAnyAlive) endSpaceShooterGame(sid, space, 'all_dead');
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -9384,6 +9641,8 @@ function startBalloonBossGame(sid, space) {
|
||||
active: true, ended: false, startMs: liveBeginsAt, liveBeginsAt,
|
||||
endsAt: lim > 0 ? liveBeginsAt + lim * 1000 + 2000 : 0,
|
||||
maxHp: bbMaxHp(md), bots: new Map(), lastEmit: 0,
|
||||
/* [P5 v4] ฐานเวลาวงลูกศร — server เป็นเจ้าของ, เลื่อนตอนคำถามพิเศษ, ส่งลงไปกับ bot-move ทุก 50ms */
|
||||
ringAnchorMs: liveBeginsAt, lastPauseShiftAt: 0,
|
||||
};
|
||||
/* คนหลุดช่วง how-to → seed บอท takeover ก่อนสร้างบอท AI */
|
||||
seedBalloonBossTakeoverBots(space, md);
|
||||
@@ -9441,7 +9700,23 @@ function stepBalloonBossBots(sid, space, dt, now) {
|
||||
if (!md || md.gameType !== 'balloon_boss') return;
|
||||
/* คำถามพิเศษเปิดอยู่ → หยุดบอท (ลอย+ยิง) + เลื่อน endsAt ไม่ให้เวลาเดิน (เหมือน gauntlet)
|
||||
กันกระสุนบอทถูก emit สะสมตอน pause แล้วไหลรัวหลังตอบเสร็จ */
|
||||
if (isSpecialQuizPausedServer(space)) { if (s.endsAt > 0) s.endsAt += BB_TICK_MS; return; }
|
||||
if (isSpecialQuizPausedServer(space)) {
|
||||
/* [P5 v4 2026-07-17] เลื่อนด้วย "เวลาจริง" ไม่ใช่ก้าว BB_TICK_MS คงที่ — บทเรียน MG5 (07-16):
|
||||
ตอนมีโหลด tick จริงเดินช้ากว่าค่าที่ตั้งไว้ → ก้าวคงที่ชดเชยไม่ครบ (MG5 ได้แค่ 68% ของเวลาที่ควรคืน) */
|
||||
const shift = Math.max(0, Math.min(1000, now - (s.lastPauseShiftAt || now)));
|
||||
if (s.endsAt > 0) s.endsAt += shift;
|
||||
s.ringAnchorMs += shift; /* วงลูกศรหยุดหมุนพร้อมกันทุกเครื่อง */
|
||||
s.lastPauseShiftAt = now;
|
||||
/* ยัง emit ระหว่าง pause → ทุกเครื่อง re-align anchor ต่อเนื่อง. ถ้าเงียบ client จะ extrapolate
|
||||
perf.now() ของตัวเองต่อ (วงหมุนต่อคนละมุม) แล้วกระตุกตอน emit กลับมา. bots:[] = no-op ฝั่ง client
|
||||
(handler แค่ forEach ไม่มี logic ลบบอท) — ตำแหน่งบอทไม่ขยับตอน pause อยู่แล้ว */
|
||||
if (now - s.lastEmit >= BB_BOT_EMIT_MS) {
|
||||
s.lastEmit = now;
|
||||
io.to(sid).emit('balloon-boss-bot-move', { bots: [], t: Date.now(), anchor: s.ringAnchorMs });
|
||||
}
|
||||
return;
|
||||
}
|
||||
s.lastPauseShiftAt = now;
|
||||
/* จบรอบ: ชนะบอส (HP→0) ก่อน, แล้วหมดเวลา */
|
||||
if (bbTeamDmg(space) >= s.maxHp) { endBalloonBossGame(sid, space, 'victory'); return; }
|
||||
if (s.endsAt > 0 && now >= s.endsAt) { endBalloonBossGame(sid, space, 'time'); return; }
|
||||
@@ -9491,8 +9766,23 @@ function stepBalloonBossBots(sid, space, dt, now) {
|
||||
alive.forEach((b) => { const cl = bbClampWorld(b.homeCx, b.homeCy, mw, mh); b.homeCx = cl.cx; b.homeCy = cl.cy; }); /* กัน home ดริฟต์หลุดขอบ */
|
||||
if (now - s.lastEmit >= BB_BOT_EMIT_MS) {
|
||||
s.lastEmit = now;
|
||||
if (outBots.length) io.to(sid).emit('balloon-boss-bot-move', { bots: outBots });
|
||||
/* [P5 v3] แนบนาฬิกา server (Date.now) → ทุก client (รวม host) ใช้เป็นฐานเวลา "วงลูกศรหมุน" แทน
|
||||
serverNow() ของตัวเอง (playClockOffset ต่างต่อเครื่อง = วงไม่ sync). ในเกมจริง MG7 เป็น server-auth
|
||||
(host ไม่ส่ง balloon-boss-sync) → ฐานเวลาต้องมาจาก server. bot-move ส่งทุก 50ms = re-align ถี่.
|
||||
ส่ง "เสมอ" (แม้ไม่มีบอท = ห้อง 6 คนจริง) เพื่อให้ฐานเวลาไหลเสมอ — empty array 20Hz cost น้อยมาก
|
||||
และอยู่ใน balloon tick อยู่แล้ว (ไม่กระทบมินิเกมอื่น) */
|
||||
/* [P5 v4] anchor = ฐานเวลาวงลูกศรของ server (pause-adjusted). v2/v3 ส่งแค่ `t` (นาฬิกา) ซึ่งตรงแล้ว
|
||||
แต่ client ยังลบด้วย anchor "ของตัวเอง" ที่ shiftSpecialQuizFrozenTimers เลื่อนเองทุกเฟรม = สะสม
|
||||
ต่อเครื่อง และไม่มี path ไหน adopt กลับในโหมด server-auth (balloon-boss-sync ถูกปิด) → ไม่มีวัน converge */
|
||||
io.to(sid).emit('balloon-boss-bot-move', { bots: outBots, t: Date.now(), anchor: s.ringAnchorMs });
|
||||
}
|
||||
/* [2026-07-17] ทุก entity ตายหมด → จบรอบ (user: "mg7 ตายหมดเกมไม่จบ"). เดิม MG7 มีเงื่อนไขจบแค่
|
||||
victory (HP บอสหมด) กับหมดเวลา → ตายครบก็นั่งรอจนหมดเวลาเปล่าๆ. ใช้ pattern เดียวกับ MG5 (:9345)
|
||||
คน: p.balloonBossEliminated (server เซ็ตจาก move payload :13564, sticky) · บอท: b.eliminated */
|
||||
let bbAnyAlive = false;
|
||||
space.peers.forEach((p) => { if (!p.balloonBossEliminated) bbAnyAlive = true; });
|
||||
if (!bbAnyAlive) s.bots.forEach((b) => { if (!b.eliminated) bbAnyAlive = true; });
|
||||
if (!bbAnyAlive) endBalloonBossGame(sid, space, 'all_dead');
|
||||
}
|
||||
|
||||
function startGauntletTicker(sid, space) {
|
||||
@@ -9759,15 +10049,17 @@ function stepLobbyBotsForSpace(sid, space, dt, nowMs) {
|
||||
isWalking: !!b.isWalking,
|
||||
}));
|
||||
if (arr.length) io.to(sid).emit('lobby-bot-move', { bots: arr });
|
||||
/* roster snapshot (L3 ฆ่าผี) — ทุก ~1.5s ส่ง id ผู้เล่นจริงทั้งหมด ให้ client ตัดตัวที่ไม่อยู่ทิ้ง */
|
||||
if (!space.lobbyRosterSyncTs || nowMs - space.lobbyRosterSyncTs > 1500) {
|
||||
space.lobbyRosterSyncTs = nowMs;
|
||||
emitLobbyRosterSync(sid, space);
|
||||
}
|
||||
/* roster snapshot (L3 ฆ่าผี) — ทุก ~1.5s ส่ง id ผู้เล่นจริงทั้งหมด ให้ client ตัดตัวที่ไม่อยู่ทิ้ง
|
||||
(throttle ย้ายเข้าไปใน emitLobbyRosterSync แล้ว — กันยิงซ้ำเมื่อทั้ง sweep นี้และ interval P3 เรียก) */
|
||||
emitLobbyRosterSync(sid, space);
|
||||
}
|
||||
|
||||
/* throttle ~1.5s ต่อห้อง — ปลอดภัยไม่ว่าถูกเรียกจาก bot sweep (ห้องมีบอท) หรือ interval P3 (ทุกห้อง) */
|
||||
function emitLobbyRosterSync(sid, space) {
|
||||
if (!space || !space.peers) return;
|
||||
const nowMs = Date.now();
|
||||
if (space.lobbyRosterSyncTs && nowMs - space.lobbyRosterSyncTs <= 1500) return;
|
||||
space.lobbyRosterSyncTs = nowMs;
|
||||
const ids = [...space.peers.keys()];
|
||||
io.to(sid).emit('lobby-roster', { ids });
|
||||
}
|
||||
@@ -9791,11 +10083,27 @@ setInterval(() => {
|
||||
for (const [sid, space] of spaces) {
|
||||
if (!space || !space.peers || space.peers.size === 0) continue;
|
||||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||||
if (!md || md.gameType === 'lobby') continue; /* lobby ใช้ lobby-roster แยก */
|
||||
if (!md || md.gameType === 'lobby') continue; /* lobby ใช้ lobby-roster แยก (heartbeat ด้านล่าง) */
|
||||
try { io.to(sid).emit('play-roster', { ids: [...space.peers.keys()] }); } catch (e) { /* กัน 1 ห้องล้ม */ }
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
/* [P3 2026-07-16] roster heartbeat ของ "lobby" — ต้องยิงทุกห้อง lobby ที่มีคน "ไม่ว่ามีบอทหรือไม่".
|
||||
บั๊ก: เดิม emitLobbyRosterSync ถูกเรียก "ที่เดียว" คือใน stepLobbyBotsForSpace (bot sweep) ซึ่ง
|
||||
return ก่อนถ้า effectiveBotSlotCount() <= 0 — และค่านั้น = 0 เสมอเมื่อ maxPlayers >= 4 (สร้างห้อง
|
||||
เลือก ≥4 คน). ผลคือห้อง 4+ คน "ไม่เคยได้ lobby-roster" → ตัวฆ่าผี L3 ไม่ทำงาน → ถ้า user-left
|
||||
หลุด/มาไม่ถึงเครื่องไหน peer ผีค้างถาวร → peers.size ต่างกัน → เลข PLAYERS ของ host ไม่ตรงคนอื่น
|
||||
("ในบาง case" = เฉพาะห้องที่เลือก ≥4 คน). ยืนยันจาก gamelog: ห้อง 'บอท 0' คือห้องที่มีอาการ.
|
||||
แยกออกมาเป็น interval ของตัวเอง → roster ไม่ผูกกับการมีบอทอีกต่อไป */
|
||||
setInterval(() => {
|
||||
for (const [sid, space] of spaces) {
|
||||
if (!space || !space.peers || space.peers.size === 0) continue;
|
||||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||||
if (!md || md.gameType !== 'lobby') continue; /* เฉพาะโถง lobby (มินิเกมใช้ play-roster ด้านบน) */
|
||||
try { emitLobbyRosterSync(sid, space); } catch (e) { /* กัน 1 ห้องล้ม */ }
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
/* เดินบอท MG1 quiz บน server (time-based) → บอทเดินเองไม่พึ่ง host (host หลุดก็ไม่หยุด) */
|
||||
let quizBotSweepTs = 0;
|
||||
setInterval(() => {
|
||||
@@ -9982,11 +10290,19 @@ function runGauntletTick(sid, space) {
|
||||
const gr = space.gauntletRun;
|
||||
if (!gr) return;
|
||||
/* หยุด run ระหว่างตอบคำถามพิเศษ (special-quiz) — เลื่อน endsAt ไปด้วยให้เวลาไม่เดิน, ไม่ไหล obstacle/ชน */
|
||||
if (space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)) {
|
||||
if (gr.endsAt != null) gr.endsAt += getGauntletTickMs();
|
||||
emitGauntletSync(sid, space);
|
||||
if (isSpecialQuizPausedServer(space)) {
|
||||
/* [2026-07-17] เดิม `gr.endsAt += getGauntletTickMs()` = ก้าวคงที่ → ชดเชยไม่ครบ (user: "เหมือนยัง
|
||||
ไม่หยุดวิ่ง" — obstacle หยุดจริงเพราะ return ออกไป แต่ deadline เดินต่อ รอบเลยจบเร็วกว่าที่ควร).
|
||||
บทเรียนเดียวกับ MG5 (07-16): interval ที่ตั้ง 33ms ของจริงเดิน ~48ms ตอนมีโหลด → บวกทีละ 33
|
||||
กู้คืนได้แค่ ~68%. ใช้ "เวลาจริงที่ผ่านไป" clamp 1s กันกระโดดตอน tick หาย */
|
||||
const nowG = Date.now();
|
||||
const shift = Math.max(0, Math.min(1000, nowG - (gr.lastPauseShiftAt || nowG)));
|
||||
if (gr.endsAt != null) gr.endsAt += shift;
|
||||
gr.lastPauseShiftAt = nowG;
|
||||
emitGauntletSync(sid, space); /* ส่ง gauntletEndsAt ใหม่ทุก tick → ทุก client ยึดเลขเดียวกับ server */
|
||||
return;
|
||||
}
|
||||
gr.lastPauseShiftAt = Date.now(); /* ไม่ pause → คง anchor ให้สด กันกระโดดตอนเริ่ม pause รอบหน้า */
|
||||
ensureGauntletEndsAtIfNeeded(space);
|
||||
if (gr.endsAt != null && Date.now() >= gr.endsAt) {
|
||||
endGauntletGame(sid, space, 'time');
|
||||
@@ -10233,6 +10549,19 @@ function serverMapIsPostCaseLobbyB(space) {
|
||||
}
|
||||
|
||||
/** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */
|
||||
/** ชื่อผู้เล่นสูงสุด — ต้องตรงกับ MAX_LEN ใน Game/public/js/display-name.js (2026-07-16: 24 → 10).
|
||||
นับเป็น code unit เหมือน <input maxlength> (ไทย: สระ/วรรณยุกต์นับแยก ไม่ใช่ 10 ตัวที่ตาเห็น). */
|
||||
const NICKNAME_MAX_LEN = 10;
|
||||
/** ตัดชื่อให้อยู่ในลิมิต — server เป็นตัวบังคับจริง: client แก้ maxlength อย่างเดียวไม่พอ
|
||||
(join-space เดิมรับ nickname ดิบ ไม่ cap เลย → ชื่อยาวเท่าไหร่ก็เข้าได้) */
|
||||
function clampNickname(n) {
|
||||
let s = String(n == null ? '' : n).trim();
|
||||
try { s = s.normalize('NFKC'); } catch (e) { /* ignore */ }
|
||||
s = s.replace(/[\x00-\x1f\x7f]/g, '');
|
||||
if (s.length > NICKNAME_MAX_LEN) s = s.slice(0, NICKNAME_MAX_LEN);
|
||||
return s;
|
||||
}
|
||||
|
||||
function normalizeLobbyNickname(nickname) {
|
||||
const raw = String(nickname || '').trim();
|
||||
if (!raw) return 'ผู้เล่น';
|
||||
@@ -10494,6 +10823,9 @@ function attemptTroublesomeRoll(sid, force) {
|
||||
const peerIds = [...space.peers.keys()];
|
||||
if (peerIds.length === 0) return;
|
||||
if (!force && space.troublesomeEligible.size < peerIds.length) return;
|
||||
/* [TC190] ทางปกติเดินแล้ว → ปลด backstop (guard troublesomeOfferSent กันซ้ำอยู่แล้ว แต่เก็บให้สะอาด
|
||||
เหมือน deb/max timer ข้างล่าง ไม่ให้ timer ค้างถือ ref ห้องไว้อีก 45 วิ) */
|
||||
if (space.troublesomeDeadlockTimer) { clearTimeout(space.troublesomeDeadlockTimer); space.troublesomeDeadlockTimer = null; }
|
||||
/* เสนอบทตัวป่วนเฉพาะเมื่อมี "คนจริง ≥ 4 คน" — ยกเว้น admin บังคับ (troublesomeForceOffer) */
|
||||
const forceOffer = !!(runtimeGameTiming && runtimeGameTiming.troublesomeForceOffer);
|
||||
if (peerIds.length < 4 && !forceOffer) {
|
||||
@@ -11130,7 +11462,9 @@ io.on('connection', (socket) => {
|
||||
chosenSkinIdx = pickLobbySkinIndexForSlot(spawnJoinOrder);
|
||||
}
|
||||
const peer = {
|
||||
id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true,
|
||||
/* [2026-07-16] cap ชื่อตรงนี้ด้วย — เดิมรับค่าดิบจาก client: แก้ maxlength ฝั่ง client อย่างเดียว
|
||||
ไม่ได้บังคับอะไรเลย (join-space เป็นทางเข้าหลักของชื่อ ไม่ใช่หน้าแก้ชื่อ) */
|
||||
id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: clampNickname(nickname) || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true,
|
||||
spawnJoinOrder,
|
||||
lobbyColorThemeIndex: chosenThemeIdx,
|
||||
lobbySkinToneIndex: chosenSkinIdx,
|
||||
@@ -11165,12 +11499,21 @@ io.on('connection', (socket) => {
|
||||
emit ทุก join → บอทเปลี่ยนสีพ้นจากสี user ทันทีทุกจอ */
|
||||
emitLobbyTintSync(spaceId, space);
|
||||
}
|
||||
/* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby) */
|
||||
/* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby)
|
||||
— แต่ "เฉพาะตอนไม่มีคนอื่นเล่นค้างอยู่": เดิมดึง "ทั้งห้อง" ออกจากมินิเกมเพราะคนเดียวที่ rejoin มา
|
||||
ด้วย map ไม่ตรง (คนหลุดกลางเกมแล้ว reconnect เข้า room-lobby) → คนที่เหลือโดนตัดกลางเกม, client
|
||||
ค้างไม่ส่ง lobbyb-loaded → gate ค้าง = ทั้งห้องเล่นต่อไม่ได้ (เคส 2026-07-15 18:48).
|
||||
ถ้ายังมีคนอื่นเล่นอยู่ → ปล่อยเกมเดินต่อ, คนที่ rejoin รอจนจบรอบแล้ว return ปกติจะดึงเข้า LobbyB เอง */
|
||||
if (space.detectiveMinigameActive) {
|
||||
const clientMap = playMapId != null ? String(playMapId).trim() : '';
|
||||
const activeMap = space.mapId != null ? String(space.mapId).trim() : '';
|
||||
if (!clientMap || !activeMap || clientMap !== activeMap) {
|
||||
returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB');
|
||||
const othersInRun = Math.max(0, space.peers.size - 1); /* คนอื่นที่ยังอยู่ในรอบมินิเกม (ไม่นับคนที่เพิ่ง join) */
|
||||
if (othersInRun === 0) {
|
||||
returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB');
|
||||
} else {
|
||||
try { glog(spaceId, space, 'lobbyb-gate', '[TC144] rejoin map ไม่ตรง (' + (clientMap || 'none') + '≠' + activeMap + ') แต่ยังมีคนเล่นอยู่ ' + othersInRun + ' คน → ไม่ดึงทั้งห้องออก (pass)', nickname || 'ผู้เล่น'); } catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (spaceAllowsQuizCarryLobbyRelaxed(space)) {
|
||||
@@ -11231,6 +11574,11 @@ io.on('connection', (socket) => {
|
||||
botSlotCount: effectiveBotSlotCount(space),
|
||||
lobbyBotThemes: syncLobbyBotThemeSlots(space),
|
||||
joinLocked: !!space.joinLocked,
|
||||
/* [TC154] host กำลังเปิด wizard เลือกระดับ/คดี = set-ready ถูก "ปฏิเสธฝั่ง server" (12391).
|
||||
เดิมไม่ได้ส่งสถานะนี้ใน join ack → คนที่เพิ่งเข้ามาไม่รู้ว่าโดนล็อก: ปุ่มพร้อมดูกดได้ปกติ
|
||||
แต่กดแล้ว server ตีกลับเงียบ ๆ = "กดไม่ติด ไม่มีอะไรบอก" (คนละอันกับ relay lobby-preplay-open
|
||||
ที่ส่งเฉพาะตอน host กด เปิด/ปิด — คนเข้าทีหลังไม่เคยได้ยิน) */
|
||||
preplayOpen: !!space.preplayOpen,
|
||||
missionLiveBeginsAt: space.missionLiveBeginsAt || null,
|
||||
missionLive: isMinigameLiveForRejoinResume(space), /* rejoin กลางเกม live → client ข้าม how-to เข้าเล่นต่อ */
|
||||
};
|
||||
@@ -11490,6 +11838,10 @@ io.on('connection', (socket) => {
|
||||
socket.to(sid).emit('balloon-boss-shot', {
|
||||
sx: data.sx, sy: data.sy, vx: data.vx, vy: data.vy,
|
||||
ownerId: data.ownerId, t0: (typeof data.t0 === 'number' ? data.t0 : Date.now()),
|
||||
/* [P5] forward the shooter's aim so the receiver can pin the arrow ring to it (fixes arrow/bullet
|
||||
desync on a viewer's screen). Redundant with atan2(vy,vx) today since vx/vy = dir*spd, but
|
||||
explicit = authoritative and future-proof if the velocity ever stops being a pure direction. */
|
||||
aim: (typeof data.aim === 'number' && Number.isFinite(data.aim)) ? data.aim : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11516,6 +11868,7 @@ io.on('connection', (socket) => {
|
||||
if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */
|
||||
const bossDmg = Math.max(0, Number(data.bossDmg) || 0);
|
||||
const anchor = Math.max(0, Number(data.anchor) || 0); /* forward anchor host → clients adopt (มุมลูกศรวงกลม sync) */
|
||||
const hostT = (typeof data.t === 'number' && Number.isFinite(data.t) && data.t > 0) ? data.t : undefined; /* [P5 v2] นาฬิกา host → clients ใช้เป็นฐานเวลาวงลูกศร (หมุน sync ต่อเนื่อง ไม่พึ่ง serverNow ต่อเครื่อง) */
|
||||
const players = Array.isArray(data.players)
|
||||
? data.players.filter((p) => p && p.id != null).slice(0, 12).map((p) => ({
|
||||
id: p.id,
|
||||
@@ -11524,7 +11877,7 @@ io.on('connection', (socket) => {
|
||||
e: !!p.e,
|
||||
}))
|
||||
: [];
|
||||
socket.to(sid).emit('balloon-boss-sync', { bossDmg, players, anchor });
|
||||
socket.to(sid).emit('balloon-boss-sync', { bossDmg, players, anchor, t: hostT });
|
||||
});
|
||||
|
||||
/* MG3 stack (shared tower) — relay ผลการวาง (เจ้าของตา/บอทของ host) ให้ทุกคนวางบล็อกเดียวกัน + เลื่อนตาพร้อมกัน */
|
||||
@@ -11726,12 +12079,10 @@ io.on('connection', (socket) => {
|
||||
if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||||
const p = space.peers.get(socket.id);
|
||||
if (!p) return reply({ ok: false, error: 'ไม่พบผู้เล่น' });
|
||||
let safe = String(nickname || '').trim();
|
||||
/* ใช้ clampNickname ตัวเดียวกับ join-space — เดิม cap ที่ 32 ตรงนี้ที่เดียว ไม่ตรงกับ client (24)
|
||||
และ join-space ไม่ cap เลย = 3 ค่าคนละอัน */
|
||||
let safe = clampNickname(nickname);
|
||||
if (!safe) return reply({ ok: false, error: 'ชื่อว่าง' });
|
||||
try {
|
||||
safe = safe.normalize('NFKC');
|
||||
} catch (e) { /* ignore */ }
|
||||
if (safe.length > 32) safe = safe.slice(0, 32);
|
||||
const oldNorm = normalizeLobbyNickname(p.nickname);
|
||||
const newNorm = normalizeLobbyNickname(safe);
|
||||
p.nickname = safe;
|
||||
@@ -11875,6 +12226,7 @@ io.on('connection', (socket) => {
|
||||
const sp = spaces.get(sid);
|
||||
if (!sp) return;
|
||||
sp.bannedPlayerId = targetId || null;
|
||||
sp.bannedPlayerKey = resolveBanTargetPlayerKey(sp, targetId); /* ตัวตนถาวร — id จะเปลี่ยนก่อนเกมเริ่ม */
|
||||
const res = beginDetectiveSuspectMinigame(sid, sp, cardEntry, selectedIndex);
|
||||
if (!res || !res.ok) {
|
||||
io.to(sid).emit('quiz-ended', { message: (res && res.error) || 'เริ่มมินิเกมไม่สำเร็จ', returnToLobbyB: true });
|
||||
@@ -11987,8 +12339,14 @@ io.on('connection', (socket) => {
|
||||
const space = sid ? spaces.get(sid) : null;
|
||||
if (!space || !serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' });
|
||||
if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host เปิดพิจารณาคดีได้' });
|
||||
/* [TC179-followup] ใช้ team progress (ตัวเลขเดียวกับที่การ์ดโชว์ทุกจอ) เป็นหลัก — หลักฐานแจก "รายคน"
|
||||
คนที่หลุด/โดนบอทแทนตอนมินิเกมของผู้ต้องสงสัยคนนั้นจะไม่มีใบนั้น → host เห็นการ์ด ✓ ครบแต่กด
|
||||
"ชี้ตัวคนร้าย" ไม่ได้ (เดิมเช็คแต่ prog ส่วนตัว). max(ส่วนตัว, ทีม) = ไม่บล็อกทั้งสองทาง */
|
||||
const prog = playerSuspectProgressFromEvidence(space, socket.id);
|
||||
if (![0, 1, 2].every((i) => (prog[i] || 0) >= 1)) return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน (เก็บหลักฐานอย่างน้อย 1 ใบต่อคน)' });
|
||||
const teamProg = Array.isArray(space.suspectTeamProgress) ? space.suspectTeamProgress : [0, 0, 0];
|
||||
if (![0, 1, 2].every((i) => Math.max((prog[i] || 0), (teamProg[i] || 0)) >= 1)) {
|
||||
return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน (เก็บหลักฐานอย่างน้อย 1 ใบต่อคน)' });
|
||||
}
|
||||
/* ชี้ตัวคนร้าย = เข้าพิจารณาคดี → ต้องรอผู้เล่นในคดีกลับเข้า LobbyB ครบก่อน (กันคนตกรอบโหวต) */
|
||||
const accuseWait = detectiveLobbyBMissingParticipants(space);
|
||||
if (accuseWait.missing > 0) {
|
||||
@@ -12192,6 +12550,10 @@ io.on('connection', (socket) => {
|
||||
if (!space.peers.has(tid)) {
|
||||
return cb && cb({ ok: false, error: 'ไม่พบผู้เล่นในห้อง' });
|
||||
}
|
||||
/* [2026-07-19] เก็บ playerKey/nickname ของคนที่ถูก kick "ก่อน" ลบ peer — ใช้ prune roster คดี */
|
||||
const kickedPeer = space.peers.get(tid) || {};
|
||||
const kickedKey = (typeof kickedPeer.playerKey === 'string') ? kickedPeer.playerKey : '';
|
||||
const kickedNick = kickedPeer.nickname || '';
|
||||
const targetSock = io.sockets.sockets.get(tid);
|
||||
if (targetSock) {
|
||||
targetSock.emit('host-console-kicked', { message: 'คุณถูก Host นำออกจากห้อง' });
|
||||
@@ -12200,6 +12562,10 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
space.peers.delete(tid);
|
||||
io.to(sid).emit('user-left', { id: tid });
|
||||
/* [2026-07-19] host ดึงคนออก = ตั้งใจถอดถาวร → ตัดออกจาก roster คดีทันที + re-emit presence
|
||||
ไม่งั้น TC144 gate ตอนเลือกผู้ต้องสงสัยจะค้าง "รอผู้เล่น (x/y)" ตลอด host กด Start ต่อไม่ได้
|
||||
(host-kick เดิมไม่เคย prune roster / ไม่ arm grace / ไม่ re-emit presence เลย) */
|
||||
pruneDetectiveParticipantNow(sid, space, kickedKey, kickedNick);
|
||||
cb && cb({ ok: true });
|
||||
});
|
||||
|
||||
@@ -12340,6 +12706,9 @@ io.on('connection', (socket) => {
|
||||
return reply({ ok: false, error: 'ยืนในพื้นที่เริ่มเกม (สีส้มในเอดิเตอร์) ก่อนกดเริ่ม' });
|
||||
}
|
||||
if (isLobbyADetectiveStart) {
|
||||
space.lobbyAMapId = space.mapId; /* item1: จำ map LobbyA เดิมไว้ (ก่อนทับด้วย LobbyB) เพื่อ restore ตอน post-case reset */
|
||||
space.lobbyAMapData = space.mapData;
|
||||
space.postCaseLobbyAReset = false; /* เริ่มคดีใหม่ → เปิด guard reset→LobbyA อีกครั้ง */
|
||||
assignDetectiveSuspectCardMinigames(space);
|
||||
space.mapId = POST_CASE_LOBBY_SPACE_ID;
|
||||
space.mapData = maps.get(POST_CASE_LOBBY_SPACE_ID);
|
||||
@@ -12386,6 +12755,26 @@ io.on('connection', (socket) => {
|
||||
space.troublesomeDebTimer = null;
|
||||
space.troublesomeMaxTimer = null;
|
||||
initTroublesomeState(space);
|
||||
/* [TC190] backstop กัน deadlock "หน้าเลือกผู้ต้องสงสัยไม่ขึ้น":
|
||||
openSuspectPhaseForRoom เข้าถึงได้ "ทางเดียว" = client ส่ง troublesome-eligible →
|
||||
scheduleTroublesomeRoll → timer. ตัว troublesomeMaxTimer (8 วิ) ที่ดูเหมือน safety net
|
||||
ถูก arm อยู่ "ข้างใน" scheduleTroublesomeRoll ที่สัญญาณนั้นเรียกเอง + สองบรรทัดข้างบนเพิ่งล้าง
|
||||
timer เป็น null → ถ้าไม่มีใครส่งเลย (ติด overlay how-to/mic, หรือ room-lobby.js:4543 ซ่อน
|
||||
mic overlay แล้วไม่เรียก tryTroublesomeLobbyGate ซ้ำ) = ห้องค้างถาวร ไม่มีอะไรกู้
|
||||
(พิสูจน์แล้ว: justice-mg-all.js --probe eligible-loss → ค้าง 40 วิ ไม่มีวันเปิด).
|
||||
arm ตรงนี้ = เฟสที่ห้อง "มาถึง LobbyB" จริง (บทเรียน §4: backstop ที่ arm ผิดเฟสจะ no-op เงียบ)
|
||||
— storyReadTimer ข้างบนรู้บทเรียนนี้อยู่แล้ว, gate นี้แค่ไม่เคยได้. 45 วิ = พ้น worst case
|
||||
ที่เคยวัดได้ (6 คน join ใช้ 10-16 วิ) เยอะพอจะไม่แย่งทางปกติ. */
|
||||
if (space.troublesomeDeadlockTimer) clearTimeout(space.troublesomeDeadlockTimer);
|
||||
space.troublesomeDeadlockTimer = setTimeout(() => {
|
||||
const sp = spaces.get(sid);
|
||||
if (!sp || sp.troublesomeOfferSent) return; /* เปิดไปแล้วตามทางปกติ */
|
||||
if (!serverMapIsPostCaseLobbyB(sp)) return; /* ไม่ได้อยู่ LobbyB แล้ว */
|
||||
try { glog(sid, sp, 'suspect-gate', '[TC190] safety-timeout 45s → force เปิดเฟสเลือกผู้ต้องสงสัย (ไม่มี client ส่ง troublesome-eligible เลย) — backstop ทำงาน (pass)'); } catch (e) { /* ignore */ }
|
||||
/* force=true ข้ามเงื่อนไข "ทุกคน eligible"; ถ้า pool ว่าง attemptTroublesomeRoll
|
||||
fallback เป็น peerIds ทั้งหมดอยู่แล้ว (10638) จึงปลอดภัยเมื่อไม่มีใคร eligible */
|
||||
attemptTroublesomeRoll(sid, true);
|
||||
}, 45000);
|
||||
space.peers.forEach((p) => {
|
||||
const sp = pickSpawnForJoin(space.mapData, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0);
|
||||
p.x = sp.x;
|
||||
@@ -12740,6 +13129,7 @@ io.on('connection', (socket) => {
|
||||
space.emptySince = Date.now();
|
||||
/* ระหว่างมินิเกมสืบสวน / คดีล็อก — อย่าลบห้องเมื่อเปลี่ยนหน้า room-lobby → play.html (รอ join กลับ) */
|
||||
const keepCaseSpace = !!(space.detectiveMinigameActive
|
||||
|| (space.postCaseKeepAliveUntil && Date.now() < space.postCaseKeepAliveUntil) /* item1: ห้อง reset→LobbyA รอ client reload กลับ */
|
||||
|| (space.joinLocked && space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0));
|
||||
/* ห้อง Quiz Battle — เก็บไว้เมื่อว่าง ให้ refresh กลับเข้าห้องเดิมได้ (TTL prune จะเก็บกวาดเมื่อว่างเกิน 45 วิ) */
|
||||
const spaceMd = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||||
@@ -12829,7 +13219,13 @@ io.on('connection', (socket) => {
|
||||
const inCaseFlowForBot = space.detectiveMinigameActive
|
||||
|| serverMapIsPostCaseLobbyB(space)
|
||||
|| (space.joinLocked && space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0);
|
||||
if (inCaseFlowForBot && leftPlayerKey) {
|
||||
if (space.suspectPhaseActive) {
|
||||
/* [2026-07-19] หลุดระหว่างเฟส "เลือกผู้ต้องสงสัย" → ตัดออกจาก roster ทันที (user: เล่นต่อทันที)
|
||||
ครอบเคส playerKey ว่างด้วย (เดิม grace block ข้ามถ้า leftPlayerKey ว่าง → gate ค้างถาวร).
|
||||
เฟสนี้อยู่ใน LobbyB ยังไม่เข้าเกม จึงไม่ต้องรอ grace 22วิ/ไม่ต้องมีบอทแทน — แค่ให้ gate ลด
|
||||
แล้ว host เริ่มต่อได้เลย. ช่วง "เล่นมินิเกมจริง" ยังใช้ grace+บอทเหมือนเดิม (else-if ล่าง) */
|
||||
pruneDetectiveParticipantNow(sid, space, leftPlayerKey, leftNick);
|
||||
} else if (inCaseFlowForBot && leftPlayerKey) {
|
||||
io.to(sid).emit('peer-disconnect-grace', { id: socket.id, nickname: leftNick, seconds: DISCONNECT_GRACE_SEC });
|
||||
if (!space.graceReconnect || typeof space.graceReconnect !== 'object') space.graceReconnect = {};
|
||||
if (!space.graceTimers || typeof space.graceTimers !== 'object') space.graceTimers = {};
|
||||
|
||||
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 1014 KiB After Width: | Height: | Size: 1.0 MiB |
@@ -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>
|
||||
@@ -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('เชื่อมต่อเซิร์ฟเวอร์ไม่ได้');
|
||||
});
|
||||
})();
|
||||
|
Before Width: | Height: | Size: 515 KiB After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 257 B |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 566 KiB |
|
After Width: | Height: | Size: 600 KiB |
|
After Width: | Height: | Size: 618 KiB |
|
After Width: | Height: | Size: 642 KiB |
|
After Width: | Height: | Size: 617 KiB |
|
After Width: | Height: | Size: 588 KiB |