fix on testcase

This commit is contained in:
2026-06-25 07:33:35 +00:00
parent 390f0e3a5e
commit bef0133a7a
15 changed files with 1870 additions and 146 deletions
+13 -7
View File
@@ -3023,18 +3023,24 @@
}
var normArr = Array.isArray(data.stackBlockNormalImageUrls) ? data.stackBlockNormalImageUrls : [];
var heavyArr = Array.isArray(data.stackBlockHeavyImageUrls) ? data.stackBlockHeavyImageUrls : [];
for (var si = 1; si <= 6; si++) {
for (var si = 1; si <= 8; si++) {
var nin = el('stack-block-normal-url-' + si);
var hin = el('stack-block-heavy-url-' + si);
if (nin) nin.value = normArr[si - 1] != null ? String(normArr[si - 1]) : '';
if (hin) hin.value = heavyArr[si - 1] != null ? String(heavyArr[si - 1]) : '';
var nv = normArr[si - 1] != null && String(normArr[si - 1]) !== '' ? String(normArr[si - 1]) : STACK_BLOCK_DEFAULT_NORMAL[si - 1];
var hv = heavyArr[si - 1] != null && String(heavyArr[si - 1]) !== '' ? String(heavyArr[si - 1]) : STACK_BLOCK_DEFAULT_HEAVY[si - 1];
if (nin) nin.value = nv || '';
if (hin) hin.value = hv || '';
updateStackBlockSeatPreview(si);
}
}
/* #4 MG3 8 สีตาม lobby theme — ค่าเริ่มต้น Block-Code-1..8 (ตรง PLAY_LOBBY_THEME_HEX) */
var STACK_BLOCK_THEME_NAMES = ['1-red', '2-orange', '3-Yellow', '4-Green', '5-Blue', '6-Navy', '7-Violet', '8-Pink'];
var STACK_BLOCK_DEFAULT_NORMAL = STACK_BLOCK_THEME_NAMES.map(function (s) { return '/Game/img/TowerBlock/Block-Code-' + s + '.png'; });
var STACK_BLOCK_DEFAULT_HEAVY = STACK_BLOCK_THEME_NAMES.map(function (s) { return '/Game/img/TowerBlock/Block-Code-' + s + '-2.png'; });
function readStackBlockNormalUrlsFromForm() {
var out = [];
for (var i = 1; i <= 6; i++) {
for (var i = 1; i <= 8; i++) {
var inp = el('stack-block-normal-url-' + i);
out.push(inp && inp.value ? String(inp.value).trim() : '');
}
@@ -3043,7 +3049,7 @@
function readStackBlockHeavyUrlsFromForm() {
var out = [];
for (var j = 1; j <= 6; j++) {
for (var j = 1; j <= 8; j++) {
var inp = el('stack-block-heavy-url-' + j);
out.push(inp && inp.value ? String(inp.value).trim() : '');
}
@@ -3068,7 +3074,7 @@
}
function bindStackBlockVisualInputs() {
for (var s = 1; s <= 6; s++) {
for (var s = 1; s <= 8; s++) {
(function (seat) {
var nIn = el('stack-block-normal-url-' + seat);
var hIn = el('stack-block-heavy-url-' + seat);
@@ -4130,7 +4136,7 @@
})
.then(function (res) {
if (res && typeof res === 'object') chk.checked = !!res.troublesomeForceOffer;
setMsg('troublesome-force-msg', on ? 'บันทึกแล้ว — บทตัวป่วนจะขึ้นเสมอ (เริ่มคดีใหม่เพื่อเทสต์)' : 'ปิดแล้ว — กลับเป็นปกติ (ขึ้นเฉพาะคนจริง > 4)', 'ok');
setMsg('troublesome-force-msg', on ? 'บันทึกแล้ว — บทตัวป่วนจะขึ้นเสมอ (เริ่มคดีใหม่เพื่อเทสต์)' : 'ปิดแล้ว — กลับเป็นปกติ (ขึ้นเฉพาะคนจริง 4)', 'ok');
})
.catch(function (e) { setMsg('troublesome-force-msg', e.message || 'บันทึกไม่ได้', 'error'); })
.then(function () { if (btn) btn.disabled = false; });
+5 -3
View File
@@ -976,6 +976,8 @@
<div class="space-shooter-ship-row stack-block-visual-row"><span class="space-shooter-ship-slot" title="ที่นั่ง 4">P4</span><label class="space-shooter-ship-url-label">ปกติ <input type="text" id="stack-block-normal-url-4" maxlength="500" spellcheck="false" placeholder="/Game/img/....png" autocomplete="off"></label><img class="space-shooter-ship-prev stack-block-prev stack-block-prev--normal" id="stack-block-normal-prev-4" alt="" width="314" height="103" decoding="async"><button type="button" class="btn btn-ghost" id="btn-stack-block-normal-clear-4">ล้าง</button><label class="space-shooter-ship-url-label">ใหญ่ <input type="text" id="stack-block-heavy-url-4" maxlength="500" spellcheck="false" placeholder="/Game/img/....png" autocomplete="off"></label><img class="space-shooter-ship-prev stack-block-prev stack-block-prev--heavy" id="stack-block-heavy-prev-4" alt="" width="382" height="72" decoding="async"><button type="button" class="btn btn-ghost" id="btn-stack-block-heavy-clear-4">ล้าง</button></div>
<div class="space-shooter-ship-row stack-block-visual-row"><span class="space-shooter-ship-slot" title="ที่นั่ง 5">P5</span><label class="space-shooter-ship-url-label">ปกติ <input type="text" id="stack-block-normal-url-5" maxlength="500" spellcheck="false" placeholder="/Game/img/....png" autocomplete="off"></label><img class="space-shooter-ship-prev stack-block-prev stack-block-prev--normal" id="stack-block-normal-prev-5" alt="" width="314" height="103" decoding="async"><button type="button" class="btn btn-ghost" id="btn-stack-block-normal-clear-5">ล้าง</button><label class="space-shooter-ship-url-label">ใหญ่ <input type="text" id="stack-block-heavy-url-5" maxlength="500" spellcheck="false" placeholder="/Game/img/....png" autocomplete="off"></label><img class="space-shooter-ship-prev stack-block-prev stack-block-prev--heavy" id="stack-block-heavy-prev-5" alt="" width="382" height="72" decoding="async"><button type="button" class="btn btn-ghost" id="btn-stack-block-heavy-clear-5">ล้าง</button></div>
<div class="space-shooter-ship-row stack-block-visual-row"><span class="space-shooter-ship-slot" title="ที่นั่ง 6">P6</span><label class="space-shooter-ship-url-label">ปกติ <input type="text" id="stack-block-normal-url-6" maxlength="500" spellcheck="false" placeholder="/Game/img/....png" autocomplete="off"></label><img class="space-shooter-ship-prev stack-block-prev stack-block-prev--normal" id="stack-block-normal-prev-6" alt="" width="314" height="103" decoding="async"><button type="button" class="btn btn-ghost" id="btn-stack-block-normal-clear-6">ล้าง</button><label class="space-shooter-ship-url-label">ใหญ่ <input type="text" id="stack-block-heavy-url-6" maxlength="500" spellcheck="false" placeholder="/Game/img/....png" autocomplete="off"></label><img class="space-shooter-ship-prev stack-block-prev stack-block-prev--heavy" id="stack-block-heavy-prev-6" alt="" width="382" height="72" decoding="async"><button type="button" class="btn btn-ghost" id="btn-stack-block-heavy-clear-6">ล้าง</button></div>
<div class="space-shooter-ship-row stack-block-visual-row"><span class="space-shooter-ship-slot" title="ที่นั่ง 7">P7</span><label class="space-shooter-ship-url-label">ปกติ <input type="text" id="stack-block-normal-url-7" maxlength="500" spellcheck="false" placeholder="/Game/img/....png" autocomplete="off"></label><img class="space-shooter-ship-prev stack-block-prev stack-block-prev--normal" id="stack-block-normal-prev-7" alt="" width="314" height="103" decoding="async"><button type="button" class="btn btn-ghost" id="btn-stack-block-normal-clear-7">ล้าง</button><label class="space-shooter-ship-url-label">ใหญ่ <input type="text" id="stack-block-heavy-url-7" maxlength="500" spellcheck="false" placeholder="/Game/img/....png" autocomplete="off"></label><img class="space-shooter-ship-prev stack-block-prev stack-block-prev--heavy" id="stack-block-heavy-prev-7" alt="" width="382" height="72" decoding="async"><button type="button" class="btn btn-ghost" id="btn-stack-block-heavy-clear-7">ล้าง</button></div>
<div class="space-shooter-ship-row stack-block-visual-row"><span class="space-shooter-ship-slot" title="ที่นั่ง 8">P8</span><label class="space-shooter-ship-url-label">ปกติ <input type="text" id="stack-block-normal-url-8" maxlength="500" spellcheck="false" placeholder="/Game/img/....png" autocomplete="off"></label><img class="space-shooter-ship-prev stack-block-prev stack-block-prev--normal" id="stack-block-normal-prev-8" alt="" width="314" height="103" decoding="async"><button type="button" class="btn btn-ghost" id="btn-stack-block-normal-clear-8">ล้าง</button><label class="space-shooter-ship-url-label">ใหญ่ <input type="text" id="stack-block-heavy-url-8" maxlength="500" spellcheck="false" placeholder="/Game/img/....png" autocomplete="off"></label><img class="space-shooter-ship-prev stack-block-prev stack-block-prev--heavy" id="stack-block-heavy-prev-8" alt="" width="382" height="72" decoding="async"><button type="button" class="btn btn-ghost" id="btn-stack-block-heavy-clear-8">ล้าง</button></div>
</div>
</fieldset>
<div class="quiz-admin-actions">
@@ -1271,10 +1273,10 @@
<p id="special-card-by-map-msg" class="msg" role="status"></p>
<h3 class="admin-subheading" style="margin-top:28px;">บังคับเสนอบท "ตัวป่วน" (เทสต์)</h3>
<p class="muted">ปกติบทตัวป่วนจะเสนอเฉพาะเมื่อมี<strong>คนจริงเกิน 4 คน</strong> · เปิดอันนี้เพื่อ<strong>บังคับให้ขึ้นเสมอ</strong>แม้เล่นคนเดียว (ไว้เทสต์)</p>
<p class="muted">ปกติบทตัวป่วนจะเสนอเฉพาะเมื่อมี<strong>คนจริงตั้งแต่ 4 คนขึ้นไป</strong> · เปิดอันนี้เพื่อ<strong>บังคับให้ขึ้นเสมอ</strong>แม้เล่นคนเดียว (ไว้เทสต์)</p>
<label style="display:flex;align-items:center;gap:10px;color:#cfd6ea;font-size:14px;cursor:pointer;">
<input type="checkbox" id="troublesome-force-offer" style="width:18px;height:18px;cursor:pointer;">
บังคับเสนอบทตัวป่วน (ข้ามเงื่อนไข > 4 คน)
บังคับเสนอบทตัวป่วน (ข้ามเงื่อนไข 4 คน)
</label>
<div style="margin-top:12px;"><button type="button" class="btn" id="btn-troublesome-force-save">บันทึก</button></div>
<p id="troublesome-force-msg" class="msg" role="status"></p>
@@ -1283,6 +1285,6 @@
</div>
</main>
</div>
<script src="admin.js?v=85"></script>
<script src="admin.js?v=87"></script>
</body>
</html>
+140 -36
View File
@@ -51,9 +51,9 @@
"providerUserId": "p_1775109142385_wq7wfy1p32j",
"notes": "auto: player-coins",
"blocked": false,
"coins": 1422,
"coins": 1672,
"createdAt": "2026-04-02T05:52:21+00:00",
"updatedAt": "2026-06-24T09:36:08+00:00",
"updatedAt": "2026-06-25T03:57:35+00:00",
"daily": {
"anchorMs": 1781197200000,
"claimedDays": [
@@ -65,45 +65,73 @@
false,
false
],
"lockUntilMs": 1782320400000
"lockUntilMs": 0
},
"score": 1252,
"score": 1492,
"scoreByCase": {
"1": 16,
"10": 358,
"8": 238,
"10": 386,
"8": 288,
"11": 20,
"13": 140,
"14": 20,
"13": 176,
"14": 30,
"9": 40,
"12": 272,
"12": 282,
"15": 20,
"4": 10,
"5": 20,
"5": 32,
"7": 6,
"3": 72,
"6": 20
"6": 44,
"2": 70
},
"lbName": "Q",
"achievements": {
"d1_minigame_solver": 20,
"a1_first_deduction": 1,
"a5_truth_hunter": 2
"a5_truth_hunter": 3
},
"lobbyColorThemeIndex": 5,
"lobbyColorThemeIndex": 6,
"lobbySkinToneIndex": 1
},
{
"id": "1d64c56fadb64a93eae68a1d",
"email": "",
"displayName": "Guest",
"displayName": "OHM",
"loginType": "guest",
"providerUserId": "p_1775128666204_xzgg2n6x8h",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"coins": 86,
"createdAt": "2026-04-02T11:17:46+00:00",
"updatedAt": "2026-04-02T11:17:46+00:00"
"updatedAt": "2026-06-24T12:38:24+00:00",
"lobbyColorThemeIndex": 4,
"lobbySkinToneIndex": 2,
"daily": {
"anchorMs": 1782234000000,
"claimedDays": [
true,
false,
false,
false,
false,
false,
false
],
"lockUntilMs": 1782320400000
},
"score": 76,
"scoreByCase": {
"6": 18,
"13": 50,
"5": 8
},
"lbName": "OHM",
"achievements": {
"d1_minigame_solver": 10,
"a1_first_deduction": 1,
"a5_truth_hunter": 1
}
},
{
"id": "d1d5c39d42a2dce9a4949d5a",
@@ -198,36 +226,39 @@
"providerUserId": "p_1780400804441_lsc3lqqvs0i",
"notes": "auto: player-coins",
"blocked": false,
"coins": 57,
"coins": 135,
"createdAt": "2026-06-02T11:46:46+00:00",
"updatedAt": "2026-06-23T06:29:36+00:00",
"updatedAt": "2026-06-24T12:38:36+00:00",
"daily": {
"anchorMs": 1781802000000,
"claimedDays": [
true,
true,
false,
true,
false,
false,
false,
false
],
"lockUntilMs": 1782234000000
"lockUntilMs": 1782320400000
},
"score": 32,
"score": 90,
"scoreByCase": {
"1": 4,
"7": 2,
"10": 26
"10": 26,
"6": 20,
"13": 32,
"5": 6
},
"lbName": "Pear",
"achievements": {
"d1_minigame_solver": 5,
"d1_minigame_solver": 17,
"a1_first_deduction": 1,
"a5_truth_hunter": 1
"a5_truth_hunter": 2
},
"lobbyColorThemeIndex": 8,
"lobbySkinToneIndex": 1
"lobbyColorThemeIndex": 6,
"lobbySkinToneIndex": 3
},
{
"id": "9404ea306bffed187b43a490",
@@ -597,18 +628,19 @@
"providerUserId": "p_1781938677448_8j2wnr5ha1b",
"notes": "auto: player-coins",
"blocked": false,
"coins": 28,
"coins": 36,
"createdAt": "2026-06-20T06:57:57+00:00",
"updatedAt": "2026-06-22T07:38:20+00:00",
"updatedAt": "2026-06-24T16:52:31+00:00",
"lobbyColorThemeIndex": 7,
"lobbySkinToneIndex": 1,
"achievements": {
"d1_minigame_solver": 3
"d1_minigame_solver": 4
},
"lbName": "MONE",
"score": 18,
"score": 26,
"scoreByCase": {
"13": 18
"13": 18,
"2": 8
}
},
{
@@ -619,21 +651,21 @@
"providerUserId": "p_1782016990238_0u37bv7pxlxs",
"notes": "auto: player-coins",
"blocked": false,
"coins": 124,
"coins": 142,
"createdAt": "2026-06-21T04:43:11+00:00",
"updatedAt": "2026-06-24T09:36:08+00:00",
"updatedAt": "2026-06-24T09:41:43+00:00",
"lobbyColorThemeIndex": 8,
"lobbySkinToneIndex": 1,
"score": 94,
"score": 112,
"scoreByCase": {
"13": 56,
"10": 20,
"10": 38,
"6": 8,
"8": 10
},
"lbName": "MONE",
"achievements": {
"d1_minigame_solver": 9
"d1_minigame_solver": 11
}
},
{
@@ -780,6 +812,78 @@
"achievements": {
"d1_minigame_solver": 3
}
},
{
"id": "434908003822311d8042718a",
"email": "",
"displayName": "Nam",
"loginType": "guest",
"providerUserId": "p_1782300287206_l5omneu1yeo",
"notes": "auto: player-coins",
"blocked": false,
"coins": 56,
"createdAt": "2026-06-24T11:24:48+00:00",
"updatedAt": "2026-06-24T12:38:36+00:00",
"lobbyColorThemeIndex": 7,
"lobbySkinToneIndex": 2,
"score": 56,
"scoreByCase": {
"6": 14,
"13": 26,
"5": 16
},
"lbName": "Nam",
"achievements": {
"d1_minigame_solver": 11,
"a1_first_deduction": 1,
"a5_truth_hunter": 1
}
},
{
"id": "fac53fca91f1f05e85e7aee7",
"email": "",
"displayName": "MONE",
"loginType": "guest",
"providerUserId": "p_1782300890577_kh50k083l2",
"notes": "auto: player-coins",
"blocked": false,
"coins": 108,
"createdAt": "2026-06-24T11:34:50+00:00",
"updatedAt": "2026-06-24T12:38:36+00:00",
"score": 108,
"scoreByCase": {
"6": 32,
"13": 60,
"5": 16
},
"lbName": "MONE",
"achievements": {
"d1_minigame_solver": 13,
"a1_first_deduction": 1,
"a5_truth_hunter": 1,
"e2_the_impostor": 1,
"e4_agent_of_chaos": 1
}
},
{
"id": "1605fc842806c0f7aa67cc47",
"email": "",
"displayName": "ผู้เล่น",
"loginType": "guest",
"providerUserId": "p_1782327780535_twosoa33hsr",
"notes": "auto: player-coins",
"blocked": false,
"coins": 30,
"createdAt": "2026-06-24T19:03:00+00:00",
"updatedAt": "2026-06-24T19:12:57+00:00",
"score": 30,
"scoreByCase": {
"10": 30
},
"lbName": "ผู้เล่น",
"achievements": {
"d1_minigame_solver": 3
}
}
]
}
+9 -3
View File
@@ -25,7 +25,9 @@
"/Game/img/TowerBlock/Block-Code-3-Yellow.png",
"/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-6-Navy.png",
"",
""
],
"stackBlockHeavyImageUrls": [
"/Game/img/TowerBlock/Block-Code-1-red-2.png",
@@ -33,7 +35,9 @@
"/Game/img/TowerBlock/Block-Code-3-Yellow-2.png",
"/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-6-Navy-2.png",
"",
""
],
"stackHeavyBlockPercent": 35,
"jumpSurviveJumpHeightMult": 1.5,
@@ -84,7 +88,9 @@
"/Game/img/MegaVirus/balloon-3.png",
"/Game/img/MegaVirus/balloon-4.png",
"/Game/img/MegaVirus/balloon-5.png",
"/Game/img/MegaVirus/balloon-6.png"
"/Game/img/MegaVirus/balloon-6.png",
"",
""
],
"balloonBossPlayerBalloonFallbackUrl": "/Game/img/MegaVirus/Artboard%209.png",
"balloonBossBalloonsPerPlayer": 3,
+18
View File
@@ -1994,3 +1994,21 @@ body.room-lobby-page .room-lobby-top-right-actions {
color: #a9c7da;
min-height: 1.2em;
}
/* ===== มือถือ: ย่อปุ่ม AI chat ที่ใหญ่เกิน (213x201) + ปุ่ม interact (F) ให้พอดีจอ ===== */
@media (max-width: 600px) {
.room-lobby-chat-ai.chat-panel-collapsed .chat-close-btn,
.room-lobby-chat-ai.chat-panel-collapsed .chat-close-btn img {
width: 92px !important;
height: 87px !important;
min-width: 92px !important;
min-height: 87px !important;
max-width: 92px !important;
max-height: 87px !important;
}
.room-lobby-chat-ai.chat-panel .chat-close-btn,
.room-lobby-chat-ai.chat-panel .chat-close-btn img {
width: 42px !important;
height: 42px !important;
}
}
@@ -214,8 +214,8 @@
width: 1500px;
height: 170px;
display: grid;
grid-template-columns: repeat(5, 1fr);
column-gap: 24px;
grid-template-columns: repeat(6, 1fr); /* #4 รองรับ 6 คน — เดิม 5 → คนที่ 6 ตกแถว 2 ล้นกรอบ (ปลิ้น) */
column-gap: 18px;
padding: 0 30px;
box-sizing: border-box;
z-index: 5;
+332 -37
View File
@@ -63,12 +63,47 @@
if (previewMode && editorEmbedReturn) {
try { document.documentElement.classList.add('play-preview-editor-embed'); } catch (e) { /* ignore */ }
}
/* #5 /asset . draw() mapData
(+ หนวงเลกนอยให asset แรกโหลด) safety timeout นคาง. ไมโชวใน editor embed preview */
let playLoadingHidden = false;
let playLoadingHideScheduled = false;
function hidePlayLoadingOverlay() {
if (playLoadingHidden) return;
playLoadingHidden = true;
try {
const el = document.getElementById('play-loading-overlay');
if (el) { el.style.opacity = '0'; setTimeout(function () { if (el && el.parentNode) el.parentNode.removeChild(el); }, 320); }
} catch (_e) { /* ignore */ }
}
(function showPlayLoadingOverlay() {
if (previewMode && editorEmbedReturn) { playLoadingHidden = true; return; }
try {
if (!document.body) return;
const css = document.createElement('style');
css.textContent = ''
+ '#play-loading-overlay{position:fixed;inset:0;z-index:99990;display:flex;flex-direction:column;align-items:center;'
+ 'justify-content:center;gap:18px;background:radial-gradient(circle at 50% 42%,#10203c,#060b16 75%);transition:opacity .32s;}'
+ '#play-loading-overlay .plo-spin{width:62px;height:62px;border-radius:50%;border:6px solid rgba(120,190,255,.18);'
+ 'border-top-color:#5fd0ff;animation:ploSpin .85s linear infinite;}'
+ '#play-loading-overlay .plo-text{color:#cfe8ff;font:600 19px system-ui,"Kanit",sans-serif;letter-spacing:.5px;text-shadow:0 2px 10px rgba(0,0,0,.5);}'
+ '@keyframes ploSpin{to{transform:rotate(360deg);}}';
document.head.appendChild(css);
const ov = document.createElement('div');
ov.id = 'play-loading-overlay';
ov.innerHTML = '<div class="plo-spin"></div><div class="plo-text">กำลังโหลดเกม…</div>';
document.body.appendChild(ov);
setTimeout(hidePlayLoadingOverlay, 8000); /* safety: กันค้างถ้าโหลดนาน/ติด */
} catch (_e) { playLoadingHidden = true; }
})();
const playMapIdFromQuery = (params.get('map') || '').trim();
/** mapId จาก join / game-start — ใช้จับฉาก crown เมื่อ URL ไม่มี ?map= */
let playSessionMapId = playMapIdFromQuery;
/** สรุปภารกิจแบบ mock (popup-result ฯลฯ) — ใช้เฉพาะฉากนี้จาก editor (id ใน URL เช่น editor.html?id=mnorwqx1) */
const quizCarryMissionSummaryMapId = 'mnorwqx1';
const quizCarryUseMissionSummaryOverlay = playMapIdFromQuery === quizCarryMissionSummaryMapId;
/* map (currentPlayMapId) ?map= URL live play ?map= false
mission flow ไมทำงาน MG4 จบหลงตอบ 1 (bug #41). เปนฟงกนเพราะ map ตอน join */
function quizCarryUseMissionSummaryOverlay() { return currentPlayMapId() === quizCarryMissionSummaryMapId; }
/** Gauntlet พรมแดง — ฉากนี้จาก editor (?map=mno9kb07): วาดตัวหันขวา + ใช้รูป lane/laser จาก game-timing */
const GAUNTLET_FACE_RIGHT_MAP_ID = 'mno9kb07';
/** Jump Survival ฉาก Jumper — UI สรุปภารกิจแบบ crown + รูปใน /img/Jumper/ (editor ?map=mnptfts2) */
@@ -142,8 +177,8 @@
const SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS = 12;
/** อุกาบาต: HP สุ่ม 1–5 ต่อก้อน (ไม่วาดหัวใจบนก้อน) — ขนาดตาม HP เทียบสเกล 5 */
const SPACE_SHOOTER_ASTEROID_MAX_HP = 5;
const SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL = 48;
const SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP = 11;
const SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL = 34;
const SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP = 8;
/* PRNG seed (mulberry32) deterministic
seed เดยวก ( liveBeginsAt ของ server) กเครองไดลำดบสมเดยวก = กาบาต/บอลลนชดเดยวก */
@@ -737,9 +772,30 @@
if (stackMini) stackMini.nextDropVisualDirty = true;
}
/* #4 MG3 block สีตาม user — Block-Code-1..8 ตรง lobby theme (PLAY_LOBBY_THEME_HEX) + heavy '-2' */
const STACK_BLOCK_THEME_NAMES = ['1-red', '2-orange', '3-Yellow', '4-Green', '5-Blue', '6-Navy', '7-Violet', '8-Pink'];
const STACK_BLOCK_THEME_NORMAL = STACK_BLOCK_THEME_NAMES.map(function (s) { return BASE + '/img/TowerBlock/Block-Code-' + s + '.png'; });
const STACK_BLOCK_THEME_HEAVY = STACK_BLOCK_THEME_NAMES.map(function (s) { return BASE + '/img/TowerBlock/Block-Code-' + s + '-2.png'; });
function stackThemeNormalUrlPlay(theme) { return (theme >= 1 && theme <= 8) ? STACK_BLOCK_THEME_NORMAL[theme - 1] : ''; }
function stackThemeHeavyUrlPlay(theme) { return (theme >= 1 && theme <= 8) ? STACK_BLOCK_THEME_HEAVY[theme - 1] : ''; }
/** theme (1-8) ของผู้เล่นที่ครอบครอง seat นั้นในลำดับตา — ใช้ลงสี block ตามผู้วาง (ไม่แตะ seat/turn logic) */
function stackSeatColorThemePlay(seat) {
if (!isStack() || !stackTurnOrderReadyPlay()) return null;
const s = Math.floor(Number(seat)) || 0;
const entry = stackPreviewTurnOrder.find(function (e) { return e && e.seat === s; });
if (!entry) return null;
let ent = null;
if (entry.id != null) ent = (myId != null && String(entry.id) === String(myId)) ? me : (others.get(entry.id) || others.get(String(entry.id)));
if (!ent && entry.botId) ent = others.get(entry.botId) || others.get(String(entry.botId));
return ent ? playLobbyThemeIndexForEntityPlay(ent) : null;
}
function pickStackBlockHeavyVisual(seat) {
const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1));
const hU = normalizeGauntletAssetUrlForPlay(playStackBlockHeavyUrls[slot] || '');
const slot = Math.max(0, Math.min(7, (Math.floor(Number(seat)) || 1) - 1));
let hU = normalizeGauntletAssetUrlForPlay(playStackBlockHeavyUrls[slot] || '');
/* ถ้าใช้สี theme (Block-Code) — heavy variant '-2' มีครบ ใช้แทน admin slot */
const _theme = stackSeatColorThemePlay(seat);
if (_theme && stackThemeHeavyUrlPlay(_theme)) hU = stackThemeHeavyUrlPlay(_theme);
/** Tower mnn93hpi: ตั้งแต่เกณฑ์ progress (ตาม stackTowerProgressBlocks) → ใช้รูป heavy จาก admin — นอก live ไม่สุ่ม heavy */
if (isStackTowerMissionUiMapPlay()) {
if (!(stackTowerMissionPhase === 'live' && stackMini)) return false;
@@ -763,9 +819,9 @@
}
function normalizePlayStackSixUrlsFromTiming(arr) {
const out = ['', '', '', '', '', ''];
const out = ['', '', '', '', '', '', '', '']; /* 8 slot ตาม lobby theme (#4) */
if (!Array.isArray(arr)) return out;
for (let i = 0; i < 6; i++) {
for (let i = 0; i < 8; i++) {
out[i] = normalizeGauntletAssetUrlForPlay(typeof arr[i] === 'string' ? arr[i] : '');
if (out[i]) ensureGauntletAssetImage(out[i]);
}
@@ -773,8 +829,19 @@
}
function resolveStackBlockSpriteRec(seat, heavy) {
const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1));
const tryHeavy = !!heavy;
/* #4 block = theme seat (Block-Code-1..8) resolve ( turn order)
fallback ไป admin slot URLs าไม theme (เกม stack /งไมลำดบตา) */
const theme = stackSeatColorThemePlay(seat);
if (theme) {
const tn = stackThemeNormalUrlPlay(theme);
const th = stackThemeHeavyUrlPlay(theme);
const torder = tryHeavy ? [th, tn] : [tn, th];
for (let ti = 0; ti < torder.length; ti++) {
if (torder[ti]) return ensureGauntletAssetImage(torder[ti]);
}
}
const slot = Math.max(0, Math.min(7, (Math.floor(Number(seat)) || 1) - 1));
const order = tryHeavy
? [playStackBlockHeavyUrls[slot], playStackBlockNormalUrls[slot]]
: [playStackBlockNormalUrls[slot], playStackBlockHeavyUrls[slot]];
@@ -3237,6 +3304,9 @@
return pool[idx % pool.length] || humanId || roster[0];
}
const MOVE_SPEED = 0.15;
/** Anti-stuck (MG1/MG4): ดันชน "เพื่อน" กี่เฟรมก่อนปลดล็อกเดินผ่าน + ช่วงเวลาที่ผ่านได้ต่อเนื่อง */
const ANTI_STUCK_PEER_TICKS = 13;
const ANTI_STUCK_WINDOW_MS = 650;
/** quiz_carry — ค่าเริ่มเมื่อไม่มี override จาก Admin (รหัสฉากตรงกันใน quiz-settings.json) */
const QUIZ_CARRY_WALK_SPEED_MULT = 1.42;
/** ค่าที่ใช้จริงต่อเฟรม — อัปเดตจาก /api/quiz-settings + join snap */
@@ -3304,8 +3374,8 @@
/** ภารกิจ Tower: ชั้นสำเร็จกี่ชั้น (ฐาน) = Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */
let playStackTowerProgressBlocks = 50;
/** รูปบล็อก Stack ต่อที่นั่ง 16 (game-timing) — ว่าง = วาดสีเดิม */
let playStackBlockNormalUrls = ['', '', '', '', '', ''];
let playStackBlockHeavyUrls = ['', '', '', '', '', ''];
let playStackBlockNormalUrls = ['', '', '', '', '', '', '', ''];
let playStackBlockHeavyUrls = ['', '', '', '', '', '', '', ''];
/** 0–100: โอกาสใช้สไปรต์ “ใหญ่” ต่อการปล่อย (ถ้ามี URL ใหญ่ช่องนั้น) */
let playStackHeavyBlockPercent = 35;
/** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัว (ch×tile) — จาก GET /api/game-timing */
@@ -3851,6 +3921,15 @@
/** ซูมที่ผู้เล่นปรับเองในฉาก quiz_battle (ล้อเมาส์ / ปุ่ม +−) */
let quizBattleUserZoomMul = 1;
const SPACE_SHOOTER_SHIP_COLORS = ['#50fa7b', '#ff79c6', '#ff5555', '#f1fa8c', '#bd93f9', '#8be9fd'];
/** ยานยิงอุกาบาต (MG6) สีตาม lobby theme 1-8 — Rocket-1..8 ตรงกับ PLAY_LOBBY_THEME_HEX */
const SPACE_SHOOTER_ROCKET_THEME_URLS = [1, 2, 3, 4, 5, 6, 7, 8].map(function (n) { return BASE + '/img/ViolentCrime/Rocket-' + n + '.png'; });
/** theme index (1-8) ของ entity จากสี body ที่ย้อม (playTint) — ใช้แมป asset สีตามผู้เล่น */
function playLobbyThemeIndexForEntityPlay(ent) {
const tint = ent && ent.playTint;
const body = (tint && typeof tint.body === 'string') ? tint.body : null;
const idx = body ? nearestIndexFromHexPlay(body, PLAY_LOBBY_THEME_HEX) : null;
return (idx >= 1 && idx <= 8) ? idx : null;
}
document.getElementById('room-id').textContent = spaceId;
@@ -5483,6 +5562,7 @@
if (!playBotsEnabled() || !mapData) return;
const human = countPlayHumans();
let wantBots = Math.max(0, playBotTargetHeadcount() - human);
wantBots = Math.min(wantBots, Math.max(0, 6 - human)); /* total ≤ 6 เสมอ (กัน over-count เช่น reconnect หลัง bot-takeover) */
/* Card 3 Ban — บอทที่ถูกแบนรอบนี้ (slot N) ไม่ลงเล่น: ลบทิ้ง + ลดเป้าจำนวนลง 1 + ข้าม slot นั้นตอนสร้าง */
if (playBannedBotSlot >= 0) {
others.delete(PREVIEW_BOT_PREFIX + playBannedBotSlot);
@@ -7275,7 +7355,7 @@
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
const top = uniq.slice(0, 5);
const top = uniq.slice(0, 6);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
return {
@@ -7350,7 +7430,7 @@
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
const top = baseRows.slice(0, 5);
const top = baseRows.slice(0, 6);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
const bs = Math.max(0, Number(row.baseScore) || 0);
@@ -7722,7 +7802,7 @@
}
const participantCount = baseRows.length;
const grade = jumpSurviveGradeFromSurvivorCount(survivorCount);
const top = baseRows.slice(0, 5);
const top = baseRows.slice(0, 6);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
const bs = Math.max(0, Number(row.baseScore) || 0);
@@ -7975,7 +8055,7 @@
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
const top = rows.slice(0, 5);
const top = rows.slice(0, 6);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
/* โบนัสอยู่รอดจนจบเกม = +20 (เฉพาะคนไม่ตาย) — รวมใน finalScore เหมือน rankBonus ของ MG2 */
@@ -8049,7 +8129,7 @@
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
const top = baseRows.slice(0, 5);
const top = baseRows.slice(0, 6);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
const bs = Math.max(0, Number(row.baseScore) || 0);
@@ -8122,7 +8202,7 @@
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
const top = baseRows.slice(0, 5);
const top = baseRows.slice(0, 6);
/* โบนัสจบเกม: ฆ่าบอส(+100)→ทุกคนที่ร่วมเล่น · รอดชีวิต(+10)→เฉพาะคนไม่ตาย (รวมใน finalScore, clamp ≥0) */
const endRBonus = mission && mission.balloonBossEndReason;
const killBonusAll = endRBonus === 'victory' ? 100 : 0;
@@ -8197,7 +8277,7 @@
rows.sort((a, b) => b.baseScore - a.baseScore
|| String(a.nickname).localeCompare(String(b.nickname), 'th')
|| String(a.id).localeCompare(String(b.id)));
const top = rows.slice(0, 5);
const top = rows.slice(0, 6);
/* โบนัสจบเกม: ฆ่าบอส(+100)→ทุกคนที่ร่วมเล่น · รอดชีวิต(+10)→เฉพาะคนไม่ตาย (รวมใน finalScore, clamp ≥0) */
const killBonusAll = reason === 'victory' ? 100 : 0;
const ranked = top.map((row, idx) => {
@@ -8545,7 +8625,7 @@
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
const top = rows.slice(0, 5);
const top = rows.slice(0, 6);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
return {
@@ -8622,7 +8702,7 @@
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
const top = baseRows.slice(0, 5);
const top = baseRows.slice(0, 6);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
const bs = Math.max(0, Number(row.baseScore) || 0);
@@ -8666,7 +8746,7 @@
function quizCarryEmbedMissionFlowActive() {
if (!isQuizCarry()) return false;
if (previewMode && editorEmbedReturn) return true;
return !!(isDetectiveMinigamePlay() && quizCarryUseMissionSummaryOverlay);
return !!(isDetectiveMinigamePlay() && quizCarryUseMissionSummaryOverlay());
}
/** quiz_carry: จุดกลางแมป (world px) — กล้องไม่ตามตัวละคร; ใช้ร่วม draw + overlay DOM */
function getQuizCarryMapCameraWorldCenterPxPlay() {
@@ -9504,6 +9584,115 @@
}, 12000);
}
/*
วบอกสถานะเน (เพมลวน ไมแตะ game logic): าย "เน็ตหน่วง"/"กำลังเชื่อมต่อใหม่"
แสดงมมขวาบน. RTT เองดวย time-sync (server handler อยแล ไมองแก server)
*/
const NET_RTT_WARN_MS = 320; /* เกินนี้ = เน็ตหน่วง (เหลือง) */
const NET_RTT_BAD_MS = 700; /* เกินนี้ = แย่มาก (แดง) */
let netStatusEl = null;
let netLastRtt = null;
let netReconnecting = false;
let netPingTimer = null;
let netOkHideTimer = null;
function ensureNetStatusEl() {
if (netStatusEl) return netStatusEl;
const css = document.createElement('style');
css.textContent = ''
+ '#play-net-status{position:fixed;top:10px;right:12px;z-index:9400;display:none;align-items:center;gap:7px;'
+ 'padding:7px 13px;border-radius:11px;font-family:inherit;font-weight:700;font-size:14px;line-height:1;'
+ 'color:#fff;box-shadow:0 4px 16px rgba(0,0,0,.42);pointer-events:none;backdrop-filter:blur(2px);'
+ 'transition:opacity .25s;letter-spacing:.2px;}'
+ '#play-net-status.is-warn{background:linear-gradient(135deg,#c9831a,#e0a52b);}'
+ '#play-net-status.is-bad{background:linear-gradient(135deg,#b3231d,#d84038);}'
+ '#play-net-status.is-ok{background:linear-gradient(135deg,#1b9e4b,#13c06a);}'
+ '#play-net-status .pns-dot{width:9px;height:9px;border-radius:50%;background:#fff;opacity:.92;}'
+ '#play-net-status.is-bad .pns-spin{width:13px;height:13px;border:2px solid rgba(255,255,255,.4);'
+ 'border-top-color:#fff;border-radius:50%;animation:pnsSpin .8s linear infinite;}'
+ '@keyframes pnsSpin{to{transform:rotate(360deg);}}';
document.head.appendChild(css);
const el = document.createElement('div');
el.id = 'play-net-status';
el.innerHTML = '<span class="pns-icon"></span><span class="pns-text"></span>';
document.body.appendChild(el);
netStatusEl = el;
return el;
}
function setNetStatus(kind, text, withSpin) {
const el = ensureNetStatusEl();
if (netOkHideTimer) { clearTimeout(netOkHideTimer); netOkHideTimer = null; }
if (kind === 'hidden') { el.style.display = 'none'; return; }
el.className = 'is-' + kind;
const icon = el.querySelector('.pns-icon');
if (icon) icon.innerHTML = withSpin ? '<span class="pns-spin"></span>' : '<span class="pns-dot"></span>';
const t = el.querySelector('.pns-text');
if (t) t.textContent = text;
el.style.display = 'flex';
el.style.opacity = '1';
if (kind === 'ok') {
netOkHideTimer = setTimeout(function () { el.style.opacity = '0'; setTimeout(function () { if (netStatusEl) netStatusEl.style.display = 'none'; }, 280); }, 1600);
}
}
function refreshNetStatus() {
if (netReconnecting || (socket && socket.connected === false)) {
setNetStatus('bad', 'กำลังเชื่อมต่อใหม่…', true);
return;
}
if (netLastRtt != null && netLastRtt >= NET_RTT_BAD_MS) {
setNetStatus('bad', 'เน็ตหน่วงมาก (' + Math.round(netLastRtt) + ' ms)', false);
} else if (netLastRtt != null && netLastRtt >= NET_RTT_WARN_MS) {
setNetStatus('warn', 'เน็ตหน่วง (' + Math.round(netLastRtt) + ' ms)', false);
} else {
setNetStatus('hidden');
}
}
function netPingOnce() {
if (!socket || !socket.connected) return;
const t0 = Date.now();
let done = false;
try {
socket.emit('time-sync', function () {
if (done) return; done = true;
netLastRtt = Date.now() - t0;
refreshNetStatus();
});
} catch (_e) { /* ignore */ }
setTimeout(function () { if (!done) { done = true; netLastRtt = Math.max(netLastRtt || 0, NET_RTT_BAD_MS); refreshNetStatus(); } }, 2500);
}
function setupNetStatusIndicator() {
if (netPingTimer) return;
try {
socket.on('disconnect', function () { netReconnecting = true; refreshNetStatus(); });
socket.on('connect', function () {
if (netReconnecting) { netReconnecting = false; setNetStatus('ok', 'เชื่อมต่อแล้ว', false); }
else { netReconnecting = false; }
netPingOnce();
});
if (socket.io && socket.io.on) {
socket.io.on('reconnect_attempt', function () { netReconnecting = true; refreshNetStatus(); });
socket.io.on('reconnect', function () { netReconnecting = false; setNetStatus('ok', 'เชื่อมต่อแล้ว', false); });
}
} catch (_e) { /* ignore */ }
netPingTimer = setInterval(netPingOnce, 4000);
netPingOnce();
}
/* #20 แสดงเลข version มุมขวาล่าง — อ่านจาก ?v= ของ <script play.js> อัตโนมัติ (sync เองทุก deploy) */
(function showPlayBuildVersion() {
try {
let ver = '';
const sc = document.querySelector('script[src*="play.js?v="]');
if (sc && sc.src) { const m = sc.src.match(/[?&]v=([^&]+)/); if (m) ver = decodeURIComponent(m[1]); }
if (!ver) return;
const el = document.createElement('div');
el.id = 'play-build-version';
el.textContent = 'v' + ver;
el.style.cssText = 'position:fixed;right:6px;bottom:5px;z-index:9300;font:600 11px system-ui,sans-serif;'
+ 'color:rgba(255,255,255,.5);background:rgba(0,0,0,.32);padding:2px 7px;border-radius:7px;pointer-events:none;letter-spacing:.3px;';
document.body.appendChild(el);
} catch (_e) { /* ignore */ }
})();
let playMissionLiveBeginsAt = 0; /* server epoch ms ที่เกมเริ่มจริงพร้อมกันทุกคน (0 = ยังไม่รู้) */
function setPlayMissionLiveBeginsAt(v) {
const n = Number(v);
@@ -10014,7 +10203,7 @@
});
});
ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
return ranks.slice(0, 5);
return ranks.slice(0, 6);
}
/** payload สำหรับ overlay สรุปผลกลาง (gauntlet-crown-mission-overlay) — ให้ MG4 ใช้หน้าจบแบบเดียวกับเกมอื่น */
@@ -10411,7 +10600,7 @@
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = 'ครบ ' + String(quizCarryRoundsCompleted) + ' ข้อ';
const missionOv = document.getElementById('quiz-carry-mission-overlay');
if (missionOv && quizCarryUseMissionSummaryOverlay) {
if (missionOv && quizCarryUseMissionSummaryOverlay()) {
cancelQuizCarrySessionCompleteResultToSummary();
hideQuizCarryResultEndLayer();
const summaryOpts = { forceGradeF: !!(playQuizPlayerLocal && playQuizPlayerLocal.eliminated) };
@@ -11133,7 +11322,8 @@
carryReadMs: clampPreviewMs(s.carryReadMs, 3000, 0, 120000),
carryAnswerMs: clampPreviewMs(s.carryAnswerMs, 5000, 1000, 300000),
};
quizCarrySessionLength = clampCarrySessionLen(s.carrySessionLength, 0);
/* default 10 ข้อ (MG4 detective ต้องจบเมื่อครบจำนวน — 0 = เล่นไม่จบ) bug #41 */
{ const _csl = clampCarrySessionLen(s.carrySessionLength, 10); quizCarrySessionLength = _csl > 0 ? _csl : 10; }
setQuizCarryMapPanelThemeFromApi(s);
setQuizCarryEmbedCountdownThemeFromApi(s);
setQuizCarryChoicePlaqueThemeFromApi(s);
@@ -16250,7 +16440,7 @@
const maxHp = 1 + Math.floor(rng() * SPACE_SHOOTER_ASTEROID_MAX_HP);
const ax = 36 + rng() * (mw - 72);
const baseY = -45 - rng() * 90;
const avy = 58 + rng() * 62;
const avy = 92 + rng() * 84; /* #47 เร็วขึ้น (เดิม 58+62) */
const spawnTimeMs = spaceShooterLiveAnchorServerMs + k * interval;
const elapsedSec = Math.max(0, (serverNow() - spawnTimeMs) / 1000);
spaceShooterAsteroids.push({
@@ -16273,7 +16463,7 @@
x: 36 + Math.random() * (mw - 72),
y: -45 - Math.random() * 90,
r: spaceShooterAsteroidRadiusFromHpPlay(hp0),
vy: 58 + Math.random() * 62,
vy: 92 + Math.random() * 84, /* #47 เร็วขึ้น (เดิม 58+62) */
maxHp: maxHp,
hp: hp0,
});
@@ -16491,7 +16681,10 @@
(เดมใช ent.spaceShooterSlot freeze ไว เพยนถ others งไมครบตอน layout) */
const slot = (idx % 6) + 1;
const col = SPACE_SHOOTER_SHIP_COLORS[(slot - 1) % SPACE_SHOOTER_SHIP_COLORS.length];
const shipUrl = (playSpaceShooterShipImageUrls[slot - 1] || '').trim();
/* สียานตาม lobby theme ของผู้เล่น (Rocket-1..8) ก่อน — fallback admin config ตาม slot (bug #3 สีตามuser) */
const shipThemeIdx = playLobbyThemeIndexForEntityPlay(ent);
const shipUrl = (shipThemeIdx ? SPACE_SHOOTER_ROCKET_THEME_URLS[shipThemeIdx - 1] : '')
|| (playSpaceShooterShipImageUrls[slot - 1] || '').trim();
const shipRec = shipUrl ? ensureGauntletAssetImage(shipUrl) : null;
const shipImg = shipRec && shipRec.ready && shipRec.img && shipRec.img.naturalWidth > 0 ? shipRec.img : null;
let shipDrawW = bodyW * 2.2;
@@ -19343,6 +19536,7 @@
desiredLobbySkinToneIndex: __chosenColors.skinIndex || undefined,
}, (res) => {
if (!res || !res.ok) {
hidePlayLoadingOverlay(); /* #5 ซ่อนหน้าโหลดก่อนโชว์ alert (ไม่งั้น alert โดนบัง) */
const errMsg = (res && res.error) || 'เข้าร่วมไม่ได้';
const caseLocked = /เริ่มคดี|ไม่รับผู้เล่น/.test(errMsg);
showPlayAlert(errMsg, {
@@ -19361,6 +19555,7 @@
myId = socket.id;
try { syncPlayClock(); } catch (_sc) { /* ignore */ }
try { startPeriodicClockSync(); } catch (_sci) { /* ignore */ }
try { setupNetStatusIndicator(); } catch (_nsi) { /* ignore */ }
if (res && res.missionLiveBeginsAt != null) setPlayMissionLiveBeginsAt(res.missionLiveBeginsAt);
if (playBannedSpectator) setupBannedSpectatorUi();
if (specialQuizForceDebug) {
@@ -19988,6 +20183,10 @@
}
o.direction = data.direction || o.direction;
if (data.characterId != null) o.characterId = data.characterId;
/* lobby index server user-move () peer hash lobbyB
(MG6 งอกาบาต/MG7 กโป ไม sync tick เหมอน gauntlet เคยตกไป hash) */
const moveTint = playTintFromJoinPeer(data);
if (moveTint) o.playTint = moveTint;
if (mapData && isQuizCarry() && data.quizCarryHeld !== undefined) {
o.quizCarryHeld = (data.quizCarryHeld == null ? null : data.quizCarryHeld);
}
@@ -20040,6 +20239,50 @@
if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud();
if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud();
});
/* ===== Disconnect grace + bot takeover (10 วิ รอ reconnect) ===== */
let _playGraceCountdownTimer = null;
function showDisconnectGraceBanner(nickname, seconds) {
let el = document.getElementById('play-grace-banner');
if (!el) {
el = document.createElement('div');
el.id = 'play-grace-banner';
el.style.cssText = 'position:fixed;left:50%;top:12px;transform:translateX(-50%);z-index:9500;'
+ 'padding:9px 18px;border-radius:12px;background:rgba(176,55,40,.93);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);
}
let remain = Math.max(1, Math.floor(Number(seconds) || 10));
const render = function () { el.textContent = '🔌 ' + nickname + ' หลุด — รอกลับมา ' + remain + ' วิ'; };
render();
el.style.display = 'block';
if (_playGraceCountdownTimer) clearInterval(_playGraceCountdownTimer);
_playGraceCountdownTimer = setInterval(function () {
remain -= 1;
if (remain <= 0) { hideDisconnectGraceBanner(); return; }
render();
}, 1000);
}
function hideDisconnectGraceBanner() {
if (_playGraceCountdownTimer) { clearInterval(_playGraceCountdownTimer); _playGraceCountdownTimer = null; }
const el = document.getElementById('play-grace-banner');
if (el) el.style.display = 'none';
}
socket.on('peer-disconnect-grace', (d) => { if (!d) return; showDisconnectGraceBanner(d.nickname || 'ผู้เล่น', d.seconds || 10); });
socket.on('peer-grace-cancel', () => { hideDisconnectGraceBanner(); });
socket.on('peer-bot-takeover', () => {
hideDisconnectGraceBanner();
/* host เติมบอท 1 ตัวเล่นแทนคนที่หลุด (ถ้ายังไม่เต็ม 6) — ใช้ rebalancePreviewBots เดิม */
try {
if (isMePlayHost()) {
detectiveCaseFillBots = true;
if (countPlayHumans() + detectiveBotSlotCount < 6) detectiveBotSlotCount += 1;
rebalancePreviewBots();
if (typeof broadcastFillBotState === 'function') broadcastFillBotState();
}
} catch (_e) { /* ignore */ }
});
socket.on('chat', (data) => {
const box = document.getElementById('chat-messages');
if (!box) return;
@@ -20339,6 +20582,8 @@
socket.on('quiz-phase', (p) => {
if (previewMode) return;
if (!p || !mapData || !isQuiz()) return;
/* จบเกมแล้ว — เพิกเฉย quiz-phase ที่มาช้า ไม่งั้น applyQuizPhaseFromServer restart timer → "TIME" เดินต่อทับสรุป (bug #34) */
if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'ended') return;
if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase !== 'live' && quizQuestionMissionPhase !== 'ended') {
quizQuestionMissionDeferredPhase = p;
return;
@@ -20439,6 +20684,9 @@
}
playQuizText = '';
playQuizPhaseEndsAt = 0;
/* " 1" playLiveQuizScores mg1AnyScore=false
result-gameover บสรปทกครงแมคนตอบถ (bug #34) */
const mg1AnyScore = Object.keys(playLiveQuizScores).some(function (k) { return (playLiveQuizScores[k] | 0) > 0; });
playLiveQuizScores = {};
hidePlayQuizHud();
if (questionMissionEnd) {
@@ -20447,8 +20695,8 @@
applyQuizQuestionMissionPanelImages();
/* : 1 = result-complete · = result-gameover ( timeup)
ดสนจาก playLiveQuizScores ตรงๆ (ครบทกคน + merge authoritative แล) ไมใช ranked
(ranked สรางจาก me+others เทาน าคนตอบถกไมอยใน others ของจอน คะแนนหาย = ผลเพยน/2 จอไมตรง) */
const mg1AnyScore = Object.keys(playLiveQuizScores).some(function (k) { return (playLiveQuizScores[k] | 0) > 0; });
(ranked สรางจาก me+others เทาน าคนตอบถกไมอยใน others ของจอน คะแนนหาย = ผลเพยน/2 จอไมตรง)
mg1AnyScore เกบไวอนลาง playLiveQuizScores แล (านบน) */
beginSimpleResultFlashThenGcmPlay(questionMissionEnd, mg1AnyScore ? 'result-complete.png' : 'result-gameover.png');
}
});
@@ -21531,7 +21779,9 @@
}
/** Same walkability as room-lobby `canWalkLobby` (LobbyA / hall). */
function canWalkLikeLobby(x, y, fromX, fromY) {
/* softPeer=true "/" (blockPlayer zone) anti-stuck
ปลดลอกเดนผานกนชวคราว แตงคงกำแพง/ขอบ/area quiz/hub กอยาง */
function canWalkLikeLobby(x, y, fromX, fromY, softPeer) {
if (!mapData || !mapData.objects) return false;
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
const w = mapData.width || 20, h = mapData.height || 15;
@@ -21549,7 +21799,7 @@
if (wallTiles.size < colW * colH) return false;
}
const bp = mapData.blockPlayer;
if (bp) {
if (bp && !softPeer) {
for (const k of quizTilesFootprintPlay(x, y)) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
@@ -21723,6 +21973,15 @@
o.botPathStuckTicks = 0;
} else {
o.botPathStuckTicks = (o.botPathStuckTicks | 0) + 1;
/* anti-stuck (MG1/MG4): ติดเพื่อนนานพอ → เขย่า + ลองเดินผ่าน peer (ignorePeers) คงกำแพง/area เหมือนผู้เล่น */
if ((isQuiz() || isQuizCarry()) && (o.botPathStuckTicks | 0) >= ANTI_STUCK_PEER_TICKS) {
o._stuckShakeUntil = Date.now() + 200;
if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o, true)) { o.x = nx; o.y = ny; }
else if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o, true)) { o.x = nx; }
else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o, true)) { o.y = ny; }
clampPlayEntityFootprintToMap(o, mapData);
if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) { o.botIsWalking = true; o.botPathStuckTicks = 0; }
}
if ((o.botPathStuckTicks | 0) >= 20) {
o.botPathStuckTicks = 0;
if (path.length) path.shift();
@@ -23560,6 +23819,11 @@
}
document.documentElement.classList.toggle('play-stack-tower-pixel-canvas', isStackTowerMissionUiMapPlay());
} catch (e) { /* ignore */ }
/* #5 ฉากเริ่มเรนเดอร์แล้ว (มี mapData) → ซ่อนหน้าโหลด หลังหน่วงสั้น ๆ ให้ asset แรกขึ้น */
if (!playLoadingHidden && !playLoadingHideScheduled) {
playLoadingHideScheduled = true;
setTimeout(hidePlayLoadingOverlay, 320);
}
if (isGauntletCrownHeistMapPlay()) ensureGauntletCrownScorePenaltyImgPlay();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const w = mapData.width, h = mapData.height;
@@ -24266,6 +24530,13 @@
for (let i = 0; i < (id || '').length; i++) h = (h * 31 + (id || '').charCodeAt(i)) >>> 0;
return { ax: ((h % 5) - 2) * 0.1, ay: ((Math.floor(h / 5) % 5) - 2) * 0.1 };
}
/** Anti-stuck (MG1/MG4): เขย่าแนวนอนเล็กๆ ตอนดันชนเพื่อน — แสดง "กำลังเบียด" ก่อนเดินผ่าน */
function antiStuckShakeXPlay(ent) {
if (!ent || !ent._stuckShakeUntil) return 0;
const now = Date.now();
if (now >= ent._stuckShakeUntil) return 0;
return Math.sin(now * 0.045 + (ent._stuckShakeSeed || 0)) * 0.07; /* ±0.07 ช่อง */
}
function shouldGauntletCrownHeistSkipAvatarDrawPlay(ent, isMeFlag, offAx, offAy) {
if (!isGauntletCrownHeistMapPlay() || gauntletCrownPregamePhase !== 'live') return false;
if (!ent || ent.gauntletEliminated) return true;
@@ -24340,7 +24611,7 @@
const peerName = botOut ? (o.nickname + ' (ตกรอบ)') : o.nickname;
if (shouldGauntletCrownHeistSkipAvatarDrawPlay(o, false, off.ax, off.ay)) return;
if (isJumpSurvive() && o.jumpSurviveEliminated) return; /* MG5 ตายแล้ว → ตัวละครหายไป */
const axO = safeX(o.x) + off.ax;
const axO = safeX(o.x) + off.ax + antiStuckShakeXPlay(o);
const ayO = safeY(o.y) + off.ay;
/* ผีต้องโชว์เสมอ แม้ทับโซนคำตอบ (เดินไปไหนก็ได้) */
const ghostActiveO = isQuizWrongGhostActiveForEnt(id);
@@ -24361,7 +24632,7 @@
} else {
if (shouldGauntletCrownHeistSkipAvatarDrawPlay(me, true, 0, 0)) return;
if (isJumpSurvive() && jumpSurviveEliminated) return; /* MG5 ตายแล้ว → ตัวละครหายไป */
const axMe = safeX(me.x);
const axMe = safeX(me.x) + antiStuckShakeXPlay(me);
const ayMe = safeY(me.y);
const ghostActiveMe = isQuizWrongGhostActiveForEnt(myId);
if (!ghostActiveMe && playQuizAvatarHideWhileOverlappingAnswerZone(me, myId, axMe, ayMe)) return;
@@ -24884,20 +25155,44 @@
const tox = me.x + (accX / len) * step;
const toy = me.y + (accY / len) * step;
const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData);
const antiStuckGame = !pathStrict && (isQuiz() || isQuizCarry());
let normalMoved = false;
if (pathStrict) {
const moved = quizBattleTryStepMovePlay(me.x, me.y, tox, toy, (nx, ny, ox, oy) => canWalkLikeLobby(nx, ny, ox, oy));
me.x = moved.x;
me.y = moved.y;
normalMoved = true;
} else if (canWalkLikeLobby(tox, toy, me.x, me.y)) {
me.x = tox;
me.y = toy;
} else {
if (canWalkLikeLobby(tox, me.y, me.x, me.y)) {
me.x = tox;
} else if (canWalkLikeLobby(me.x, toy, me.x, me.y)) {
me.y = toy;
normalMoved = true;
} else if (canWalkLikeLobby(tox, me.y, me.x, me.y)) {
me.x = tox;
normalMoved = true;
} else if (canWalkLikeLobby(me.x, toy, me.x, me.y)) {
me.y = toy;
normalMoved = true;
} else if (antiStuckGame) {
/* "" (soft /area) ""
ดเพอน: (แสดงการด) + บเฟรม นครบ ANTI_STUCK_PEER_TICKS แลวปลดลอกเดนผานชวคราว */
const sF = canWalkLikeLobby(tox, toy, me.x, me.y, true);
const sX = canWalkLikeLobby(tox, me.y, me.x, me.y, true);
const sY = canWalkLikeLobby(me.x, toy, me.x, me.y, true);
if (sF || sX || sY) {
me._stuckPeerTicks = (me._stuckPeerTicks || 0) + 1;
me._stuckShakeUntil = Date.now() + 200;
const winOpen = me._antiStuckUntil && Date.now() < me._antiStuckUntil;
if (me._stuckPeerTicks >= ANTI_STUCK_PEER_TICKS || winOpen) {
if (sF) { me.x = tox; me.y = toy; } else if (sX) { me.x = tox; } else if (sY) { me.y = toy; }
me._antiStuckUntil = Date.now() + ANTI_STUCK_WINDOW_MS;
}
} else {
me._stuckPeerTicks = 0; /* ติดกำแพง/area ไม่ใช่เพื่อน — ไม่ปลดล็อก */
}
}
if (normalMoved) me._stuckPeerTicks = 0;
} else {
me._stuckPeerTicks = 0;
}
clampPlayEntityFootprintToMap(me, mapData);
enforceQuizBattleLaneOnMePlay();
+158 -40
View File
@@ -496,6 +496,8 @@
ov.id = 'room-loading-overlay';
ov.innerHTML = '<div id="room-loading-spinner"></div><div class="rl-txt">กำลังโหลดตัวละคร…</div>';
(document.body || document.documentElement).appendChild(ov);
/* safety: กัน overlay ค้างถาวรถ้า preload/manifest แฮงค์ (ไม่มี roomPreloadReady) */
setTimeout(hideRoomLoading, 12000);
}
function hideRoomLoading() {
@@ -507,6 +509,27 @@
function maybeHideRoomLoading() { if (roomJoinReady && roomPreloadReady) hideRoomLoading(); }
/* #5 "" ( scene)
opts.persist=true ไมองต safety auto-hide (ใชอน navigate ออกจากหน) */
function showRoomLoadingOverlay(text, opts) {
roomLoadingHidden = false;
var ov = document.getElementById('room-loading-overlay');
if (!ov) { injectRoomLoadingOverlay(); ov = document.getElementById('room-loading-overlay'); }
if (ov) { ov.classList.remove('is-hidden'); ov.style.opacity = ''; }
var t = ov && ov.querySelector('.rl-txt');
if (t) t.textContent = text || 'กำลังโหลด…';
if (!(opts && opts.persist)) setTimeout(hideRoomLoading, 12000);
}
/* แสดง % บนหน้าโหลด LobbyB (ตอนกลับจากมินิเกม) — ตามความคืบหน้า preload ตัวละคร */
function setRoomLoadingPct(pct) {
if (roomLoadingHidden) return;
var el = document.querySelector('#room-loading-overlay .rl-txt');
if (!el) return;
var p = Math.round(Math.max(0, Math.min(100, Number(pct) || 0)));
el.textContent = 'กำลังโหลดตัวละคร… ' + p + '%';
}
function preloadLobbyTintedCharacter(id, theme, skin, cb) {
if (!id || (!theme && !skin)) { if (cb) cb(); return; }
var dirs = ['down', 'up', 'left', 'right'];
@@ -547,31 +570,32 @@
}
/** อุ่น tint บอท + peer ที่มีสี — กันกระตุกตอนบอทเดิน/idle ครั้งแรก */
function preloadAllLobbyTintedOccupants(cb) {
var pending = 1;
function doneOne() {
pending--;
if (pending <= 0 && cb) cb();
}
function preloadAllLobbyTintedOccupants(cb, onProgress) {
var tasks = [];
var pid = getStoredCharacterId();
if (pid && (myTintTheme || myTintSkin)) {
pending++;
preloadLobbyTintedCharacter(pid, myTintTheme, myTintSkin, doneOne);
}
if (pid && (myTintTheme || myTintSkin)) tasks.push([pid, myTintTheme, myTintSkin]);
lobbyBots.forEach(function (b, id) {
if (!b || !b.characterId) return;
if (!b.colorTheme && !b.colorSkin) return;
saveLobbyBotTint(id, b); /* sync สีบอท lobby → เกม (เกมอ่าน sessionStorage นี้ ให้สีตรงกัน) */
pending++;
preloadLobbyTintedCharacter(b.characterId, b.colorTheme, b.colorSkin, doneOne);
tasks.push([b.characterId, b.colorTheme, b.colorSkin]);
});
peers.forEach(function (p) {
if (!p || !p.characterId) return;
if (!p.colorTheme && !p.colorSkin) return;
pending++;
preloadLobbyTintedCharacter(p.characterId, p.colorTheme, p.colorSkin, doneOne);
tasks.push([p.characterId, p.colorTheme, p.colorSkin]);
});
var total = tasks.length;
var done = 0;
if (!total) { if (onProgress) onProgress(1, 1); if (cb) cb(); return; }
if (onProgress) onProgress(0, total);
tasks.forEach(function (t) {
preloadLobbyTintedCharacter(t[0], t[1], t[2], function () {
done++;
if (onProgress) onProgress(done, total);
if (done >= total && cb) cb();
});
});
doneOne();
}
function readLobbyCharImgSize(img) {
@@ -1055,7 +1079,8 @@
let lastMoveSend = 0;
const moveCodes = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
let lobbyInteractPulse = null;
let lobbyZoom = 1.2;
/* มือถือจอแคบ → zoom ต่ำลง ให้เห็นพื้นที่ lobby กว้างขึ้น (PC ย่อส่วน) · PC คงเดิม 1.2 */
let lobbyZoom = (typeof window !== 'undefined' && (window.innerWidth || 0) > 0 && window.innerWidth < 600) ? 0.85 : 1.2;
const LOBBY_ZOOM_MIN = 0.4;
const LOBBY_ZOOM_MAX = 2.5;
let mapTransform = { offsetX: 0, offsetY: 0, ts: 32, w: 20, h: 15 };
@@ -1423,7 +1448,10 @@
const drawW = imgScale > 0 ? iw * imgScale : 0;
const drawH = imgScale > 0 ? ih * imgScale : 0;
const drawY = cellBottom - drawH;
const shadowImg = getDefaultShadowImg(dir);
/* #26 1 default-shadow overlay ( 'shadow') = 2
overlay เหลอเงาในสไปรตนเดยว ( RL_LOBBY_DRAW_DEFAULT_SHADOW=true เพอกลบมาวาด) */
const RL_LOBBY_DRAW_DEFAULT_SHADOW = false;
const shadowImg = RL_LOBBY_DRAW_DEFAULT_SHADOW ? getDefaultShadowImg(dir) : null;
if (shadowImg) {
const sw = shadowImg.naturalWidth;
const sh = shadowImg.naturalHeight;
@@ -1968,12 +1996,20 @@
function applySuspectPickImages() {
var cards = document.querySelectorAll('#suspect-cards-row .suspect-card');
if (!cards.length) return;
/* ทันที: เข้าสถานะ loading (ซ่อนรูป generic/เก่าใน HTML + spinner) ก่อนรอ case media — กันรูปแว่บ (#1) */
cards.forEach(function (card) { card.classList.add('is-img-loading'); });
function paint() {
cards.forEach(function (card) {
var idx = parseInt(card.getAttribute('data-index'), 10);
if (Number.isNaN(idx) || idx < 0 || idx > 2) return;
var img = card.querySelector(':scope > img');
if (img) img.src = getSuspectPickImageUrl(idx);
if (img) {
var reveal = function () { card.classList.remove('is-img-loading'); };
img.onload = reveal;
img.onerror = reveal; /* กัน loading ค้างถ้ารูปเสีย */
img.src = getSuspectPickImageUrl(idx);
if (img.complete && img.naturalWidth > 0) reveal(); /* cached แล้ว */
}
/* ปุ่มแว่นขยาย — ดูการ์ดเต็ม + ปิดได้ (ไม่ชนการเลือก/โหวต) */
if (!card.querySelector('.suspect-zoom-btn')) {
if (getComputedStyle(card).position === 'static') card.style.position = 'relative';
@@ -4366,7 +4402,8 @@
}
function canInvestigateSuspect(si) {
return true; // เก็บได้ไม่จำกัด
/* #1 สืบผู้ต้องสงสัยละไม่เกิน 3 ครั้ง — เต็ม 3 หลักฐานแล้วเลือกสืบซ้ำไม่ได้ (server ก็ reject) */
return getSuspectEvidenceCount(si) < 3;
}
function persistMyPlayerEvidenceSession() {
@@ -5779,7 +5816,9 @@
if (!btn || !img) return;
var SC = '/Main-Lobby/IMAGE/Showcard/';
var req = Math.max(1, Math.min(2, tmState.requiredPicks || 2));
var totalMembers = (tmState.members || []).length;
/* ใช้จำนวน authoritative จาก server (ลดเมื่อคนหลุด) ถ้ามี — ไม่งั้น fallback members.length */
var totalMembers = (Number.isFinite(tmState.totalPlayers) && tmState.totalPlayers > 0)
? tmState.totalPlayers : (tmState.members || []).length;
var submittedCount = (tmState.submittedIds || []).length;
var allDone = totalMembers > 0 && submittedCount >= totalMembers;
var isHost = !!(tmState.hostId && tmState.hostId === socket.id);
@@ -6010,6 +6049,8 @@
var ov = ensureTestimonyOverlay();
tmState.round = (data && typeof data.round === 'number') ? data.round : 0;
tmState.members = (data && Array.isArray(data.members)) ? data.members : [];
tmState.totalPlayers = (data && Number.isFinite(data.totalPlayers) && data.totalPlayers > 0)
? data.totalPlayers : tmState.members.length;
tmState.picks = [];
tmState.submitted = false;
tmState.submittedPicksById = {};
@@ -6071,6 +6112,9 @@
if (!data) return;
tmState.submittedIds = Array.isArray(data.submitted) ? data.submitted.slice() : [];
if (data.hostId) tmState.hostId = data.hostId;
/* authoritative server () host members.length
ไมลดตอนคนหล (นคาง "รอคนที่ออกไปแล้ว" host เปดหลกฐานไมได) */
if (Number.isFinite(data.totalPlayers) && data.totalPlayers > 0) tmState.totalPlayers = data.totalPlayers;
es3RenderMembers(tmState.submittedIds, [], false);
es3UpdateActionButton();
/* อัปเดต status text */
@@ -6923,6 +6967,9 @@
var cutsceneCid = (data && data.caseId != null) ? data.caseId : null;
if (cutsceneCid) {
ensureCaseMediaLoaded().then(function () { showCaseCutscene(cutsceneCid, function () {}); });
} else {
/* #5 ไม่มี cutscene → โชว์หน้าโหลดเต็มจอระหว่าง fetch+swap ฉาก LobbyB (กันเห็นฉาก LobbyA เก่า) */
showRoomLoadingOverlay('กำลังโหลดฉาก LobbyB…');
}
fetch(SERVER + '/api/maps/' + encodeURIComponent(mid))
.then(function (r) { return r.ok ? r.json() : null; })
@@ -6980,6 +7027,7 @@
resizeAndDraw();
updateHostStartGameButton();
renderPeers();
if (!cutsceneCid) setTimeout(hideRoomLoading, 250); /* #5 ฉาก LobbyB วาดแล้ว → ปิดหน้าโหลด */
ensureReadyControlEnabled();
if (data.cardMinigames) setSuspectCardMinigames(data.cardMinigames);
applyPersonalSuspectState(data);
@@ -6999,6 +7047,7 @@
})
.catch(function () {
appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ');
hideRoomLoading(); /* #5 กัน overlay ค้างถ้าโหลดแมปไม่สำเร็จ */
});
}
@@ -7147,6 +7196,9 @@
appendLobbySystemChat('— บอทถูกแบนรอบนี้ (Ban Card) · ไม่ได้ลงเล่นมินิเกมรอบนี้');
}
}
/* #5 navigate LobbyB play.html
(persist: ไม auto-hide เพราะกำลงออกจากหน play.html play-loading-overlay บชวงต) */
try { showRoomLoadingOverlay('กำลังเข้าเกม…', { persist: true }); } catch (e) { /* ignore */ }
location.href = q;
});
@@ -7275,8 +7327,44 @@
lobbyZoomPctHideTimer = null;
}, 1500);
}
/** เดินไปยังจุดที่กด/แตะบน canvas (ใช้ร่วมกัน dblclick (PC) + tap (มือถือ)) — DPI-safe */
function lobbyWalkToClientXY(clientX, clientY) {
if (!mapData || !canvas) return;
const me = peers.get(socket.id);
if (!me) return;
const r = canvas.getBoundingClientRect();
if (!r.width || !r.height) return;
/* สเกลด้วย canvas.width/r.width — canvas อาจมีความละเอียดต่างจากขนาดแสดง (มือถือ DPI) */
const sx = (clientX - r.left) * (canvas.width / r.width);
const sy = (clientY - r.top) * (canvas.height / r.height);
const { cw, ch, lobbyZoom: z, tileSize: t, meX, meY, w, h } = mapTransform;
const gx = (sx - cw / 2) / (z * t) + meX;
const gy = (sy - ch / 2) / (z * t) + meY;
const tx = Math.floor(gx);
const ty = Math.floor(gy);
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return;
if (!canWalkLobby(tx + 0.5, ty + 0.5)) return;
const path = pathfindLobby(me.x, me.y, tx + 0.5, ty + 0.5);
if (path.length <= 1) return;
lobbyPath = path.slice(1);
}
if (canvas) {
canvas.addEventListener('pointerdown', () => { focusLobbyMapCanvas(); });
try { canvas.style.touchAction = 'none'; } catch (_e) { /* กัน browser zoom/pan รบกวนการแตะเดิน */ }
let _lobbyTapStart = null;
canvas.addEventListener('pointerdown', (e) => {
focusLobbyMapCanvas();
/* มือถือ: จำจุดเริ่มแตะ → แตะสั้นๆ = เดิน (PC ใช้ dblclick เหมือนเดิม ไม่เพี้ยน) */
if (e.pointerType === 'touch') _lobbyTapStart = { x: e.clientX, y: e.clientY, t: Date.now() };
});
canvas.addEventListener('pointerup', (e) => {
if (e.pointerType !== 'touch' || !_lobbyTapStart) return;
const dx = e.clientX - _lobbyTapStart.x, dy = e.clientY - _lobbyTapStart.y, dt = Date.now() - _lobbyTapStart.t;
_lobbyTapStart = null;
/* แตะสั้นๆ ไม่ลาก = เดิน · ลาก/กดค้าง = ข้าม (กันชนกับการ scroll/zoom) */
if (dx * dx + dy * dy > 24 * 24 || dt > 700) return;
lobbyWalkToClientXY(e.clientX, e.clientY);
});
canvas.addEventListener('pointercancel', () => { _lobbyTapStart = null; });
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
lobbyZoom *= e.deltaY > 0 ? 0.9 : 1.1;
@@ -7284,27 +7372,38 @@
redrawLobbyMap();
showZoomPct();
}, { passive: false });
canvas.addEventListener('dblclick', (e) => {
if (!mapData) return;
const me = peers.get(socket.id);
if (!me) return;
const r = canvas.getBoundingClientRect();
const sx = e.clientX - r.left;
const sy = e.clientY - r.top;
const { cw, ch, lobbyZoom: z, tileSize: t, meX, meY, w, h } = mapTransform;
const gx = (sx - cw / 2) / (z * t) + meX;
const gy = (sy - ch / 2) / (z * t) + meY;
const tx = Math.floor(gx);
const ty = Math.floor(gy);
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return;
if (!canWalkLobby(tx + 0.5, ty + 0.5)) return;
const targX = tx + 0.5, targY = ty + 0.5;
const path = pathfindLobby(me.x, me.y, targX, targY);
if (path.length <= 1) return;
lobbyPath = path.slice(1);
});
canvas.addEventListener('dblclick', (e) => { lobbyWalkToClientXY(e.clientX, e.clientY); });
}
/* (touch-primary) "" F ( )
dispatch synthetic KeyF ใช handler interaction เด (host เลอกระด/คด, องแตงต ฯลฯ) PC ไมเหนปมน */
(function setupMobileLobbyInteractBtn() {
try {
var coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
if (!coarse) return;
if (document.getElementById('lobby-mobile-interact-btn')) return;
var btn = document.createElement('button');
btn.id = 'lobby-mobile-interact-btn';
btn.type = 'button';
btn.setAttribute('aria-label', 'โต้ตอบ (F)');
btn.innerHTML = '<span class="lmi-key">F</span><span class="lmi-txt">โต้ตอบ</span>';
btn.style.cssText = 'position:fixed;left:max(1rem,env(safe-area-inset-left));bottom:max(1.1rem,env(safe-area-inset-bottom));'
+ 'z-index:410;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;'
+ 'width:64px;height:64px;border-radius:50%;border:2px solid #ffd166;background:rgba(20,28,48,.86);'
+ 'color:#ffe9a8;font-family:inherit;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.45);touch-action:manipulation;';
var st = document.createElement('style');
st.textContent = '#lobby-mobile-interact-btn .lmi-key{font-weight:800;font-size:20px;line-height:1}'
+ '#lobby-mobile-interact-btn .lmi-txt{font-size:11px;line-height:1;opacity:.92}'
+ '#lobby-mobile-interact-btn:active{transform:scale(.93);background:rgba(255,209,102,.25)}';
document.head.appendChild(st);
btn.addEventListener('click', function (e) {
e.preventDefault();
try { document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyF', key: 'f', bubbles: true })); } catch (_e) { /* ignore */ }
});
document.body.appendChild(btn);
} catch (_e) { /* ignore */ }
})();
if (readyCheck) {
ensureReadyControlEnabled();
updateReadyLabelVisual();
@@ -7354,7 +7453,10 @@
preloadMyTintedCharacter(function () {
preloadAllLobbyTintedOccupants(function () {
roomPreloadReady = true;
setRoomLoadingPct(100);
maybeHideRoomLoading();
}, function (d, t) {
setRoomLoadingPct(t ? (d / t) * 100 : 100);
});
});
});
@@ -7370,4 +7472,20 @@
window.addEventListener('storage', function (e) {
if (e.key == null || e.key === 'gameCharacterId') syncLobbyAvatarFromStorage();
});
/* #20 แสดงเลข version มุมขวาล่าง — อ่านจาก ?v= ของ <script room-lobby.js> อัตโนมัติ */
(function showLobbyBuildVersion() {
try {
var ver = '';
var sc = document.querySelector('script[src*="room-lobby.js?v="]');
if (sc && sc.src) { var m = sc.src.match(/[?&]v=([^&]+)/); if (m) ver = decodeURIComponent(m[1]); }
if (!ver) return;
var el = document.createElement('div');
el.id = 'lobby-build-version';
el.textContent = 'v' + ver;
el.style.cssText = 'position:fixed;right:6px;bottom:5px;z-index:9300;font:600 11px system-ui,sans-serif;'
+ 'color:rgba(255,255,255,.5);background:rgba(0,0,0,.32);padding:2px 7px;border-radius:7px;pointer-events:none;letter-spacing:.3px;';
document.body.appendChild(el);
} catch (_e) { /* ignore */ }
})();
})();
+6 -1
View File
@@ -5304,6 +5304,11 @@
#special-quiz-overlay .sq-ov-award-msg:empty { display: none; }
#special-quiz-overlay .sq-ov-award-btn { margin-top: 4px; cursor: pointer; border: none; background: url('/Game/img/special-quiz/reward/btn-continue-play.png') center/100% 100% no-repeat; width: clamp(220px, 26vw, 320px); aspect-ratio: 354 / 120; font-size: 0; color: transparent; transition: filter .12s, transform .08s; }
#special-quiz-overlay .sq-ov-award-btn:hover { filter: brightness(1.1); transform: translateY(-2px); }
/* #3 ตอนรอผู้เล่นอื่น (หลังกดเล่นต่อ) — แทนรูปปุ่มด้วยข้อความ "รอผู้เล่นอื่น… (X/Y)" ให้รู้ว่าไม่ค้าง */
#special-quiz-overlay .sq-ov-award-btn.is-waiting { background: rgba(18,26,46,0.92); background-image: none; border: 1px solid rgba(122,162,247,0.45); border-radius: 12px; font-size: clamp(15px,1.7vw,19px); color: #bcd2ff; font-weight: 700; letter-spacing: .02em; display: flex; align-items: center; justify-content: center; gap: 8px; cursor: progress; opacity: .95; }
#special-quiz-overlay .sq-ov-award-btn.is-waiting::before { content: ''; width: 16px; height: 16px; border-radius: 50%; border: 2px solid rgba(122,162,247,.35); border-top-color: #7aa2f7; animation: sqWaitSpin .8s linear infinite; }
#special-quiz-overlay .sq-ov-award-btn.is-waiting:hover { filter: none; transform: none; }
@keyframes sqWaitSpin { to { transform: rotate(360deg); } }
#special-quiz-overlay .sq-ov-members-head { margin-top: 16px; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: #7aa2f7; font-weight: 700; }
#special-quiz-overlay .sq-ov-members { margin-top: 8px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
@media (max-width: 560px) { #special-quiz-overlay .sq-ov-members { grid-template-columns: 1fr; } }
@@ -5371,7 +5376,7 @@
<script src="/app-base.js?v=2"></script>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0306"></script>
<script src="js/play.js?v=0.006242301"></script>
<script src="js/play.js?v=0.006250400"></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)
+13 -3
View File
@@ -7,11 +7,11 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/room-lobby.css?v=0.0175">
<link rel="stylesheet" href="css/room-lobby.css?v=0.0176">
<link rel="stylesheet" href="css/profile-popup.css?v=7">
<link rel="stylesheet" href="css/customize-popup.css?v=42">
<link rel="stylesheet" href="css/leaderboard-popup.css?v=2">
<link rel="stylesheet" href="css/testimony-overlay.css?v=17">
<link rel="stylesheet" href="css/testimony-overlay.css?v=18">
<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%; }
@@ -224,7 +224,17 @@
width: 405px;
height: 625px;
border-radius: 10px;
transition: opacity 0.25s ease;
}
/* #1 กันรูปการ์ดเก่า/mock แว่บก่อนรูปจริงโหลด — ซ่อนรูปไว้ + spinner จนรูปคดีจริงโหลดเสร็จ */
.suspect-card.is-img-loading > img { opacity: 0; }
.suspect-card.is-img-loading::after {
content: ''; position: absolute; left: 50%; top: 42%;
width: 56px; height: 56px; margin: -28px 0 0 -28px; border-radius: 50%;
border: 5px solid rgba(159, 233, 255, 0.22); border-top-color: #9fe9ff;
animation: suspectCardSpin 0.9s linear infinite; z-index: 6; pointer-events: none;
}
@keyframes suspectCardSpin { to { transform: rotate(360deg); } }
.suspect-card--host {
cursor: pointer;
}
@@ -1617,7 +1627,7 @@
<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/room-lobby.js?v=0.0304"></script>
<script src="js/room-lobby.js?v=0.0311"></script>
<div class="version-tag">v —</div>
</body>
</html>
+144 -14
View File
@@ -532,6 +532,8 @@ const SPECIAL_QUIZ_ICON_TYPES = ['lawyer', 'police'];
/** โอกาสโผล่ = 1 ใน 3 ของการเล่นแต่ละรอบ */
/** โอกาสขึ้นคำถามพิเศษต่อรอบ (เล่นจริง) — 1/3 · โหมดเทสต์บังคับการ์ดต่อเกมจะ force ขึ้นเสมอแยกต่างหาก */
const SPECIAL_QUIZ_SPAWN_CHANCE = 1 / 3;
/** หลุดกลางมินิเกม → ให้เวลา reconnect (วิ) ก่อน bot เข้าเล่นแทน */
const DISCONNECT_GRACE_SEC = 10;
/** โหมดเทสต์ (admin): บังคับ "การ์ดพิเศษใบไหน → เกมไหน" — { mapId: cardId(1-7) } · เกมที่ตั้งไว้จะ force ขึ้นเสมอด้วยการ์ดนั้น */
function normalizeTestSpecialCardByMap(v) {
@@ -710,6 +712,29 @@ function pickSpecialQuizSpawnTile(md, occupiedLaneCount) {
return { x: p.x + 0.5, y: p.y + 0.5 };
}
}
/* jump_survive ( mnptfts2): "" walkable ()
ไอคอนคล/แตะเกบไดกมเกม (trySpecialQuizClickAt) วางบนแพลตฟอรมใกลดเก (อยในจอตงแต คลกได)
= แก #44 เวนตเศษ (ทนาย/เทพ) ไมนเพราะ BFS หา walkable ไมเจอ return null */
if (md.gameType === 'jump_survive' && Array.isArray(md.jumpSurvivePlatformArea) && md.jumpSurvivePlatformArea.length) {
const sp = md.spawn || { x: Math.floor(w / 2), y: Math.floor(h / 2) };
const bx = Math.floor(Number(sp.x));
const by = Math.floor(Number(sp.y));
const plats = [];
for (let y = 0; y < h; y++) {
const row = md.jumpSurvivePlatformArea[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (row[x]) plats.push({ x, y });
}
}
if (plats.length) {
plats.sort((a, b) => (Math.abs(a.x - bx) + Math.abs(a.y - by)) - (Math.abs(b.x - bx) + Math.abs(b.y - by)));
const near = plats.slice(0, Math.min(8, plats.length));
const p = near[Math.floor(Math.random() * near.length)];
const iy = (p.y - 1 >= 0) ? p.y - 1 : p.y; /* เหนือแพลตฟอร์ม 1 ช่อง = ระดับตัวผู้เล่น (ไม่จมในแพลตฟอร์ม) */
return { x: p.x + 0.5, y: iy + 0.5 };
}
}
/* seed BFS (spawnArea)
fallback: md.spawn (าเดนได) แลวคอยสแกนหา walkable tile แรก */
let sx = -1;
@@ -803,7 +828,8 @@ function specialQuizIconPayload(space) {
return { iconType: sq.iconType, x: sq.x, y: sq.y, expiresAt: sq.expiresAt || 0 };
}
/** เริ่มมินิเกมสืบสวน: สุ่ม 1/3 ว่ารอบนี้จะมีไอคอนพิเศษไหม + เลือกตำแหน่ง */
/** : 1/3 + (1 "1 3")
Test Mode (testSpecialCardByMap / forceSpecialQuiz) าม roll นเสมอ */
function maybeSpawnSpecialQuizForRun(space) {
clearSpecialQuizTimers(space);
space.specialQuiz = null;
@@ -2062,8 +2088,8 @@ function defaultGameTiming() {
stackTeamMissesMax: 3,
/** Stack ภารกิจ Tower: จำนวนชั้นสำเร็จ (ไม่คิดคอมโบ) ที่รวมแล้วได้ Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */
stackTowerProgressBlocks: 50,
stackBlockNormalImageUrls: ['', '', '', '', '', ''],
stackBlockHeavyImageUrls: ['', '', '', '', '', ''],
stackBlockNormalImageUrls: ['', '', '', '', '', '', '', ''],
stackBlockHeavyImageUrls: ['', '', '', '', '', '', '', ''],
/** โอกาสใช้รูป “ใหญ่” ต่อการปล่อยหนึ่งครั้ง (0–100) — ถ้าไม่มี URL ใหญ่ช่องนั้น = ใช้ปกติ */
stackHeavyBlockPercent: 35,
/** ยิงยานอวกาศ / space_shooter: จำกัดเวลารอบ (วินาที) — 0 = ไคลเอนต์ใช้ค่าเริ่ม 90; แมป spaceShooterTimeSec > 0 ทับ */
@@ -2086,7 +2112,7 @@ function defaultGameTiming() {
forcedMinigameKeys: ['', '', ''],
/** โหมดเทสต์: บังคับการ์ดพิเศษต่อเกม { mapId: cardId } */
testSpecialCardByMap: {},
/** โหมดเทสต์: บังคับเสนอบทตัวป่วน (ปกติเสนอเฉพาะเมื่อมีคนจริง > 4) */
/** โหมดเทสต์: บังคับเสนอบทตัวป่วน (ปกติเสนอเฉพาะเมื่อมีคนจริง 4) */
troublesomeForceOffer: false,
/** เวลาที่ไอคอนคำถามพิเศษอยู่บนฉากก่อนหายไป (วินาที) — 0 = ไม่หาย */
specialQuizIconExpireSec: 10,
@@ -2149,7 +2175,7 @@ function normalizeStackBlockSixImageUrls(val) {
}
if (!Array.isArray(arr)) arr = [];
const out = [];
for (let i = 0; i < 6; i++) {
for (let i = 0; i < 8; i++) { /* 8 slot ตาม lobby theme (#4) — เดิม 6 */
out.push(sanitizeGauntletAssetUrl(arr[i]));
}
return out;
@@ -3428,6 +3454,12 @@ function awardRandomEvidenceCardToPlayer(space, playerId, suspectIndex, grade) {
: [[], [], []];
}
const list = normalizeEvidenceSlotList(space.playerEvidence[playerId][suspectIndex]);
/* กันเกิน 3 ใบ/ผู้ต้องสงสัย (#1) — defensive ถ้ามีรอบที่ 4 หลุดมา */
if (list.length >= 3) {
space.playerEvidence[playerId][suspectIndex] = list;
syncEvidenceByNick(space, playerId);
return null;
}
const card = pickEvidenceCardForSuspect(space, suspectIndex, grade);
if (!card) {
space.playerEvidence[playerId][suspectIndex] = list;
@@ -3539,6 +3571,8 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
space.coinAwardDoneForRun = false;
/* รีเซ็ตคะแนนที่ client รายงาน (stack/quiz_carry) ของรอบก่อน */
space.peers.forEach((p) => { p.reportedMiniScore = 0; p.reportedMiniSurvived = true; });
/* เริ่มรอบใหม่ → ล้าง over event เก่า (กัน resume ส่ง over ของรอบก่อนให้คน reconnect) */
space.lastMinigameOver = null;
/* Card 3 Ban — ผู้ที่ถูกแบนไม่ได้เล่นมินิเกมรอบนี้ (1 รอบ) */
space.bannedThisRunPlayerId = space.bannedPlayerId || null;
console.log('[ban-debug] startMinigame: bannedThisRunPlayerId=', space.bannedThisRunPlayerId);
@@ -5926,6 +5960,7 @@ const server = http.createServer((req, res) => {
const list = [...spaces]
.filter(([, s]) => !s.isPrivate)
.filter(([, s]) => (s.peers && s.peers.size > 0))
.filter(([, s]) => !s.joinLocked) /* ห้องที่เริ่มคดีแล้ว (ออกจาก LobbyA) → ซ่อนจาก Join Room list ไม่ให้ใครเข้า */
.map(([id, s]) => ({
spaceId: id,
spaceName: s.spaceName || id,
@@ -7024,6 +7059,23 @@ function pickSpawnForJoin(md, joinOrderIndex) {
/* spawnJoinOrder (lobbyAlobbyBminigame): playerKey ( browser)
ไมใช peers.size เปลยนตามลำด reconnect ตำแหนงเก / / slot ของแตละคนสลบกนม */
/** host host (spawnJoinOrder )
เสถยรขามการ reconnect (spawnJoinOrder กก playerKey) แทน Map-first สลบได */
function pickNextHostPeer(space) {
if (!space || !space.peers || space.peers.size === 0) return null;
let best = null;
let bestOrd = Infinity;
space.peers.forEach((p, id) => {
if (!p) return;
const ord = (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) ? p.spawnJoinOrder : 9999;
if (ord < bestOrd || (ord === bestOrd && (best == null || String(id) < String(best.id)))) {
bestOrd = ord;
best = p;
}
});
return best;
}
function resolveSpawnJoinOrder(space, playerKey) {
if (!space.spawnOrderByKey || typeof space.spawnOrderByKey !== 'object') space.spawnOrderByKey = {};
const key = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : '';
@@ -7081,7 +7133,7 @@ function initTroublesomeState(space) {
if (space.suspectPhaseActive == null) space.suspectPhaseActive = false;
}
/** เปิดเฟส "เลือกผู้ต้องสงสัย" ให้ทั้งห้อง — เรียกหลังตอบข้อเสนอตัวป่วน หรือเมื่อข้ามการเสนอ (คน 4) */
/** เปิดเฟส "เลือกผู้ต้องสงสัย" ให้ทั้งห้อง — เรียกหลังตอบข้อเสนอตัวป่วน หรือเมื่อข้ามการเสนอ (คน < 4) */
function openSuspectPhaseForRoom(sid, space, disruptorAccepted) {
if (!space) return;
let idx = Math.floor(Number(space.suspectPickIndex));
@@ -7114,10 +7166,10 @@ function attemptTroublesomeRoll(sid, force) {
const peerIds = [...space.peers.keys()];
if (peerIds.length === 0) return;
if (!force && space.troublesomeEligible.size < peerIds.length) return;
/* เสนอบทตัวป่วนเฉพาะเมื่อมี "คนจริง > 4 คน" — ยกเว้น admin บังคับ (troublesomeForceOffer) */
/* เสนอบทตัวป่วนเฉพาะเมื่อมี "คนจริง 4 คน" — ยกเว้น admin บังคับ (troublesomeForceOffer) */
const forceOffer = !!(runtimeGameTiming && runtimeGameTiming.troublesomeForceOffer);
if (peerIds.length <= 4 && !forceOffer) {
/* ไม่เสนอบทตัวป่วน (คน 4) → ข้ามไปเปิดหน้าเลือกผู้ต้องสงสัยเลย (ไม่มีตัวป่วน) */
if (peerIds.length < 4 && !forceOffer) {
/* ไม่เสนอบทตัวป่วน (คน < 4) → ข้ามไปเปิดหน้าเลือกผู้ต้องสงสัยเลย (ไม่มีตัวป่วน) */
space.troublesomeOfferSent = true;
space.troublesomeResponseReceived = true;
space.troublesomeAccept = false;
@@ -7593,6 +7645,12 @@ io.on('connection', (socket) => {
if (space.gauntletCrownLobbyReady) delete space.gauntletCrownLobbyReady[pid];
io.to(spaceId).emit('user-left', { id: pid });
});
/* กลับมาทัน grace 10 วิ → ยกเลิก bot takeover + ปิด banner countdown */
if (space.graceReconnect && space.graceReconnect[joinPlayerKey]) {
delete space.graceReconnect[joinPlayerKey];
if (space.graceTimers && space.graceTimers[joinPlayerKey]) { clearTimeout(space.graceTimers[joinPlayerKey]); delete space.graceTimers[joinPlayerKey]; }
io.to(spaceId).emit('peer-grace-cancel', { nickname: nickname || 'ผู้เล่น' });
}
}
/* reconnect dedup 2: playerKey match (/ peer lobbyB playerKey)
คนเดมกลบเขามากลายเปนตวซ (ghost) playerKey block บไมได เห "ตัวละครซ้ำ 2 ตัว" บน "ทุกจอรวม host"
@@ -7763,6 +7821,22 @@ io.on('connection', (socket) => {
const sqIcon = specialQuizIconPayload(space);
if (sqIcon) joinCb.specialQuizIcon = sqIcon;
if (typeof cb === 'function') cb(joinCb);
/* reconnect: event "" (balloon-boss-over/stack-over/space-shooter-over)
host งครงเดยวผาน socket.to client หลดตอนนนจะคางถาวร (ไม replay).
เก over าสดไว resend ให socket เพ (re)join ภายใน 45s ายงอยในแมปมเกม
(gameType !== 'lobby' = งไมทรานซนกล). delay เลกนอยให client applyMapAndStart เสรจกอน */
const lmo = space.lastMinigameOver;
if (lmo && lmo.event && (Date.now() - lmo.atMs) < 45000 && mapDataOut && mapDataOut.gameType !== 'lobby') {
const targetSid = socket.id;
setTimeout(() => {
try {
const sp = spaces.get(spaceId);
if (sp && sp.lastMinigameOver === lmo && io.sockets.sockets.get(targetSid)) {
io.to(targetSid).emit(lmo.event, lmo.payload);
}
} catch (_e) { /* ignore */ }
}, 400);
}
if (space.hostId !== prevHostId) io.to(spaceId).emit('host-changed', { hostId: space.hostId }); /* host เปลี่ยน (เช่น host ตัวจริงกลับเข้ามา) → sync ทุกคน */
emitLobbyTintSync(spaceId, space);
socket.to(spaceId).emit('user-joined', peer);
@@ -7994,7 +8068,10 @@ io.on('connection', (socket) => {
const scores = Array.isArray(data.scores)
? data.scores.filter((s) => s && s.id != null).map((s) => ({ id: s.id, score: Math.max(0, Number(s.score) || 0), eliminated: !!s.eliminated }))
: [];
socket.to(sid).emit('balloon-boss-over', { reason, scores });
const overPayload = { reason, scores };
socket.to(sid).emit('balloon-boss-over', overPayload);
/* เก็บไว้ resend ให้คน reconnect (host ยิงครั้งเดียว — หลุดตอนนั้นจะค้างถาวร) */
space.lastMinigameOver = { event: 'balloon-boss-over', payload: overPayload, atMs: Date.now() };
});
/* MG7 balloon_boss host HP : relay + (/)
@@ -8076,7 +8153,9 @@ io.on('connection', (socket) => {
const space = sid ? spaces.get(sid) : null;
if (!space) return;
const outcome = (data && typeof data.outcome === 'string') ? data.outcome.slice(0, 32) : 'time_up';
socket.to(sid).emit('stack-over', { outcome });
const overPayload = { outcome };
socket.to(sid).emit('stack-over', overPayload);
space.lastMinigameOver = { event: 'stack-over', payload: overPayload, atMs: Date.now() };
});
/* MG6 space_shooter — host ตัดสินจบเกม + แนบคะแนนสุดท้าย → ทุกคนจบ/อันดับตรงกัน */
@@ -8088,7 +8167,9 @@ io.on('connection', (socket) => {
const scores = Array.isArray(data.scores)
? data.scores.filter((s) => s && s.id != null).map((s) => ({ id: s.id, score: Math.max(0, Number(s.score) || 0), hits: Math.max(0, Number(s.hits) || 0), eliminated: !!s.eliminated }))
: [];
socket.to(sid).emit('space-shooter-over', { kind, scores });
const overPayload = { kind, scores };
socket.to(sid).emit('space-shooter-over', overPayload);
space.lastMinigameOver = { event: 'space-shooter-over', payload: overPayload, atMs: Date.now() };
});
/* MG6 space_shooter — relay กระสุนผู้เล่น/บอท ให้ทุกคนเห็นตรงกัน (host เห็นยังไง client เห็นแบบนั้น) */
@@ -8225,6 +8306,17 @@ io.on('connection', (socket) => {
if (!cardEntry || !cardEntry.mapId) {
return reply({ ok: false, error: 'ยังไม่ได้สุ่มมินิเกมสำหรับการ์ดนี้' });
}
/* 3 (#1) max
(แตละรอบทกคนได +1) นเลนครงท 4 + นการดพเศษโผลรอบท 4 (#2) */
let timesPlayedThisSuspect = 0;
space.peers.forEach((_p, pid) => {
const prog = playerSuspectProgressFromEvidence(space, pid);
const n = Array.isArray(prog) ? (prog[selectedIndex] | 0) : 0;
if (n > timesPlayedThisSuspect) timesPlayedThisSuspect = n;
});
if (timesPlayedThisSuspect >= 3) {
return reply({ ok: false, error: 'สืบผู้ต้องสงสัยคนนี้ครบ 3 ครั้งแล้ว — เลือกคนอื่น' });
}
/* Card 3 Ban — ก่อนเริ่มมินิเกมรอบหน้า โหวตเลือกผู้เล่น 1 คนที่ห้ามเล่น */
const pend = findPendingSpecialCard(space, 3);
if (pend && !space.pickVote) {
@@ -8734,6 +8826,10 @@ io.on('connection', (socket) => {
}
if (!space || !space.peers.has(socket.id)) return;
const wasHost = space.hostId === socket.id;
/* เก็บข้อมูลก่อนลบ peer — ใช้ทำ grace 10 วิ (รอ reconnect) + bot takeover ระหว่างมินิเกม */
const leftPeerInfo = space.peers.get(socket.id) || {};
const leftPlayerKey = (typeof leftPeerInfo.playerKey === 'string') ? leftPeerInfo.playerKey : '';
const leftNick = (leftPeerInfo.nickname || '').trim() || 'ผู้เล่น';
if (space.voiceInits) delete space.voiceInits[socket.id];
if (space.troublesomeEligible) space.troublesomeEligible.delete(socket.id);
if (space.quizSession && space.quizSession.players) delete space.quizSession.players[socket.id];
@@ -8770,7 +8866,7 @@ io.on('connection', (socket) => {
spaces.delete(sid);
} else {
if (wasHost) {
const firstPeer = space.peers.values().next().value;
const firstPeer = pickNextHostPeer(space); /* คนอยู่นานสุด (spawnJoinOrder ต่ำสุด) — แทน Map-first */
if (firstPeer) {
space.hostId = firstPeer.id; /* ย้าย pointer host ชั่วคราวให้คนที่เหลือ (liveness) */
/* hostPlayerKey host reclaim rejoin play.html
@@ -8788,6 +8884,40 @@ io.on('connection', (socket) => {
if (space.specialQuizContinueAcks) space.specialQuizContinueAcks.delete(socket.id);
maybeFinishSpecialQuizContinue(sid, space);
}
/* (testimony) submit/ready re-emit
เพอให "จำนวนที่ต้องส่ง/ready" ลดตามจร (peers.delete ไปแลวดานบน) ไม host รอคนทไมอย = ไปตอไมได */
if (space.testimonyActive) {
if (space.testimonySubmitted) delete space.testimonySubmitted[socket.id];
if (space.testimonyReadyNext) delete space.testimonyReadyNext[socket.id];
emitTestimonyStatus(sid, space);
if (space.testimonyRevealed) {
io.to(sid).emit('testimony-ready-update', {
ready: Object.keys(space.testimonyReadyNext || {}),
totalPlayers: testimonyTotalParticipants(space),
hostId: space.hostId,
});
}
}
/* "" ( LobbyB) reconnect 10 + countdown banner
ครบเวลายงไมกล broadcast bot-takeover ให host เตมบอทเลนแทน */
if (space.detectiveMinigameActive && 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 = {};
if (space.graceTimers[leftPlayerKey]) clearTimeout(space.graceTimers[leftPlayerKey]);
space.graceReconnect[leftPlayerKey] = Date.now();
space.graceTimers[leftPlayerKey] = setTimeout(() => {
const sp = spaces.get(sid);
if (!sp) return;
if (sp.graceTimers) delete sp.graceTimers[leftPlayerKey];
/* กลับมาแล้ว (playerKey อยู่ใน peers) → ไม่ต้อง takeover */
let back = false;
if (sp.peers) sp.peers.forEach((p) => { if (p && p.playerKey === leftPlayerKey) back = true; });
if (sp.graceReconnect) delete sp.graceReconnect[leftPlayerKey];
if (back) return;
io.to(sid).emit('peer-bot-takeover', { nickname: leftNick });
}, DISCONNECT_GRACE_SEC * 1000);
}
}
});
@@ -9130,7 +9260,7 @@ io.on('connection', (socket) => {
p.direction = data.direction || p.direction;
const posUnchanged = Math.abs(nx - prevX) < 1e-5 && Math.abs(ny - prevY) < 1e-5 && p.direction === prevDir;
if (!posUnchanged || moveForceEmit) {
const out = { id: socket.id, x: nx, y: ny, direction: p.direction, characterId: p.characterId };
const out = { id: socket.id, x: nx, y: ny, direction: p.direction, characterId: p.characterId, lobbyColorThemeIndex: p.lobbyColorThemeIndex || null, lobbySkinToneIndex: p.lobbySkinToneIndex || null };
if (md && md.gameType === 'space_shooter') {
out.spaceShooterScore = Math.max(0, p.spaceShooterScore | 0);
out.spaceShooterHits = Math.max(0, p.spaceShooterHits | 0);
+605
View File
@@ -0,0 +1,605 @@
[
{
"n": 1,
"name": "แต่งตัวไม่ได้",
"detail": "แต่งตัวยังไม่ได้ มีพี่คิวทำได้คนเดียว",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 2,
"name": "ตัวละครไม่แสดง",
"detail": "หน้าเลือกเกม Quiz battle ภาพตัวละครไม่แสดง",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 3,
"name": "ระบบ join เกมมีปัญหา",
"detail": "เมื่อเข้ามินิเกม : บางคนเล่นเกมกะบอท บางคนเล่นกะเพื่อน แต่ละคนเข้ามินิเกมแล้วเจอกันบ้าง ไม่เจอกันบ้าง",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 4,
"name": "ระบบ Host มีปัญหา - 1",
"detail": "เป็น host ในเกม ตอนที่เลือกผู้ต้องสงสัย รอบที่ 2 ระบบบอกว่าให้รอ host ไม่สามารถเลือกการ์ดและกดเริ่มสืบสวนได้",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 5,
"name": "ระบบ Host มีปัญหา - 2",
"detail": "เมื่อกดออกจากเกม Host เปลี่ยนจากพี่ต้อมเป็นพี่คิวอัติโนมัติ",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 6,
"name": "ระบบ Host มีปัญหา - 3",
"detail": "เมื่อเข้ามินิเกม ทุกคนเห็นตัวเองเป็น host หมดเลย แต่กดเริ่มเกมไม่ได้",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 7,
"name": "ระบบสีตัวละคร ยังใช้ไม่ได้",
"detail": "สีตัวละครเปลี่ยนไปเรื่อย ไม่รุ้ว่าเราสีอะไรแน่ งงไปหมด",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 8,
"name": "หน้าเข้าร่วมเกมส์",
"detail": "ห้องไหนที่เริ่มเกมส์ไปแล้ว เมื่อกดเข้าร่วมแสดง popup alert error ปรับดีไซต์ให้สวยงามกว่านี้",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 9,
"name": "เกมวิ่งขึ้นบันได ผิด flow",
"detail": "ปรับ flow ให้ถูกต้อง คือ ใครตกจอตาย แต่ถ้าขึ้นไปอยู่บนสุดแล้วจะกระโดดไม่ไปต่อเฉยๆ แต่ไม่ตาย",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 10,
"name": "เกมวิ่งขึ้นบันได",
"detail": "เวลาหมดแล้ว ให้เล่นรอบ 2 auto / รอบ 3 มี popup ให้กดเล่นต่อ",
"prio": "",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 11,
"name": "เกมยิงลูกโป่ง",
"detail": "ฉากเกมยิงลูกโป่งเล็กไป",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 12,
"name": "เกมต่อตึก ผิด flow",
"detail": "ปรับ flow ให้ถูกต้อง คือ เล่นทีละคน เรียงตามคิวไปเรื่อย โดยที่ blog ตามสีผู้เล่นด้วย",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 13,
"name": "เข้าร่วมห้องไม่ได้",
"detail": "สร้างห้องส่วนตัวเเล้ว เเต่เพื่อนจอยเข้าไปไม่ได้ ทั้งที่กรอกข้อมูลครบ",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 14,
"name": "Room ID",
"detail": "เวลาจอยห้องให้กรอกเเค่ ID กับรหัสที่สร้าง",
"prio": "ต่ำ",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 15,
"name": "จอยห้องคนอื่น",
"detail": "ในกรณีที่จอยห้องคนอื่น ไปหน้าเข้าร่วม เเละกดปุ่มเข้าร่วมไปเเล้ว จากนั้นกดปุ่ม exit จะพาไปหน้าสร้างห้อง สิ่งที่ถูกต้อง มันควรมาหน้า ห้องเข้าร่วมเกม",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 16,
"name": "grid กับ ฉาก ไม่ตรงกัน",
"detail": "เกม Quiz battle เกมที่ 1 ฉากกับกริดไม่ตรงกัน ทำให้เดินทะลุ หรือเดินติดในจุดที่ไม่ควรติด",
"prio": "สูง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 17,
"name": "ตัวละครวิ่งเร็วเกินไป",
"detail": "เกม Quiz battle เกมที่ 1 ตัวละครผู้เล่นเร็วเกินไป",
"prio": "กลาง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 18,
"name": "Oauth Social log-in error",
"detail": "ไม่สามารถ log-inด้วย facebook / Google ได้ path: /oauth-public.php",
"prio": "ต่ำ",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 19,
"name": "หน้า login",
"detail": "นโยบายความเป็นส่วนตัวกดไม่ได้",
"prio": "กลาง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 20,
"name": "แสดงเลข version ตรงมุมจอ",
"detail": "แสดงเลข version ตรงมุมจอขวาบนหรือขวาล่างก็ได้",
"prio": "ต่ำ",
"status": "แก้ไขแล้ว",
"updated": "2026-06-24",
"note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — badge มุมขวาล่าง อ่าน ?v= อัตโนมัติ"
},
{
"n": 21,
"name": "ลดขนาดตัวละคร",
"detail": "ลดขนาดตัวละครในห้อง lobby ลงอีก 2เท่า",
"prio": "สูง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 22,
"name": "เปลี่ยน ui popup ตอนพูดบนหัว",
"detail": "แก้ png seq บนหัวใหม่ เป็นแบบอื่น",
"prio": "กลาง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 23,
"name": "ชื่่อตัวละครทับลำโพง/ทับ bubble",
"detail": "ใน lobby ขยับชื่อลงมาอย่าให้ทับ",
"prio": "กลาง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 24,
"name": "UI แต่งตัว : การแจ้งเตือนสีตัวละครถูกใช้แล้ว",
"detail": "ตอนเลือกสีให้มีคำแจ้งเตือน used",
"prio": "กลาง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 25,
"name": "เพิ่ม ข้อความแจ้งเตือนอัพเดทกลางจอ",
"detail": "ถ้ามีการอัพเดทซีน อัพเดทสีตัวละครให้มีป็อปอัพเด้งมากลางหน้าจอ ว่ามีการอัพเดทและให้รอโหลด",
"prio": "สูง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 26,
"name": "เอาเงาตัวละครออก 1 อัน",
"detail": "ตอนนี้ตัวละครมีเงา 2 ชั้น เอาออกไป 1 ชั้น",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-24",
"note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — ปิด default-shadow overlay เหลือเงาในสไปรต์"
},
{
"n": 27,
"name": "optimize ตัวละครให้เล็กลงอีก มันโหลดช้า",
"detail": "",
"prio": "สูง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 28,
"name": "ปุ่ม start ใน lobby",
"detail": "ทำให้ดูโต้ตอบหน่อย เช่นพอเดินเข้าไปใกล้ให้มัน glow ขึ้นมา",
"prio": "กลาง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 29,
"name": "เกมต่อตึก ผิด flow",
"detail": "สี blog กับคนเล่นยังไม่สัมพันกัน ต้องเรียงตามสี และตามคิว",
"prio": "วิกฤต",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 30,
"name": "เกมต่อตึก บั๊กคนที่ 5",
"detail": "คนที่ 5 ไม่สามารถเล่นได้",
"prio": "วิกฤต",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 31,
"name": "เอา mock การ์ดผู้ต้องหาเก่าออกไป",
"detail": "มันแว่บๆมา",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 v0.006242530 (รอเทส) — ซ่อนรูปจนรูปคดีจริงโหลด + spinner"
},
{
"n": 32,
"name": "เอา popup ขออนุญาตใช้ไมค์ออกไปเลย น่ารำคาญ",
"detail": "",
"prio": "สูง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 33,
"name": "หน้า howto เปลี่ยนสีปุ่ม ready ด้วยตอน กดready แล้ว",
"detail": "เปลี่ยนเป้นสีเขียวพร้อมเครื่องหมายถูกตามดีไซน์",
"prio": "สูง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 34,
"name": "เกม ใช่ หรือไม่ : ตอนจบบั๊ค",
"detail": "ตอนสรุปคะแนน ยังนับเวลาอยุ่ และขึ้น gameover ทับหน้าสรุปคะแนน",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "2026-06-24",
"note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG1 ใช่/ไม่: capture mg1AnyScore ก่อนล้าง (gameover) + guard quiz-phase หลังจบ (timer)"
},
{
"n": 35,
"name": "เกมวิ่ง : กดปุ่มไม่กระโดด",
"detail": "ปุ่ม jump กดแล้วไม่กระโดด",
"prio": "วิกฤต",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 36,
"name": "เกมวิ่ง : ตอนจบบั๊ค วิ่งเลยฉาก",
"detail": "ตอนจบบั๊ค วิ่งเลยฉาก (ในบางรอบ)",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "2026-06-24",
"note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG2 วิ่งเลยฉาก: walk-to-finish ค่อยๆเดินเข้าเส้น + clamp ไม่เลยเส้น"
},
{
"n": 37,
"name": "popup รับหลักฐาน ทับไมค์",
"detail": "กดไมค์ไม่ได้ ตอนรับหลักฐาน ไปดูเรื่องเลเยอร์อีกที",
"prio": "กลาง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 38,
"name": "หน้าส่งหลักฐาน ปุ่มส่งหลักฐานหลุดจอ (เครื่องของเนส)",
"detail": "เป้นเครื่องเนสคนเดียว",
"prio": "วิกฤต",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 39,
"name": "ห้องส่งหลักฐาน : เมื่อมีคนหลุด 1 คน จะไปต่อไม่ได้เลย",
"detail": "",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "2026-06-24",
"note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — testimony: disconnect ลบ submit/ready + client ใช้ totalPlayers authoritative"
},
{
"n": 40,
"name": "จำนวนคน ให้เล่นได้ตั้งแต่ 4-6 คน",
"detail": "กรณีเลือกผู้เล่นตั้งแต่ 4 คนขึ้นไปไม่ต้องมี bot",
"prio": "วิกฤต",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 41,
"name": "เกม หยิบแผ่นป้าย",
"detail": "ต้องเล่นให้ครบ 10 ข้อก่อน ค่อยจบเกม (ตอนนี้เล่นถูก 1 ข้อ จบเกมเลย)",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "2026-06-24",
"note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG4 หยิบป้าย: ใช้ map จริง (currentPlayMapId) เปิด mission flow + default 10 ข้อ"
},
{
"n": 42,
"name": "เกม กระโดด",
"detail": "ท่ากระโดดแปลก ยืนซอยเท้าทำไม",
"prio": "กลาง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 43,
"name": "เกม กระโดด",
"detail": "ขั้นบันไดมันถี่ไป บางทีกระโดดแล้วเบิ้ลขั้นเลย ปรับให้ระยะห่างแต่ละขั้นมันเท่ากัน",
"prio": "สูง",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 44,
"name": "เกมกระโดด",
"detail": "อีเวนท์พิเศษ (ทนาย/เทพความรู้) หายไป",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "2026-06-24",
"note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG5 อีเวนต์พิเศษ: เพิ่ม branch jump_survive วาง icon บน platform"
},
{
"n": 45,
"name": "เกมไม่รองรับ 4K",
"detail": "หน้ารับหลักฐาน (หลังจากเล่นเกมจบ)",
"prio": "วิกฤต",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 46,
"name": "เกมยิิงอุกาบาด",
"detail": "สีตัวละครยังไม่ตรงกับที่เลือกมา",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "2026-06-24",
"note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG6 สี: ยาน Rocket-1..8 ตาม lobby theme ของผู้เล่น"
},
{
"n": 47,
"name": "เกมยิิงอุกาบาด",
"detail": "อุกาบาดให้พุ่งเร็วกว่านี้ และลูกเล็กกว่านี้เกมง่ายไป",
"prio": "สูง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-24",
"note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG6: อุกาบาตเร็วขึ้น (+~50%) + เล็กลง (48->34/11->8)"
},
{
"n": 48,
"name": "เพิ่ม popup เมื่อเกมค้าง เล่นต้่อไม่ได้ ให้มีปุ่มกดออกไปหน้าแรกสุด",
"detail": "สาเหตุเกิดจากมีคนหลุดระหว่างรอ host กด",
"prio": "วิกฤต",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 49,
"name": "กรณีผู้เล่นหลุดออกจากเกม",
"detail": "ถ้ามีคนหลุด ให้กลายเป้นบอทแทน",
"prio": "วิกฤต",
"status": "เปิด",
"updated": "",
"note": ""
},
{
"n": 50,
"name": "ผู้เล่น 6 คน: บางเกมเล่นได้แค่ 5 (คนที่ 6 หายจากสรุป)",
"detail": "สรุปคะแนนฝั่ง client slice(0,5) ตัดคนที่ 6 — แก้เป็น slice(0,6) ทุกมินิเกม (MG1/3/4/5/6/7)",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "2026-06-24",
"note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส)"
},
{
"n": 51,
"name": "ผู้เล่น 6 คน: คะแนนตอนจบไม่ตรงกันแต่ละจอ",
"detail": "MG1 merge คะแนน server / MG6,7 merge คะแนน host -> ครบ 6 ตรงกัน (MG3 ยัง local)",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "2026-06-24",
"note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส)"
},
{
"n": 52,
"name": "หน้าไต่สวน/สรุปหลักฐาน: กรอบปลิ้นตอน 6 คน",
"detail": "member row grid repeat(5)->repeat(6) ใน testimony-overlay.css (คนที่6 ตกแถว2)",
"prio": "สูง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 v0.006242530 (รอเทส)"
},
{
"n": 53,
"name": "เพิ่มหน้าโหลด (loading) ตอนเข้าเกม",
"detail": "overlay spinner กันเห็นจอเปล่า/asset แว่บ ซ่อนเมื่อ draw เฟรมแรก + safety 8s",
"prio": "สูง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 v0.006242530 (รอเทส)"
},
{
"n": 54,
"name": "host โหลดนานมากตอนเข้าเกม",
"detail": "optimize asset/char (เกี่ยว row 28) — loading overlay ช่วยซ่อน แต่ยังโหลดช้าจริง",
"prio": "สูง",
"status": "เปิด",
"updated": "2026-06-25",
"note": "loading overlay บังอาการแล้ว · root = canvas tint ภาพตัวละครใหญ่ (source ไม่อยู่ใน repo) → ต้อง optimize ภาพแยก"
},
{
"n": 55,
"name": "หลังออกจาก LobbyA ไม่ให้ใครเข้า join",
"detail": "server มี joinLocked (set ตอน LobbyA->case ~8656, check ~7585 whitelist nickname) — ต้อง repro ว่าใครเข้าได้ยังไง (room list ยังโชว์? ชื่อซ้ำ whitelist?)",
"prio": "สูง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 — กรองห้อง joinLocked ออกจาก /api/spaces list (ห้องเริ่มคดีแล้วหาย) + server reject อยู่แล้ว"
},
{
"n": 56,
"name": "ตอนกลับ LobbyB โชว์รูปแรกก่อนแล้วค่อยถูก",
"detail": "เหมือน #1 การ์ด — ต้องใส่ loading state ตอน return LobbyB ด้วย (มี room-loading-overlay อยู่แต่ซ่อนเร็วไป)",
"prio": "สูง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 v0.0308 — การ์ดมี spinner (#1) + room-loading overlay ใส่ % + รอ char preload เสร็จก่อนซ่อน + safety 12s"
},
{
"n": 57,
"name": "เล่นมินิเกมเกิน 3 ครั้งต่อผู้ต้องสงสัย (+การ์ดพิเศษโผล่รอบที่4)",
"detail": "server cap suspect-pick-start (max หลักฐาน>=3 reject) + award cap 3 + client canInvestigateSuspect<3 (เดิม stub return true). UI messaging มีอยู่แล้ว",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 v0.0309/server (รอเทส)"
},
{
"n": 58,
"name": "ปุ่ม \"เล่นเกมต่อ\" ไม่รู้ว่าค้างหรือรอคนอื่น",
"detail": "text \"รอผู้เล่นอื่น (X/Y)\" ถูก set อยู่แล้วแต่ CSS font-size:0 ซ่อน → เพิ่ม .is-waiting CSS โชว์ข้อความ+spinner แทนรูปปุ่ม",
"prio": "สูง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 v0.0309/server (รอเทส)"
},
{
"n": 59,
"name": "loading ไม่คลุม scene transition (เห็นฉากเก่าก่อนเปลี่ยน)",
"detail": "LobbyA->B (in-page swap) โชว์ room-loading ระหว่าง fetch+swap · LobbyB->เกม โชว์ overlay ก่อน location.href · play.html มี overlay รับช่วง",
"prio": "สูง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 v0.0309/server (รอเทส)"
},
{
"n": 60,
"name": "ทำหน้า testcase บนเซิร์ฟเวอร์",
"detail": "srv1361159.hstgr.cloud/testcase — bug list + filter/search/นับสถานะ generate จาก xlsx",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 v0.0309/server (รอเทส)"
},
{
"n": 61,
"name": "MG3 สี block ไม่ตรง user (เกม+admin)",
"detail": "PNG Block-Code-1..8 deploy ครบแล้ว · ต้อง wire สี block = lobby theme ของผู้วาง (seat=theme refactor, ต้องเทส 2 จอ) + admin 8-slot config",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 v0.006250200 — block สีตาม theme ผู้เล่นที่ seat (Block-Code-1..8) ตอนวาด ไม่แตะ turn/sync"
},
{
"n": 62,
"name": "MG1/MG4 user/bot ติดกัน (เดินไม่ผ่าน)",
"detail": "เพิ่ม anti-stuck: ดันชนเพื่อน → เขย่า (shake) + ปลดล็อกเดินผ่านกันชั่วคราว คงกำแพง/area/quiz-lock; ทั้งผู้เล่นและบอท",
"prio": "สูง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 v0.006250100 (รอเทส)"
},
{
"n": 63,
"name": "LobbyA มือถือ กดเดินไม่ได้",
"detail": "lobby canvas มีแค่ dblclick (ไม่มี touch) → มือถือแตะเดินไม่ได้. แก้: เพิ่ม tap-to-walk (เฉพาะ touch, PC ยัง dblclick) + DPI-safe coord + touch-action:none + zoom-out default บนจอ<600px (PC ย่อส่วน)",
"prio": "วิกฤต",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 room-lobby.js?v=0.0310 (รอเทสมือถือ) · PC ไม่กระทบ"
},
{
"n": 64,
"name": "มือถือ: ปุ่มแชท AI ใหญ่ไป + ปุ่ม F (host) ไม่มี",
"detail": "AI chat collapsed btn 213x201px → ใหญ่บนมือถือ. + การกด F (interact: host เลือกระดับ/คดี, ห้องแต่งตัว) ไม่มีบนมือถือ",
"prio": "สูง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 (css 0.0176/js 0.0311): media<600px ย่อปุ่ม AI (92px) + ปุ่มลอย \"โต้ตอบ(F)\" บน touch-primary device (dispatch synthetic KeyF). PC ไม่เห็น"
},
{
"n": 65,
"name": "อีเวนต์พิเศษ (ทนาย/เทพ) = 1 ใน 3 (ตามดีไซน์)",
"detail": "ดีไซน์: 3 เกม/LobbyB เล่นได้เกมละ 3 ครั้ง · อีเวนต์พิเศษ 1/3 ต่อครั้ง (1 ใบ) · Test Mode บังคับ. revert การแก้ \">1 ต่อรอบ\" กลับเป็น 1 ใบ/ครั้งที่ติด",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ revert 2026-06-25 — กลับเป็น 1/3 (1 ใบ/ครั้งที่ติด) ตามดีไซน์ · ยืนยันโครงสร้าง 3เกม×3ครั้ง + disruptor toggle (>4คน/Test Mode) ถูกต้อง"
},
{
"n": 66,
"name": "host หลุด → ย้าย host ให้คนอยู่นานสุด",
"detail": "pickNextHostPeer เลือก spawnJoinOrder ต่ำสุด (เสถียรข้าม reconnect) แทน Map-first + host จริง reclaim ได้",
"prio": "สูง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 (server) — Part 1 host migration"
},
{
"n": 67,
"name": "คนหลุดกลางเกม → 10 วิ reconnect + บอทเล่นแทน",
"detail": "peer-disconnect-grace แบนเนอร์นับ 10 วิ · กลับทัน=cancel เล่นต่อ · ครบเวลา=peer-bot-takeover host เติมบอท 1 ตัว (rebalance, cap≤6). บอทไม่สืบทอดตำแหน่ง/คะแนน",
"prio": "สูง",
"status": "แก้ไขแล้ว",
"updated": "2026-06-25",
"note": "✅ แก้แล้ว 2026-06-25 (server+play.js v0.006250400) — Part 2 แบบ A · seamless(host คุม entity เดิม)=ค้าง(เสี่ยง)"
}
]
+98
View File
@@ -0,0 +1,98 @@
{
"1": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-24 18:19"
},
"2": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-24 18:19"
},
"3": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-24 18:19"
},
"4": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 03:30"
},
"5": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 03:30"
},
"6": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 03:30"
},
"20": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 03:30"
},
"26": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 03:30"
},
"31": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 03:31"
},
"34": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 03:35"
},
"7": {
"result": "pass",
"comment": "✅ ผ่าน — สีตัวละครเปลี่ยนใน dressing room ถูกต้อง อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 07:05"
},
"13": {
"result": "pass",
"comment": "✅ ผ่าน — เข้าร่วมห้องส่วนตัวด้วย Room ID + Password สำเร็จ อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 07:06"
},
"14": {
"result": "pass",
"comment": "✅ ผ่าน — ฟอร์มเข้าร่วมแสดงเฉพาะ Room ID และ Password เท่านั้น อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 07:06"
},
"15": {
"result": "fail",
"comment": "❌ ยังไม่ผ่าน — กด exit จาก lobby ที่ join มา กลับไปหน้า \"สร้างห้องเกม\" แทนหน้า \"เข้าร่วมเกม\" bug ยังอยู่ อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 07:06"
},
"53": {
"result": "pass",
"comment": "✅ ผ่าน — หน้าโหลด \"กำลังเข้าสู่การสืบสวน\" มีอยู่และแสดงผลก่อน redirect อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 07:17"
},
"60": {
"result": "none",
"comment": "✅ ผ่าน — หน้า testcase บนเซิร์ฟเวอร์ใช้งานได้ที่ \/testcase\/ อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 07:17"
}
}
File diff suppressed because one or more lines are too long
+236
View File
@@ -0,0 +1,236 @@
<?php
// Justice — Test Case / Bug List tracker (QA verification + admin)
$DATA_DIR = __DIR__ . '/data';
$BUGS = $DATA_DIR . '/bugs.json';
$VERIFY = $DATA_DIR . '/verify.json';
$UPLOADS = __DIR__ . '/uploads';
$ADMIN_PASS = 'justice2026'; // รหัสโหมดแอดมิน
function tc_load($f){ return is_file($f) ? (json_decode(file_get_contents($f), true) ?: []) : []; }
// ---------- API: บันทึกผลตรวจ (POST) ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (($_POST['api'] ?? '') === 'save')) {
header('Content-Type: application/json; charset=utf-8');
if (($_POST['pass'] ?? '') !== $ADMIN_PASS) { http_response_code(403); echo json_encode(['ok'=>false,'error'=>'รหัสแอดมินไม่ถูกต้อง'], JSON_UNESCAPED_UNICODE); exit; }
$id = preg_replace('/[^0-9]/', '', (string)($_POST['id'] ?? ''));
if ($id === '') { echo json_encode(['ok'=>false,'error'=>'no id'], JSON_UNESCAPED_UNICODE); exit; }
if (!is_dir($DATA_DIR)) @mkdir($DATA_DIR, 0775, true);
$verify = tc_load($VERIFY);
$rec = $verify[$id] ?? ['result'=>'none','comment'=>'','images'=>[]];
if (!isset($rec['images']) || !is_array($rec['images'])) $rec['images'] = [];
$res = $_POST['result'] ?? '';
if (in_array($res, ['pass','fail','none'], true)) $rec['result'] = $res;
if (isset($_POST['comment'])) $rec['comment'] = mb_substr(trim((string)$_POST['comment']), 0, 2000);
// ลบรูป
if (($_POST['removeImage'] ?? '') !== '') {
$rm = basename((string)$_POST['removeImage']);
$rec['images'] = array_values(array_filter($rec['images'], function($x) use ($rm){ return $x !== $rm; }));
@unlink($UPLOADS . '/' . $rm);
}
// อัปโหลดรูป
if (!empty($_FILES['image']['tmp_name']) && is_uploaded_file($_FILES['image']['tmp_name'])) {
$ext = strtolower(pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION));
if (in_array($ext, ['jpg','jpeg','png','gif','webp'], true) && $_FILES['image']['size'] < 8*1024*1024) {
if (!is_dir($UPLOADS)) @mkdir($UPLOADS, 0775, true);
$fn = $id . '_' . time() . '_' . bin2hex(random_bytes(3)) . '.' . $ext;
if (move_uploaded_file($_FILES['image']['tmp_name'], $UPLOADS . '/' . $fn)) $rec['images'][] = $fn;
}
}
$rec['at'] = date('Y-m-d H:i');
$verify[$id] = $rec;
file_put_contents($VERIFY, json_encode($verify, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
echo json_encode(['ok'=>true,'rec'=>$rec], JSON_UNESCAPED_UNICODE); exit;
}
$bugs = tc_load($BUGS);
$verify = tc_load($VERIFY);
?><!DOCTYPE html>
<html lang="th"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Justice Test Case / Bug List</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0f1e;--panel:#121a30;--panel2:#0e1526;--line:#22304f;--txt:#dce6ff;--mut:#8aa0c8;--cyan:#22d3ee;--green:#22c55e;--red:#f87171;--amber:#fbbf24;}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Kanit,system-ui,sans-serif;background:radial-gradient(circle at 50% -10%,#13213f,#070b16 70%);color:var(--txt);min-height:100vh;padding:24px 16px 80px}
.wrap{max-width:1240px;margin:0 auto}
h1{font-weight:600;font-size:26px;letter-spacing:.5px;margin-bottom:4px}
h1 .sub{color:var(--mut);font-size:14px;font-weight:300;display:block;margin-top:4px}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:12px;margin:18px 0}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
.card .v{font-size:26px;font-weight:600}.card .l{font-size:12px;color:var(--mut);margin-top:2px}
.card.ok .v{color:var(--green)}.card.open .v{color:var(--amber)}.card.pass .v{color:var(--cyan)}.card.fail .v{color:var(--red)}
.bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:14px 0}
.bar input[type=text],.bar input[type=password]{background:var(--panel2);border:1px solid var(--line);border-radius:10px;color:var(--txt);padding:9px 14px;font-family:inherit;font-size:14px}
#q{flex:1;min-width:180px}
.bar input:focus{outline:none;border-color:var(--cyan)}
.chip{background:var(--panel2);border:1px solid var(--line);border-radius:20px;color:var(--mut);padding:7px 15px;font-size:13px;cursor:pointer;font-family:inherit}
.chip:hover{border-color:var(--cyan);color:var(--txt)}
.chip.on{background:var(--cyan);border-color:var(--cyan);color:#04121a;font-weight:600}
.adminbox{margin-left:auto;display:flex;gap:8px;align-items:center}
.adminbox .ok{color:var(--green);font-size:13px}
.lockhint{font-size:13px;color:var(--amber);white-space:nowrap}
.qabadge{cursor:pointer}
.qabadge:hover{filter:brightness(1.15);text-decoration:underline}
@keyframes apFlash{0%,100%{box-shadow:none}30%,70%{box-shadow:0 0 0 3px rgba(248,113,113,.55)}}
#adminpass.flash{animation:apFlash .7s ease}
table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
th{text-align:left;font-size:12px;color:var(--mut);font-weight:500;padding:11px 12px;background:var(--panel2);border-bottom:1px solid var(--line)}
td{padding:11px 12px;border-bottom:1px solid var(--line);font-size:13.5px;vertical-align:top;line-height:1.45}
tr:last-child td{border-bottom:none}
.n{color:var(--mut);font-size:12px}.nm{font-weight:500}.dt{color:var(--mut);font-size:12.5px;margin-top:3px}.nt{color:#7fd4a8;font-size:12px;margin-top:4px}
.b{display:inline-block;border-radius:6px;padding:3px 9px;font-size:11.5px;font-weight:500;white-space:nowrap}
.s-open{background:rgba(251,191,36,.16);color:var(--amber)}.s-fix{background:rgba(34,197,94,.16);color:var(--green)}.s-other{background:rgba(138,160,200,.16);color:var(--mut)}
.p-crit{background:rgba(248,113,113,.16);color:var(--red)}.p-high{background:rgba(251,146,60,.16);color:#fb923c}.p-mid{background:rgba(251,191,36,.14);color:var(--amber)}.p-low{background:rgba(138,160,200,.14);color:var(--mut)}
.qa{min-width:240px}
.r-pass{background:rgba(34,211,238,.16);color:var(--cyan)}.r-fail{background:rgba(248,113,113,.18);color:var(--red)}.r-none{background:rgba(138,160,200,.14);color:var(--mut)}
.qcom{color:#cfe0ff;font-size:12.5px;margin-top:5px;white-space:pre-wrap}
.qat{color:var(--mut);font-size:11px;margin-top:3px}
.thumbs{display:flex;flex-wrap:wrap;gap:6px;margin-top:7px}
.thumbs a{display:block;width:54px;height:54px;border-radius:7px;border:1px solid var(--line);background-size:cover;background-position:center;position:relative}
.thumbs .rmimg{position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;background:var(--red);color:#fff;font-size:12px;line-height:18px;text-align:center;cursor:pointer;border:none}
.edit{margin-top:8px;display:none;border-top:1px dashed var(--line);padding-top:8px}
.edit.show{display:block}
.edit .rb{display:inline-flex;gap:6px;margin-bottom:6px}
.edit .rb button{border:1px solid var(--line);background:var(--panel2);color:var(--mut);border-radius:8px;padding:5px 11px;font-size:12.5px;cursor:pointer;font-family:inherit}
.edit .rb button.sel-pass{background:var(--cyan);color:#04121a;border-color:var(--cyan)}
.edit .rb button.sel-fail{background:var(--red);color:#fff;border-color:var(--red)}
.edit .rb button.sel-none{background:#33415c;color:#fff}
.edit textarea{width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:8px;color:var(--txt);font-family:inherit;font-size:13px;padding:8px;resize:vertical;min-height:46px}
.edit .erow{display:flex;gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap}
.edit .save{background:var(--green);color:#042c14;border:none;border-radius:8px;padding:7px 16px;font-weight:600;cursor:pointer;font-family:inherit}
.edit .msg{font-size:12px}
.editbtn{margin-top:7px;background:none;border:1px dashed var(--line);color:var(--mut);border-radius:8px;padding:4px 11px;font-size:12px;cursor:pointer;font-family:inherit}
.foot{color:var(--mut);font-size:12px;margin-top:16px;text-align:center}
.hidden{display:none}
@media(max-width:760px){.hide-sm{display:none}td,th{padding:9px 7px}}
</style></head>
<body><div class="wrap">
<h1>Justice Test Case / Bug List<span class="sub">รายการบัก + ผลตรวจ QA · srv1361159.hstgr.cloud/testcase</span></h1>
<div class="cards" id="cards"></div>
<div class="bar">
<input type="text" id="q" placeholder="ค้นหา… (ชื่อ / รายละเอียด / คำอธิบาย)">
<button class="chip on" data-f="all">ทั้งหมด</button>
<button class="chip" data-f="open">ยังเปิด</button>
<button class="chip" data-f="fixed">แก้แล้ว</button>
<button class="chip" data-f="pass">ผ่าน QA</button>
<button class="chip" data-f="fail">ยังไม่ผ่าน</button>
<button class="chip" data-f="untested">ยังไม่ตรวจ</button>
<span class="adminbox">
<span class="lockhint" id="lockhint">🔒 แก้ผลตรวจ ใส่รหัส:</span>
<input type="password" id="adminpass" placeholder="รหัสแอดมิน" autocomplete="off">
<span id="adminok" class="ok hidden">โหมดแก้ไข </span>
</span>
</div>
<table><thead><tr><th style="width:30px">#</th><th>ปัญหา</th><th style="width:78px">สำคัญ</th><th style="width:88px">Dev</th><th class="qa">ผลตรวจ QA</th></tr></thead>
<tbody id="tb"></tbody></table>
<div class="foot">Justice bug tracker · ผลตรวจบันทึกบนเซิร์ฟเวอร์ (แก้ไขได้เมื่อใส่รหัสแอดมิน) · <?php echo count($bugs); ?> รายการ</div>
</div>
<script>
var BUGS = <?php echo json_encode($bugs, JSON_UNESCAPED_UNICODE); ?>;
var VERIFY = <?php echo json_encode((object)$verify, JSON_UNESCAPED_UNICODE); ?>;
var filt="all",q="",adminPass="";
function esc(s){return (s||"").replace(/[&<>"]/g,function(c){return{"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]})}
function sCls(s){return s.indexOf("แก้")>=0?"s-fix":s==="เปิด"?"s-open":"s-other"}
function pCls(p){return p.indexOf("วิกฤต")>=0?"p-crit":p.indexOf("สูง")>=0?"p-high":p.indexOf("กลาง")>=0?"p-mid":p?"p-low":""}
function vof(n){return VERIFY[n]||{result:"none",comment:"",images:[]}}
function rLabel(r){return r==="pass"?"✅ เช็คแล้ว (ผ่าน)":r==="fail"?"❌ ยังไม่ผ่าน":"⬜ ยังไม่ตรวจ"}
function rCls(r){return r==="pass"?"r-pass":r==="fail"?"r-fail":"r-none"}
function counts(){
var o=0,f=0,p=0,fa=0,un=0;
BUGS.forEach(function(b){if(b.status==="เปิด")o++;if(b.status.indexOf("แก้")>=0)f++;var v=vof(b.n);if(v.result==="pass")p++;else if(v.result==="fail")fa++;else un++;});
return {o:o,f:f,p:p,fa:fa,un:un};
}
function render(){
var c=counts();
document.getElementById("cards").innerHTML=
'<div class="card"><div class="v">'+BUGS.length+'</div><div class="l">ทั้งหมด</div></div>'+
'<div class="card open"><div class="v">'+c.o+'</div><div class="l">Dev: ยังเปิด</div></div>'+
'<div class="card ok"><div class="v">'+c.f+'</div><div class="l">Dev: แก้แล้ว</div></div>'+
'<div class="card pass"><div class="v">'+c.p+'</div><div class="l">QA: ผ่าน</div></div>'+
'<div class="card fail"><div class="v">'+c.fa+'</div><div class="l">QA: ยังไม่ผ่าน</div></div>'+
'<div class="card"><div class="v">'+c.un+'</div><div class="l">QA: ยังไม่ตรวจ</div></div>';
var list=BUGS.filter(function(b){
var v=vof(b.n);
if(filt==="open"&&b.status!=="เปิด")return false;
if(filt==="fixed"&&b.status.indexOf("แก้")<0)return false;
if(filt==="pass"&&v.result!=="pass")return false;
if(filt==="fail"&&v.result!=="fail")return false;
if(filt==="untested"&&v.result&&v.result!=="none")return false;
if(q){var h=(b.name+" "+b.detail+" "+b.note+" "+(v.comment||"")).toLowerCase();if(h.indexOf(q)<0)return false}
return true;});
document.getElementById("tb").innerHTML=list.map(rowHtml).join("")||'<tr><td colspan="5" style="text-align:center;color:var(--mut);padding:30px">ไม่พบรายการ</td></tr>';
}
function thumbsHtml(v,n,admin){
return (v.images||[]).map(function(im){
var rm=admin?'<button class="rmimg" data-rm="'+esc(im)+'" data-id="'+n+'" title="ลบรูป">×</button>':'';
return '<a href="uploads/'+encodeURIComponent(im)+'" target="_blank" style="background-image:url(uploads/'+encodeURIComponent(im)+')">'+rm+'</a>';
}).join("");
}
function rowHtml(b){
var v=vof(b.n),admin=!!adminPass;
var qa='<span class="b '+rCls(v.result)+' qabadge" data-qa="'+b.n+'" title="'+(admin?'คลิกเพื่อแก้ผลตรวจ':'ใส่รหัสแอดมินมุมขวาบนเพื่อแก้')+'">'+rLabel(v.result)+'</span>'+
(v.comment?'<div class="qcom">'+esc(v.comment)+'</div>':'')+
(v.at?'<div class="qat">อัปเดต '+esc(v.at)+'</div>':'')+
'<div class="thumbs">'+thumbsHtml(v,b.n,admin)+'</div>';
if(admin){
qa+='<button class="editbtn" data-edit="'+b.n+'">✎ แก้ผลตรวจ</button>'+
'<div class="edit" id="edit-'+b.n+'">'+
'<div class="rb" data-rbid="'+b.n+'">'+
'<button data-r="pass" class="'+(v.result==="pass"?"sel-pass":"")+'">ผ่าน</button>'+
'<button data-r="fail" class="'+(v.result==="fail"?"sel-fail":"")+'">ยังไม่ผ่าน</button>'+
'<button data-r="none" class="'+(v.result==="none"||!v.result?"sel-none":"")+'">ยังไม่ตรวจ</button>'+
'</div>'+
'<textarea id="com-'+b.n+'" placeholder="คำอธิบายผลตรวจ…">'+esc(v.comment||"")+'</textarea>'+
'<div class="erow"><input type="file" id="img-'+b.n+'" accept="image/*">'+
'<button class="save" data-save="'+b.n+'">บันทึก</button>'+
'<span class="msg" id="msg-'+b.n+'"></span></div>'+
'</div>';
}
return '<tr><td class="n">'+b.n+'</td><td><div class="nm">'+esc(b.name)+'</div>'+
(b.detail?'<div class="dt">'+esc(b.detail)+'</div>':'')+
(b.note?'<div class="nt">'+esc(b.note)+'</div>':'')+'</td>'+
'<td>'+(b.prio?'<span class="b '+pCls(b.prio)+'">'+esc(b.prio)+'</span>':'')+'</td>'+
'<td><span class="b '+sCls(b.status)+'">'+esc(b.status)+'</span></td>'+
'<td class="qa">'+qa+'</td></tr>';
}
// events (delegate)
function flashAdmin(){var a=document.getElementById("adminpass");a.classList.remove("flash");void a.offsetWidth;a.classList.add("flash");a.focus();}
document.getElementById("tb").addEventListener("click",function(e){
var t=e.target;
if(t.dataset.qa){ if(!adminPass){ flashAdmin(); } else { var ee=document.getElementById("edit-"+t.dataset.qa); if(ee) ee.classList.toggle("show"); } return; }
if(t.dataset.edit){var ed=document.getElementById("edit-"+t.dataset.edit);if(ed)ed.classList.toggle("show");return;}
if(t.dataset.r){var rb=t.parentNode;[].forEach.call(rb.children,function(x){x.className=""});t.className="sel-"+t.dataset.r;rb.dataset.sel=t.dataset.r;return;}
if(t.dataset.save){doSave(t.dataset.save);return;}
if(t.dataset.rm){doRemoveImg(t.dataset.id,t.dataset.rm);return;}
});
function curResult(n){var rb=document.querySelector('.rb[data-rbid="'+n+'"]');if(rb&&rb.dataset.sel)return rb.dataset.sel;var v=vof(n);return v.result||"none";}
function doSave(n){
var fd=new FormData();fd.append("api","save");fd.append("pass",adminPass);fd.append("id",n);
fd.append("result",curResult(n));fd.append("comment",document.getElementById("com-"+n).value);
var f=document.getElementById("img-"+n);if(f&&f.files&&f.files[0])fd.append("image",f.files[0]);
var msg=document.getElementById("msg-"+n);msg.textContent="กำลังบันทึก…";msg.style.color="var(--mut)";
fetch(location.pathname,{method:"POST",body:fd}).then(function(r){return r.json()}).then(function(j){
if(j.ok){VERIFY[n]=j.rec;msg.textContent="บันทึกแล้ว ✓";msg.style.color="var(--green)";setTimeout(render,500);}
else{msg.textContent=j.error||"ผิดพลาด";msg.style.color="var(--red)";}
}).catch(function(){msg.textContent="เชื่อมต่อไม่ได้";msg.style.color="var(--red)";});
}
function doRemoveImg(n,im){
if(!confirm("ลบรูปนี้?"))return;
var fd=new FormData();fd.append("api","save");fd.append("pass",adminPass);fd.append("id",n);fd.append("removeImage",im);
fetch(location.pathname,{method:"POST",body:fd}).then(function(r){return r.json()}).then(function(j){if(j.ok){VERIFY[n]=j.rec;render();}});
}
// admin pass
var ap=document.getElementById("adminpass");
ap.value=localStorage.getItem("tcAdminPass")||"";
function applyAdmin(){adminPass=ap.value.trim();localStorage.setItem("tcAdminPass",adminPass);
document.getElementById("adminok").classList.toggle("hidden",!adminPass);
document.getElementById("lockhint").classList.toggle("hidden",!!adminPass);render();}
ap.addEventListener("input",applyAdmin);applyAdmin();
document.getElementById("q").addEventListener("input",function(e){q=e.target.value.toLowerCase().trim();render()});
[].forEach.call(document.querySelectorAll(".chip"),function(b){b.addEventListener("click",function(){
[].forEach.call(document.querySelectorAll(".chip"),function(x){x.classList.remove("on")});
b.classList.add("on");filt=b.getAttribute("data-f");render();})});
render();
</script></body></html>