(function () { // Error "message channel closed" มาจาก extension ของเบราว์เซอร์ ไม่ใช่โค้ดเกม — ลองโหมดไม่ระบุตัวตนหรือปิด extension const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; const SERVER = BASE; const MAIN_LOBBY_AI_BTN_ICON = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png') : '/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png'; const params = new URLSearchParams(location.search); const spaceId = params.get('space'); const nick = (params.get('nick') || '').trim() || 'ผู้เล่น'; const DISPLAY_NAME_STORAGE_KEY = 'roomLobbyDisplayName'; let profileDisplayName = nick; const DESIGN_MENU = typeof designMainMenuHref === 'function' ? designMainMenuHref() : '/Main-Menu/'; if (!spaceId) { location.href = DESIGN_MENU; return; } const roomIdValueEl = document.getElementById('room-id-value'); const displayRoomParam = (params.get('displayRoom') || '').trim(); if (displayRoomParam) { try { localStorage.setItem('lastCreatedSpaceName', displayRoomParam); } catch (e) { /* ignore */ } if (roomIdValueEl) roomIdValueEl.textContent = displayRoomParam; } else if (roomIdValueEl) { roomIdValueEl.textContent = String(spaceId); } const CREATE_ROOM_URL = typeof appPath === 'function' ? appPath('/Create%20Room/') : '/Create%20Room/'; function wasPageReload() { try { const entries = performance.getEntriesByType('navigation'); if (entries && entries.length && entries[0].type === 'reload') return true; } catch (e) { /* ignore */ } try { if (typeof performance !== 'undefined' && performance.navigation && performance.navigation.type === 1) return true; } catch (e2) { /* ignore */ } return false; } /** Lobby หลังคดี — ต้องตรงกับ server POST_CASE_LOBBY_SPACE_ID */ const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h'; const PLAYER_KEY = 'jdPlayerKey'; /** ตรงกับ character.js / Main-Lobby — composite idle ทิศ down */ const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:'; const LOBBY_EVIDENCE_ASSET_BASE = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE/See%20evidence') : '/Main-Lobby/IMAGE/See%20evidence'; const LOBBY_EVIDENCE_RARITY = { common: 'Common', rare: 'Rare', legendary: 'Legendary' }; /** ข้อมูลตัวอย่างตาม caseId — แก้/โหลดจาก API ได้ภายหลัง */ const LOBBY_EVIDENCE_CASES = { '1': { suspects: [ { linkName: 'สมชาย', cards: [ { titleTh: 'ก้นบุหรี่เปื้อนลิปสติก', titleEn: 'Lipstick-stained cigarette', body: 'พบใกล้จุดเกิดเหตุ มีคราบลิปสติกสีแดงเข้ม อาจเชื่อมกับ DNA ของผู้ต้องสงสัย', rarity: 'common', stars: 1 }, { titleTh: 'แว่นตากรอบหัก', titleEn: 'Broken glasses', body: 'กรอบดำแตกหักตามเส้นทางหลบหนี คาดถูกเหยียบขณะวิ่ง', rarity: 'rare', stars: 2 }, { titleTh: 'ลายนิ้วมือเลือดบนมีด', titleEn: 'Bloody fingerprint on knife', body: 'คราบเลือดปนลายนิ้วมือที่ไม่ตรงกับเหยื่อ — หลักฐานชี้ชัด', rarity: 'legendary', stars: 3 } ] }, { linkName: 'สมหญิง', cards: [ { titleTh: 'บัตรเข้าลานจอด', titleEn: 'Parking gate log', body: 'เวลาเข้าออกไม่ตรงกับคำให้การเดิม', rarity: 'common', stars: 1 }, { titleTh: 'ข้อความบนมือถือ', titleEn: 'SMS fragment', body: 'ชวนให้ลบข้อมูลในคลาวด์ก่อนวันเกิดเหตุ', rarity: 'rare', stars: 2 }, { titleTh: 'กุญแจเข้ารหัส', titleEn: 'Encrypted USB token', body: 'อุปกรณ์รหัสเดียวกับที่พบในเซิร์ฟเวอร์เกม', rarity: 'legendary', stars: 3 } ] }, { linkName: 'วิชัย', cards: [ { titleTh: 'หูฟังเกมมิ่ง', titleEn: 'Headset mic trace', body: 'ไมค์ติดเสียงแม็กหน้าร้านขณะเจรจา', rarity: 'common', stars: 1 }, { titleTh: 'สกรีนล็อกพินผิด', titleEn: 'Failed unlock pattern', body: 'มีความพยายามปลดล็อกรูปแบบเดียวกับเหยื่อ', rarity: 'rare', stars: 2 }, { titleTh: 'ไฟล์คราสเชอร์', titleEn: 'Crashed session log', body: 'บันทึก RCE จากบัญชีที่ผูกกับไอดีของผู้ต้องสงสัย', rarity: 'legendary', stars: 3 } ] } ] }, '2': { suspects: [ { linkName: 'เปี๊ยก', cards: [ { titleTh: 'เศษกระจกโชว์เคส', titleEn: 'Display case shard', body: 'ตรงกับรอยแตกที่หน้าร้านอัญมณี', rarity: 'common', stars: 1 }, { titleTh: 'มือถือหมุดตำแหน่งคลาดเคลื่อน', titleEn: 'GPS mismatch', body: 'แอปแม็ปแสดงว่าอยู่แถวตลาดในช่วงปล้น', rarity: 'rare', stars: 2 }, { titleTh: 'ถุงมือซิลิโคนใช้ครั้งเดียว', titleEn: 'Disposable silicone glove', body: 'ภายในพบผงเพชรจิ๋วเหมือนในร้าน', rarity: 'legendary', stars: 3 } ] }, { linkName: 'มาลี', cards: [ { titleTh: 'คูปองรับของ', titleEn: 'Parcel stub', body: 'พัสดุส่งถึงที่พักของผู้ต้องสงสัยในคืนก่อนเหตุ', rarity: 'common', stars: 1 }, { titleTh: 'กล้องวงจรปิดเบลอ', titleEn: 'Blurred CCTV frame', body: 'เงาร่างสวมหมวกใบเดียวกับที่พบในรถเข็นหลังร้าน', rarity: 'rare', stars: 2 }, { titleTh: 'แม่แรงเล็ก', titleEn: 'Mini pry bar', body: 'รอยครูดตรงรางกระจกตรงกับเครื่องมือชุดนี้', rarity: 'legendary', stars: 3 } ] }, { linkName: 'เสก', cards: [ { titleTh: 'ป้ายหมุดร้านค้า', titleEn: 'Geotagged photo', body: 'โพสต์ SNS ก่อน 30 นาทีที่ประตูร้าน', rarity: 'common', stars: 1 }, { titleTh: 'ใบเสร็จตัดเลเซอร์', titleEn: 'Laser service receipt', body: 'สั่งตัดกระจกความหนาเฉพาะทางก่อนวันปล้น', rarity: 'rare', stars: 2 }, { titleTh: 'คำสั่งเช่ารถขนของ', titleEn: 'Van rental order', body: 'ทะเบียนตรงกับคันรถหลบที่ลานกว้าง', rarity: 'legendary', stars: 3 } ] } ] }, '3': { suspects: [ { linkName: 'ธนา', cards: [ { titleTh: 'ยาหม่องกลิ่นแปลก', titleEn: 'Scented patch', body: 'กลิ่นไม่ตรงกับของในห้อง — อาจติดมาจากที่อื่น', rarity: 'common', stars: 1 }, { titleTh: 'คีย์การ์ดหมดอายุ', titleEn: 'Expired keycard', body: 'สแกนเข้าตึกหลังเที่ยงคืนได้ทั้งที่ควรถูกระงับ', rarity: 'rare', stars: 2 }, { titleTh: 'กล้องถ่ายรูปฟิล์ม', titleEn: 'Film camera', body: 'ฟิล์มสุดท้ายเป็นภาพเงาที่ประตูห้องเหยื่อ', rarity: 'legendary', stars: 3 } ] }, { linkName: 'รุ้ง', cards: [ { titleTh: 'เมนูร้านกาแฟ', titleEn: 'Coffee sleeve note', body: 'มีเบอร์เขียนด้วยมือเหมือนในโน้ตเหยื่อ', rarity: 'common', stars: 1 }, { titleTh: 'รอยยางรองเท้า', titleEn: 'Mud sole pattern', body: 'ตรงกับรองเท้ากีฬารุ่นหายาก', rarity: 'rare', stars: 2 }, { titleTh: 'ผ้าขนหนูเปียก', titleEn: 'Damp towel', body: 'มีคราบสารเคมีทำความสะอาดกระเบื้องเฉพาะจุด', rarity: 'legendary', stars: 3 } ] }, { linkName: 'ภูผา', cards: [ { titleTh: 'เสียงบันทึกสั้น', titleEn: 'Voice memo clip', body: 'ได้ยินเสียงกุญแจตกพื้นก่อนเสียงฉุกเฉิน', rarity: 'common', stars: 1 }, { titleTh: 'เศษเส้นด้าย', titleEn: 'Fiber snag', body: 'สีเสื้อไม่ตรงกับคนในบ้าน แต่ตรงกับ CCTV ลิฟต์', rarity: 'rare', stars: 2 }, { titleTh: 'เวลากล้อง CCTV เพี้ยน', titleEn: 'Timestamp drift', body: 'เซิร์ฟเวอร์บันทึกเวลาขาด 7 นาที ช่วงที่เหยื่อหายใจสุดท้าย', rarity: 'legendary', stars: 3 } ] } ] } }; const socketOrigin = (typeof GAME_NODE_ORIGIN !== 'undefined' && GAME_NODE_ORIGIN) ? GAME_NODE_ORIGIN : (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined); if (typeof io !== 'function') { var rlErr = document.createElement('div'); rlErr.setAttribute('role', 'alert'); rlErr.style.cssText = 'position:fixed;inset:0;z-index:999999;display:flex;align-items:center;justify-content:center;padding:24px;background:#0a0e1a;color:#e8f4ff;font-family:Kanit,system-ui,sans-serif;text-align:center;line-height:1.6'; rlErr.innerHTML = '

โหลด Socket ไม่สำเร็จ (localhost)

เพิ่มในไฟล์ room-lobby.html บนเครื่องคุณเท่านั้น:
<script src="https://srv1361159.hstgr.cloud/Game/dev-local/bootstrap.js"></script>
ดู Game/dev-local/INSTALL-WINDOWS.txt

กลับเมนูหลัก
'; (document.body || document.documentElement).appendChild(rlErr); throw new Error('socket.io not loaded'); } const socket = io(socketOrigin, { path: '/Game/socket.io' }); window.__jdGameSocket = socket; const roomPlayersHud = document.getElementById('room-players-hud'); const peersList = document.getElementById('peers-list'); const readyCheck = document.getElementById('ready-check'); const btnStart = document.getElementById('btn-start'); const hostOnly = document.getElementById('host-only'); const nonHostMsg = document.getElementById('non-host-msg'); const playMapSelect = document.getElementById('play-map-select'); const canvas = document.getElementById('lobby-map-canvas'); const READY_IMG_IDLE = SERVER + '/img/btn-ready-idle.png?v=1'; const READY_IMG_ACTIVE = SERVER + '/img/btn-ready-active.png?v=1'; let mapData = null; let quizModeActive = false; let quizPhaseLocal = null; let quizPhaseEndsAt = 0; let quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; let quizTimerInterval = null; let lastQuizQuestionText = ''; let lastQuizScores = {}; /** ผู้เล่นที่ตอบผิดแล้ว — วาดแบบ ghost ให้คนอื่นเห็น (สถานะของตัวเองมาจาก quizPlayerLocal) */ let quizPeersLocked = {}; let lastQuizQIdx = 0; let lastQuizQTotal = 0; /** mapId ฉากปัจจุบันจากเซิร์ฟ (เช่น mn8nx46h = LobbyB) — ใช้ตรวจ UI LobbyB ให้แม่น */ let clientLobbyMapId = null; let hostId = null; let spaceName = ''; let maxPlayers = 10; let lobbyBotSlotCount = 0; /** จำนวนช่องรวมในโถง (คน + บอท) — ตรง server LOBBY_SLOT_TOTAL */ const LOBBY_SLOT_TOTAL = 6; const LOBBY_BOT_PREFIX = '__lobby_bot_'; const lobbyBots = new Map(); let suspectPickOverlayOpen = false; let suspectSelectedIndex = 0; /** ตรงกับเซิร์ฟ — เฟสเลือกผู้ต้องสงสัยยังไม่จบ (ปิด overlay แล้วยังเปิดได้จากปุ่มโถง) */ let serverSuspectPhaseActive = false; /** มินิเกม 3 แบบที่สุ่มแล้ว — ล็อกกับการ์ด 0–2 จนกว่าสร้างห้องใหม่ */ let suspectCardMinigames = []; /** หลักฐาน (ติกถูก) ที่เก็บได้ต่อผู้ต้องสงสัย 0–2 (เล่นมินิเกมจบ +1 ต่อช่อง สูงสุด 3) */ let suspectProgress = [0, 0, 0]; /** ขั้นพิจารณาคดี: null | 'voting' | 'revealed' */ let trialMode = null; let myTrialVote = null; let trialVoteCounts = [0, 0, 0]; let mapBackgroundImg = null; let hostIconImg = null; const peers = new Map(); /** รองรับ x/y string จาก Socket — ไม่งั้นตำแหน่งจากสุ่มจุดเกิดจะถูกทิ้ง */ function normalizeLobbyPeerFromServer(p, mapRef) { const sp = mapRef && mapRef.spawn; const sx = Number(sp && sp.x); const sy = Number(sp && sp.y); const fx = Number.isFinite(sx) ? sx : 1; const fy = Number.isFinite(sy) ? sy : 1; const x = Number(p.x); const y = Number(p.y); const nx = Number.isFinite(x) ? x : fx; const ny = Number.isFinite(y) ? y : fy; return { ...p, x: nx, y: ny, tx: nx, ty: ny }; } const characterImages = {}; /** ลำดับโหลดรูปยืนเฉย/เดิน ต่อทิศ — idle ก่อน (ตรงกับเซิร์ฟ upload) */ function characterSpriteUrlCandidates(id, dir) { const d = dir || 'down'; const enc = encodeURIComponent(id); const q = '?ch=' + enc; const base = SERVER + '/img/characters/' + enc + '_' + d; return [ base + '_idle.png' + q, base + '_idle_0.png' + q, base + '.png' + q, base + '_0.png' + q, ]; } function createDefaultAvatarImg() { const c = document.createElement('canvas'); c.width = 64; c.height = 64; const ctx = c.getContext('2d'); ctx.fillStyle = '#7aa2f7'; ctx.beginPath(); ctx.arc(32, 22, 14, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#9ece6a'; ctx.beginPath(); ctx.arc(32, 48, 18, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#1a1b26'; ctx.beginPath(); ctx.arc(28, 20, 3, 0, Math.PI * 2); ctx.arc(36, 20, 3, 0, Math.PI * 2); ctx.fill(); const img = new Image(); img.src = c.toDataURL('image/png'); return img; } const defaultAvatarImg = createDefaultAvatarImg(); const characterAnimations = {}; const CHARACTER_ANIM_FRAMES = 4; const CHARACTER_ANIM_FRAME_MS = 200; function walkAnimPhaseIndex(now, isWalking) { const t = isWalking ? (typeof now === 'number' ? now : Date.now()) : 0; return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES; } function pickLoadedWalkFrameIndex(anim, phase) { if (!anim || !anim.frames || !anim.frames.length) return -1; const maxK = Math.min(phase, anim.frames.length - 1, CHARACTER_ANIM_FRAMES - 1); for (var k = maxK; k >= 0; k--) { var f = anim.frames[k]; if (f && f.complete && f.naturalWidth) return k; } return -1; } function getCharacterImg(id, direction) { if (!id) return null; const dir = direction || 'down'; const key = id + '_' + dir; if (characterImages[key]) return characterImages[key]; const img = new Image(); const urls = characterSpriteUrlCandidates(id, dir); let uidx = 0; img.onerror = function () { uidx += 1; if (uidx >= urls.length) { img.onerror = null; return; } img.src = urls[uidx]; }; img.src = urls[0]; characterImages[key] = img; return img; } function getCharacterFrame(id, direction, now, isWalking) { if (!id) return null; const dir = direction || 'down'; const key = id + '_' + dir; let anim = characterAnimations[key]; if (!anim) { anim = { frames: [], fallback: null }; characterAnimations[key] = anim; anim.fallback = getCharacterImg(id, dir); var q = '?ch=' + encodeURIComponent(id); var tryIdleWalk = characterSpriteUrlCandidates(id, dir); var frame0 = new Image(); frame0.onload = function () { for (var i = 1; i < CHARACTER_ANIM_FRAMES; i++) { var img = new Image(); img.src = SERVER + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png' + q; anim.frames.push(img); } if (mapData && canvas) drawLobbyMap(); }; var tix = 0; frame0.onerror = function () { tix += 1; if (tix >= tryIdleWalk.length) { if (mapData && canvas) drawLobbyMap(); return; } frame0.src = tryIdleWalk[tix]; }; frame0.src = tryIdleWalk[0]; anim.frames.push(frame0); } var phase = walkAnimPhaseIndex(now, isWalking); // ยืนนิ่ง → ใช้รูป idle (_idle.png) ก่อน if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; var fi = pickLoadedWalkFrameIndex(anim, phase); if (fi >= 0) return anim.frames[fi]; const fb = anim.fallback; if (fb && fb.complete && fb.naturalWidth) return fb; return null; } function getAvatarImg(characterId, direction, now, isWalking) { const img = characterId ? getCharacterFrame(characterId, direction, now, isWalking) : null; if (img) return img; return defaultAvatarImg; } /* ===== Phase 3a: tint ตัวละคร (composite layer + ทาสี) — ตอนนี้ใช้กับตัวผู้เล่นเอง ===== */ var RL_CUSTOMIZE_ASSET = SERVER + '/img/03-5-Customize/'; var RL_LAYER_NAMES = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; var myTintTheme = null; var myTintSkin = null; var rlSwatchCache = {}; var tintedCharCache = {}; var rlLayerMissing = {}; function rlLoadImg(src, cb) { if (rlLayerMissing[src]) { cb(null); return; } // เคย 404 แล้ว ไม่ขอซ้ำ (กัน console spam) var img = new Image(); img.onload = function () { cb(img); }; img.onerror = function () { rlLayerMissing[src] = true; cb(null); }; img.src = src; } function rlHexToRgbCss(hex) { var h = String(hex || '').replace('#', '').trim(); if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; var n = parseInt(h, 16); if (!Number.isFinite(n)) return null; return 'rgb(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ')'; } function rlSampleSwatch(group, idx, cb) { var key = group + '-' + idx; if (rlSwatchCache[key]) { cb(rlSwatchCache[key]); return; } var hexArr = group === 'color' ? ROOM_CZ_THEME_HEX : ROOM_CZ_SKIN_HEX; var hi = parseInt(idx, 10) - 1; if (hexArr && hexArr[hi]) { var fromHex = rlHexToRgbCss(hexArr[hi]); if (fromHex) { rlSwatchCache[key] = fromHex; cb(fromHex); return; } } rlLoadImg(RL_CUSTOMIZE_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png', function (img) { if (!img || !img.naturalWidth) { cb(null); return; } try { var c = document.createElement('canvas'); c.width = 1; c.height = 1; var x = c.getContext('2d'); x.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, 1, 1); var d = x.getImageData(0, 0, 1, 1).data; var rgb = 'rgb(' + d[0] + ',' + d[1] + ',' + d[2] + ')'; rlSwatchCache[key] = rgb; cb(rgb); } catch (e) { cb(null); } }); } var LOBBY_PLAYER_TINT_KEY = 'justiceLobbyPlayerTint'; function rgbCssToHexLobby(rgb) { if (!rgb) return ''; var s = String(rgb).trim(); if (s.charAt(0) === '#') return s; var m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); if (!m) return ''; return '#' + [m[1], m[2], m[3]].map(function (n) { var v = Math.max(0, Math.min(255, parseInt(n, 10))); return v.toString(16).padStart(2, '0'); }).join(''); } function lobbyBotTintsStorageKey() { return 'justiceLobbyBotTints:' + (spaceId || 'default'); } function loadLobbyBotTintsMap() { try { var raw = sessionStorage.getItem(lobbyBotTintsStorageKey()); return raw ? JSON.parse(raw) : {}; } catch (e) { return {}; } } function saveLobbyBotTint(botId, bot) { if (!botId || !bot) return; var map = loadLobbyBotTintsMap(); map[String(botId)] = { colorTheme: bot.colorTheme || '', colorSkin: bot.colorSkin || '', }; try { sessionStorage.setItem(lobbyBotTintsStorageKey(), JSON.stringify(map)); } catch (e) { /* ignore */ } } function applySavedLobbyBotColors(botId, bot) { var saved = loadLobbyBotTintsMap()[String(botId)]; if (!saved) return false; if (saved.colorTheme) bot.colorTheme = saved.colorTheme; if (saved.colorSkin) bot.colorSkin = saved.colorSkin; return !!(saved.colorTheme || saved.colorSkin); } function persistLobbyPlayerTintJson() { if (!myTintTheme && !myTintSkin) return; var bodyHex = rgbCssToHexLobby(myTintTheme); var headHex = rgbCssToHexLobby(myTintSkin); try { localStorage.setItem(LOBBY_PLAYER_TINT_KEY, JSON.stringify({ themeRgb: myTintTheme || '', skinRgb: myTintSkin || '', head: headHex || '', hair: bodyHex || '', body: bodyHex || '', savedAt: Date.now(), })); } catch (e) { /* ignore */ } } function rlResolveMyColors(cb) { var c = '', s = ''; try { c = localStorage.getItem('lobbyThemeColor') || ''; s = localStorage.getItem('lobbySkinTone') || ''; } catch (e) {} var need = 0, done = false; function go() { if (done) return; if (need <= 0) { done = true; persistLobbyPlayerTintJson(); if (cb) cb(); } } if (c) { need++; rlSampleSwatch('color', c, function (rgb) { myTintTheme = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); } if (s) { need++; rlSampleSwatch('skin', s, function (rgb) { myTintSkin = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); } if (need === 0) go(); } /* ===== Preload + Loading overlay ก่อนเข้า room-lobby (กันสีตัวละครกระตุก) ===== */ var roomJoinReady = false, roomPreloadReady = false, roomLoadingHidden = false; function injectRoomLoadingOverlay() { if (document.getElementById('room-loading-overlay')) return; var style = document.createElement('style'); style.textContent = '#room-loading-overlay{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:18px;background:radial-gradient(circle at 50% 38%, #0b1430, #060818);color:#cfe9ff;font-family:Kanit,Sarabun,system-ui,sans-serif;transition:opacity .45s ease}#room-loading-overlay.is-hidden{opacity:0;pointer-events:none}#room-loading-spinner{width:56px;height:56px;border:5px solid rgba(34,211,238,.22);border-top-color:#22d3ee;border-radius:50%;animation:rlspin 1s linear infinite}@keyframes rlspin{to{transform:rotate(360deg)}}#room-loading-overlay .rl-txt{font-size:1.1rem;letter-spacing:.04em;opacity:.9}'; document.head.appendChild(style); var ov = document.createElement('div'); ov.id = 'room-loading-overlay'; ov.innerHTML = '
กำลังโหลดตัวละคร…
'; (document.body || document.documentElement).appendChild(ov); } function hideRoomLoading() { if (roomLoadingHidden) return; roomLoadingHidden = true; var ov = document.getElementById('room-loading-overlay'); if (ov) { ov.classList.add('is-hidden'); setTimeout(function () { if (ov.parentNode) ov.parentNode.removeChild(ov); }, 600); } } function maybeHideRoomLoading() { if (roomJoinReady && roomPreloadReady) hideRoomLoading(); } function preloadLobbyTintedCharacter(id, theme, skin, cb) { if (!id || (!theme && !skin)) { if (cb) cb(); return; } var dirs = ['down', 'up', 'left', 'right']; var suffixes = ['idle', '0', '1', '2', '3']; var total = dirs.length * suffixes.length; var doneCount = 0; var finished = false; function one() { doneCount++; if (!finished && doneCount >= total) { finished = true; if (cb) cb(); } } setTimeout(function () { if (!finished) { finished = true; if (cb) cb(); } }, 6000); dirs.forEach(function (dir) { var ckey = id + '|' + (theme || '') + '|' + (skin || '') + '|' + dir; var anim = tintedCharCache[ckey] || (tintedCharCache[ckey] = { frames: [], fallback: null }); suffixes.forEach(function (suf) { rlBuildTintedFrame(id, dir, suf, theme, skin, function (img) { if (img) { if (suf === 'idle') anim.fallback = img; else anim.frames[parseInt(suf, 10)] = img; } one(); }); }); }); } function preloadMyTintedCharacter(cb) { preloadLobbyTintedCharacter(getStoredCharacterId(), myTintTheme, myTintSkin, cb); } /** อุ่น tint บอท + peer ที่มีสี — กันกระตุกตอนบอทเดิน/idle ครั้งแรก */ function preloadAllLobbyTintedOccupants(cb) { var pending = 1; function doneOne() { pending--; if (pending <= 0 && cb) cb(); } var pid = getStoredCharacterId(); if (pid && (myTintTheme || myTintSkin)) { pending++; preloadLobbyTintedCharacter(pid, myTintTheme, myTintSkin, doneOne); } lobbyBots.forEach(function (b) { if (!b || !b.characterId) return; if (!b.colorTheme && !b.colorSkin) return; pending++; preloadLobbyTintedCharacter(b.characterId, b.colorTheme, b.colorSkin, doneOne); }); peers.forEach(function (p) { if (!p || !p.characterId) return; if (!p.colorTheme && !p.colorSkin) return; pending++; preloadLobbyTintedCharacter(p.characterId, p.colorTheme, p.colorSkin, doneOne); }); doneOne(); } function readLobbyCharImgSize(img) { if (!img) return { iw: 0, ih: 0 }; if (img.tagName === 'CANVAS' && img.width > 0 && img.height > 0) { return { iw: img.width, ih: img.height }; } if (img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) { return { iw: img.naturalWidth, ih: img.naturalHeight }; } return { iw: 0, ih: 0 }; } /* ===== ห้องแต่งตัว (Customize) ในห้อง room-lobby — ปุ่มล่างซ้าย + popup + tint สด ===== */ /** ธีมสี 8 ช่อง — ตรงกับ customize-popup / play.js */ var ROOM_CZ_THEME_HEX = [ '#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe' ]; /** สีผิว 3 ช่อง */ var ROOM_CZ_SKIN_HEX = ['#eaa78a', '#fbd5c4', '#fae9e1']; /** จุด "ห้องแต่งตัว" ในฉาก — ตั้งจาก mapData.customizeSpot (วางใน editor) ถ้าไม่มี = null = ไม่แสดง */ var ROOM_CZ_SPOT = null; var roomCzIcon = new Image(); roomCzIcon.src = '/Main-Lobby/IMAGE/btn-cloth.png'; roomCzIcon.onload = function () { if (typeof mapData !== 'undefined' && mapData && canvas) drawLobbyMap(); }; /** จุด "ตั้งค่าโฮสต์" — mapData.hostConsoleSpot (วางใน editor) */ var ROOM_HC_SPOT = null; var roomHcIcon = new Image(); roomHcIcon.src = SERVER + '/img/btn-setting-host.png'; roomHcIcon.onload = function () { if (typeof mapData !== 'undefined' && mapData && canvas) drawLobbyMap(); }; function markRoomCzInteractiveCell() { if (!mapData) return; if (mapData.customizeSpot && Number.isFinite(Number(mapData.customizeSpot.x)) && Number.isFinite(Number(mapData.customizeSpot.y))) { ROOM_CZ_SPOT = { x: Math.floor(Number(mapData.customizeSpot.x)), y: Math.floor(Number(mapData.customizeSpot.y)) }; } else { ROOM_CZ_SPOT = null; // ไม่มีจุดแต่งตัวใน map นี้ → ไม่แสดง icon / ไม่มี interact return; } var w = mapData.width || 20, h = mapData.height || 15; if (ROOM_CZ_SPOT.x < 0 || ROOM_CZ_SPOT.x >= w || ROOM_CZ_SPOT.y < 0 || ROOM_CZ_SPOT.y >= h) { ROOM_CZ_SPOT = null; return; } if (!Array.isArray(mapData.interactive)) mapData.interactive = []; for (var y = 0; y < h; y++) { if (!Array.isArray(mapData.interactive[y])) mapData.interactive[y] = []; } mapData.interactive[ROOM_CZ_SPOT.y][ROOM_CZ_SPOT.x] = 1; } function markHostHcInteractiveCell() { if (!mapData) return; if (mapData.hostConsoleSpot && Number.isFinite(Number(mapData.hostConsoleSpot.x)) && Number.isFinite(Number(mapData.hostConsoleSpot.y))) { ROOM_HC_SPOT = { x: Math.floor(Number(mapData.hostConsoleSpot.x)), y: Math.floor(Number(mapData.hostConsoleSpot.y)) }; } else { ROOM_HC_SPOT = null; return; } var w = mapData.width || 20, h = mapData.height || 15; if (ROOM_HC_SPOT.x < 0 || ROOM_HC_SPOT.x >= w || ROOM_HC_SPOT.y < 0 || ROOM_HC_SPOT.y >= h) { ROOM_HC_SPOT = null; return; } if (!Array.isArray(mapData.interactive)) mapData.interactive = []; for (var y = 0; y < h; y++) { if (!Array.isArray(mapData.interactive[y])) mapData.interactive[y] = []; } mapData.interactive[ROOM_HC_SPOT.y][ROOM_HC_SPOT.x] = 1; } function isRoomCzInteractTarget(t) { return !!(t && ROOM_CZ_SPOT && t.x === ROOM_CZ_SPOT.x && t.y === ROOM_CZ_SPOT.y); } function isNearRoomCzSpot(me) { if (!me || !ROOM_CZ_SPOT || typeof me.x !== 'number' || typeof me.y !== 'number') return false; var dx = me.x - ROOM_CZ_SPOT.x, dy = me.y - ROOM_CZ_SPOT.y; return (dx * dx + dy * dy) <= 2.25; // ภายในรัศมี ~1.5 ช่อง (ยืนใกล้ตู้ก็กด F ได้) } function isHostHcInteractTarget(t) { return !!(t && ROOM_HC_SPOT && t.x === ROOM_HC_SPOT.x && t.y === ROOM_HC_SPOT.y); } function isNearHostHcSpot(me) { if (!me || !ROOM_HC_SPOT || typeof me.x !== 'number' || typeof me.y !== 'number') return false; var dx = me.x - ROOM_HC_SPOT.x, dy = me.y - ROOM_HC_SPOT.y; return (dx * dx + dy * dy) <= 2.25; } function openHostConsoleIfHost() { if (hostId !== socket.id) { appendLobbySystemChat('— เฉพาะ Host เท่านั้น'); return; } if (hostConsoleOverlay && typeof hostConsoleOverlay.open === 'function') hostConsoleOverlay.open(); } /** เปิดห้องแต่งตัว — production ใช้ #room-cz-overlay + CSS จาก customize-popup.css */ function openRoomCustomize() { if (typeof window.openCustomizePopup === 'function') { window.openCustomizePopup(); } } function closeRoomCustomize() { if (typeof window.closeCustomizePopup === 'function') { window.closeCustomizePopup(); } } function setupRoomCustomize() { window.openRoomCustomize = openRoomCustomize; window.closeRoomCustomize = closeRoomCustomize; window.addEventListener('customize-popup:applied', function () { rlResolveMyColors(function () { persistLobbyPlayerTintJson(); updateRoomProfileAvatarTinted(); if (mapData && canvas) drawLobbyMap(); renderPeers(); }); }); try { document.dispatchEvent(new CustomEvent('room-lobby-ready')); } catch (e) { /* ignore */ } } function rlTintMask(img, color) { var c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight; var x = c.getContext('2d'); x.drawImage(img, 0, 0); x.globalCompositeOperation = 'source-in'; x.fillStyle = color; x.fillRect(0, 0, c.width, c.height); return c; } var rlCharManifest = null; var rlManifestId = null; /** ตัวละคร default — ใช้เมื่อผู้เล่น/บอทไม่มี characterId (กันตัวละครหายเป็นวงกลม blob) */ var rlDefaultCharId = ''; (function rlLoadDefaultChar() { try { fetch(SERVER + '/api/characters', { cache: 'no-store' }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (list) { if (!Array.isArray(list) || !list.length) return; var pick = list.filter(function (x) { return x && x.hasLayerFiles; })[0] || list[0]; if (pick && pick.id) { rlDefaultCharId = pick.id; if (typeof mapData !== 'undefined' && mapData && typeof canvas !== 'undefined' && canvas) drawLobbyMap(); } }) .catch(function () { /* ignore */ }); } catch (e) { /* ignore */ } })(); function rlEnsureManifest(cb) { var id = getStoredCharacterId(); if (!id) { if (cb) cb(null); return; } if (rlManifestId === id && rlCharManifest) { if (cb) cb(rlCharManifest); return; } fetch(SERVER + '/api/characters', { cache: 'no-store' }) .then(function (r) { return r.json(); }) .then(function (list) { var c = Array.isArray(list) ? list.filter(function (x) { return x && x.id === id; })[0] : null; if (c && c.layerManifest) { rlCharManifest = c.layerManifest; rlManifestId = id; if (cb) cb(rlCharManifest); } else if (cb) cb(null); }) .catch(function () { if (cb) cb(null); }); } function rlFrameLayerMap(id, dir, frameSuffix) { if (!rlCharManifest || rlManifestId !== id) return null; var entry = (frameSuffix === 'idle') ? (rlCharManifest.byDirIdle && rlCharManifest.byDirIdle[dir]) : (rlCharManifest.byDir && rlCharManifest.byDir[dir]); if (!entry || !entry.frames || !entry.frames.length) return null; if (frameSuffix === 'idle') return entry.frames[0] || null; var fi = parseInt(frameSuffix, 10); return entry.frames[fi] || entry.frames[0] || null; } function rlBuildTintedFrame(id, dir, frameSuffix, theme, skin, cb) { var map = rlFrameLayerMap(id, dir, frameSuffix); if (!map) { cb(null); return; } var names = RL_LAYER_NAMES.filter(function (n) { return map[n]; }); if (!names.length) { cb(null); return; } var loaded = {}, pending = names.length; names.forEach(function (name) { rlLoadImg(SERVER + '/img/characters/' + map[name], function (img) { loaded[name] = img; if (--pending === 0) done(); }); }); function done() { var ref = null, i; for (i = 0; i < RL_LAYER_NAMES.length; i++) { if (loaded[RL_LAYER_NAMES[i]]) { ref = loaded[RL_LAYER_NAMES[i]]; break; } } if (!ref) { cb(null); return; } var c = document.createElement('canvas'); c.width = ref.naturalWidth; c.height = ref.naturalHeight; var x = c.getContext('2d'); RL_LAYER_NAMES.forEach(function (name) { var img = loaded[name]; if (!img || !img.naturalWidth) return; if (theme && (name === 'bodyColor' || name === 'hairColor')) x.drawImage(rlTintMask(img, theme), 0, 0); else if (skin && name === 'headColor') x.drawImage(rlTintMask(img, skin), 0, 0); else x.drawImage(img, 0, 0); }); var out = new Image(); try { out.src = c.toDataURL('image/png'); } catch (e) { cb(null); return; } cb(out); } } function getTintedFrame(id, theme, skin, dir, now, isWalking) { var ckey = id + '|' + (theme || '') + '|' + (skin || '') + '|' + dir; var anim = tintedCharCache[ckey]; if (!anim) { anim = { frames: [], fallback: null }; tintedCharCache[ckey] = anim; rlBuildTintedFrame(id, dir, 'idle', theme, skin, function (img) { if (img) anim.fallback = img; if (mapData && canvas) drawLobbyMap(); }); for (var i = 0; i < CHARACTER_ANIM_FRAMES; i++) { (function (idx) { rlBuildTintedFrame(id, dir, String(idx), theme, skin, function (img) { if (img) anim.frames[idx] = img; if (mapData && canvas) drawLobbyMap(); }); })(i); } } var phase = walkAnimPhaseIndex(now, isWalking); // ยืนนิ่ง → ใช้เฟรม idle (byDirIdle) ก่อน ไม่ใช่เฟรมเดินเฟรมแรก if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; for (var k = Math.min(phase, CHARACTER_ANIM_FRAMES - 1); k >= 0; k--) { var f = anim.frames[k]; if (f && f.complete && f.naturalWidth) return f; } if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; return null; } function getAvatarImgColored(characterId, theme, skin, direction, now, isWalking) { if (characterId && (theme || skin)) { var t = getTintedFrame(characterId, theme || null, skin || null, direction || 'down', now, isWalking); if (t) return t; } return getAvatarImg(characterId, direction, now, isWalking); } function updateRoomProfileAvatarTinted() { var id = getStoredCharacterId(); if (!id || (!myTintTheme && !myTintSkin)) return; rlBuildTintedFrame(id, 'down', 'idle', myTintTheme, myTintSkin, function (img) { if (!img) return; var av = document.getElementById('room-lobby-profile-avatar'); if (av) av.src = img.src; }); } injectRoomLoadingOverlay(); setTimeout(hideRoomLoading, 8000); // safety: ไม่บัง overlay เกิน 8 วิ const keys = {}; const MOVE_SPEED = 0.15; let lastMoveSend = 0; const moveCodes = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; let lobbyInteractPulse = null; let lobbyZoom = 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 }; /** true เฉพาะช่องพิมพ์ข้อความ — ไม่รวม READY / แผนที่ (ให้กด F โต้ตอบได้โดยไม่ต้องกดพร้อมก่อน) */ function isChatFocused() { const el = document.activeElement; if (!el) return false; if (el.id === 'ready-check') return false; if (el.closest && el.closest('.room-lobby-ready-fixed')) return false; if (el.id === 'lobby-map-canvas') return false; if (el.id === 'chat-input' || el.id === 'ai-chat-input') return true; if (el.tagName === 'TEXTAREA') return true; if (el.tagName === 'INPUT') { const t = (el.type || '').toLowerCase(); return t === 'text' || t === 'search' || t === 'tel' || t === 'url' || t === 'email' || t === 'password' || t === ''; } if (el.isContentEditable) return true; return false; } /** ปุ่มเดียวกับ F บนแป้น US + แป้นไทยมาตรฐาน (ช่องเดียวกับ F → ด) + ยังทำงานระหว่างสลับภาษา */ function isLobbyInteractKeyDown(e) { if (e.isComposing || e.keyCode === 229) return false; if (e.code === 'KeyF') return true; const k = e.key; if (k === 'f' || k === 'F') return true; if (k === 'ด') return true; return false; } hostIconImg = new Image(); hostIconImg.src = SERVER + '/img/host-icon.png'; hostIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); }; const lobbyReadyIconImg = new Image(); lobbyReadyIconImg.src = SERVER + '/img/icon-ready.png?v=1'; lobbyReadyIconImg.onerror = function () { lobbyReadyIconImg.src = SERVER + '/img/lobby-icon-ready.png?v=1'; }; lobbyReadyIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); }; const readyLabelImg = document.getElementById('ready-label-img'); const readyLabelEl = document.getElementById('ready-label'); /** พร้อม/ไม่พร้อม ใช้ได้ทุกจำนวนคนในห้อง — ไม่ล็อกตาม peers.size */ function ensureReadyControlEnabled() { if (readyCheck) readyCheck.disabled = false; } function updateReadyLabelVisual() { if (!readyCheck || !readyLabelImg) return; var on = readyCheck.checked; readyLabelImg.src = on ? READY_IMG_ACTIVE : READY_IMG_IDLE; readyLabelImg.alt = on ? 'พร้อมแล้ว — กดเพื่อยกเลิก' : 'ยังไม่พร้อม — กดเพื่อพร้อม'; if (readyLabelEl) readyLabelEl.classList.toggle('ready-label--active', on); } const defaultShadowImgs = { up: new Image(), down: new Image(), left: new Image(), right: new Image() }; ['up', 'down', 'left', 'right'].forEach(function (d) { defaultShadowImgs[d].src = SERVER + '/img/default-shadow-' + d + '.png'; defaultShadowImgs[d].onload = function () { if (mapData && canvas) drawLobbyMap(); }; }); function getDefaultShadowImg(dir) { const d = dir || 'down'; return defaultShadowImgs[d] && defaultShadowImgs[d].complete && defaultShadowImgs[d].naturalWidth ? defaultShadowImgs[d] : null; } const speakingBubbleFrames = []; const SPEAKING_BUBBLE_FRAME_MS = 120; for (var sb = 0; sb < 4; sb++) { var img = new Image(); img.src = SERVER + '/img/speaking-bubble-0' + sb + '.png'; img.onload = function () { if (mapData && canvas) drawLobbyMap(); }; speakingBubbleFrames.push(img); } function getSpeakingBubbleFrame() { var idx = Math.floor((typeof Date !== 'undefined' ? Date.now() : 0) / SPEAKING_BUBBLE_FRAME_MS) % 4; var img = speakingBubbleFrames[idx]; return img && img.complete && img.naturalWidth ? img : (speakingBubbleFrames[0] && speakingBubbleFrames[0].complete ? speakingBubbleFrames[0] : null); } function getQuizQuestionAreaTileBounds(md) { if (!md || md.gameType !== 'quiz') return null; const grid = md.quizQuestionArea; if (!grid || !grid.length) return null; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (let yy = 0; yy < grid.length; yy++) { const row = grid[yy]; if (!row) continue; for (let xx = 0; xx < row.length; xx++) { if (row[xx] === 1) { if (xx < minX) minX = xx; if (yy < minY) minY = yy; if (xx > maxX) maxX = xx; if (yy > maxY) maxY = yy; } } } if (minX === Infinity) return null; return { minX, minY, maxX, maxY }; } function syncQuizMapQuestionPanel() { const panel = document.getElementById('quiz-map-question-panel'); const textEl = document.getElementById('quiz-map-question-text'); if (!panel || !textEl) return; const bounds = (quizModeActive && mapData && mapData.gameType === 'quiz' && (lastQuizQuestionText || '').trim()) ? getQuizQuestionAreaTileBounds(mapData) : null; if (!bounds || !mapTransform || mapTransform.cw == null) { panel.classList.add('is-hidden'); panel.setAttribute('aria-hidden', 'true'); return; } const cw = mapTransform.cw; const ch = mapTransform.ch; const z = mapTransform.lobbyZoom; const ts = mapTransform.tileSize; const meX = mapTransform.meX; const meY = mapTransform.meY; const leftPx = cw / 2 + z * (bounds.minX * ts - meX * ts); const topPx = ch / 2 + z * (bounds.minY * ts - meY * ts); const wPx = z * (bounds.maxX - bounds.minX + 1) * ts; const hPx = z * (bounds.maxY - bounds.minY + 1) * ts; textEl.textContent = lastQuizQuestionText || ''; panel.style.left = Math.round(leftPx) + 'px'; panel.style.top = Math.round(topPx) + 'px'; panel.style.width = Math.round(Math.max(48, wPx)) + 'px'; panel.style.height = Math.round(Math.max(40, hPx)) + 'px'; panel.classList.remove('is-hidden'); panel.setAttribute('aria-hidden', 'false'); } function drawLobbyMap() { if (!canvas || !mapData) return; const ctx = canvas.getContext('2d'); const w = mapData.width || 20; const h = mapData.height || 15; const tileSize = mapData.tileSize || 32; const mapWpx = w * tileSize; const mapHpx = h * tileSize; const cw = canvas.width; const ch = canvas.height; const characterCells = mapData.characterCells || 1; const cellsW = Math.max(1, Math.min(4, Number(mapData.characterCellsW) || characterCells || 1)); const cellsH = Math.max(1, Math.min(4, Number(mapData.characterCellsH) || characterCells || 1)); const me = peers.get(socket.id); const meX = (me && typeof me.x === 'number') ? me.x : 1; const meY = (me && typeof me.y === 'number') ? me.y : 1; const ts = tileSize * lobbyZoom; mapTransform = { cw, ch, lobbyZoom, tileSize, meX, meY, w, h }; ctx.fillStyle = '#303770'; ctx.fillRect(0, 0, cw, ch); ctx.save(); ctx.translate(cw / 2, ch / 2); ctx.scale(lobbyZoom, lobbyZoom); ctx.translate(-meX * tileSize, -meY * tileSize); if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) { const nw = mapBackgroundImg.naturalWidth; const nh = mapBackgroundImg.naturalHeight; ctx.drawImage(mapBackgroundImg, 0, 0, nw, nh, 0, 0, mapWpx, mapHpx); } const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false'; const timeMs = Date.now(); if (showGrid) { for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const sx = x * tileSize; const sy = y * tileSize; const ob = mapData.objects?.[y]?.[x] ?? 0; const cellColor = mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x]; if (ob === 1) { ctx.fillStyle = 'rgba(65,72,104,0.92)'; ctx.fillRect(sx, sy, tileSize, tileSize); ctx.strokeStyle = '#565f89'; ctx.strokeRect(sx, sy, tileSize, tileSize); } else if (cellColor) { ctx.fillStyle = cellColor; ctx.fillRect(sx, sy, tileSize, tileSize); } else if (!mapBackgroundImg || !mapBackgroundImg.complete) { ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335'; ctx.fillRect(sx, sy, tileSize, tileSize); } const isInter = mapData.interactive && mapData.interactive[y] && mapData.interactive[y][x] === 1; if (isInter) { ctx.fillStyle = 'rgba(158,206,106,0.35)'; ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); ctx.strokeStyle = 'rgba(158,206,106,0.8)'; ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); const pulse = lobbyInteractPulse; if (pulse && pulse.x === x && pulse.y === y && timeMs < pulse.until) { const t = (pulse.until - timeMs) / 700; ctx.fillStyle = 'rgba(255, 214, 102,' + (0.25 + 0.35 * t) + ')'; ctx.fillRect(sx, sy, tileSize, tileSize); } } const isStartArea = mapData.gameType === 'lobby' && mapData.startGameArea && mapData.startGameArea[y] && mapData.startGameArea[y][x] === 1; if (isStartArea) { ctx.fillStyle = 'rgba(255, 158, 100, 0.4)'; ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); ctx.strokeStyle = 'rgba(255, 120, 60, 0.92)'; ctx.lineWidth = 2; ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); ctx.lineWidth = 1; } const isQuizQ = mapData.gameType === 'quiz' && mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1; if (isQuizQ) { ctx.fillStyle = 'rgba(255, 214, 102, 0.32)'; ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)'; ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); } const isQuizT = mapData.gameType === 'quiz' && mapData.quizTrueArea && mapData.quizTrueArea[y] && mapData.quizTrueArea[y][x] === 1; if (isQuizT) { ctx.fillStyle = 'rgba(86, 202, 255, 0.38)'; ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)'; ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); } const isQuizF = mapData.gameType === 'quiz' && mapData.quizFalseArea && mapData.quizFalseArea[y] && mapData.quizFalseArea[y][x] === 1; if (isQuizF) { ctx.fillStyle = 'rgba(247, 118, 190, 0.38)'; ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)'; ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); } } } } if (mapData.gameType === 'quiz' && showGrid) { function tileBoundsForGrid(gr) { let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; for (let yy = 0; yy < h; yy++) { for (let xx = 0; xx < w; xx++) { if (gr && gr[yy] && gr[yy][xx] === 1) { minX = Math.min(minX, xx); maxX = Math.max(maxX, xx); minY = Math.min(minY, yy); maxY = Math.max(maxY, yy); } } } return minX === Infinity ? null : { minX, maxX, minY, maxY }; } function drawQuizZoneLabel(gr, en, th, icon, stroke, fill) { const b = tileBoundsForGrid(gr); if (!b) return; const sx = b.minX * tileSize; const sy = b.minY * tileSize; const sw = (b.maxX - b.minX + 1) * tileSize; const sh = (b.maxY - b.minY + 1) * tileSize; const mcx = sx + sw / 2; const mcy = sy + sh / 2; ctx.save(); ctx.shadowColor = stroke; ctx.shadowBlur = 16; ctx.strokeStyle = stroke; ctx.lineWidth = 3; ctx.strokeRect(sx + 1, sy + 1, sw - 2, sh - 2); ctx.shadowBlur = 0; const iconSz = Math.min(sw, sh) * 0.4; ctx.font = 'bold ' + Math.round(iconSz) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; ctx.fillStyle = fill; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(icon, mcx, mcy - sh * 0.1); ctx.font = 'bold ' + Math.max(11, Math.round(tileSize * 0.44)) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; ctx.fillStyle = '#e2e8f0'; ctx.fillText(en, mcx, mcy + sh * 0.08); ctx.font = Math.max(10, Math.round(tileSize * 0.32)) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; ctx.fillStyle = 'rgba(226,232,240,0.92)'; ctx.fillText(th, mcx, mcy + sh * 0.2); ctx.restore(); } drawQuizZoneLabel(mapData.quizTrueArea, 'SAFE', 'ปลอดภัย', '✓', 'rgba(122,220,255,0.95)', 'rgba(200,240,255,0.95)'); drawQuizZoneLabel(mapData.quizFalseArea, 'SCAM', 'อันตราย', '✕', 'rgba(255,130,200,0.95)', 'rgba(255,210,225,0.95)'); } function peerVisualOffset(id) { if (mapData && mapData.lobbySpawnMode === 'slots6') return { ax: 0, ay: 0 }; let h = 0; for (let i = 0; i < (id || '').length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; const ax = ((h % 5) - 2) * 0.1; const ay = ((Math.floor(h / 5) % 5) - 2) * 0.1; return { ax, ay }; } // ไอคอน "ห้องแต่งตัว" ในฉาก (จุด interact — เดินไปกด F) if (ROOM_CZ_SPOT && roomCzIcon && roomCzIcon.complete && roomCzIcon.naturalWidth) { const _czx = ROOM_CZ_SPOT.x * tileSize; const _czy = ROOM_CZ_SPOT.y * tileSize; const _czS = tileSize * 1.5; ctx.save(); ctx.globalAlpha = 0.5; ctx.fillStyle = 'rgba(34,211,238,0.35)'; ctx.beginPath(); ctx.ellipse(_czx + tileSize / 2, _czy + tileSize - 3, tileSize * 0.55, tileSize * 0.22, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); ctx.drawImage(roomCzIcon, _czx + tileSize / 2 - _czS / 2, _czy + tileSize - _czS, _czS, _czS); } if (ROOM_HC_SPOT && roomHcIcon && hostId === socket.id && roomHcIcon.complete && roomHcIcon.naturalWidth) { const _hcx = ROOM_HC_SPOT.x * tileSize; const _hcy = ROOM_HC_SPOT.y * tileSize; const _hcS = tileSize * 1.5; ctx.save(); ctx.globalAlpha = 0.5; ctx.fillStyle = 'rgba(236,72,153,0.35)'; ctx.beginPath(); ctx.ellipse(_hcx + tileSize / 2, _hcy + tileSize - 3, tileSize * 0.55, tileSize * 0.22, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); ctx.drawImage(roomHcIcon, _hcx + tileSize / 2 - _hcS / 2, _hcy + tileSize - _hcS, _hcS, _hcS); } const peerList = [...peers.entries(), ...lobbyBots.entries()].sort(function (a, b) { const pa = a[1], pb = b[1]; const ya = pa.y != null ? pa.y : 1, yb = pb.y != null ? pb.y : 1; if (Math.abs(ya - yb) > 0.01) return ya - yb; return (pa.x != null ? pa.x : 1) - (pb.x != null ? pb.x : 1); }); peerList.forEach(function (entry) { const id = entry[0], p = entry[1]; const off = peerVisualOffset(id); const px = ((p.x != null ? p.x : 1) + off.ax) * tileSize; const py = ((p.y != null ? p.y : 1) + off.ay) * tileSize; const screenX = px; const screenY = py; const cx = screenX + tileSize / 2; const cellBottom = screenY + tileSize; const boxW = tileSize * cellsW * 1.15; const boxH = tileSize * cellsH * 1.35; const dir = p.direction || 'down'; const isWalking = id === socket.id ? !!(me && me.isWalking) : (p.tx != null || p.ty != null) ? !!((p.tx != null && Math.abs((p.tx || p.x) - p.x) > 0.02) || (p.ty != null && Math.abs((p.ty || p.y) - p.y) > 0.02)) : !!p.isWalking; /* บอท (ไม่มี tx/ty) ใช้ flag isWalking ที่ stepLobbyCaseBots ตั้ง */ const peerLockedOut = quizModeActive && ( quizPeersLocked[id] || (id === socket.id && quizPlayerLocal && quizPlayerLocal.cannotTrue && quizPlayerLocal.cannotFalse) ); if (peerLockedOut) ctx.save(); if (peerLockedOut) { ctx.globalAlpha = 0.45; ctx.filter = 'grayscale(1) brightness(1.25)'; } const peerTheme = (id === socket.id) ? myTintTheme : (p.colorTheme || null); const peerSkin = (id === socket.id) ? myTintSkin : (p.colorSkin || null); const cid = p.characterId || rlDefaultCharId; // กันตัวละครหายเป็นวงกลม blob เมื่อ characterId ว่าง const charImg = getAvatarImgColored(cid, peerTheme, peerSkin, dir, timeMs, isWalking); const sz = readLobbyCharImgSize(charImg); const iw = sz.iw; const ih = sz.ih; const imgScale = (iw > 0 && ih > 0) ? Math.min(boxW / iw, boxH / ih, 1) : 0; const drawW = imgScale > 0 ? iw * imgScale : 0; const drawH = imgScale > 0 ? ih * imgScale : 0; const drawY = cellBottom - drawH; const shadowImg = getDefaultShadowImg(dir); if (shadowImg) { const sw = shadowImg.naturalWidth; const sh = shadowImg.naturalHeight; const shadowScale = Math.min(drawW / sw, drawH / sh, 1.2); const shadowW = sw * shadowScale; const shadowH = sh * shadowScale; const shadowY = cellBottom - shadowH + (tileSize * 0.08); ctx.globalAlpha = 0.7; ctx.drawImage(shadowImg, 0, 0, sw, sh, cx - shadowW / 2, shadowY, shadowW, shadowH); ctx.globalAlpha = 1; } if (drawW > 0 && drawH > 0 && charImg) { var sIw = iw; var sIh = ih; if (charImg.tagName === 'CANVAS') { sIw = charImg.width; sIh = charImg.height; } else if (charImg.naturalWidth > 0 && charImg.naturalHeight > 0) { sIw = charImg.naturalWidth; sIh = charImg.naturalHeight; } ctx.drawImage(charImg, 0, 0, sIw, sIh, cx - drawW / 2, drawY, drawW, drawH); } else { const r = Math.max(8, ts / 2 - 2); const cy = screenY + ts / 2; ctx.fillStyle = id === socket.id ? '#7aa2f7' : '#9ece6a'; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#c0caf5'; ctx.lineWidth = 2; ctx.stroke(); } const nameFontSize = Math.max(10, Math.round(tileSize * 0.4)); const labelY = cellBottom - drawH - (tileSize * 0.2); const headTop = cellBottom - drawH - 4; const now = Date.now(); if (p.speakingUntil > now) { var bubbleImg = getSpeakingBubbleFrame(); if (bubbleImg) { var bw = tileSize * 0.9; var bh = (bubbleImg.naturalHeight / bubbleImg.naturalWidth) * bw; var bubbleY = headTop - bh - tileSize * 0.08; ctx.drawImage(bubbleImg, 0, 0, bubbleImg.naturalWidth, bubbleImg.naturalHeight, cx - bw / 2, bubbleY, bw, bh); } else { var level = (p.speakingLevel != null ? p.speakingLevel : 0.5); var r0 = tileSize * (0.2 + level * 0.25); ctx.strokeStyle = 'rgba(158, 206, 106, 0.85)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(cx, headTop, r0, 0, Math.PI * 2); ctx.stroke(); } } const nameText = (p.nickname || id.slice(0, 6)); const isHost = id === hostId; const nameY = labelY + (tileSize * 0.125); const voiceIconY = labelY - tileSize * 0.12; if (p.voiceMicOn === false) { const iconSize = Math.max(10, Math.round(tileSize * 0.35)); ctx.font = iconSize + 'px NotoSansThai, Kanit, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#f7768e'; ctx.fillText('🔇', cx, voiceIconY); ctx.textBaseline = 'alphabetic'; } if (p.ready && lobbyReadyIconImg && lobbyReadyIconImg.complete && lobbyReadyIconImg.naturalWidth) { var nwI = lobbyReadyIconImg.naturalWidth; var nhI = lobbyReadyIconImg.naturalHeight; if (nwI > 0 && nhI > 0) { var rih = 82; var riw = 89; var iconBottom = Math.round(nameY - nameFontSize * 1.0); var iconTop = iconBottom - rih; var iconLeft = Math.round(cx - riw / 2); ctx.save(); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(lobbyReadyIconImg, 0, 0, nwI, nhI, iconLeft, iconTop, riw, rih); ctx.restore(); } } ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#111827'; ctx.lineWidth = Math.max(2, Math.round(nameFontSize * 0.22)); ctx.lineJoin = 'round'; ctx.font = '400 ' + nameFontSize + 'px NotoSansThai, Kanit, sans-serif'; ctx.textAlign = 'center'; if (isHost && hostIconImg && hostIconImg.complete && hostIconImg.naturalWidth) { const iconW = 53; const iconH = 40; const gap = Math.max(2, Math.round(tileSize * 0.06)); const textW = ctx.measureText(nameText).width; const totalW = iconW + gap + textW; const startX = cx - totalW / 2; ctx.drawImage(hostIconImg, 0, 0, hostIconImg.naturalWidth, hostIconImg.naturalHeight, startX, labelY - iconH / 2, iconW, iconH); ctx.textAlign = 'left'; ctx.strokeText(nameText, startX + iconW + gap, nameY); ctx.fillText(nameText, startX + iconW + gap, nameY); ctx.textAlign = 'center'; } else { ctx.strokeText(nameText, cx, nameY); ctx.fillText(nameText, cx, nameY); } if (peerLockedOut) ctx.restore(); }); ctx.restore(); syncQuizMapQuestionPanel(); } function resizeAndDraw() { if (!canvas) return; const vp = window.visualViewport; const w = Math.max(vp ? vp.width : 0, window.innerWidth || 0, document.documentElement.clientWidth || 0) || 800; const h = Math.max(vp ? vp.height : 0, window.innerHeight || 0, document.documentElement.clientHeight || 0) || 600; const pw = Math.floor(w); const ph = Math.floor(h); if (canvas.width !== pw || canvas.height !== ph) { canvas.width = pw; canvas.height = ph; } drawLobbyMap(); } function redrawLobbyMap() { if (canvas && mapData) drawLobbyMap(); } function getFacingCellOffset(direction) { const d = direction || 'down'; if (d === 'up') return { dx: 0, dy: -1 }; if (d === 'down') return { dx: 0, dy: 1 }; if (d === 'left') return { dx: -1, dy: 0 }; return { dx: 1, dy: 0 }; } function cellIsInteractiveLobby(tx, ty) { if (!mapData || !mapData.interactive) return false; const row = mapData.interactive[ty]; return !!(row && row[tx] === 1); } /** ช่องที่กด F ได้: ช่องหน้าทิศทางก่อน แล้วค่อยช่องที่ยืนอยู่ */ function getLobbyInteractTarget(me) { if (!mapData || !me) return null; const w = mapData.width || 20, h = mapData.height || 15; const px = Math.floor(me.x), py = Math.floor(me.y); const { dx, dy } = getFacingCellOffset(me.direction); const fx = px + dx, fy = py + dy; if (fx >= 0 && fx < w && fy >= 0 && fy < h && cellIsInteractiveLobby(fx, fy)) return { x: fx, y: fy }; if (cellIsInteractiveLobby(px, py)) return { x: px, y: py }; return null; } function appendLobbySystemChat(text) { const el = document.getElementById('chat-messages'); if (!el) return; const div = document.createElement('div'); div.className = 'chat-msg chat-msg-system'; div.textContent = text; el.appendChild(div); el.scrollTop = 1e9; } function quizCellOnLobby(grid, tx, ty) { return !!(grid && grid[ty] && grid[ty][tx] === 1); } function quizTilesFootprintLobby(px, py) { const s = new Set(); if (!mapData) return s; const cells = Math.max(1, Math.min(4, mapData.characterCells || 1)); const w = mapData.width || 20, h = mapData.height || 15; const minTx = Math.floor(px); const minTy = Math.floor(py); const maxTx = Math.min(w - 1, minTx + cells - 1); const maxTy = Math.min(h - 1, minTy + cells - 1); for (let ty = minTy; ty <= maxTy; ty++) { for (let tx = minTx; tx <= maxTx; tx++) { if (tx >= 0 && ty >= 0) s.add(tx + ',' + ty); } } return s; } function quizAnswerTileForbiddenLobby(tx, ty) { if (!quizPlayerLocal || quizPlayerLocal.eliminated) return false; if (quizPlayerLocal.cannotTrue && quizCellOnLobby(mapData.quizTrueArea, tx, ty)) return true; if (quizPlayerLocal.cannotFalse && quizCellOnLobby(mapData.quizFalseArea, tx, ty)) return true; return false; } function quizLockFootprintBlocksLobby(px, py) { if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false; for (const k of quizTilesFootprintLobby(px, py)) { const p = k.split(','); const txi = +p[0], tyi = +p[1]; if (quizAnswerTileForbiddenLobby(txi, tyi)) return true; } return false; } function quizLockWouldEnterForbiddenLobby(ox, oy, nx, ny) { if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false; const fromS = quizTilesFootprintLobby(ox, oy); const toS = quizTilesFootprintLobby(nx, ny); for (const k of toS) { if (fromS.has(k)) continue; const p = k.split(','); const txi = +p[0], tyi = +p[1]; if (quizAnswerTileForbiddenLobby(txi, tyi)) return true; } return false; } function canWalkLobbyBot(x, y, fromX, fromY, botId) { if (!mapData) return false; const w = mapData.width || 20, h = mapData.height || 15; const tx = Math.floor(x), ty = Math.floor(y); if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; const ob = mapData.objects?.[ty]?.[tx] ?? 0; if (ob === 1) return false; const bp = mapData.blockPlayer; if (bp && bp[ty] && bp[ty][tx] === 1) { for (const [, p] of peers) { if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false; } for (const [id, b] of lobbyBots) { if (id === botId) continue; if (Math.floor(b.x) === tx && Math.floor(b.y) === ty) return false; } } return true; } function canWalkLobby(x, y, fromX, fromY) { if (!mapData) return false; const w = mapData.width || 20, h = mapData.height || 15; const tx = Math.floor(x), ty = Math.floor(y); if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; const ob = mapData.objects?.[ty]?.[tx] ?? 0; if (ob === 1) return false; const bp = mapData.blockPlayer; if (bp && bp[ty] && bp[ty][tx] === 1) { for (const [id, p] of peers) { if (id === socket.id) continue; if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false; } } if (mapData.gameType === 'quiz' && quizModeActive && quizPlayerLocal && !quizPlayerLocal.eliminated) { const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY); if (hasFrom) { if (quizLockWouldEnterForbiddenLobby(fromX, fromY, x, y)) return false; } else if (quizLockFootprintBlocksLobby(x, y)) { return false; } } return true; } /** A* pathfinding on lobby grid. Returns array of { x, y } (cell centers). */ function pathfindLobby(fromX, fromY, toX, toY) { if (!mapData) return []; const w = mapData.width || 20, h = mapData.height || 15; const fx = Math.floor(fromX), fy = Math.floor(fromY); const tx = Math.floor(toX), ty = Math.floor(toY); if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLobby(tx + 0.5, ty + 0.5)) return []; if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }]; const key = (gx, gy) => gx + ',' + gy; const open = [{ gx: fx, gy: fy, f: 0, g: 0 }]; const closed = new Set(); const cameFrom = {}; const gScore = { [key(fx, fy)]: 0 }; const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty); const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }]; while (open.length) { open.sort((a, b) => a.f - b.f); const cur = open.shift(); const ck = key(cur.gx, cur.gy); if (closed.has(ck)) continue; closed.add(ck); if (cur.gx === tx && cur.gy === ty) { const path = []; let u = cur; while (u) { path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 }); u = cameFrom[key(u.gx, u.gy)]; } return path; } for (const d of dirs) { const nx = cur.gx + d.dx, ny = cur.gy + d.dy; if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue; if (!canWalkLobby(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5)) continue; const nk = key(nx, ny); if (closed.has(nk)) continue; const g = (gScore[ck] ?? Infinity) + 1; if (g >= (gScore[nk] ?? Infinity)) continue; gScore[nk] = g; cameFrom[nk] = cur; open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g }); } } return []; } let lobbyPath = []; function lobbyMapRequiresStartGameArea() { if (!mapData) return false; var nameOk = String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase(); if (mapData.gameType !== 'lobby' && !nameOk) return false; var a = mapData.startGameArea; if (!a || !a.length) return false; for (var y = 0; y < a.length; y++) { var row = a[y]; if (!row) continue; for (var x = 0; x < row.length; x++) if (row[x] === 1) return true; } return false; } /** ช่องกริดที่ตัวละครครอบคลุม (สูง×กว้าง จากแมป) — ใช้ตรวจพื้นที่ส้ม/เขียว ไม่ใช่แค่มุม anchor */ function lobbyCharacterFootprintTiles(px, py) { const tiles = new Set(); if (!mapData) return tiles; const w = mapData.width || 20; const h = mapData.height || 15; const cellsW = Math.max(1, Math.min(4, mapData.characterCellsW || mapData.characterCells || 1)); const cellsH = Math.max(1, Math.min(4, mapData.characterCellsH || mapData.characterCells || 1)); const minTx = Math.floor(px); const minTy = Math.floor(py); const maxTx = Math.min(w - 1, minTx + cellsW - 1); const maxTy = Math.min(h - 1, minTy + cellsH - 1); for (let ty = minTy; ty <= maxTy; ty++) { for (let tx = minTx; tx <= maxTx; tx++) { if (tx >= 0 && ty >= 0) tiles.add(tx + ',' + ty); } } return tiles; } function peerStandingInStartGameArea(me) { if (!me || !mapData) return false; const area = mapData.startGameArea; if (!area || !area.length) return false; for (const key of lobbyCharacterFootprintTiles(me.x, me.y)) { const parts = key.split(','); const tx = +parts[0]; const ty = +parts[1]; if (area[ty] && area[ty][tx] === 1) return true; } return false; } function hostStandingInStartGameArea() { return peerStandingInStartGameArea(peers.get(socket.id)); } function updateHostStartGameButton() { if (!btnStart) return; if (hostId !== socket.id) return; var hint = document.getElementById('host-start-area-hint'); if (isCurrentRoomLobbyA()) { btnStart.disabled = true; btnStart.setAttribute('hidden', ''); btnStart.setAttribute('aria-hidden', 'true'); if (hint) { hint.hidden = false; var reqA = lobbyMapRequiresStartGameArea(); var okA = !reqA || hostStandingInStartGameArea(); if (reqA && !okA) { hint.textContent = 'LobbyA: กดพร้อมก่อน · ยืนพื้นส้ม แล้วกด F / ด เพื่อเลือกระดับและคดี'; } else if (reqA) { hint.textContent = 'LobbyA: กดพร้อมก่อน · ในพื้นส้ม กด F / ด เพื่อเลือกระดับและคดี'; } else { hint.textContent = 'LobbyA: กดพร้อมก่อน · กด F / ด เพื่อเลือกระดับและคดี'; } } return; } btnStart.removeAttribute('hidden'); btnStart.removeAttribute('aria-hidden'); var req = lobbyMapRequiresStartGameArea(); var ok = !req || hostStandingInStartGameArea(); btnStart.disabled = !ok; btnStart.title = req ? (ok ? 'เริ่มเกม' : 'ยืนในพื้นที่สีส้ม (เริ่มเกม) ก่อน') : 'เริ่มเกม'; if (hint) { if (req && !ok) { hint.hidden = false; hint.textContent = 'ยืนในพื้นที่สีส้มบนแผนที่ (จุดเริ่มเกม) แล้วค่อยกดเริ่ม'; } else { hint.hidden = true; hint.textContent = ''; } } } /** โถง LobbyA — host กดเริ่มแล้วให้เลือกระดับแล้วคดีก่อนเข้า play (ตรง Create Room → mlsbbxfe) */ const LOBBY_A_MAP_NAME = 'LobbyA'; const LOBBY_A_MAP_ID = 'mlsbbxfe'; function isCurrentRoomLobbyA() { if (!mapData) return false; if (String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase()) return true; if (clientLobbyMapId === LOBBY_A_MAP_ID) return true; if (String(mapData.id || '').trim() === LOBBY_A_MAP_ID) return true; return false; } /** Host บน LobbyA — หลังกดพร้อมแล้ว กด F ในพื้นที่ส้ม (หรือทั้งแมปถ้าไม่มีส้ม) เพื่อเลือกระดับ/คดี */ function hostCanOpenLobbyAPreplayWithF() { if (!isCurrentRoomLobbyA() || hostId !== socket.id) return false; var req = lobbyMapRequiresStartGameArea(); return !req || hostStandingInStartGameArea(); } let preplaySelectedLevel = null; let preplayOverlay = null; let preplayStepLevel = null; let preplayStepCase = null; let preplayCaseDetailOverlay = null; function getPreplayEls() { if (!preplayOverlay) { preplayOverlay = document.getElementById('lobby-preplay-overlay'); preplayStepLevel = document.getElementById('lobby-preplay-step-level'); preplayStepCase = document.getElementById('lobby-preplay-step-case'); preplayCaseDetailOverlay = document.getElementById('lobby-preplay-case-detail-overlay'); } return !!preplayOverlay; } function closeLobbyPreplayWizard() { getPreplayEls(); if (!preplayOverlay) return; preplayOverlay.classList.add('is-hidden'); preplayOverlay.setAttribute('aria-hidden', 'true'); try { document.body.classList.remove('room-lobby--preplay-open'); } catch (e) { /* ignore */ } preplaySelectedLevel = null; delete preplayOverlay.dataset.preplayLevel; if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); }); } function openLobbyPreplayWizard() { if (!getPreplayEls()) return; initLobbyPreplayWizard(); preplaySelectedLevel = null; delete preplayOverlay.dataset.preplayLevel; preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); }); preplayOverlay.classList.remove('is-hidden'); preplayOverlay.setAttribute('aria-hidden', 'false'); try { document.body.classList.add('room-lobby--preplay-open'); } catch (e) { /* ignore */ } if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden'); if (preplayStepCase) preplayStepCase.classList.add('is-hidden'); if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); } function syncPreplayCaseStepScale() { if (!getPreplayEls() || !preplayStepCase || !preplayOverlay) return; if (preplayOverlay.classList.contains('is-hidden')) return; if (preplayStepCase.classList.contains('is-hidden')) return; var rect = preplayStepCase.getBoundingClientRect(); if (!rect.width || !rect.height) return; var baseWidth = 1700; var baseHeight = 900; var byWidth = rect.width / baseWidth; var byHeight = rect.height / baseHeight; var targetScale = 1.08; var scale = Math.max(0.56, Math.min(targetScale, Math.min(byWidth, byHeight) * targetScale)); preplayStepCase.style.setProperty('--lobby-preplay-case-scale', scale.toFixed(4)); } function showPreplayCaseStep() { if (preplayStepLevel) preplayStepLevel.classList.add('is-hidden'); if (preplayStepCase) preplayStepCase.classList.remove('is-hidden'); syncPreplayCaseStepScale(); requestAnimationFrame(syncPreplayCaseStepScale); } function showPreplayLevelStep() { if (preplayStepCase) preplayStepCase.classList.add('is-hidden'); if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden'); if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); } function initLobbyPreplayWizard() { if (!getPreplayEls() || !preplayOverlay || preplayOverlay.dataset.bound === '1') return; preplayOverlay.dataset.bound = '1'; preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (btn) { btn.addEventListener('click', function () { preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (b) { b.classList.remove('is-selected'); }); btn.classList.add('is-selected'); preplaySelectedLevel = btn.getAttribute('data-level'); if (preplaySelectedLevel) { preplayOverlay.dataset.preplayLevel = preplaySelectedLevel; showPreplayCaseStep(); } }); }); var btnCloseLevel = document.getElementById('lobby-preplay-close-level'); if (btnCloseLevel) btnCloseLevel.addEventListener('click', function () { closeLobbyPreplayWizard(); }); var btnCloseCase = document.getElementById('lobby-preplay-close-case'); if (btnCloseCase) btnCloseCase.addEventListener('click', function () { closeLobbyPreplayWizard(); }); var btnBackCase = document.getElementById('lobby-preplay-back-case'); if (btnBackCase) btnBackCase.addEventListener('click', function () { showPreplayLevelStep(); }); var caseRow = preplayOverlay.querySelector('.lobby-preplay-case-row'); var casePrev = document.getElementById('lobby-preplay-case-prev'); var caseNext = document.getElementById('lobby-preplay-case-next'); function scrollCaseRow(dir) { if (!caseRow) return; var firstCard = caseRow.querySelector('.lobby-case-card-wrap'); var step = firstCard ? Math.max(120, Math.round(firstCard.getBoundingClientRect().width * 0.92)) : Math.max(140, Math.round(caseRow.clientWidth * 0.65)); caseRow.scrollBy({ left: dir * step, behavior: 'smooth' }); } if (casePrev) casePrev.addEventListener('click', function () { scrollCaseRow(-1); }); if (caseNext) caseNext.addEventListener('click', function () { scrollCaseRow(1); }); preplayOverlay.addEventListener('click', function (ev) { var startBtn = ev.target.closest('.lobby-case-start'); if (!startBtn || !preplayOverlay.contains(startBtn)) return; var cid = (startBtn.getAttribute('data-case') || '').trim(); var level = (preplaySelectedLevel && String(preplaySelectedLevel).trim()) || (preplayOverlay.dataset.preplayLevel || '').trim(); if (!cid) return; if (!level) { try { alert('กรุณาเลือกระดับความท้าทายก่อน (กลับไปขั้นตอนก่อนหน้าแล้วเลือกระดับ)'); } catch (e0) { /* ignore */ } return; } var acked = false; var t = setTimeout(function () { if (!acked) { try { alert('ไม่ได้รับตอบจากเซิร์ฟเวอร์ — ลองใหม่หรือรีเฟรชหน้า'); } catch (eT) { /* ignore */ } } }, 12000); /* ไม่ส่ง mapId — กันประเภทเซิร์ฟรับแล้วไป play.html แทน LobbyB เมื่อเงื่อนไขย้ายแผนที่ไม่ผ่าน */ socket.emit('start-game', { lobbyLevel: level, caseId: cid, detectiveLobbyBStart: true }, function (res) { acked = true; clearTimeout(t); if (res && res.ok === false && res.error) { try { alert(res.error); } catch (e2) { /* ignore */ } } }); }); document.querySelectorAll('.lobby-case-detail').forEach(function (btn) { btn.addEventListener('click', function () { var o = document.getElementById('lobby-preplay-case-detail-overlay'); if (o) o.classList.remove('is-hidden'); }); }); var detailClose = document.getElementById('lobby-preplay-case-detail-close'); if (detailClose) detailClose.addEventListener('click', function () { var o = document.getElementById('lobby-preplay-case-detail-overlay'); if (o) o.classList.add('is-hidden'); }); window.addEventListener('resize', syncPreplayCaseStepScale); window.addEventListener('orientationchange', function () { setTimeout(syncPreplayCaseStepScale, 180); }); } const PATH_ARRIVE_THRESH = 0.15; const LOBBY_BOT_WANDER_DIRS = [[0, -1], [0, 1], [-1, 0], [1, 0]]; function stepLobbyCaseBots() { if (!lobbyBotSlotCount || !mapData || lobbyBots.size === 0) return; const w = mapData.width || 20, h = mapData.height || 15; const now = Date.now(); lobbyBots.forEach((b, id) => { if (typeof b.botWanderDx !== 'number' || typeof b.botWanderDy !== 'number' || (b.botWanderDx === 0 && b.botWanderDy === 0)) { const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; b.botWanderDx = d[0]; b.botWanderDy = d[1]; } if (typeof b.botWanderNextTurn !== 'number') b.botWanderNextTurn = now + 600; if (now >= b.botWanderNextTurn) { b.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200); if (Math.random() < 0.55) { const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; b.botWanderDx = d[0]; b.botWanderDy = d[1]; } } const accX = b.botWanderDx; const accY = b.botWanderDy; if (Math.abs(accY) > Math.abs(accX)) b.direction = accY > 0 ? 'down' : 'up'; else if (accX !== 0) b.direction = accX > 0 ? 'right' : 'left'; const ox = b.x, oy = b.y; const step = MOVE_SPEED; const nx = b.x + accX * step; const ny = b.y + accY * step; if (canWalkLobbyBot(nx, ny, b.x, b.y, id)) { b.x = nx; b.y = ny; } else if (canWalkLobbyBot(nx, b.y, b.x, b.y, id)) { b.x = nx; } else if (canWalkLobbyBot(b.x, ny, b.x, b.y, id)) { b.y = ny; } else { const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; b.botWanderDx = d[0]; b.botWanderDy = d[1]; b.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600); } b.x = Math.max(0, Math.min(w - 0.01, b.x)); b.y = Math.max(0, Math.min(h - 0.01, b.y)); b.isWalking = Math.abs(b.x - ox) > 1e-5 || Math.abs(b.y - oy) > 1e-5; }); } function lobbyTick() { const me = peers.get(socket.id); if (!mapData || !me) { requestAnimationFrame(lobbyTick); return; } peers.forEach((p, id) => { if (id !== socket.id && (p.tx != null || p.ty != null)) { if (p.tx != null) p.x += (p.tx - p.x) * LERP; if (p.ty != null) p.y += (p.ty - p.y) * LERP; } }); if (!suspectPickOverlayOpen && lobbyBotSlotCount > 0) stepLobbyCaseBots(); const w = mapData.width || 20, h = mapData.height || 15; const preWalkX = me.x, preWalkY = me.y; let accX = 0, accY = 0; let usePath = false; if (!suspectPickOverlayOpen) { const keyPressed = !isChatFocused() && (keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD']); if (lobbyPath.length > 0 && keyPressed) lobbyPath = []; if (lobbyPath.length > 0) { const way = lobbyPath[0]; const dx = way.x - me.x, dy = way.y - me.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist <= PATH_ARRIVE_THRESH) { lobbyPath.shift(); while (lobbyPath.length > 0) { const w2 = lobbyPath[0]; const ux = w2.x - me.x, uy = w2.y - me.y; if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break; lobbyPath.shift(); } if (lobbyPath.length === 0) { me.isWalking = false; redrawLobbyMap(); requestAnimationFrame(lobbyTick); return; } usePath = true; const next = lobbyPath[0]; accX = next.x - me.x; accY = next.y - me.y; } else { usePath = true; accX = dx; accY = dy; } if (usePath && (accX !== 0 || accY !== 0)) { if (Math.abs(accY) > Math.abs(accX)) me.direction = accY > 0 ? 'down' : 'up'; else me.direction = accX > 0 ? 'right' : 'left'; } } if (!usePath && !isChatFocused()) { if (keys['ArrowUp'] || keys['KeyW']) { accY = -1; me.direction = 'up'; } if (keys['ArrowDown'] || keys['KeyS']) { accY = 1; me.direction = 'down'; } if (keys['ArrowLeft'] || keys['KeyA']) { accX = -1; me.direction = 'left'; } if (keys['ArrowRight'] || keys['KeyD']) { accX = 1; me.direction = 'right'; } } if (accX !== 0 || accY !== 0) { const len = Math.sqrt(accX * accX + accY * accY) || 1; const step = Math.min(MOVE_SPEED, len); const nx = me.x + (accX / len) * step; const ny = me.y + (accY / len) * step; if (canWalkLobby(nx, ny, me.x, me.y)) { me.x = nx; me.y = ny; } else if (canWalkLobby(nx, me.y, me.x, me.y)) { me.x = nx; } else if (canWalkLobby(me.x, ny, me.x, me.y)) { me.y = ny; } me.x = Math.max(0, Math.min(w - 0.01, me.x)); me.y = Math.max(0, Math.min(h - 0.01, me.y)); const now = Date.now(); if (now - lastMoveSend > 80) { lastMoveSend = now; socket.emit('move', { x: me.x, y: me.y, direction: me.direction || 'down' }); } } } /* !suspectPickOverlayOpen */ const movedThisTick = !suspectPickOverlayOpen && (Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5); me.isWalking = suspectPickOverlayOpen ? false : (!!(accX !== 0 || accY !== 0) || lobbyPath.length > 0 || movedThisTick); updateHostStartGameButton(); redrawLobbyMap(); requestAnimationFrame(lobbyTick); } function lobbyOccupantCount() { return peers.size + lobbyBots.size; } function lobbyOccupantSlots() { return maxPlayers + lobbyBotSlotCount; } function lobbySpawnTileWalkable(tx, ty) { if (!mapData) return false; const w = mapData.width || 20; const h = mapData.height || 15; if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; const row = mapData.objects && mapData.objects[ty]; return !(row && row[tx] === 1); } function lobbySpawnFootprintFits(anchorX, anchorY) { if (!mapData) return false; const cellsW = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsW) || Number(mapData.characterCells) || 1))); const cellsH = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsH) || Number(mapData.characterCells) || 1))); const w = mapData.width || 20; const h = mapData.height || 15; const maxX = Math.min(w, anchorX + cellsW); const maxY = Math.min(h, anchorY + cellsH); for (let ty = anchorY; ty < maxY; ty++) { for (let tx = anchorX; tx < maxX; tx++) { if (!lobbySpawnTileWalkable(tx, ty)) return false; } } return true; } function parseLobbyPlayerSpawnsFromMapLobby(md) { const w = md.width || 20; const h = md.height || 15; const out = [null, null, null, null, null, null]; const raw = md && md.lobbyPlayerSpawns; if (!Array.isArray(raw)) return out; for (let i = 0; i < 6 && i < raw.length; i++) { const cell = raw[i]; if (!cell || typeof cell !== 'object') continue; const x = Math.floor(Number(cell.x)); const y = Math.floor(Number(cell.y)); if (!Number.isFinite(x) || !Number.isFinite(y)) continue; if (x < 0 || x >= w || y < 0 || y >= h) continue; out[i] = { x, y }; } return out; } function pickRandomLobbySpawnFromMap() { const fb = mapData.spawn || { x: 1, y: 1 }; const fx = Number.isFinite(Number(fb.x)) ? Number(fb.x) : 1; const fy = Number.isFinite(Number(fb.y)) ? Number(fb.y) : 1; const grid = mapData.spawnArea; if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; const w = mapData.width || 20; const h = mapData.height || 15; const pool = []; for (let yy = 0; yy < h; yy++) { const row = grid[yy]; if (!row) continue; for (let xx = 0; xx < w; xx++) { if (Number(row[xx]) === 1 && lobbySpawnFootprintFits(xx, yy)) pool.push({ x: xx, y: yy }); } } if (!pool.length) return { x: fx, y: fy }; const pick = pool[Math.floor(Math.random() * pool.length)]; return { x: pick.x, y: pick.y }; } /** สอดคล้อง server pickSpawnForJoin — P1…P6 ตามลำดับเข้า */ function pickLobbySpawnForJoin(joinOrderIndex) { if (!mapData) return { x: 1, y: 1 }; const mode = mapData.lobbySpawnMode; const ord = joinOrderIndex | 0; if (mode === 'slots6' && ord >= 6) return pickRandomLobbySpawnFromMap(); const j = Math.min(Math.max(0, ord), 5); if (mode === 'fixed' && mapData.spawn) { const fx = Number.isFinite(Number(mapData.spawn.x)) ? Math.floor(Number(mapData.spawn.x)) : 1; const fy = Number.isFinite(Number(mapData.spawn.y)) ? Math.floor(Number(mapData.spawn.y)) : 1; const w = mapData.width || 20; const h = mapData.height || 15; const x = Math.max(0, Math.min(w - 1, fx)); const y = Math.max(0, Math.min(h - 1, fy)); if (lobbySpawnFootprintFits(x, y)) return { x, y }; return pickRandomLobbySpawnFromMap(); } if (mode === 'slots6') { const slots = parseLobbyPlayerSpawnsFromMapLobby(mapData); const pick = slots[j]; if (pick && lobbySpawnFootprintFits(pick.x, pick.y)) return { x: pick.x, y: pick.y }; return pickRandomLobbySpawnFromMap(); } return pickRandomLobbySpawnFromMap(); } function pickLobbyBotSpawn(index) { const sp = pickLobbySpawnForJoin(index); return { x: sp.x, y: sp.y }; } function syncLobbyCaseBots() { if (!lobbyBotSlotCount || !mapData) { lobbyBots.clear(); return; } while (lobbyBots.size < lobbyBotSlotCount) { const i = lobbyBots.size; const id = LOBBY_BOT_PREFIX + i; const sp = pickLobbyBotSpawn(peers.size + i); const wanderDirs = [[0, -1], [0, 1], [-1, 0], [1, 0]]; const wd = wanderDirs[Math.floor(Math.random() * wanderDirs.length)]; const bot = { x: sp.x, y: sp.y, direction: 'down', nickname: 'บอท ' + (i + 1), ready: true, characterId: getStoredCharacterId(), isWalking: false, botWanderDx: wd[0], botWanderDy: wd[1], botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 900), }; lobbyBots.set(id, bot); var restored = applySavedLobbyBotColors(id, bot); if (restored) { if (bot.characterId) preloadLobbyTintedCharacter(bot.characterId, bot.colorTheme, bot.colorSkin, function () {}); if (mapData && canvas) drawLobbyMap(); } else { var botColorIdx = 1 + Math.floor(Math.random() * 8); var botSkinIdx = 1 + Math.floor(Math.random() * 3); var colorDone = false; var skinDone = false; function maybeFinishBotTint() { if (!colorDone || !skinDone) return; saveLobbyBotTint(id, bot); if (bot.characterId) preloadLobbyTintedCharacter(bot.characterId, bot.colorTheme, bot.colorSkin, function () {}); } rlSampleSwatch('color', botColorIdx, function (rgb) { colorDone = true; if (rgb) { bot.colorTheme = rgb; if (mapData && canvas) drawLobbyMap(); } maybeFinishBotTint(); }); rlSampleSwatch('skin', botSkinIdx, function (rgb) { skinDone = true; if (rgb) { bot.colorSkin = rgb; if (mapData && canvas) drawLobbyMap(); } maybeFinishBotTint(); }); } } while (lobbyBots.size > lobbyBotSlotCount) { const keys = [...lobbyBots.keys()]; lobbyBots.delete(keys[keys.length - 1]); } setTimeout(function () { try { preloadAllLobbyTintedOccupants(function () {}); } catch (e) { /* ignore */ } }, 400); } function updatePlayersHud() { const hudText = 'PLAYERS : ' + lobbyOccupantCount() + '/' + lobbyOccupantSlots(); if (roomPlayersHud) roomPlayersHud.textContent = hudText; const sph = document.getElementById('suspect-players-hud'); if (sph) sph.textContent = hudText; } function renderPeers() { if (!peersList) return; peersList.innerHTML = ''; const arr = [...peers.values()]; arr.forEach(p => { const div = document.createElement('div'); div.className = 'room-lobby-peer'; const name = p.nickname || p.id.slice(0, 8); const readyText = p.ready ? ' ✓ พร้อม' : ' ยังไม่พร้อม'; div.textContent = name + readyText; peersList.appendChild(div); }); if (quizModeActive) renderQuizScoreboard(lastQuizScores); } function getStoredCharacterId() { try { const v = (localStorage.getItem('gameCharacterId') || '').trim(); if (v && v !== 'Chatest') return v; // 'Chatest' = legacy placeholder ไม่มี sprite จริง return rlDefaultCharId || ''; // ไม่มีตัวที่เลือก → ใช้ตัว default ที่มี sprite จริง } catch (e) { return rlDefaultCharId || ''; } } function updateLobbyProfileAvatar() { const img = document.getElementById('room-lobby-profile-avatar'); if (!img) return; const id = getStoredCharacterId(); if (!id) { img.removeAttribute('src'); img.alt = (profileDisplayName || nick || 'ผู้เล่น'); return; } if (myTintTheme || myTintSkin) { updateRoomProfileAvatarTinted(); return; } // ใช้รูป tint สี กันถูกเขียนทับเป็นรูป baked try { const du = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id); if (du && typeof du === 'string' && du.indexOf('data:image/') === 0) { img.onload = null; img.onerror = null; img.src = du; img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; return; } } catch (e) { /* ignore */ } const urls = characterSpriteUrlCandidates(id, 'down'); let uidx = 0; img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; img.onerror = function () { uidx += 1; if (uidx >= urls.length) { img.onerror = null; img.removeAttribute('src'); return; } img.src = urls[uidx]; }; img.onload = function () { img.onerror = null; }; img.src = urls[0]; } function loadProfileDisplayName() { try { const saved = (localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) || '').trim(); if (saved) profileDisplayName = saved; } catch (e) { /* ignore */ } } function saveProfileDisplayName(nextName) { const safeName = String(nextName || '').trim(); if (!safeName) return; profileDisplayName = safeName; try { localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, safeName); } catch (e) { /* ignore */ } } function getProfileDisplayName() { const me = peers.get(socket.id); const serverName = me && typeof me.nickname === 'string' ? me.nickname.trim() : ''; if (serverName) return serverName; return profileDisplayName || nick || 'PLAYER'; } window.getProfileDisplayName = getProfileDisplayName; loadProfileDisplayName(); function padAgentId(n) { let s = String(n || ''); while (s.length < 6) s = '0' + s; return s; } function ensureAgentDisplayId() { const key = 'agentDisplayId'; try { let v = (localStorage.getItem(key) || '').trim(); if (!/^\d{6}$/.test(v)) { v = padAgentId(100000 + Math.floor(Math.random() * 899999)); localStorage.setItem(key, v); } return v; } catch (e) { return padAgentId(100000 + Math.floor(Math.random() * 899999)); } } function ensurePlayerKey() { try { let k = (localStorage.getItem(PLAYER_KEY) || '').trim(); if (!k || k.length < 8) { k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); localStorage.setItem(PLAYER_KEY, k); } return k; } catch (e) { return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); } } function getStoredCoins() { try { const raw = localStorage.getItem('jdCoins'); const n = Math.max(0, parseInt(raw, 10) || 0); return n; } catch (e) { return 0; } } function syncLobbyAvatarFromStorage() { updateLobbyProfileAvatar(); if (typeof redrawLobbyMap === 'function') redrawLobbyMap(); } class RoomLobbyProfileOverlay { constructor() { this.overlay = document.getElementById('room-lobby-profile-overlay'); this.backdrop = document.getElementById('room-lobby-profile-backdrop'); this.closeBtn = document.getElementById('room-lobby-profile-close'); this.dialogEl = document.querySelector('.room-lobby-profile-dialog'); this.innerFrameEl = document.querySelector('.room-lobby-profile-inner-frame'); this.coinsRowEl = document.querySelector('.room-lobby-profile-coins'); this.coinsLabelEl = document.querySelector('.room-lobby-profile-coins-label'); this.coinsIconEl = document.querySelector('.room-lobby-profile-coins-icon'); this.avatarEl = document.getElementById('room-lobby-profile-overlay-avatar'); this.nameEl = document.getElementById('room-lobby-profile-overlay-name'); this.agentEl = document.getElementById('room-lobby-profile-overlay-agent'); this.coinsEl = document.getElementById('room-lobby-profile-coins-val'); this.musicBtn = document.getElementById('room-lobby-profile-music'); this.sfxBtn = document.getElementById('room-lobby-profile-sfx'); this.archivGroupBtns = Array.from(document.querySelectorAll('.room-lobby-profile-archiv-group-btn')); this.archivListEl = document.getElementById('room-lobby-profile-achievements-list'); this.archivScrollEl = document.querySelector('.room-lobby-profile-archiv-scroll'); this.archivScrollBarEl = document.querySelector('.room-lobby-profile-archiv-scroll-bar'); this.editBtn = document.getElementById('room-lobby-profile-edit-btn'); this.activeArchivGroup = 1; this._boundSyncProfileFrameScale = () => this._syncProfileFrameScale(); this._profileFrameScaleObserver = null; this._bindEvents(); this._initProfileFrameScaleObserver(); this._syncProfileFrameScale(); this._applySwitchVisual(this.musicBtn, true); this._applySwitchVisual(this.sfxBtn, false); this._setArchivGroup(1); this._syncCoinsFromServer(); } _bindEvents() { this.backdrop?.addEventListener('click', () => this.close()); this.closeBtn?.addEventListener('click', () => this.close()); this.musicBtn?.addEventListener('click', () => this._toggleChip(this.musicBtn)); this.sfxBtn?.addEventListener('click', () => this._toggleChip(this.sfxBtn)); this.archivGroupBtns.forEach((btn) => { btn.addEventListener('click', () => { const raw = parseInt(btn.getAttribute('data-group'), 10); this._setArchivGroup(Number.isFinite(raw) ? raw : 1); }); }); this.editBtn?.addEventListener('click', () => this._editDisplayName()); this.archivListEl?.addEventListener('scroll', () => this._syncArchivScroll(), { passive: true }); window.addEventListener('resize', this._boundSyncProfileFrameScale); } _syncArchivScroll() { if (!this.archivListEl || !this.archivScrollEl || !this.archivScrollBarEl) return; const total = this.archivListEl.scrollHeight || 0; const visible = this.archivListEl.clientHeight || 0; const overflow = total > visible + 1; this.archivScrollEl.classList.toggle('is-hidden', !overflow); this.archivScrollEl.setAttribute('aria-hidden', overflow ? 'false' : 'true'); if (!overflow) return; const max = Math.max(1, total - visible); const ratio = Math.max(0, Math.min(1, this.archivListEl.scrollTop / max)); const barPct = Math.max(18, Math.min(42, (visible / Math.max(1, total)) * 100)); this.archivScrollBarEl.style.height = barPct.toFixed(2) + '%'; this.archivScrollBarEl.style.top = ((100 - barPct) * ratio).toFixed(2) + '%'; } _initProfileFrameScaleObserver() { if (!this.innerFrameEl) return; if (typeof ResizeObserver === 'undefined') return; this._profileFrameScaleObserver = new ResizeObserver(() => this._syncProfileFrameScale()); this._profileFrameScaleObserver.observe(this.innerFrameEl); } _syncProfileFrameScale() { let dialogScale = 1; if (this.dialogEl) { const rect = this.dialogEl.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { const scaleW = rect.width / 1701; const scaleH = rect.height / 951; dialogScale = Math.max(0.35, Math.min(1, Math.min(scaleW, scaleH))); this.dialogEl.style.setProperty('--rp-scale', String(dialogScale.toFixed(4))); } } if (!this.innerFrameEl) return; this.innerFrameEl.style.setProperty('--pf-scale', String(dialogScale.toFixed(4))); this._syncArchivScroll(); } _toggleChip(btn) { if (!btn) return; const current = String(btn.getAttribute('data-on') || '').toLowerCase() === 'true'; this._applySwitchVisual(btn, !current); } _applySwitchVisual(btn, isOn) { if (!btn) return; const on = !!isOn; btn.setAttribute('data-on', on ? 'true' : 'false'); const img = btn.querySelector('img'); if (!img) return; const nextSrc = on ? 'img/03-6-Profile/btn-on.png' : 'img/03-6-Profile/btn-off.png'; img.src = nextSrc; img.alt = on ? 'เปิด' : 'ปิด'; } _setArchivGroup(groupIndex) { let idx = parseInt(groupIndex, 10); if (!Number.isFinite(idx) || idx < 1 || idx > 5) idx = 1; this.activeArchivGroup = idx; this.archivGroupBtns.forEach((btn) => { const raw = parseInt(btn.getAttribute('data-group'), 10); const id = Number.isFinite(raw) ? raw : 1; const active = id === idx; btn.classList.toggle('is-active', active); const img = btn.querySelector('img'); if (!img) return; img.src = active ? 'img/03-6-Profile/archiv-group-0' + id + '-a.png' : 'img/03-6-Profile/archiv-group-0' + id + '.png'; }); this._syncArchivScroll(); } setProfileData(data) { const payload = data || {}; const avatarSrc = payload.avatarSrc || document.getElementById('room-lobby-profile-avatar')?.src || ''; if (this.avatarEl && avatarSrc) this.avatarEl.src = avatarSrc; if (this.nameEl) this.nameEl.textContent = payload.displayName || getProfileDisplayName(); const agentId = payload.agentIdLabel || ('AGENT ID : ' + ensureAgentDisplayId()); if (this.agentEl) this.agentEl.textContent = agentId; const coinsVal = payload.coins != null ? payload.coins : getStoredCoins(); if (this.coinsEl) this.coinsEl.textContent = String(Math.max(0, parseInt(coinsVal, 10) || 0)); } _syncCoinsFromServer() { const key = ensurePlayerKey(); const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins.php') : '/Admin/api/player-coins.php') + '?playerKey=' + encodeURIComponent(key); fetch(url, { credentials: 'omit' }) .then((r) => r.json()) .then((d) => { if (!d || !d.ok) return; const coins = Math.max(0, parseInt(d.coins, 10) || 0); try { localStorage.setItem('jdCoins', String(coins)); } catch (e) { /* ignore */ } if (this.coinsEl) this.coinsEl.textContent = String(coins); }) .catch(() => { /* ignore */ }); } _editDisplayName() { const self = this; const currentName = getProfileDisplayName(); function afterRename(nextName) { if (!nextName) return; saveProfileDisplayName(nextName); self.setProfileData({ displayName: nextName }); updateLobbyProfileAvatar(); renderPeers(); updatePlayersHud(); refreshHostConsoleOverlayIfOpen(); } if (window.jdDisplayName && typeof window.jdDisplayName.openEditor === 'function') { window.jdDisplayName.openEditor({ initialValue: currentName }).then(function (draft) { if (draft == null) return; if (typeof window.jdApplyDisplayNameChange === 'function') { const result = window.jdApplyDisplayNameChange(draft, { socket }); if (result.ok) afterRename(result.displayName); return; } afterRename(draft); }); return; } const draft = window.prompt('แก้ไขชื่อผู้เล่น', currentName || ''); if (draft == null) return; const nextName = String(draft).trim(); if (!nextName) { alert('กรุณากรอกชื่อผู้เล่น'); return; } if (typeof window.jdApplyDisplayNameChange === 'function') { const result = window.jdApplyDisplayNameChange(nextName, { socket }); if (!result.ok) return; afterRename(result.displayName); return; } afterRename(nextName); } open(data) { if (!this.overlay) return; this.setProfileData(data); this._syncCoinsFromServer(); this.overlay.classList.remove('is-hidden'); this.overlay.setAttribute('aria-hidden', 'false'); this._syncProfileFrameScale(); requestAnimationFrame(() => this._syncProfileFrameScale()); requestAnimationFrame(() => requestAnimationFrame(() => this._syncProfileFrameScale())); this._syncArchivScroll(); this.closeBtn?.focus(); } close() { if (!this.overlay) return; this.overlay.classList.add('is-hidden'); this.overlay.setAttribute('aria-hidden', 'true'); } toggle(force, data) { if (!this.overlay) return; const shouldOpen = typeof force === 'boolean' ? force : this.overlay.classList.contains('is-hidden'); if (shouldOpen) this.open(data); else this.close(); } } const roomLobbyProfileOverlay = new RoomLobbyProfileOverlay(); window.RoomLobbyProfileOverlay = RoomLobbyProfileOverlay; window.roomLobbyProfileOverlay = roomLobbyProfileOverlay; class HostConsoleOverlay { constructor() { this.overlay = document.getElementById('host-console-overlay'); this.backdrop = document.getElementById('host-console-backdrop'); this.dialogEl = document.querySelector('.room-lobby-host-console-dialog'); this.closeBtn = document.getElementById('host-console-close'); this.confirmBtn = document.getElementById('host-console-confirm'); this.decBtn = document.getElementById('host-console-max-dec'); this.incBtn = document.getElementById('host-console-max-inc'); this.maxValueEl = document.getElementById('host-console-max-value'); this.summaryEl = document.getElementById('host-console-summary'); this.membersListEl = document.getElementById('host-console-members-list'); this.pendingMaxPlayers = maxPlayers; this._boundSyncScale = () => this._syncScale(); this._scaleObserver = null; this._bindEvents(); this._initScaleObserver(); this._syncScale(); } _bindEvents() { // Close only via the X hit area button (and Esc key handler). this.closeBtn?.addEventListener('click', () => this.close()); this.decBtn?.addEventListener('click', () => this._changeMax(-1)); this.incBtn?.addEventListener('click', () => this._changeMax(1)); this.confirmBtn?.addEventListener('click', () => this._confirm()); window.addEventListener('resize', this._boundSyncScale); } _initScaleObserver() { if (!this.dialogEl) return; if (typeof ResizeObserver === 'undefined') return; this._scaleObserver = new ResizeObserver(() => this._syncScale()); this._scaleObserver.observe(this.dialogEl); } _syncScale() { if (!this.dialogEl) return; const w = this.dialogEl.clientWidth || 0; const h = this.dialogEl.clientHeight || 0; if (!w || !h) return; const scaleW = w / 990; const scaleH = h / 881; const scale = Math.max(0.5, Math.min(1.08, Math.min(scaleW, scaleH))); this.dialogEl.style.setProperty('--hc-scale', String(scale.toFixed(4))); } _isHost() { return hostId === socket.id; } _getPlayersCount() { return Math.max(0, peers.size); } _changeMax(delta) { if (!this._isHost()) return; const humans = this._getPlayersCount(); const minAllowed = Math.max(humans, 2); const next = this.pendingMaxPlayers + delta; this.pendingMaxPlayers = Math.max(minAllowed, Math.min(LOBBY_SLOT_TOTAL, next)); this._render(); } _kickMember(row) { if (!this._isHost()) return; if (!row || !row.id || row.isHost) return; socket.emit('host-console-kick-peer', { targetId: row.id }, (res) => { if (!res || !res.ok) { var msg = (res && res.error) ? res.error : 'ลบสมาชิกไม่สำเร็จ'; try { alert(msg); } catch (e) { /* ignore */ } return; } }); } _renderMembers() { if (!this.membersListEl) return; this.membersListEl.textContent = ''; const rows = [...peers.entries()].map(([id, p]) => ({ id, isHost: id === hostId, name: (p && p.nickname) ? String(p.nickname).trim() : id.slice(0, 8) })); rows.sort((a, b) => { if (a.isHost && !b.isHost) return -1; if (!a.isHost && b.isHost) return 1; return a.name.localeCompare(b.name, 'th'); }); const frag = document.createDocumentFragment(); rows.forEach((row) => { const li = document.createElement('li'); li.className = 'room-lobby-host-console-member-item' + (row.isHost ? ' is-host' : ''); const name = document.createElement('span'); name.className = 'room-lobby-host-console-member-name'; name.textContent = row.isHost ? (row.name + ' (Host)') : row.name; li.appendChild(name); if (!row.isHost) { const delBtn = document.createElement('button'); delBtn.type = 'button'; delBtn.className = 'room-lobby-host-console-delete-btn'; delBtn.disabled = !this._isHost(); delBtn.setAttribute('aria-label', 'ลบสมาชิก ' + row.name); const delImg = document.createElement('img'); delImg.src = 'img/03-4-Host-Console/host-console-delete.png'; delImg.alt = ''; delImg.decoding = 'async'; delBtn.appendChild(delImg); delBtn.addEventListener('click', () => this._kickMember(row)); li.appendChild(delBtn); } frag.appendChild(li); }); this.membersListEl.appendChild(frag); } _render() { const players = this._getPlayersCount(); const bots = Math.max(0, this.pendingMaxPlayers - players); if (this.maxValueEl) this.maxValueEl.textContent = String(this.pendingMaxPlayers); if (this.summaryEl) this.summaryEl.textContent = 'สรุปจำนวน : ' + players + ' ผู้เล่น + ' + bots + ' Bot'; this._renderMembers(); const canEdit = this._isHost(); if (this.decBtn) this.decBtn.disabled = !canEdit; if (this.incBtn) this.incBtn.disabled = !canEdit; if (this.confirmBtn) this.confirmBtn.disabled = !canEdit; } _confirm() { if (!this._isHost()) return; const humans = this._getPlayersCount(); const total = Math.min(LOBBY_SLOT_TOTAL, Math.max(humans, this.pendingMaxPlayers)); const bots = total - humans; lobbyBotSlotCount = bots; maxPlayers = Math.max(humans, total - bots); this.pendingMaxPlayers = total; syncLobbyCaseBots(); updatePlayersHud(); if (mapData && canvas) drawLobbyMap(); const self = this; socket.emit('host-console-apply', { totalSlots: total, botSlotCount: bots, maxPlayers: maxPlayers }, function (res) { if (!res || !res.ok) { var msg = (res && res.error) ? res.error : 'บันทึกการตั้งค่าไม่สำเร็จ'; try { alert(msg); } catch (e) { /* ignore */ } return; } if (res.maxPlayers != null) maxPlayers = res.maxPlayers; if (res.botSlotCount != null) { lobbyBotSlotCount = Math.max(0, parseInt(res.botSlotCount, 10) || 0); syncLobbyCaseBots(); updatePlayersHud(); if (mapData && canvas) drawLobbyMap(); } self.close(); }); } open() { if (!this.overlay) return; if (!this._isHost()) return; const totalNow = Math.min(LOBBY_SLOT_TOTAL, Math.max(peers.size, lobbyOccupantCount())); this.pendingMaxPlayers = totalNow > 0 ? totalNow : Math.min(LOBBY_SLOT_TOTAL, Math.max(2, (maxPlayers || 2) + (lobbyBotSlotCount || 0))); this._render(); this.overlay.classList.remove('is-hidden'); this.overlay.setAttribute('aria-hidden', 'false'); this._syncScale(); requestAnimationFrame(() => this._syncScale()); this.closeBtn?.focus(); } close() { if (!this.overlay) return; this.overlay.classList.add('is-hidden'); this.overlay.setAttribute('aria-hidden', 'true'); } } const hostConsoleOverlay = new HostConsoleOverlay(); window.hostConsoleOverlay = hostConsoleOverlay; function refreshHostConsoleOverlayIfOpen() { if (!hostConsoleOverlay || !hostConsoleOverlay.overlay) return; if (hostConsoleOverlay.overlay.classList.contains('is-hidden')) return; hostConsoleOverlay._render(); } function applyPeerDisplayNameUpdate(data) { if (!data || !data.id) return; const p = peers.get(data.id); if (p) p.nickname = data.nickname; if (data.id === socket.id) { saveProfileDisplayName(data.nickname); try { localStorage.setItem('playerName', data.nickname); } catch (e) { /* ignore */ } if (window.jdDisplayName && typeof window.jdDisplayName.syncDom === 'function') { window.jdDisplayName.syncDom(data.nickname); } } renderPeers(); updatePlayersHud(); refreshHostConsoleOverlayIfOpen(); } socket.on('peer-display-name', applyPeerDisplayNameUpdate); socket.on('connect', () => { socket.emit('join-space', { spaceId, nickname: getProfileDisplayName(), characterId: getStoredCharacterId() }, (res) => { if (!res || !res.ok) { var joinErr = (res && res.error) || 'เข้าไม่ได้'; if (/เริ่มคดี|ไม่รับผู้เล่น/.test(joinErr)) { alert(joinErr + '\n\nถ้าคุณเคยอยู่ในห้องนี้แล้ว: ให้ใช้ nick ในลิงก์ให้ตรงกับชื่อที่ใช้ตอนเข้าห้องครั้งแรก แล้วรีเฟรชหน้า'); } else { alert(joinErr); } location.href = DESIGN_MENU; return; } mapData = res.mapData; roomJoinReady = true; maybeHideRoomLoading(); markRoomCzInteractiveCell(); markHostHcInteractiveCell(); if (ROOM_CZ_SPOT) appendLobbySystemChat('— เดินไปช่องเขียว แล้วกด F เพื่อเปิดห้องแต่งตัว'); if (ROOM_HC_SPOT && hostId === socket.id) appendLobbySystemChat('— เดินไปจุดโฮสต์ (ไอคอนชมพู) แล้วกด F เพื่อเปิด Host Console'); clientLobbyMapId = res.mapId != null ? res.mapId : null; hostId = res.hostId || null; spaceName = (res.spaceName || '').trim() || spaceId; (res.peers || []).forEach(p => { peers.set(p.id, normalizeLobbyPeerFromServer(p, mapData)); }); if (mapData && mapData.backgroundImage) { mapBackgroundImg = new Image(); mapBackgroundImg.src = mapData.backgroundImage; mapBackgroundImg.onload = () => { resizeAndDraw(); }; } if (!mapData.interactive) mapData.interactive = []; if (!mapData.startGameArea) mapData.startGameArea = []; if (!mapData.quizTrueArea) mapData.quizTrueArea = []; if (!mapData.quizFalseArea) mapData.quizFalseArea = []; if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; maxPlayers = res.maxPlayers != null ? res.maxPlayers : 10; lobbyBotSlotCount = res.botSlotCount != null ? Math.max(0, parseInt(res.botSlotCount, 10) || 0) : 0; if (lobbyBotSlotCount === 0 && maxPlayers > 0 && maxPlayers < 6) { lobbyBotSlotCount = 6 - maxPlayers; } if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); updatePlayersHud(); syncLobbyBUiChrome(); if (wasPageReload() && (hostId === socket.id || peers.size <= 1)) { window.location.replace(CREATE_ROOM_URL); return; } renderPeers(); updateLobbyProfileAvatar(); var meAfterJoin = peers.get(socket.id); if (readyCheck && meAfterJoin) { readyCheck.checked = !!meAfterJoin.ready; updateReadyLabelVisual(); } ensureReadyControlEnabled(); if (res.cardMinigames) setSuspectCardMinigames(res.cardMinigames); if (Array.isArray(res.suspectProgress)) setSuspectProgress(res.suspectProgress); serverSuspectPhaseActive = !!res.suspectPhaseActive; if (res.suspectPhaseActive) { openSuspectOverlay(res.suspectPickIndex != null ? res.suspectPickIndex : 0); } else { updateSuspectFloatingOpenBtn(); } if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID || res.suspectPhaseActive) { try { var uLobbyB = new URL(window.location.href); uLobbyB.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID); history.replaceState({}, '', uLobbyB.pathname + uLobbyB.search); } catch (eMap) { /* ignore */ } } const isHost = hostId === socket.id; if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none'; if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline'; if (isHost && playMapSelect) { fetch(SERVER + '/api/maps') .then(r => r.json()) .then(list => { playMapSelect.innerHTML = ''; (list || []).forEach(m => { const opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.name || m.id; if (m.name) opt.dataset.mapName = m.name; playMapSelect.appendChild(opt); }); }) .catch(() => { playMapSelect.innerHTML = ''; }); } resizeAndDraw(); updateHostStartGameButton(); syncLobbyBUiChrome(); lobbyTick(); setTimeout(() => { unlockAudio(); const hearBtn = document.getElementById('btn-hear'); if (hearBtn) { hearBtn.textContent = '✓ เปิดรับเสียงแล้ว'; hearBtn.disabled = true; } if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return; navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => { stream.getTracks().forEach(t => t.stop()); const permBtn = document.getElementById('btn-voice-permission'); if (permBtn) { permBtn.textContent = '✓ อนุญาตแล้ว'; permBtn.disabled = true; } if (typeof populateVoiceDevices === 'function') populateVoiceDevices(); }).catch(() => {}); }, 600); }); }); const LERP = 0.2; socket.on('user-joined', (data) => { peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); updatePlayersHud(); renderPeers(); redrawLobbyMap(); }); socket.on('user-left', (data) => { if (typeof closePeer === 'function') closePeer(data.id); peers.delete(data.id); updatePlayersHud(); renderPeers(); redrawLobbyMap(); }); socket.on('host-console-kicked', (data) => { var msg = (data && data.message) ? String(data.message) : 'คุณถูก Host นำออกจากห้อง'; try { alert(msg); } catch (e) { /* ignore */ } window.location.href = CREATE_ROOM_URL; }); socket.on('host-console-settings', (data) => { if (!data || typeof data !== 'object') return; if (data.maxPlayers != null) maxPlayers = Math.max(peers.size, parseInt(data.maxPlayers, 10) || peers.size); if (data.botSlotCount != null) { lobbyBotSlotCount = Math.max(0, parseInt(data.botSlotCount, 10) || 0); syncLobbyCaseBots(); } updatePlayersHud(); refreshHostConsoleOverlayIfOpen(); if (mapData && canvas) drawLobbyMap(); }); socket.on('peer-ready', (data) => { const p = peers.get(data.id); if (p) { p.ready = data.ready; renderPeers(); redrawLobbyMap(); } if (data && data.id === socket.id && readyCheck) { readyCheck.checked = !!data.ready; updateReadyLabelVisual(); } ensureReadyControlEnabled(); }); socket.on('user-move', (data) => { const p = peers.get(data.id); if (p) { if (data.id === socket.id) { if (data.x != null) { const x = Number(data.x); if (Number.isFinite(x)) { p.x = x; p.tx = x; } } if (data.y != null) { const y = Number(data.y); if (Number.isFinite(y)) { p.y = y; p.ty = y; } } } else { if (data.x != null) p.tx = data.x; if (data.y != null) p.ty = data.y; } if (data.direction) p.direction = data.direction; if (data.characterId != null) p.characterId = data.characterId; redrawLobbyMap(); } }); function hideQuizScoreboardAndFeedback() { var fb = document.getElementById('quiz-feedback-banner'); if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; } } function renderQuizScoreboard(scores) { var ov = document.getElementById('quiz-game-overlay'); var ul = document.getElementById('quiz-scoreboard-list'); if (!ul) return; if (!quizModeActive) { if (ov) ov.classList.add('is-hidden'); return; } if (ov) ov.classList.remove('is-hidden'); var merged = scores && typeof scores === 'object' ? Object.assign({}, scores) : {}; peers.forEach(function (_, id) { if (merged[id] == null) merged[id] = 0; }); ul.textContent = ''; var rows = []; peers.forEach(function (p, id) { rows.push({ id: id, nick: (p && p.nickname) ? String(p.nickname) : id, sc: merged[id] != null ? merged[id] : 0, characterId: p && p.characterId ? String(p.characterId) : '', }); }); rows.sort(function (a, b) { if (b.sc !== a.sc) return b.sc - a.sc; return a.nick.localeCompare(b.nick, 'th'); }); rows.forEach(function (row) { var li = document.createElement('li'); if (row.id === socket.id) li.className = 'quiz-scoreboard-me'; var av = document.createElement(row.characterId ? 'img' : 'div'); av.className = 'quiz-sb-avatar'; if (row.characterId) { av.alt = ''; var duSb = ''; try { duSb = localStorage.getItem(LOBBY_IDLE_DOWN_LS + row.characterId) || ''; } catch (eDu) { duSb = ''; } if (duSb && duSb.indexOf('data:image/') === 0) { av.src = duSb; } else { var urlsSb = characterSpriteUrlCandidates(row.characterId, 'down'); var qi = 0; av.onerror = function () { qi += 1; if (qi >= urlsSb.length) { av.onerror = null; av.removeAttribute('src'); return; } av.src = urlsSb[qi]; }; av.src = urlsSb[0]; } } var meta = document.createElement('div'); meta.className = 'quiz-sb-meta'; var spName = document.createElement('span'); spName.className = 'quiz-scoreboard-name'; spName.textContent = row.nick; var spVal = document.createElement('span'); spVal.className = 'quiz-scoreboard-val'; spVal.textContent = String(row.sc); meta.appendChild(spName); meta.appendChild(spVal); li.appendChild(av); li.appendChild(meta); ul.appendChild(li); }); } function initQuizScoreboardZeros() { if (!quizModeActive) return; lastQuizScores = {}; peers.forEach(function (_, id) { lastQuizScores[id] = 0; }); renderQuizScoreboard(lastQuizScores); } function showQuizRoundFeedback(r) { var el = document.getElementById('quiz-feedback-banner'); if (!el || !r || !r.results) return; var mine = null; for (var i = 0; i < r.results.length; i++) { if (r.results[i].id === socket.id) { mine = r.results[i]; break; } } if (!mine) return; el.classList.remove('is-hidden'); if (mine.right) { el.className = 'quiz-feedback-banner quiz-feedback-ok'; el.textContent = 'คุณตอบถูก · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม'; } else { el.className = 'quiz-feedback-banner quiz-feedback-bad'; if (mine.choice == null) { el.textContent = 'คุณไม่ได้ยืนในโซนตอบ (จริง/เท็จ) — นับเป็นผิด'; } else { el.textContent = 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีกในเกมนี้'; } } if (typeof window.__quizFeedbackHideT === 'number') clearTimeout(window.__quizFeedbackHideT); window.__quizFeedbackHideT = setTimeout(function () { el.classList.add('is-hidden'); }, 4200); } function showQuizOverlay() { var ov = document.getElementById('quiz-game-overlay'); if (ov) ov.classList.remove('is-hidden'); } function hideQuizOverlay() { var ov = document.getElementById('quiz-game-overlay'); if (ov) ov.classList.add('is-hidden'); if (quizTimerInterval) { clearInterval(quizTimerInterval); quizTimerInterval = null; } var panel = document.getElementById('quiz-map-question-panel'); if (panel) { panel.classList.add('is-hidden'); panel.setAttribute('aria-hidden', 'true'); } hideQuizScoreboardAndFeedback(); } function updateQuizTimerDisplay() { var el = document.getElementById('quiz-game-timer'); if (!el || !quizPhaseEndsAt) return; var s = Math.max(0, Math.ceil((quizPhaseEndsAt - Date.now()) / 1000)); el.textContent = String(s); } socket.on('quiz-phase', function (p) { if (!p) return; if (p.text) lastQuizQuestionText = p.text; quizPhaseLocal = p.phase; quizPhaseEndsAt = p.endsAt || 0; lastQuizQIdx = typeof p.questionIndex === 'number' ? p.questionIndex : 0; lastQuizQTotal = typeof p.questionTotal === 'number' ? p.questionTotal : 0; var phaseEl = document.getElementById('quiz-game-phase-label'); var qEl = document.getElementById('quiz-game-question'); var numEl = document.getElementById('quiz-hud-quiz-num'); if (numEl) { numEl.textContent = '- Quiz ' + (p.questionIndex || '—') + ' / ' + (p.questionTotal || '—') + ' -'; } if (phaseEl) { phaseEl.textContent = p.phase === 'read' ? 'อ่านคำถาม' : 'เดินเข้าโซน SAFE (จริง) หรือ SCAM (เท็จ)'; } if (qEl) qEl.textContent = lastQuizQuestionText || ''; showQuizOverlay(); if (quizTimerInterval) clearInterval(quizTimerInterval); quizTimerInterval = setInterval(updateQuizTimerDisplay, 200); updateQuizTimerDisplay(); if (Object.keys(lastQuizScores).length) renderQuizScoreboard(lastQuizScores); redrawLobbyMap(); }); socket.on('quiz-player-state', function (st) { if (!st) return; quizPlayerLocal = { cannotTrue: !!st.cannotTrue, cannotFalse: !!st.cannotFalse, eliminated: !!st.eliminated, score: typeof st.score === 'number' ? st.score : (quizPlayerLocal.score || 0), }; redrawLobbyMap(); }); socket.on('quiz-result', function (r) { if (!r) return; var correct = r.correctTrue ? 'เฉลยข้อนี้: ถูก = จริง' : 'เฉลยข้อนี้: ถูก = เท็จ'; var line = '— ข้อ ' + (r.questionIndex || '') + ' ' + correct; if (r.allWrong) line += ' · ทุกคนผิด — จบเกม'; appendLobbySystemChat(line); if (r.results) { r.results.forEach(function (row) { if (!row.right) quizPeersLocked[row.id] = true; }); } if (r.scores) { lastQuizScores = r.scores; renderQuizScoreboard(lastQuizScores); } showQuizRoundFeedback(r); if (r.results) { r.results.forEach(function (row) { if (row.id === socket.id) { var det = row.right ? 'ถูก' : 'ผิด'; if (!row.right && row.choice == null) det += ' (ไม่ได้ยืนในโซน)'; appendLobbySystemChat('— คุณตอบ' + det + ' · คะแนนรวม ' + (typeof row.score === 'number' ? row.score : 0)); } }); } redrawLobbyMap(); }); socket.on('quiz-ended', function (d) { quizModeActive = false; quizPhaseLocal = null; quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; quizPeersLocked = {}; lastQuizQuestionText = ''; lastQuizScores = {}; try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ } hideQuizOverlay(); appendLobbySystemChat('— ' + (d && d.message ? d.message : 'จบเกมตอบคำถาม')); if (d && d.returnToLobbyB) { serverSuspectPhaseActive = true; updateSuspectFloatingOpenBtn(); } redrawLobbyMap(); }); socket.on('detective-minigame-ended', function (data) { applyDetectiveReturnToLobbyB(data || {}); }); socket.on('lobby-interact', (data) => { if (!data || data.x == null || data.y == null) return; lobbyInteractPulse = { x: data.x, y: data.y, until: Date.now() + 700 }; const name = (data.nickname || 'ผู้เล่น').trim(); appendLobbySystemChat('★ ' + name + ' โต้ตอบกับจุดในห้อง'); redrawLobbyMap(); }); socket.on('chat', (data) => { const el = document.getElementById('chat-messages'); if (!el) return; const div = document.createElement('div'); const isMe = data.id === socket.id; div.className = 'chat-msg ' + (isMe ? 'chat-msg-mine' : 'chat-msg-other'); div.textContent = (data.nickname || '') + ': ' + (data.text || ''); el.appendChild(div); el.scrollTop = 1e9; }); const chatForm = document.getElementById('chat-form'); const chatInput = document.getElementById('chat-input'); if (chatForm && chatInput) { chatForm.addEventListener('submit', function (e) { e.preventDefault(); var text = (chatInput.value || '').trim(); if (text) { socket.emit('chat', text); chatInput.value = ''; } }); } const chatCloseBtn = document.getElementById('chat-close-btn'); const chatToggleImg = document.getElementById('chat-toggle-img'); if (chatCloseBtn && chatToggleImg) { function updateChatToggleIcon() { const panel = document.querySelector('.room-lobby-chat-peers'); const collapsed = panel && panel.classList.contains('chat-panel-collapsed'); chatToggleImg.src = collapsed ? SERVER + '/img/btn-chat.png' : SERVER + '/img/chat-close-btn.png'; chatCloseBtn.setAttribute('title', collapsed ? 'เปิดแชท' : 'ปิดแชท'); chatCloseBtn.setAttribute('aria-label', collapsed ? 'เปิดแชท' : 'ปิดแชท'); } chatCloseBtn.addEventListener('click', function () { const panel = document.querySelector('.room-lobby-chat-peers'); if (panel) { panel.classList.toggle('chat-panel-collapsed'); const collapsed = panel.classList.contains('chat-panel-collapsed'); panel.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); updateChatToggleIcon(); } }); const peerPanelInit = document.querySelector('.room-lobby-chat-peers'); if (peerPanelInit) { peerPanelInit.setAttribute('aria-expanded', peerPanelInit.classList.contains('chat-panel-collapsed') ? 'false' : 'true'); } updateChatToggleIcon(); } const aiChatCloseBtn = document.getElementById('ai-chat-close-btn'); const aiChatPanel = document.getElementById('ai-chat-panel'); const aiChatToggleImg = document.getElementById('ai-chat-toggle-img'); if (aiChatCloseBtn && aiChatPanel) { function updateAiChatToggleIcon() { const collapsed = aiChatPanel.classList.contains('chat-panel-collapsed'); if (aiChatToggleImg) aiChatToggleImg.src = collapsed ? MAIN_LOBBY_AI_BTN_ICON : SERVER + '/img/chat-close-btn.png'; if (aiChatToggleImg) aiChatToggleImg.alt = collapsed ? 'เทพความรู้' : 'ปิด'; aiChatCloseBtn.setAttribute('title', collapsed ? 'เปิดแชท AI' : 'ปิดแชท AI'); aiChatCloseBtn.setAttribute('aria-label', aiChatCloseBtn.getAttribute('title')); } aiChatCloseBtn.addEventListener('click', function () { aiChatPanel.classList.toggle('chat-panel-collapsed'); const collapsed = aiChatPanel.classList.contains('chat-panel-collapsed'); aiChatPanel.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); updateAiChatToggleIcon(); }); aiChatPanel.setAttribute('aria-expanded', aiChatPanel.classList.contains('chat-panel-collapsed') ? 'false' : 'true'); updateAiChatToggleIcon(); } const aiChatForm = document.getElementById('ai-chat-form'); const aiChatInput = document.getElementById('ai-chat-input'); const aiChatSendBtn = aiChatForm ? aiChatForm.querySelector('button[type="submit"]') : null; var aiChatLoadingEl = null; function appendAiMessage(text, isAi) { var el = document.getElementById('ai-chat-messages'); if (!el) return; if (isAi) removeAiChatLoading(); var div = document.createElement('div'); div.className = 'chat-msg ' + (isAi ? 'chat-msg-ai' : 'chat-msg-mine'); div.textContent = (isAi ? 'AI: ' : '') + text; el.appendChild(div); el.scrollTop = 1e9; } function showAiChatLoading() { var el = document.getElementById('ai-chat-messages'); if (!el || aiChatLoadingEl) return; aiChatLoadingEl = document.createElement('div'); aiChatLoadingEl.className = 'chat-msg chat-msg-ai ai-chat-typing'; aiChatLoadingEl.setAttribute('aria-label', 'กำลังพิมพ์'); aiChatLoadingEl.innerHTML = ''; el.appendChild(aiChatLoadingEl); el.scrollTop = 1e9; } function removeAiChatLoading() { if (aiChatLoadingEl && aiChatLoadingEl.parentNode) { aiChatLoadingEl.parentNode.removeChild(aiChatLoadingEl); aiChatLoadingEl = null; } } function setAiChatWaiting(waiting) { if (aiChatInput) aiChatInput.disabled = waiting; if (aiChatSendBtn) aiChatSendBtn.disabled = waiting; if (waiting) showAiChatLoading(); else removeAiChatLoading(); } if (aiChatForm && aiChatInput) { aiChatForm.addEventListener('submit', function (e) { e.preventDefault(); var text = (aiChatInput.value || '').trim(); if (!text) return; appendAiMessage(text, false); setAiChatWaiting(true); aiChatInput.value = ''; fetch(SERVER + '/api/ai-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, sessionId: socket.id }), }) .then(function (r) { return r.json(); }) .then(function (data) { if (data.response != null) appendAiMessage(data.response, true); else if (data.error) appendAiMessage('[ข้อผิดพลาด: ' + data.error + ']', true); }) .catch(function () { appendAiMessage('[ส่งไม่สำเร็จ]', true); }) .finally(function () { setAiChatWaiting(false); }); }); } let localStream = null; let voiceActivityInterval = null; let voiceAnalyserContext = null; let voiceAnalyser = null; const peerConnections = {}; const remoteAudios = {}; let audioUnlocked = false; function startVoiceActivityDetection(stream) { if (!stream || !stream.getTracks().length) return; try { var ctx = new (window.AudioContext || window.webkitAudioContext)(); if (ctx.state === 'suspended') ctx.resume().catch(function () {}); var src = ctx.createMediaStreamSource(stream); var analyser = ctx.createAnalyser(); analyser.fftSize = 256; analyser.smoothingTimeConstant = 0.5; src.connect(analyser); voiceAnalyserContext = ctx; voiceAnalyser = analyser; var dataArray = new Uint8Array(analyser.frequencyBinCount); var lastEmit = 0; voiceActivityInterval = setInterval(function () { if (!voiceAnalyser || !stream.active) return; voiceAnalyser.getByteFrequencyData(dataArray); var sum = 0; for (var i = 0; i < dataArray.length; i++) sum += dataArray[i]; var avg = sum / dataArray.length; var level = Math.min(1, avg / 60); if (level > 0.08 && Date.now() - lastEmit > 80) { lastEmit = Date.now(); socket.emit('voice-activity', { level: level }); var me = peers.get(socket.id); if (me) { me.speakingUntil = Date.now() + 400; me.speakingLevel = level; redrawLobbyMap(); } } }, 100); } catch (e) { console.warn('Voice activity detection:', e); } } function stopVoiceActivityDetection() { if (voiceActivityInterval) { clearInterval(voiceActivityInterval); voiceActivityInterval = null; } if (voiceAnalyserContext) { try { voiceAnalyserContext.close(); } catch (e) {} voiceAnalyserContext = null; } voiceAnalyser = null; } function unlockAudio() { if (audioUnlocked) return; try { var a = new Audio(); a.volume = 0; a.play().then(function() { audioUnlocked = true; playAllRemoteAudios(); }).catch(function() {}); } catch (e) {} } function playAllRemoteAudios() { Object.keys(remoteAudios).forEach(function(peerId) { tryPlayPeer(peerId); }); } function tryPlayPeer(peerId) { var el = remoteAudios[peerId]; if (el && el.srcObject) el.play().catch(function() {}); } function addRemoteAudio(peerId, stream) { if (!stream || !stream.getTracks().length) return; unlockAudio(); if (remoteAudios[peerId]) { remoteAudios[peerId].srcObject = stream; tryPlayPeer(peerId); return; } var el = document.createElement('audio'); el.autoplay = true; el.setAttribute('playsinline', ''); el.volume = 1; el.srcObject = stream; el.style.cssText = 'position:absolute;width:0;height:0;opacity:0;pointer-events:none;'; remoteAudios[peerId] = el; (document.getElementById('chat-messages') || document.body).appendChild(el); el.onloadedmetadata = function() { tryPlayPeer(peerId); }; el.oncanplay = function() { tryPlayPeer(peerId); }; tryPlayPeer(peerId); } function setupUnlockOnFirstClick() { var once = function() { unlockAudio(); document.body.removeEventListener('click', once); }; document.body.addEventListener('click', once, { once: true }); } socket.on('peer-voice-state', ({ id, micOn }) => { const p = peers.get(id); if (p) { p.voiceMicOn = micOn !== false; redrawLobbyMap(); } }); socket.on('peer-speaking', function (data) { var id = data.id, level = data.level, until = data.until; var p = peers.get(id); if (p) { p.speakingUntil = until; p.speakingLevel = level; redrawLobbyMap(); } }); const iceQueue = {}; const defaultIceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; let cachedIceServers = null; async function getIceServers() { if (cachedIceServers) return cachedIceServers; try { const r = await fetch(SERVER + '/api/ice-servers'); const d = await r.json(); if (d && d.iceServers && d.iceServers.length) cachedIceServers = d.iceServers; else cachedIceServers = defaultIceServers; } catch (e) { cachedIceServers = defaultIceServers; } return cachedIceServers; } async function createPeerConnection(peerId, fromOffer) { if (peerConnections[peerId]) return peerConnections[peerId]; const iceServers = await getIceServers(); const pc = new RTCPeerConnection({ iceServers: iceServers }); pc._isInitiator = !fromOffer; if (localStream) localStream.getTracks().forEach(t => pc.addTrack(t, localStream)); pc.ontrack = (e) => { if (e.track.kind !== 'audio') return; e.track.enabled = true; const stream = (e.streams && e.streams[0]) ? e.streams[0] : new MediaStream([e.track]); if (window.DEBUG_VOICE) console.log('[voice] ontrack จาก', peerId, 'streamId=', stream.id, 'track=', e.track.id); addRemoteAudio(peerId, stream); }; pc.onicecandidate = (e) => { if (e.candidate) socket.emit('webrtc-signal', { to: peerId, type: 'ice', candidate: e.candidate }); }; pc.oniceconnectionstatechange = () => { if (window.DEBUG_VOICE) console.log('[voice] ICE', peerId, pc.iceConnectionState); if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') tryPlayPeer(peerId); }; pc.onnegotiationneeded = async () => { if (!pc._isInitiator) return; try { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); socket.emit('webrtc-signal', { to: peerId, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); } catch (err) { console.warn('WebRTC offer error', err); } }; peerConnections[peerId] = pc; iceQueue[peerId] = []; return pc; } async function drainIceQueue(peerId) { const pc = peerConnections[peerId]; const q = iceQueue[peerId]; if (!pc || !q || q.length === 0) return; while (q.length > 0) { const c = q.shift(); try { await pc.addIceCandidate(new RTCIceCandidate(c)); } catch (e) { console.warn('addIceCandidate', e); } } } function closePeer(peerId) { const pc = peerConnections[peerId]; if (pc) { pc.close(); delete peerConnections[peerId]; } var el = remoteAudios[peerId]; if (el) { el.srcObject = null; el.remove(); delete remoteAudios[peerId]; } } socket.on('webrtc-signal', async (data) => { const { from, type, sdp, candidate } = data; if (!from || from === socket.id) return; try { const sdpObj = (sdp && (sdp.sdp !== undefined)) ? sdp : (sdp ? { type: sdp.type, sdp: sdp.sdp || '' } : null); if (type === 'offer' && sdpObj) { const pc = await createPeerConnection(from, true); await pc.setRemoteDescription(new RTCSessionDescription(sdpObj)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); socket.emit('webrtc-signal', { to: from, type: 'answer', sdp: { type: answer.type, sdp: answer.sdp } }); await drainIceQueue(from); } else if (type === 'answer' && sdpObj) { const pc = peerConnections[from]; if (pc) { await pc.setRemoteDescription(new RTCSessionDescription(sdpObj)); await drainIceQueue(from); } } else if (type === 'ice' && candidate) { const pc = peerConnections[from]; if (pc) { if (pc.remoteDescription) { try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) { (iceQueue[from] = iceQueue[from] || []).push(candidate); } } else (iceQueue[from] = iceQueue[from] || []).push(candidate); } } } catch (err) { console.warn('WebRTC signal error', err); } }); socket.on('host-changed', (data) => { hostId = data.hostId || null; const isHost = hostId === socket.id; if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none'; if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline'; renderPeers(); if (isHost && playMapSelect) { fetch(SERVER + '/api/maps') .then(r => r.json()) .then(list => { playMapSelect.innerHTML = ''; (list || []).forEach(m => { const opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.name || m.id; if (m.name) opt.dataset.mapName = m.name; playMapSelect.appendChild(opt); }); }) .catch(() => { playMapSelect.innerHTML = ''; }); } updateHostStartGameButton(); ensureReadyControlEnabled(); updateSuspectHostUi(); updateSuspectFloatingOpenBtn(); refreshHostConsoleOverlayIfOpen(); }); socket.off('user-left'); socket.on('user-left', (data) => { closePeer(data.id); peers.delete(data.id); updatePlayersHud(); renderPeers(); ensureReadyControlEnabled(); redrawLobbyMap(); refreshHostConsoleOverlayIfOpen(); }); const btnVoice = document.getElementById('btn-voice'); const voiceDeviceSelect = document.getElementById('voice-device-select'); function setVoiceButtonIcon(btn, micOn) { if (!btn) return; const img = btn.querySelector('#btn-voice-icon-img') || btn.querySelector('img'); if (img) { img.src = micOn ? SERVER + '/img/btn-mic-on.png' : SERVER + '/img/btn-mic-mute.png'; img.alt = micOn ? 'ปิดเสียง' : 'เปิดเสียง'; } else { btn.textContent = micOn ? '🔇 ปิดเสียง' : '🔊 เปิดเสียง'; } btn.title = micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด'; } async function populateVoiceDevices() { if (!voiceDeviceSelect) return; try { const devs = await navigator.mediaDevices.enumerateDevices(); const inputs = devs.filter(d => d.kind === 'audioinput'); voiceDeviceSelect.innerHTML = ''; if (inputs.length === 0) { voiceDeviceSelect.innerHTML = ''; return; } voiceDeviceSelect.appendChild(new Option('ไมค์เริ่มต้น (default)', '')); inputs.forEach(d => { voiceDeviceSelect.appendChild(new Option(d.label || 'ไมค์ ' + (voiceDeviceSelect.options.length), d.deviceId)); }); } catch (e) { voiceDeviceSelect.innerHTML = ''; } } if (voiceDeviceSelect) { populateVoiceDevices(); navigator.mediaDevices.addEventListener('devicechange', populateVoiceDevices); } let troublesomeEligibleSent = false; function tryTroublesomeLobbyGate() { if (!isPostCaseLobbyRoom()) return; const howtoEl = document.getElementById('room-howto-overlay'); const micEl = document.getElementById('mic-permission-overlay'); const howtoOk = !howtoEl || howtoEl.classList.contains('hidden') || howtoEl.classList.contains('is-hidden'); const micOk = !micEl || micEl.classList.contains('hidden') || micEl.classList.contains('is-hidden'); if (!howtoOk || !micOk) return; if (troublesomeEligibleSent) return; troublesomeEligibleSent = true; socket.emit('troublesome-eligible'); } function hideMicPermissionOverlay() { var el = document.getElementById('mic-permission-overlay'); if (el) el.classList.add('hidden'); tryTroublesomeLobbyGate(); } const roomHowtoOverlay = document.getElementById('room-howto-overlay'); const roomHowtoBtnGotIt = document.getElementById('room-howto-btn-got-it'); if (roomHowtoBtnGotIt) { roomHowtoBtnGotIt.addEventListener('click', function () { if (roomHowtoOverlay) { roomHowtoOverlay.classList.add('hidden'); roomHowtoOverlay.classList.add('is-hidden'); } tryTroublesomeLobbyGate(); }); } document.getElementById('btn-room-howto')?.addEventListener('click', function () { if (roomHowtoOverlay) { roomHowtoOverlay.classList.remove('hidden'); roomHowtoOverlay.classList.remove('is-hidden'); } }); var micPermissionOverlay = document.getElementById('mic-permission-overlay'); var micPermissionAllow = document.getElementById('mic-permission-allow'); var micPermissionClose = document.getElementById('mic-permission-close'); if (micPermissionAllow) { micPermissionAllow.addEventListener('click', async function () { unlockAudio(); if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { alert('เบราว์เซอร์นี้ไม่รองรับการขอสิทธิ์ไมค์'); hideMicPermissionOverlay(); return; } micPermissionAllow.disabled = true; micPermissionAllow.title = 'กำลังขอสิทธิ์...'; try { var stream = await navigator.mediaDevices.getUserMedia({ audio: true }); if (stream) stream.getTracks().forEach(function (t) { t.stop(); }); if (typeof populateVoiceDevices === 'function') await populateVoiceDevices(); hideMicPermissionOverlay(); var permBtn = document.getElementById('btn-voice-permission'); if (permBtn) { permBtn.textContent = '✓ อนุญาตแล้ว'; permBtn.disabled = true; } } catch (err) { micPermissionAllow.disabled = false; micPermissionAllow.title = 'อนุญาต'; alert('ไมค์ถูกปฏิเสธหรือไม่พบอุปกรณ์: ' + (err.message || err)); } }); } if (micPermissionClose) { micPermissionClose.addEventListener('click', function () { hideMicPermissionOverlay(); }); } const btnVoicePermission = document.getElementById('btn-voice-permission'); if (btnVoicePermission) { btnVoicePermission.addEventListener('click', async () => { unlockAudio(); if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { alert('เบราว์เซอร์นี้ไม่รองรับการขอสิทธิ์ไมค์'); return; } btnVoicePermission.disabled = true; btnVoicePermission.textContent = 'กำลังขอสิทธิ์...'; let stream = null; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); if (stream) stream.getTracks().forEach(t => t.stop()); await populateVoiceDevices(); btnVoicePermission.textContent = '✓ อนุญาตแล้ว'; hideMicPermissionOverlay(); } catch (err) { btnVoicePermission.disabled = false; btnVoicePermission.textContent = '🎤 ขอสิทธิ์ไมค์'; alert('ไมค์ถูกปฏิเสธหรือไม่พบอุปกรณ์: ' + (err.message || err)); } }); } var btnHear = document.getElementById('btn-hear'); if (btnHear) { btnHear.addEventListener('click', function() { unlockAudio(); playAllRemoteAudios(); btnHear.textContent = '✓ เปิดรับเสียงแล้ว'; btnHear.disabled = true; }); } setupUnlockOnFirstClick(); if (btnVoice) { btnVoice.addEventListener('click', async () => { unlockAudio(); if (localStream) { stopVoiceActivityDetection(); Object.keys(peerConnections).forEach(peerId => { const pc = peerConnections[peerId]; if (pc && pc.getSenders) { pc.getSenders().forEach(sender => { try { pc.removeTrack(sender); } catch (e) {} }); } }); localStream.getTracks().forEach(t => t.stop()); localStream = null; socket.emit('voice-state', { micOn: false }); const me = peers.get(socket.id); if (me) me.voiceMicOn = false; setVoiceButtonIcon(btnVoice, false); return; } const deviceId = voiceDeviceSelect && voiceDeviceSelect.value ? voiceDeviceSelect.value.trim() : ''; const audioConstraints = { audio: deviceId ? { deviceId: { ideal: deviceId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true } : { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }; try { localStream = await navigator.mediaDevices.getUserMedia(audioConstraints); startVoiceActivityDetection(localStream); await populateVoiceDevices(); setVoiceButtonIcon(btnVoice, true); socket.emit('voice-state', { micOn: true }); const me = peers.get(socket.id); if (me) me.voiceMicOn = true; for (const peerId of peers.keys()) { if (peerId === socket.id) continue; const pc = await createPeerConnection(peerId); if (!localStream || !pc) continue; const senders = pc.getSenders(); let added = false; localStream.getTracks().forEach(track => { const hasTrack = senders.find(s => s.track === track); if (!hasTrack) { pc.addTrack(track, localStream); added = true; } }); if (added) { try { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); socket.emit('webrtc-signal', { to: peerId, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); } catch (e) { console.warn('WebRTC renegotiate offer error', e); } } } } catch (err) { alert('ไม่สามารถเปิดไมค์ได้: ' + (err.message || err)); } }); } function getLobbyEvidenceCasePayload() { try { const meta = window.__detectiveLobbyMeta; let cid = meta && meta.caseId != null ? String(meta.caseId).trim() : '1'; if (!/^[123]$/.test(cid)) cid = '1'; return LOBBY_EVIDENCE_CASES[cid] || LOBBY_EVIDENCE_CASES['1']; } catch (e) { return LOBBY_EVIDENCE_CASES['1']; } } function setLobbyEvidenceTabImage(idx) { const img = document.getElementById('lobby-evidence-tabs-img'); if (!img) return; const n = Math.max(0, Math.min(2, idx)) + 1; img.src = `${LOBBY_EVIDENCE_ASSET_BASE}/evidence-tab-${n}.png`; } function renderLobbyEvidenceCards(suspectIdx) { const root = document.getElementById('lobby-evidence-cards-root'); if (!root) return; const payload = getLobbyEvidenceCasePayload(); const suspects = payload.suspects || []; const si = Math.max(0, Math.min(suspects.length - 1, suspectIdx)); const s = suspects[si]; root.textContent = ''; if (!s || !s.cards) return; s.cards.forEach((c) => { let rar = String(c.rarity || 'common').toLowerCase(); if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common'; const nStar = Math.max(1, Math.min(3, Number(c.stars) || 1)); let stars = ''; for (let st = 0; st < nStar; st++) stars += '★'; const card = document.createElement('article'); card.className = `lobby-evidence-card lobby-evidence-card--${rar}`; const head = document.createElement('div'); head.className = 'lobby-evidence-card-head'; const icon = document.createElement('span'); icon.className = 'lobby-evidence-card-icon'; icon.textContent = '🔎'; icon.setAttribute('aria-hidden', 'true'); const titles = document.createElement('div'); titles.className = 'lobby-evidence-card-titles'; const hTh = document.createElement('p'); hTh.className = 'lobby-evidence-card-title-th'; hTh.textContent = c.titleTh || ''; const hEn = document.createElement('p'); hEn.className = 'lobby-evidence-card-title-en'; hEn.textContent = c.titleEn ? `(${c.titleEn})` : ''; titles.appendChild(hTh); titles.appendChild(hEn); head.appendChild(icon); head.appendChild(titles); const art = document.createElement('div'); art.className = 'lobby-evidence-card-art'; art.setAttribute('aria-hidden', 'true'); art.textContent = '◆'; const body = document.createElement('p'); body.className = 'lobby-evidence-card-body'; body.textContent = c.body || ''; const foot = document.createElement('div'); foot.className = 'lobby-evidence-card-foot'; const link = document.createElement('div'); link.className = 'lobby-evidence-link'; const av = document.createElement('span'); av.className = 'lobby-evidence-avatar'; const nm = s.linkName || '?'; av.textContent = nm.charAt(0); const nmEl = document.createElement('span'); nmEl.className = 'lobby-evidence-link-name'; nmEl.textContent = nm; link.appendChild(av); link.appendChild(nmEl); const fm = document.createElement('div'); fm.className = 'lobby-evidence-foot-meta'; const rl = document.createElement('span'); rl.className = 'lobby-evidence-rarity'; rl.textContent = `(${LOBBY_EVIDENCE_RARITY[rar]})`; const stEl = document.createElement('div'); stEl.className = 'lobby-evidence-stars'; stEl.textContent = stars; fm.appendChild(rl); fm.appendChild(stEl); foot.appendChild(link); foot.appendChild(fm); card.appendChild(head); card.appendChild(art); card.appendChild(body); card.appendChild(foot); root.appendChild(card); }); } let lobbyEvidenceSuspectIdx = 0; function syncLobbyEvidenceTabUi(idx) { lobbyEvidenceSuspectIdx = Math.max(0, Math.min(2, idx)); setLobbyEvidenceTabImage(lobbyEvidenceSuspectIdx); document.querySelectorAll('[data-evidence-tab]').forEach((b) => { const i = parseInt(b.getAttribute('data-evidence-tab'), 10); b.setAttribute('aria-selected', i === lobbyEvidenceSuspectIdx ? 'true' : 'false'); }); renderLobbyEvidenceCards(lobbyEvidenceSuspectIdx); } function openLobbyEvidenceModal() { const ov = document.getElementById('lobby-evidence-overlay'); if (!ov) return; ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); syncLobbyEvidenceTabUi(0); document.getElementById('lobby-evidence-close')?.focus(); } function closeLobbyEvidenceModal() { const ov = document.getElementById('lobby-evidence-overlay'); if (!ov) return; ov.classList.add('is-hidden'); ov.setAttribute('aria-hidden', 'true'); } document.getElementById('lobby-b-btn-evidence')?.addEventListener('click', () => { if (!isPostCaseLobbyRoom()) return; openLobbyEvidenceModal(); }); document.getElementById('lobby-evidence-backdrop')?.addEventListener('click', () => { closeLobbyEvidenceModal(); }); document.getElementById('lobby-evidence-close')?.addEventListener('click', () => { closeLobbyEvidenceModal(); }); document.getElementById('lobby-evidence-overlay')?.addEventListener('click', (e) => { const hit = e.target.closest('[data-evidence-tab]'); if (!hit) return; const i = parseInt(hit.getAttribute('data-evidence-tab'), 10); if (!Number.isNaN(i)) syncLobbyEvidenceTabUi(i); }); const LOBBY_RANK_MEDAL_BASE = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE') : '/Main-Lobby/IMAGE'; const LOBBY_RANK_MOCK_LEADERS = [ { name: 'Golden L.', score: 9951 }, { name: 'Mixie Kim', score: 9878 }, { name: 'Leena', score: 9800 }, { name: 'JeeJee256', score: 9785 }, { name: 'Peach M.', score: 9762 }, { name: 'Pony', score: 9720 }, { name: 'Jubjib S.', score: 9688 }, { name: 'Miss Berlin', score: 9620 }, { name: 'Lemon Honey', score: 9560 }, { name: 'Rosae BP.', score: 9500 }, ]; function getLobbyRankCaseTitle() { try { const meta = window.__detectiveLobbyMeta; const cid = meta && meta.caseId != null ? String(meta.caseId).trim() : ''; if (cid === '2') return 'คดีปล้นร้านอัญมณี'; if (cid === '3') return 'คดีฆาตกรรมปริศนา'; return 'คดีโจรกรรมไซเบอร์'; } catch (e) { return 'คดีโจรกรรมไซเบอร์'; } } function buildRankPedestal(rank, entry, medalFile, pedClass) { const wrap = document.createElement('div'); wrap.className = `lobby-rank-ped ${pedClass}`; if (rank === 1) { const crown = document.createElement('div'); crown.className = 'lobby-rank-crown'; crown.innerHTML = '1'; wrap.appendChild(crown); } const medWrap = document.createElement('div'); medWrap.className = 'lobby-rank-medal-wrap'; const img = document.createElement('img'); img.className = 'lobby-rank-medal-img'; img.src = `${LOBBY_RANK_MEDAL_BASE}/${medalFile}`; img.alt = `เหรียญอันดับ ${rank}`; medWrap.appendChild(img); wrap.appendChild(medWrap); const av = document.createElement('div'); av.className = 'lobby-rank-ped-avatar'; av.textContent = (entry.name || '?').charAt(0); wrap.appendChild(av); const nm = document.createElement('div'); nm.className = 'lobby-rank-ped-name'; nm.textContent = entry.name || ''; wrap.appendChild(nm); const sc = document.createElement('div'); sc.className = 'lobby-rank-ped-score'; sc.textContent = String(entry.score); wrap.appendChild(sc); return wrap; } function renderLobbyRankModalContent() { const titleEl = document.getElementById('lobby-rank-case-title'); if (titleEl) titleEl.textContent = getLobbyRankCaseTitle(); const sorted = [...LOBBY_RANK_MOCK_LEADERS].sort((a, b) => b.score - a.score); const podium = document.getElementById('lobby-rank-podium'); const tbody = document.getElementById('lobby-rank-tbody'); const selfFoot = document.getElementById('lobby-rank-self'); if (!podium || !tbody || !selfFoot) return; podium.textContent = ''; const first = sorted[0]; const second = sorted[1]; const third = sorted[2]; if (second) podium.appendChild(buildRankPedestal(2, second, 'leaderboard-2.png', 'lobby-rank-ped--2')); if (first) podium.appendChild(buildRankPedestal(1, first, 'leaderboard-1.png', 'lobby-rank-ped--1')); if (third) podium.appendChild(buildRankPedestal(3, third, 'leaderboard-3.png', 'lobby-rank-ped--3')); tbody.textContent = ''; for (let i = 3; i < sorted.length && i < 10; i++) { const row = sorted[i]; const tr = document.createElement('tr'); const tdR = document.createElement('td'); tdR.textContent = String(i + 1); const tdN = document.createElement('td'); const wrap = document.createElement('div'); wrap.className = 'lobby-rank-row-av'; const mav = document.createElement('span'); mav.className = 'lobby-rank-mini-av'; mav.textContent = (row.name || '?').charAt(0); const sp = document.createElement('span'); sp.className = 'lobby-rank-row-name'; sp.textContent = row.name || ''; sp.title = row.name || ''; wrap.appendChild(mav); wrap.appendChild(sp); tdN.appendChild(wrap); const tdS = document.createElement('td'); tdS.textContent = String(row.score); tr.appendChild(tdR); tr.appendChild(tdN); tr.appendChild(tdS); tbody.appendChild(tr); } selfFoot.textContent = ''; const selfRank = document.createElement('span'); selfRank.className = 'lobby-rank-self-rank'; selfRank.textContent = '28'; const selfAv = document.createElement('div'); selfAv.className = 'lobby-rank-self-av'; selfAv.textContent = (nick || '?').charAt(0); const meta = document.createElement('div'); meta.className = 'lobby-rank-self-meta'; const selfName = document.createElement('div'); selfName.className = 'lobby-rank-self-name'; selfName.textContent = nick || 'ผู้เล่น'; meta.appendChild(selfName); const selfScore = document.createElement('div'); selfScore.className = 'lobby-rank-self-score'; selfScore.textContent = '7250'; selfFoot.appendChild(selfRank); selfFoot.appendChild(selfAv); selfFoot.appendChild(meta); selfFoot.appendChild(selfScore); } function openLobbyRankModal() { const ov = document.getElementById('lobby-rank-overlay'); if (!ov) return; renderLobbyRankModalContent(); ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); document.getElementById('lobby-rank-close')?.focus(); } function closeLobbyRankModal() { const ov = document.getElementById('lobby-rank-overlay'); if (!ov) return; ov.classList.add('is-hidden'); ov.setAttribute('aria-hidden', 'true'); } document.getElementById('lobby-b-btn-rank')?.addEventListener('click', () => { if (!isPostCaseLobbyRoom()) return; openLobbyRankModal(); }); document.getElementById('btn-profile-set')?.addEventListener('click', () => { roomLobbyProfileOverlay.open(); }); document.getElementById('btn-setting-host')?.addEventListener('click', () => { openHostConsoleIfHost(); }); document.getElementById('btn-customize')?.addEventListener('click', () => { openRoomCustomize(); }); document.getElementById('room-lobby-profile-logout')?.addEventListener('click', () => { window.location.href = CREATE_ROOM_URL; }); document.getElementById('lobby-rank-backdrop')?.addEventListener('click', () => { closeLobbyRankModal(); }); document.getElementById('lobby-rank-close')?.addEventListener('click', () => { closeLobbyRankModal(); }); socket.off('user-joined'); socket.on('user-joined', async (data) => { peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); updatePlayersHud(); renderPeers(); ensureReadyControlEnabled(); redrawLobbyMap(); refreshHostConsoleOverlayIfOpen(); if (localStream && data.id !== socket.id) await createPeerConnection(data.id); }); let troublesomeTickTimer = null; const troublesomeTimerPausedForTuning = false; function closeTroublesomeOverlay() { const ov = document.getElementById('troublesome-overlay'); if (ov) ov.classList.add('is-hidden'); if (troublesomeTickTimer) { clearInterval(troublesomeTickTimer); troublesomeTickTimer = null; } } function refreshSuspectCaseLabel() { const el = document.getElementById('suspect-case-label'); if (!el) return; let meta = null; try { meta = window.__detectiveLobbyMeta; } catch (e) { meta = null; } const cid = meta && meta.caseId != null ? String(meta.caseId).trim() : ''; if (cid === '2') el.textContent = 'คดีปล้นร้านอัญมณี'; else if (cid === '3') el.textContent = 'คดีฆาตกรรมปริศนา'; else el.textContent = 'คดีโจรกรรมไซเบอร์'; } function setSuspectCardMinigames(cards) { if (!cards || !cards.length) return; suspectCardMinigames = cards; } var SUSPECT_EVID_FULL = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card.png'; // เก็บหลักฐานแล้ว (ติกถูก) var SUSPECT_EVID_EMPTY = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card-bg.png'; // ช่องว่าง (?) /** อัปเดต suspectProgress จากเซิร์ฟ แล้ววาดติกถูกใหม่ */ function setSuspectProgress(arr) { if (!Array.isArray(arr)) return; suspectProgress = [0, 1, 2].map(function (i) { var v = Math.floor(Number(arr[i])); return (Number.isFinite(v) && v > 0) ? Math.min(3, v) : 0; }); applySuspectProgressVisual(); if (suspectPickOverlayOpen) updateSuspectHostUi(); // อัปเดตปุ่มชี้ตัว (โชว์เมื่อสืบครบ) } /** วางติกถูกในช่อง "หลักฐาน" (.suspect-symbol-list) ตามจำนวนที่เก็บได้ */ function applySuspectProgressVisual() { for (var i = 0; i <= 2; i++) { var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); if (!card) continue; var slots = card.querySelectorAll('.suspect-symbol-list img'); var done = Math.max(0, Math.min(3, (suspectProgress && suspectProgress[i]) || 0)); for (var s = 0; s < slots.length; s++) { var want = (s < done) ? SUSPECT_EVID_FULL : SUSPECT_EVID_EMPTY; if (slots[s].getAttribute('src') !== want) slots[s].src = want; } } } /* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย (ใช้ overlay เดิม) ===== */ function injectTrialStyle() { if (document.getElementById('trial-style')) return; var st = document.createElement('style'); st.id = 'trial-style'; var RV = '/Main-Lobby/IMAGE/RoomVote/'; st.textContent = '.suspect-pick--trial{background:linear-gradient(rgba(4,8,18,.35),rgba(4,8,18,.55)),url(' + RV + 'roomvote-bg.png) center/cover no-repeat,#04081a !important}' + '.suspect-pick--trial .suspect-pick-actions{display:none!important}' + '.suspect-pick--trial #suspect-btn-accuse{display:none!important}' + /* #1 ปุ่มชี้ตัวต้องหายตอนหน้าโหวต */ '#trial-vote-counter{position:absolute;right:22px;top:14px;color:#7fe9ff;font:800 24px/1 Kanit,system-ui,sans-serif;display:none;z-index:62;text-shadow:0 0 12px rgba(34,211,238,.7)}' + '#trial-vote-counter b{color:#fff}' + '.suspect-pick--trial #trial-vote-counter{display:block}' + '.suspect-pick--trial .suspect-pick-title-img{display:none!important}' + '#trial-title{display:none;text-align:center;margin:0 0 4px}' + '#trial-title img{display:block;margin:0 auto;max-width:90vw}' + '#trial-title-main{height:92px;width:auto}' + '#trial-title-sub{height:46px;width:auto;margin-top:6px}' + '.suspect-pick--trial #trial-title{display:block}' + '.suspect-pick--trial .suspect-card{cursor:pointer;position:relative}' + // ตอนโหวต: ใบที่เราเลือก = ปุ่มภาพ "เลือกแล้ว" (btn-selected.png) '.suspect-card .trial-vote-badge{position:absolute;left:50%;bottom:10px;transform:translateX(-50%);display:none;z-index:7;white-space:nowrap}' + '.suspect-pick--trial .suspect-card--myvote .trial-vote-badge{display:block;width:204px;height:83px;max-width:80%;background:url(' + RV + 'btn-selected.png) center/contain no-repeat;color:transparent;font-size:0;padding:0;box-shadow:none}' + // ตอนเปิดเผยผล: โชว์คะแนนโหวตทุกใบ (ป้ายฟ้า) '.suspect-pick--trial.trial-revealed .trial-vote-badge{display:block;background:rgba(34,211,238,.97);color:#06121f;font:700 15px/1 Kanit;padding:7px 16px;border-radius:999px;box-shadow:0 2px 10px rgba(0,0,0,.45)}' + '.suspect-card--myvote{outline:3px solid #22c55e;outline-offset:-3px;border-radius:16px}' + '.suspect-card--culprit{outline:4px solid #f7768e;outline-offset:-4px;border-radius:16px;box-shadow:0 0 34px rgba(247,118,142,.85)}' + // แสตมป์ "ผู้บริสุทธิ์" ทับใบที่โหวตผิด + เทาใบนั้น '.suspect-card .trial-stamp{position:absolute;left:50%;top:44%;transform:translate(-50%,-50%) rotate(-8deg);width:78%;max-width:300px;height:auto;z-index:9;display:none;pointer-events:none}' + '.suspect-card--innocent .trial-stamp{display:block}' + '.suspect-card--innocent{filter:grayscale(1) brightness(.8)}' + '#trial-lose-txt{position:absolute;left:50%;top:4%;transform:translateX(-50%);width:712px;max-width:88vw;height:auto;z-index:61;display:none}' + '#trial-result-banner{position:absolute;left:50%;top:5%;transform:translateX(-50%);background:rgba(6,12,24,.96);border:2px solid #22d3ee;border-radius:16px;padding:14px 26px;color:#e7ecff;font:600 20px/1.5 Kanit,system-ui,sans-serif;text-align:center;z-index:60;max-width:82%;box-shadow:0 8px 40px rgba(0,0,0,.6)}' + '#trial-reveal-btn{position:absolute;left:50%;bottom:5%;transform:translateX(-50%);background:linear-gradient(180deg,#22d3ee,#0e7490);color:#06121f;border:none;border-radius:14px;padding:14px 30px;font:700 20px/1 Kanit,system-ui,sans-serif;cursor:pointer;z-index:60;box-shadow:0 6px 22px rgba(34,211,238,.5)}'; document.head.appendChild(st); } function ensureTrialBadges() { for (var i = 0; i <= 2; i++) { var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); if (!card) continue; if (!card.querySelector('.trial-vote-badge')) { var b = document.createElement('span'); b.className = 'trial-vote-badge'; b.textContent = ''; card.appendChild(b); } } } function applyTrialBadges() { for (var i = 0; i <= 2; i++) { var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); if (!card) continue; var b = card.querySelector('.trial-vote-badge'); if (b) b.textContent = 'โหวต ' + ((trialVoteCounts && trialVoteCounts[i]) || 0); } } function ensureTrialBanner() { var ov = document.getElementById('suspect-pick-overlay'); var el = document.getElementById('trial-result-banner'); if (!el && ov) { el = document.createElement('div'); el.id = 'trial-result-banner'; el.style.display = 'none'; ov.appendChild(el); } return el; } function ensureTrialCounter() { var ov = document.getElementById('suspect-pick-overlay'); var el = document.getElementById('trial-vote-counter'); if (!el && ov) { el = document.createElement('div'); el.id = 'trial-vote-counter'; ov.appendChild(el); } return el; } function setTrialCounter(voted, total) { var el = ensureTrialCounter(); if (el) el.innerHTML = 'ลงติกแล้ว : ' + (voted || 0) + '/' + (total || 0) + ''; } function ensureTrialRevealBtn() { var ov = document.getElementById('suspect-pick-overlay'); var btn = document.getElementById('trial-reveal-btn'); if (!btn && ov) { btn = document.createElement('button'); btn.id = 'trial-reveal-btn'; btn.type = 'button'; btn.textContent = 'เปิดเผยผล'; btn.addEventListener('click', function () { socket.emit('trial-reveal', {}, function (res) { if (!(res && res.ok)) appendLobbySystemChat('— เปิดเผยผลไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); }); }); ov.appendChild(btn); } if (btn) btn.style.display = (trialMode === 'voting' && hostId === socket.id) ? 'block' : 'none'; } /** หัวข้อ "พิจารณาคดี" (แทนรูป title เลือกผู้ต้องสงสัยตอนอยู่โหมดโหวต) */ function ensureTrialTitle() { var RV = '/Main-Lobby/IMAGE/RoomVote/'; var row = document.getElementById('suspect-cards-row'); var t = document.getElementById('trial-title'); if (!t && row && row.parentNode) { t = document.createElement('div'); t.id = 'trial-title'; t.innerHTML = 'พิจารณาคดี' + 'เลือกผู้ร้ายตัวจริง'; row.parentNode.insertBefore(t, row); } return t; } // mode: 'vote' = roomvote-txt-2, 'result' = roomvote-txt-3 function setTrialSubtitle(mode) { var RV = '/Main-Lobby/IMAGE/RoomVote/'; var sub = document.getElementById('trial-title-sub'); if (sub) sub.src = (mode === 'result') ? (RV + 'roomvote-txt-3.png') : (RV + 'roomvote-txt-2.png'); } function castTrialVote(idx) { if (trialMode !== 'voting') return; myTrialVote = idx; document.querySelectorAll('.suspect-card').forEach(function (c) { var sel = parseInt(c.getAttribute('data-index'), 10) === idx; c.classList.toggle('suspect-card--myvote', sel); var b = c.querySelector('.trial-vote-badge'); if (b && sel) b.textContent = '✓ เลือกแล้ว'; }); socket.emit('trial-vote', { index: idx }, function (res) { if (!(res && res.ok)) appendLobbySystemChat('— โหวตไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); }); } function openTrialVoteUI(data) { trialMode = 'voting'; myTrialVote = null; trialVoteCounts = [0, 0, 0]; if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); if (data && Array.isArray(data.suspectProgress)) suspectProgress = [0, 1, 2].map(function (i) { return Math.min(3, Math.max(0, Math.floor(Number(data.suspectProgress[i])) || 0)); }); injectTrialStyle(); var ov = document.getElementById('suspect-pick-overlay'); if (!ov) return; bindSuspectPickScaleListeners(); suspectPickOverlayOpen = true; serverSuspectPhaseActive = false; ov.classList.remove('is-hidden', 'trial-revealed'); ov.classList.add('suspect-pick--trial'); ov.setAttribute('aria-hidden', 'false'); document.querySelectorAll('.suspect-card').forEach(function (c) { c.classList.remove('suspect-card--myvote', 'suspect-card--culprit', 'suspect-card--selected', 'suspect-card--innocent'); }); var loseEl = document.getElementById('trial-lose-txt'); if (loseEl) loseEl.style.display = 'none'; applySuspectProgressVisual(); ensureTrialTitle(); var ttl = document.getElementById('trial-title'); if (ttl) ttl.style.display = 'block'; setTrialSubtitle('vote'); ensureTrialBadges(); document.querySelectorAll('.trial-vote-badge').forEach(function (b) { b.textContent = ''; }); setTrialCounter(0, (data && data.totalPlayers) || (peers ? peers.size : 0) || 1); // กันปุ่มชี้ตัวค้าง: ใส่ is-hidden ตอนเข้าหน้าโหวต var accuseBtn0 = document.getElementById('suspect-btn-accuse'); if (accuseBtn0) accuseBtn0.classList.add('is-hidden'); ensureTrialRevealBtn(); var banner = ensureTrialBanner(); if (banner) banner.style.display = 'none'; var actions = document.getElementById('suspect-pick-actions'); if (actions) actions.classList.remove('suspect-pick-actions--visible'); var hint = document.getElementById('suspect-pick-hint'); if (hint) { hint.classList.remove('is-hidden'); hint.textContent = 'คลิกการ์ดที่คิดว่าเป็นคนร้าย'; } updateSuspectFloatingOpenBtn(); scheduleSuspectPickScale(); } function ensureTrialLoseTxt() { var ov = document.getElementById('suspect-pick-overlay'); var el = document.getElementById('trial-lose-txt'); if (!el && ov) { el = document.createElement('img'); el.id = 'trial-lose-txt'; el.src = '/Main-Lobby/IMAGE/RoomVote/txt-lose.png'; el.alt = 'ไม่นะ..คุณจับแพะ!!'; ov.appendChild(el); } return el; } function showTrialResult(data) { trialMode = 'revealed'; var culprit = (data && typeof data.culpritIndex === 'number') ? data.culpritIndex : 0; if (data && Array.isArray(data.counts)) trialVoteCounts = data.counts; var counts = trialVoteCounts || [0, 0, 0]; var ov = document.getElementById('suspect-pick-overlay'); if (ov) ov.classList.add('trial-revealed'); setTrialSubtitle('result'); // หาผู้ต้องสงสัยที่ถูกโหวตมากสุด var mostVoted = 0; for (var k = 1; k <= 2; k++) { if ((counts[k] || 0) > (counts[mostVoted] || 0)) mostVoted = k; } var anyVotes = ((counts[0] || 0) + (counts[1] || 0) + (counts[2] || 0)) > 0; var correct = anyVotes && (mostVoted === culprit); document.querySelectorAll('.suspect-card').forEach(function (c) { var ci = parseInt(c.getAttribute('data-index'), 10); c.classList.remove('suspect-card--myvote', 'suspect-card--innocent'); c.classList.toggle('suspect-card--culprit', ci === culprit); var b = c.querySelector('.trial-vote-badge'); if (b) b.textContent = 'โหวต ' + (counts[ci] || 0); var stamp = c.querySelector('.trial-stamp'); if (!stamp) { stamp = document.createElement('img'); stamp.className = 'trial-stamp'; stamp.src = '/Main-Lobby/IMAGE/RoomVote/stamp-innocent.png'; stamp.alt = 'ผู้บริสุทธิ์'; c.appendChild(stamp); } // โหวตผิด: ตีตรา "ผู้บริสุทธิ์" บนใบที่ถูกโหวตมากสุด (ไม่ใช่คนร้าย) if (!correct && anyVotes && ci === mostVoted && ci !== culprit) c.classList.add('suspect-card--innocent'); }); // โหวตผิด → โชว์ "ไม่นะ..คุณจับแพะ!!" + ซ่อนหัวข้อปกติ var lose = ensureTrialLoseTxt(); var titleEl = document.getElementById('trial-title'); if (!correct && anyVotes) { if (lose) lose.style.display = 'block'; if (titleEl) titleEl.style.display = 'none'; } else { if (lose) lose.style.display = 'none'; if (titleEl) titleEl.style.display = 'block'; } var names = (data && data.winnerNames && data.winnerNames.length) ? data.winnerNames.join(', ') : 'ไม่มีใครตอบถูก'; var iWon = !!(data && Array.isArray(data.winners) && data.winners.indexOf(socket.id) >= 0); var banner = ensureTrialBanner(); if (banner) { banner.style.top = (!correct && anyVotes) ? '17%' : '5%'; banner.innerHTML = 'คนร้ายตัวจริงคือ ผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + '
' + 'ผู้ตอบถูก: ' + names + (iWon ? '
🎉 คุณตอบถูก! +100' : ''); banner.style.display = 'block'; } var rb = document.getElementById('trial-reveal-btn'); if (rb) rb.style.display = 'none'; var hint = document.getElementById('suspect-pick-hint'); if (hint) hint.textContent = 'กำลังสรุปผล...'; appendLobbySystemChat('— เปิดเผยผล: คนร้ายคือผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + ' · ผู้ตอบถูก: ' + names); // โชว์คะแนนโหวตสักครู่ แล้วไปหน้าผลเต็มเสมอ (ถูก = เรือนจำไซเบอร์ / ผิด/ไม่โหวต = ภารกิจล้มเหลว) if (trialFinalTimer) clearTimeout(trialFinalTimer); trialFinalTimer = setTimeout(function () { showFinalResult(correct, culprit); }, 2600); } var trialFinalTimer = null; function injectFinalResultStyle() { if (document.getElementById('trial-final-style')) return; var R = '/Main-Lobby/IMAGE/Result/'; var st = document.createElement('style'); st.id = 'trial-final-style'; st.textContent = '#trial-final{position:fixed;inset:0;z-index:9500;background:#04060c center/cover no-repeat}' + '#trial-final.is-hidden{display:none}' + // ตัวคนร้ายในช่องคุก (ตอนตอบถูก) '#trial-final-culprit{position:absolute;left:50%;top:55%;transform:translate(-50%,-50%);height:52%;width:auto;z-index:2;display:none;filter:drop-shadow(0 0 20px rgba(255,200,80,.55))}' + // หัวข้อ "ภารกิจล้มเหลว" (ตอนตอบผิด) วางทับ lose-bg '#trial-final-txt{position:absolute;left:50%;top:7%;transform:translateX(-50%);width:780px;max-width:90vw;height:auto;z-index:2;display:none}' + '#trial-final-home{position:absolute;left:50%;bottom:5%;transform:translateX(-50%);width:300px;height:104px;max-width:70vw;background:url(' + R + 'btn-gohome.png) center/contain no-repeat;border:none;cursor:pointer;font-size:0;color:transparent;z-index:3;filter:drop-shadow(0 4px 14px rgba(0,0,0,.6))}'; document.head.appendChild(st); } function showFinalResult(correct, culprit) { injectFinalResultStyle(); var R = '/Main-Lobby/IMAGE/Result/'; var CS = '/Main-Lobby/IMAGE/Choose%20a%20suspect/'; var ov = document.getElementById('trial-final'); if (!ov) { ov = document.createElement('div'); ov.id = 'trial-final'; ov.className = 'is-hidden'; ov.innerHTML = 'คนร้ายภารกิจล้มเหลว' + ''; document.body.appendChild(ov); ov.querySelector('#trial-final-home').addEventListener('click', function () { window.location.href = '/Main-Lobby/'; }); } var culpEl = ov.querySelector('#trial-final-culprit'); var txtEl = ov.querySelector('#trial-final-txt'); if (correct) { ov.style.backgroundImage = 'url(' + R + 'cyber-prison.png)'; if (culpEl) { culpEl.src = CS + 'suspect-' + (((typeof culprit === 'number' ? culprit : 0)) + 1) + '.png'; culpEl.style.display = 'block'; } if (txtEl) txtEl.style.display = 'none'; } else { ov.style.backgroundImage = 'url(' + R + 'lose-bg.png)'; if (txtEl) { txtEl.src = R + 'txt-your-lose.png'; txtEl.style.display = 'block'; } if (culpEl) culpEl.style.display = 'none'; } ov.classList.remove('is-hidden'); try { closeSuspectOverlay(); } catch (e) { /* ignore */ } } /* ===== ห้องสรุปหลักฐาน — การไต่สวน (เลือกหลักฐาน 2 ใบ/ปากคำ ก่อนโหวต) ===== */ var tmState = { round: 0, members: [], picks: [], submitted: false, mode: 'select' }; var SUSPECT_NAMES_EN = ['SOMCHAI', 'ARIN', 'JIRAPHA']; function injectTestimonyStyle() { if (document.getElementById('testimony-style')) return; var st = document.createElement('style'); st.id = 'testimony-style'; var SC = '/Main-Lobby/IMAGE/Showcard/'; st.textContent = '#testimony-overlay{position:fixed;inset:0;z-index:9000;background:#04060c center/cover no-repeat;display:flex;align-items:center;justify-content:center;font-family:Kanit,system-ui,sans-serif;color:#e7ecff;overflow:hidden}' + '#testimony-overlay.is-hidden{display:none}' + '.tm-stage{position:relative;width:min(96vw,1560px);aspect-ratio:16/9;max-height:97vh;max-height:97dvh}' + // ---- กรอบบน box-1 (title baked) ---- '.tm-top{position:absolute;left:6%;top:1.5%;width:88%;height:42%;background:url(' + SC + 'box-1.png) center/100% 100% no-repeat}' + '.tm-title{position:absolute;left:50%;top:14%;transform:translateX(-50%);white-space:nowrap;font:800 clamp(15px,2.1vw,30px)/1 Kanit;color:#fff;text-shadow:0 0 14px rgba(34,211,238,.6)}' + '.tm-sub2{position:absolute;left:50%;top:25%;transform:translateX(-50%);font:600 clamp(10px,1.1vw,16px)/1 Kanit;color:#9fe7ff;white-space:nowrap;max-width:80%;overflow:hidden;text-overflow:ellipsis}' + '.tm-suspect{position:absolute;left:3.5%;top:30%;width:12%}' + '.tm-sus-frame{position:relative;width:100%;aspect-ratio:291/289;background:url(' + SC + 'suspect-bg.png) center/contain no-repeat;display:flex;align-items:center;justify-content:center}' + '.tm-sus-frame img{width:78%;height:78%;object-fit:cover;object-position:center 16%;border-radius:10px}' + '.tm-sus-name{text-align:center;margin-top:2px;font:700 clamp(10px,1vw,15px)/1.1 Kanit;color:#bfe9ff}' + '.tm-members{position:absolute;left:18%;right:2%;top:34%;bottom:8%;display:flex;gap:1%;align-items:flex-start;justify-content:center;flex-wrap:nowrap;overflow:hidden}' + '.tm-member{flex:0 1 17%;max-width:17%;border:2px solid #1e6f8c;border-radius:10px;padding:clamp(4px,0.6vh,8px) 4px;text-align:center;background:rgba(8,20,38,.6);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:clamp(2px,0.4vh,6px)}' + '.tm-member .tm-mname{font:700 clamp(9px,0.85vw,14px)/1 Kanit;color:#bfe9ff;white-space:nowrap;display:flex;align-items:center;justify-content:center;gap:4px}' + '.tm-member .tm-mstat{font:600 clamp(9px,0.8vw,13px)/1.1 Kanit;color:#8aa0c0;display:flex;flex-direction:column;align-items:center;gap:4px}' + '.tm-member .tm-mstat img{height:26%;max-height:34px;width:auto}' + '.tm-member.tm-ready{border-color:#22c55e;box-shadow:0 0 14px rgba(34,197,94,.5)}.tm-member.tm-ready .tm-mstat{color:#22c55e}' + '.tm-member.tm-me{outline:2px solid #22d3ee;outline-offset:2px}' + '.tm-crown img{height:14px;width:auto;vertical-align:middle}' + // ---- กรอบล่าง tab-0N (แท็บ baked) ---- '.tm-bottom{position:absolute;left:6%;right:6%;top:47%;bottom:1.5%;background:top center/100% 100% no-repeat}' + '.tm-bhead{position:absolute;left:3%;top:17%;font:600 clamp(11px,1.2vw,18px)/1.2 Kanit;color:#cfe9ff}' + '.tm-count{position:absolute;right:3%;top:17%;color:#ffd54a;font:700 clamp(12px,1.3vw,20px)/1 Kanit}' + '.tm-cards{position:absolute;left:3%;right:3%;top:22%;bottom:6%;display:flex;gap:1.5%;align-items:stretch;justify-content:center}' + '.tm-cards .lobby-evidence-card{cursor:pointer;transition:transform .12s,box-shadow .12s;max-height:100%;height:100%;flex:0 1 200px;overflow:hidden}' + '#testimony-overlay .lobby-evidence-card-art{height:clamp(40px,8vh,80px);min-height:36px;font-size:0;margin:2px 0;display:flex;align-items:center;justify-content:center;background:rgba(0,40,60,.3);border-radius:6px;overflow:hidden}#testimony-overlay .lobby-evidence-card-art img{width:100%;height:100%;object-fit:cover;border-radius:6px}' + '#testimony-overlay .tm-cards .lobby-evidence-card-body{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}' + '.tm-cards .lobby-evidence-card.tm-sel{outline:3px solid #22d3ee;outline-offset:2px;transform:translateY(-6px);box-shadow:0 8px 26px rgba(34,211,238,.55)}' + '.tm-status{position:absolute;left:3%;bottom:6%;text-align:left;color:#ffd54a;font:600 clamp(11px,1.1vw,16px)/1.3 Kanit}' + '.tm-actions{position:absolute;right:3%;bottom:4%;display:flex;gap:1.2%;align-items:center;z-index:4}' + '.tm-imgbtn{background:center/contain no-repeat;border:none;cursor:pointer;font-size:0;color:transparent;padding:0}' + '.tm-imgbtn:disabled{filter:grayscale(.65) opacity(.45);cursor:not-allowed}' + '#tm-submit{width:clamp(150px,15vw,234px);height:clamp(64px,6.5vw,100px);background-image:url(' + SC + 'btn-send.png)}' + '#tm-force-reveal{width:clamp(170px,17vw,280px);height:clamp(50px,5vw,84px);background-image:url(' + SC + 'btn-open-card.png)}' + // ---- โหมดเปิดเผย (reveal) ---- '.tm-reveal-grid{position:absolute;left:2%;right:2%;top:5%;bottom:18%;display:flex;gap:1.2%;align-items:flex-start;justify-content:center;overflow-y:auto;overflow-x:hidden}' + '.tm-rev-col{flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0;max-width:19.5%}' + '.tm-rev-col .tm-rev-name{font:700 clamp(9px,0.9vw,14px)/1 Kanit;color:#bfe9ff;display:flex;align-items:center;gap:4px}' + '.tm-rev-col .lobby-evidence-card{width:100%;max-width:118px;transform:none;font-size:10px}' + '.tm-rev-col .lobby-evidence-card .lobby-evidence-card-body{display:none}' + '#tm-ready{position:absolute;left:50%;bottom:2%;transform:translateX(-50%);width:clamp(160px,15vw,240px);height:clamp(62px,6vw,94px);background-image:url(' + SC + 'btn-ready.png)}#tm-mic{position:absolute;right:1%;bottom:1%;width:clamp(44px,4.5vw,68px);height:clamp(44px,4.5vw,68px);border-radius:50%;border:2px solid #22d3ee;background:rgba(8,20,38,.7) center/60% no-repeat;cursor:pointer;z-index:5;display:flex;align-items:center;justify-content:center;font-size:clamp(18px,2vw,28px)}#tm-mic:hover{background-color:rgba(34,211,238,.15)}'; document.head.appendChild(st); } function tmEvidenceCardEl(c, linkName, cardIndex, selectable) { var rar = String(c.rarity || 'common').toLowerCase(); if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common'; var nStar = Math.max(1, Math.min(3, Number(c.stars) || 1)); var stars = ''; for (var st = 0; st < nStar; st++) stars += '★'; var card = document.createElement('article'); card.className = 'lobby-evidence-card lobby-evidence-card--' + rar; if (selectable) card.setAttribute('data-card-index', String(cardIndex)); card.innerHTML = '
' + '

' + '' + '

' + '
' + '
(' + LOBBY_EVIDENCE_RARITY[rar] + ')
' + stars + '
'; card.querySelector('.lobby-evidence-card-title-th').textContent = c.titleTh || ''; card.querySelector('.lobby-evidence-card-title-en').textContent = c.titleEn ? '(' + c.titleEn + ')' : ''; card.querySelector('.lobby-evidence-card-body').textContent = c.body || ''; card.querySelector('.lobby-evidence-avatar').textContent = (linkName || '?').charAt(0); card.querySelector('.lobby-evidence-link-name').textContent = linkName || ''; return card; } function tmSuspectData(round) { var payload = getLobbyEvidenceCasePayload(); var suspects = payload.suspects || []; var si = Math.max(0, Math.min(suspects.length - 1, round)); return suspects[si] || { linkName: '?', cards: [] }; } function ensureTestimonyOverlay() { var ov = document.getElementById('testimony-overlay'); if (ov) return ov; injectTestimonyStyle(); var SC = '/Main-Lobby/IMAGE/Showcard/'; ov = document.createElement('div'); ov.id = 'testimony-overlay'; ov.className = 'is-hidden'; ov.innerHTML = '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
[เลือกพยานหลักฐาน 2 ชิ้น เพื่อประกอบสำนวนคดีนี้]
' + '
เลือกหลักฐาน 0/2
' + '
' + '
' + '
' + '' + '
' + '' + '
' + '
'; document.body.appendChild(ov); var stg = ov.querySelector('#tm-stage'); if (stg) stg.style.background = 'url(' + SC + 'bg-showcard.png) center/cover no-repeat #04060c'; ov.querySelector('#tm-cards').addEventListener('click', function (e) { var card = e.target.closest('.lobby-evidence-card'); if (!card || tmState.submitted) return; var idx = parseInt(card.getAttribute('data-card-index'), 10); if (!(idx >= 0)) return; var pos = tmState.picks.indexOf(idx); if (pos >= 0) { tmState.picks.splice(pos, 1); card.classList.remove('tm-sel'); } else { if (tmState.picks.length >= 2) return; tmState.picks.push(idx); card.classList.add('tm-sel'); } tmUpdateCount(); }); ov.querySelector('#tm-submit').addEventListener('click', function () { if (tmState.picks.length !== 2 || tmState.submitted) return; socket.emit('testimony-submit', { picks: tmState.picks.slice(0, 2) }, function (res) { if (res && res.ok) { tmState.submitted = true; tmSetStatus('ส่งหลักฐานแล้ว — รอผู้เล่นอื่น...'); document.getElementById('tm-submit').disabled = true; } else appendLobbySystemChat('— ส่งหลักฐานไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); }); }); ov.querySelector('#tm-force-reveal').addEventListener('click', function () { socket.emit('testimony-reveal', {}, function (res) { if (!(res && res.ok)) appendLobbySystemChat('— เปิดเผยไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); }); }); ov.querySelector('#tm-ready').addEventListener('click', function () { this.disabled = true; socket.emit('testimony-ready-next', {}, function (res) { if (!(res && res.ok)) { document.getElementById('tm-ready').disabled = false; appendLobbySystemChat('— READY ไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); } }); }); return ov; } function tmSetStatus(txt) { var el = document.getElementById('tm-status'); if (el) el.textContent = txt || ''; } function tmUpdateCount() { var el = document.getElementById('tm-count'); if (el) el.textContent = 'เลือกหลักฐาน ' + tmState.picks.length + '/2'; var sub = document.getElementById('tm-submit'); if (sub) sub.disabled = (tmState.picks.length !== 2 || tmState.submitted); } function tmRenderMembers(submittedIds, readyIds, revealMode) { var root = document.getElementById('tm-members'); if (!root) return; root.textContent = ''; (tmState.members || []).forEach(function (m) { var d = document.createElement('div'); d.className = 'tm-member' + (m.id === socket.id ? ' tm-me' : ''); var done = revealMode ? (readyIds && readyIds.indexOf(m.id) >= 0) : (submittedIds && submittedIds.indexOf(m.id) >= 0); if (done) d.classList.add('tm-ready'); var SC = '/Main-Lobby/IMAGE/Showcard/'; var crown = m.isHost ? '' : ''; var stat = done ? ' พร้อม' : ' ' + (revealMode ? 'กำลังดู..' : 'กำลังเลือก..'); d.innerHTML = '
' + crown + (m.nickname || '') + '
' + stat + '
'; root.appendChild(d); }); } function openTestimony(data) { if (typeof closeSuspectOverlay === 'function') closeSuspectOverlay(); var ov = ensureTestimonyOverlay(); tmState.round = (data && typeof data.round === 'number') ? data.round : 0; tmState.members = (data && Array.isArray(data.members)) ? data.members : []; tmState.picks = []; tmState.submitted = false; tmState.mode = 'select'; var SC = '/Main-Lobby/IMAGE/Showcard/'; var CS = '/Main-Lobby/IMAGE/Choose%20a%20suspect/'; var sd = tmSuspectData(tmState.round); document.getElementById('tm-title').textContent = 'การไต่สวน : ปากคำที่ ' + (tmState.round + 1) + ' - ' + (sd.linkName || ('ผู้ต้องสงสัย ' + (tmState.round + 1))); document.getElementById('tm-sub2').textContent = ''; // avatar ผู้ต้องสงสัย + ชื่อ var susImg = document.getElementById('tm-sus-img'); if (susImg) susImg.src = CS + 'suspect-' + (tmState.round + 1) + '.png'; var susName = document.getElementById('tm-sus-name'); if (susName) susName.textContent = sd.linkName || ''; // กรอบล่าง = tab ของผู้ต้องสงสัยปัจจุบัน var bottom = document.getElementById('tm-bottom'); if (bottom) { bottom.classList.remove('tm-bottom--reveal'); bottom.style.backgroundImage = 'url(' + SC + 'tab-0' + (tmState.round + 1) + '.png)'; } document.getElementById('tm-select-wrap').style.display = 'block'; document.getElementById('tm-reveal-wrap').style.display = 'none'; var fr = document.getElementById('tm-force-reveal'); if (fr) fr.style.display = ((data && data.hostId) === socket.id) ? 'inline-block' : 'none'; tmSetStatus(''); // การ์ดของผู้ต้องสงสัยปัจจุบัน (ทุกคนเห็นครบ) var cardsRoot = document.getElementById('tm-cards'); cardsRoot.textContent = ''; (sd.cards || []).forEach(function (c, i) { cardsRoot.appendChild(tmEvidenceCardEl(c, sd.linkName, i, true)); }); tmUpdateCount(); tmRenderMembers([], [], false); ov.classList.remove('is-hidden'); } function testimonyStatusUpdate(data) { if (!data) return; tmRenderMembers(data.submitted || [], [], false); } function testimonyReveal(data) { tmState.mode = 'reveal'; var ov = ensureTestimonyOverlay(); ov.classList.remove('is-hidden'); if (data && Array.isArray(data.members)) tmState.members = data.members; document.getElementById('tm-select-wrap').style.display = 'none'; document.getElementById('tm-reveal-wrap').style.display = 'block'; var bottom = document.getElementById('tm-bottom'); if (bottom) { bottom.classList.add('tm-bottom--reveal'); bottom.style.backgroundImage = 'none'; } document.getElementById('tm-sub2').textContent = '[วิเคราะห์พยานหลักฐานกับทีม ก่อนไปปากคำถัดไป]'; var rd = document.getElementById('tm-ready'); if (rd) rd.disabled = false; var sd = tmSuspectData(tmState.round); var SC = '/Main-Lobby/IMAGE/Showcard/'; var grid = document.getElementById('tm-reveal-grid'); grid.textContent = ''; var picks = (data && data.picks) || {}; (tmState.members || []).forEach(function (m) { var col = document.createElement('div'); col.className = 'tm-rev-col'; var nm = document.createElement('div'); nm.className = 'tm-rev-name'; nm.innerHTML = (m.isHost ? ' ' : '') + (m.nickname || ''); col.appendChild(nm); var sel = picks[m.id] || []; if (!sel.length) { var none = document.createElement('div'); none.className = 'tm-status'; none.textContent = '(ไม่ได้ส่ง)'; col.appendChild(none); } sel.forEach(function (ci) { var c = (sd.cards || [])[ci]; if (c) col.appendChild(tmEvidenceCardEl(c, sd.linkName, ci, false)); }); grid.appendChild(col); }); tmRenderMembers([], [], true); } function testimonyReadyUpdate(data) { tmRenderMembers([], (data && data.ready) || [], true); } function hideTestimonyOverlay() { var ov = document.getElementById('testimony-overlay'); if (ov) ov.classList.add('is-hidden'); } function applySuspectSelectionVisual(idx) { let i = Math.floor(Number(idx)); if (Number.isNaN(i) || i < 0 || i > 2) i = 0; suspectSelectedIndex = i; document.querySelectorAll('.suspect-card').forEach((btn) => { const j = parseInt(btn.getAttribute('data-index'), 10); btn.classList.toggle('suspect-card--selected', j === i); }); } function applyDetectiveReturnToLobbyB(data) { quizModeActive = false; quizPhaseLocal = null; quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; quizPeersLocked = {}; try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ } hideQuizOverlay(); const mid = (data && data.mapId) ? String(data.mapId).trim() : POST_CASE_LOBBY_SPACE_ID; const reopenSuspect = !!(data && data.suspectPhaseActive); const pickIdx = (data && typeof data.suspectPickIndex === 'number') ? data.suspectPickIndex : suspectSelectedIndex; if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); applyRoomLobbyBTransition({ mapId: mid, peersSnap: (data && data.peersSnap) ? data.peersSnap : [], lobbyLevel: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.level) || null, caseId: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.caseId) || null, }); if (reopenSuspect) { serverSuspectPhaseActive = true; setTimeout(function () { openSuspectOverlay(pickIdx); updateSuspectFloatingOpenBtn(); syncLobbyBUiChrome(); updatePlayersHud(); }, 500); } syncLobbyBUiChrome(); updatePlayersHud(); appendLobbySystemChat('— จบมินิเกม · กลับ LobbyB แล้ว'); } const SUSPECT_DESIGN_WIDTH = 1200; let suspectPickScaleListenersBound = false; function scheduleSuspectPickScale() { requestAnimationFrame(function () { requestAnimationFrame(layoutSuspectPickScale); }); } function bindSuspectPickScaleListeners() { if (suspectPickScaleListenersBound) return; suspectPickScaleListenersBound = true; window.addEventListener('resize', function () { if (suspectPickOverlayOpen) scheduleSuspectPickScale(); }); if (window.visualViewport) { window.visualViewport.addEventListener('resize', function () { if (suspectPickOverlayOpen) scheduleSuspectPickScale(); }); } document.querySelectorAll('#suspect-cards-row img, .suspect-pick-title-img, #suspect-btn-start img, #suspect-btn-accuse img').forEach(function (img) { img.addEventListener('load', function () { if (suspectPickOverlayOpen) scheduleSuspectPickScale(); }); }); } function layoutSuspectPickScale() { const ov = document.getElementById('suspect-pick-overlay'); const wrap = document.getElementById('suspect-pick-scale-wrap'); const inner = wrap && wrap.querySelector('.suspect-pick-inner'); if (!ov || !wrap || !inner || ov.classList.contains('is-hidden') || !suspectPickOverlayOpen) return; inner.style.width = SUSPECT_DESIGN_WIDTH + 'px'; inner.style.transform = 'none'; const naturalH = inner.offsetHeight; const padX = 24; const padY = 40; const availW = window.innerWidth - padX * 2; const availH = window.innerHeight - padY; const sW = availW / SUSPECT_DESIGN_WIDTH; const sH = availH / Math.max(1, naturalH); const s = Math.min(1, sW, sH); inner.style.transformOrigin = 'top center'; inner.style.transform = 'scale(' + s + ')'; wrap.style.height = Math.ceil(naturalH * s) + 'px'; wrap.style.overflow = 'hidden'; } function lobbyMapQueryParam() { try { return (new URLSearchParams(location.search).get('map') || '').trim(); } catch (e) { return ''; } } function isPostCaseLobbyRoom() { if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID) return true; if (lobbyMapQueryParam() === POST_CASE_LOBBY_SPACE_ID) return true; if (mapData) { const nm = String(mapData.name || '').trim().toLowerCase(); if (nm === 'lobbyb') return true; } if (spaceId === POST_CASE_LOBBY_SPACE_ID) return true; return false; } function syncLobbyBUiChrome() { const on = isPostCaseLobbyRoom(); try { document.body.classList.toggle('room-lobby--lobby-b', on); } catch (e) { /* ignore */ } const row = document.getElementById('lobby-b-extra-row'); if (row) { row.classList.toggle('is-hidden', !on); row.setAttribute('aria-hidden', on ? 'false' : 'true'); } } function updateSuspectFloatingOpenBtn() { const btn = document.getElementById('btn-suspect-reopen'); if (!btn) return; const show = !!serverSuspectPhaseActive && !suspectPickOverlayOpen && isPostCaseLobbyRoom(); btn.classList.toggle('is-hidden', !show); } /** ชี้ตัวคนร้ายได้ต่อเมื่อสืบครบทั้ง 3 ผู้ต้องสงสัย (เล่นมินิเกมอย่างน้อยการ์ดละ 1 ครั้ง) */ function allSuspectsInvestigated() { return [0, 1, 2].every(function (i) { return ((suspectProgress && suspectProgress[i]) || 0) >= 1; }); } function updateSuspectHostUi() { if (!suspectPickOverlayOpen) return; if (trialMode) { ensureTrialRevealBtn(); return; } // โหมดพิจารณาคดีคุม UI เอง const isHost = hostId === socket.id; const canAccuse = isHost && allSuspectsInvestigated(); const actions = document.getElementById('suspect-pick-actions'); const accuseBtn = document.getElementById('suspect-btn-accuse'); const hint = document.getElementById('suspect-pick-hint'); if (hint) { hint.textContent = isHost ? (allSuspectsInvestigated() ? 'สืบครบทุกผู้ต้องสงสัยแล้ว — กด "ชี้ตัวคนร้าย" เพื่อเข้าสู่การพิจารณาคดี' : 'เลือกการ์ดผู้ต้องสงสัยแล้วกดเริ่มสืบสวน (ต้องสืบให้ครบทั้ง 3 ก่อนชี้ตัวคนร้าย)') : 'รอ Host เลือกการ์ดและกดเริ่มสืบสวน'; hint.classList.toggle('is-hidden', false); } if (actions) { actions.classList.toggle('suspect-pick-actions--visible', isHost); actions.setAttribute('aria-hidden', isHost ? 'false' : 'true'); } if (accuseBtn) { // โชว์ปุ่มชี้ตัวเฉพาะเมื่อ host + สืบครบ 3 ผู้ต้องสงสัยแล้ว accuseBtn.classList.toggle('is-hidden', !canAccuse); accuseBtn.setAttribute('aria-hidden', canAccuse ? 'false' : 'true'); } document.querySelectorAll('.suspect-card').forEach((c) => { c.classList.toggle('suspect-card--host', isHost); }); scheduleSuspectPickScale(); } function openSuspectOverlay(selectedIndex) { const ov = document.getElementById('suspect-pick-overlay'); if (!ov) return; bindSuspectPickScaleListeners(); refreshSuspectCaseLabel(); suspectPickOverlayOpen = true; ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); applySuspectSelectionVisual(typeof selectedIndex === 'number' ? selectedIndex : 0); applySuspectProgressVisual(); updateSuspectHostUi(); updatePlayersHud(); updateSuspectFloatingOpenBtn(); } function closeSuspectOverlay() { const ov = document.getElementById('suspect-pick-overlay'); // รีเซ็ตสถานะพิจารณาคดี if (trialMode) { trialMode = null; myTrialVote = null; trialVoteCounts = [0, 0, 0]; if (ov) ov.classList.remove('suspect-pick--trial', 'trial-revealed'); document.querySelectorAll('.suspect-card').forEach(function (c) { c.classList.remove('suspect-card--myvote', 'suspect-card--culprit', 'suspect-card--innocent'); }); document.querySelectorAll('.trial-vote-badge').forEach(function (b) { b.textContent = ''; }); var banner = document.getElementById('trial-result-banner'); if (banner) banner.style.display = 'none'; var rb = document.getElementById('trial-reveal-btn'); if (rb) rb.style.display = 'none'; var loseEl = document.getElementById('trial-lose-txt'); if (loseEl) loseEl.style.display = 'none'; } const wrap = document.getElementById('suspect-pick-scale-wrap'); const inner = wrap && wrap.querySelector('.suspect-pick-inner'); if (inner) { inner.style.transform = ''; inner.style.width = ''; } if (wrap) { wrap.style.height = ''; wrap.style.overflow = ''; } if (ov) { ov.classList.add('is-hidden'); ov.setAttribute('aria-hidden', 'true'); } suspectPickOverlayOpen = false; syncLobbyBUiChrome(); updateSuspectFloatingOpenBtn(); } socket.on('troublesome-offer', (data) => { const seconds = (data && Number(data.seconds)) > 0 ? Number(data.seconds) : 15; const ov = document.getElementById('troublesome-overlay'); const secEl = document.getElementById('troublesome-sec'); if (!ov) return; ov.classList.remove('is-hidden'); if (troublesomeTickTimer) clearInterval(troublesomeTickTimer); let left = seconds; if (secEl) secEl.textContent = String(left); if (troublesomeTimerPausedForTuning) { troublesomeTickTimer = null; return; } troublesomeTickTimer = setInterval(() => { left -= 1; if (secEl) secEl.textContent = String(Math.max(0, left)); if (left <= 0) { if (troublesomeTickTimer) clearInterval(troublesomeTickTimer); troublesomeTickTimer = null; socket.emit('troublesome-response', { accept: false }); closeTroublesomeOverlay(); } }, 1000); }); const troublesomeDecline = document.getElementById('troublesome-decline'); const troublesomeAccept = document.getElementById('troublesome-accept'); if (troublesomeDecline) { troublesomeDecline.addEventListener('click', () => { socket.emit('troublesome-response', { accept: false }); closeTroublesomeOverlay(); }); } if (troublesomeAccept) { troublesomeAccept.addEventListener('click', () => { socket.emit('troublesome-response', { accept: true }); closeTroublesomeOverlay(); }); } socket.on('suspect-phase-open', (data) => { serverSuspectPhaseActive = true; if (data && data.hostId != null) hostId = data.hostId; if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; openSuspectOverlay(idx); }); socket.on('suspect-pick-update', (data) => { const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; applySuspectSelectionVisual(idx); }); // ===== ห้องสรุปหลักฐาน (ไต่สวน) ===== socket.on('testimony-open', (data) => { openTestimony(data || {}); }); socket.on('testimony-status', (data) => { testimonyStatusUpdate(data || {}); }); socket.on('testimony-reveal', (data) => { testimonyReveal(data || {}); }); socket.on('testimony-ready-update', (data) => { testimonyReadyUpdate(data || {}); }); // ===== ขั้นพิจารณาคดี ===== socket.on('trial-open', (data) => { hideTestimonyOverlay(); // ปิดห้องสรุปหลักฐานก่อนเข้าโหวต openTrialVoteUI(data || {}); }); socket.on('trial-vote-update', (data) => { if (data && Array.isArray(data.counts)) trialVoteCounts = data.counts; // เก็บไว้โชว์ตอนเปิดเผยผล (ไม่โชว์ต่อใบตอนโหวต) if (trialMode === 'voting') setTrialCounter((data && data.voted) || 0, (data && data.totalPlayers) || 0); const hint = document.getElementById('suspect-pick-hint'); if (hint && trialMode === 'voting') { hint.textContent = 'คลิกการ์ดที่คิดว่าเป็นคนร้าย'; } }); socket.on('trial-result', (data) => { showTrialResult(data || {}); }); socket.on('suspect-investigation-start', (data) => { serverSuspectPhaseActive = false; closeSuspectOverlay(); const n = (data && typeof data.selectedIndex === 'number') ? (data.selectedIndex + 1) : (suspectSelectedIndex + 1); const mgLabel = (data && data.minigameLabel) ? String(data.minigameLabel) : ''; appendLobbySystemChat('— เริ่มสืบสวน · ผู้ต้องสงสัยหมายเลข ' + n + (mgLabel ? ' · ' + mgLabel : '')); }); /** เลือกการ์ดผู้ต้องสงสัยเท่านั้น — ยังไม่เริ่มมินิเกม */ function selectSuspectCard(idx) { if (!suspectPickOverlayOpen || hostId !== socket.id) return; if (idx < 0 || idx > 2) return; const prevIdx = suspectSelectedIndex; applySuspectSelectionVisual(idx); socket.emit('suspect-pick-select', { index: idx }); if (prevIdx !== idx) { appendLobbySystemChat('— เลือกผู้ต้องสงสัยหมายเลข ' + (idx + 1)); } } /** เริ่มมินิเกมตามการ์ดที่เลือกแล้ว — กดปุ่มเริ่มสืบสวน / ชี้ตัวคนร้าย */ function beginSuspectInvestigation(sourceLabel) { if (!suspectPickOverlayOpen || hostId !== socket.id) return; const idx = suspectSelectedIndex; if (idx < 0 || idx > 2) return; socket.emit('suspect-pick-start', {}, function (res) { if (res && res.ok) return; const err = (res && res.error) ? String(res.error) : (sourceLabel || 'เริ่มสืบสวนไม่สำเร็จ'); appendLobbySystemChat('— ' + err); try { console.warn('suspect-pick-start', res); } catch (e2) { /* ignore */ } }); } document.getElementById('suspect-cards-row')?.addEventListener('click', (ev) => { const card = ev.target.closest('.suspect-card'); if (!card || !suspectPickOverlayOpen) return; const idx = parseInt(card.getAttribute('data-index'), 10); if (!(idx >= 0 && idx <= 2)) return; if (trialMode === 'voting') { castTrialVote(idx); return; } // โหมดโหวต: ทุกคนคลิกได้ if (trialMode === 'revealed') return; if (hostId !== socket.id) return; selectSuspectCard(idx); }); document.getElementById('suspect-btn-start')?.addEventListener('click', () => { beginSuspectInvestigation('เริ่มสืบสวนไม่สำเร็จ'); }); document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => { if (!suspectPickOverlayOpen || hostId !== socket.id) return; if (!allSuspectsInvestigated()) { appendLobbySystemChat('— ต้องสืบให้ครบทั้ง 3 ผู้ต้องสงสัยก่อน จึงจะชี้ตัวคนร้ายได้'); return; } // เข้าสู่ขั้นพิจารณาคดี/โหวต (ไม่ใช่เริ่มมินิเกม) socket.emit('suspect-accuse-open', {}, function (res) { if (!(res && res.ok)) appendLobbySystemChat('— เปิดพิจารณาคดีไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); }); }); document.getElementById('suspect-pick-close')?.addEventListener('click', () => { if (!suspectPickOverlayOpen) return; closeSuspectOverlay(); }); document.getElementById('btn-suspect-reopen')?.addEventListener('click', () => { if (!serverSuspectPhaseActive || suspectPickOverlayOpen) return; openSuspectOverlay(suspectSelectedIndex); }); function applyRoomLobbyBTransition(data) { const mid = (data && data.mapId) ? String(data.mapId).trim() : ''; if (!mid) return; fetch(SERVER + '/api/maps/' + encodeURIComponent(mid)) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (json) { if (!json) { appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ'); return; } serverSuspectPhaseActive = false; clientLobbyMapId = mid; mapData = json; closeSuspectOverlay(); if (!mapData.interactive) mapData.interactive = []; if (!mapData.startGameArea) mapData.startGameArea = []; if (!mapData.quizTrueArea) mapData.quizTrueArea = []; if (!mapData.quizFalseArea) mapData.quizFalseArea = []; if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; ROOM_CZ_SPOT = null; ROOM_HC_SPOT = null; markRoomCzInteractiveCell(); markHostHcInteractiveCell(); (data.peersSnap || []).forEach(function (row) { if (!row || !row.id) return; const p = peers.get(row.id); if (!p) return; p.x = row.x; p.y = row.y; p.direction = row.direction || 'down'; if (row.nickname) p.nickname = row.nickname; p.ready = !!row.ready; if (row.characterId != null) p.characterId = row.characterId; }); const meAfter = peers.get(socket.id); if (readyCheck && meAfter) { readyCheck.checked = !!meAfter.ready; updateReadyLabelVisual(); } if (mapData.backgroundImage) { mapBackgroundImg = new Image(); mapBackgroundImg.onload = function () { resizeAndDraw(); }; mapBackgroundImg.src = mapData.backgroundImage; } else { mapBackgroundImg = null; } lobbyPath = []; try { window.__detectiveLobbyMeta = { level: data.lobbyLevel || null, caseId: data.caseId || null }; } catch (e) { /* ignore */ } troublesomeEligibleSent = false; resizeAndDraw(); updateHostStartGameButton(); renderPeers(); ensureReadyControlEnabled(); if (data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); if (Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); appendLobbySystemChat('— ย้ายไป LobbyB แล้ว · ห้องนี้ไม่รับผู้เล่นใหม่'); tryTroublesomeLobbyGate(); updateSuspectFloatingOpenBtn(); syncLobbyBUiChrome(); updatePlayersHud(); try { if (String(mid) === POST_CASE_LOBBY_SPACE_ID) { var u = new URL(window.location.href); u.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID); history.replaceState({}, '', u.pathname + u.search); } } catch (e3) { /* ignore */ } }) .catch(function () { appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ'); }); } function focusLobbyMapCanvas() { try { if (canvas && typeof canvas.focus === 'function') canvas.focus({ preventScroll: true }); } catch (e) { /* ignore */ } } function applyLobbyPeersSnapFromServer(rows) { if (!rows || !rows.length) return; rows.forEach(function (row) { if (!row || !row.id) return; const p = peers.get(row.id); if (!p) return; p.x = row.x; p.y = row.y; p.direction = row.direction || 'down'; if (row.nickname) p.nickname = row.nickname; p.ready = !!row.ready; if (row.characterId != null) p.characterId = row.characterId; }); const meAfter = peers.get(socket.id); if (readyCheck && meAfter) { readyCheck.checked = !!meAfter.ready; updateReadyLabelVisual(); } } function applyQuizGameStartInLobby(data) { quizModeActive = true; quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; quizPeersLocked = {}; lastQuizQuestionText = ''; lastQuizScores = {}; try { document.body.classList.add('room-lobby--quiz-active'); } catch (e) { /* ignore */ } showQuizOverlay(); initQuizScoreboardZeros(); var qWait = document.getElementById('quiz-game-question'); if (qWait) qWait.textContent = 'กำลังโหลดคำถาม…'; var phaseWait = document.getElementById('quiz-game-phase-label'); if (phaseWait) phaseWait.textContent = 'เกมตอบคำถาม'; appendLobbySystemChat('— เริ่มเกมตอบคำถาม (เวลาอ่าน/ตอบตั้งใน Admin → คำถามเกม)'); focusLobbyMapCanvas(); } socket.on('game-start', (data) => { if (data && data.detectiveReturn && data.stayInRoomLobby) { applyDetectiveReturnToLobbyB(data); return; } serverSuspectPhaseActive = false; closeTroublesomeOverlay(); closeSuspectOverlay(); closeLobbyPreplayWizard(); if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); if (data && data.quizMode && data.stayInRoomLobby) { const qMid = (data.mapId != null) ? String(data.mapId).trim() : ''; applyLobbyPeersSnapFromServer(data.peersSnap); lobbyPath = []; applyQuizGameStartInLobby(data); if (qMid) { fetch(SERVER + '/api/maps/' + encodeURIComponent(qMid)) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (json) { if (json) { mapData = json; clientLobbyMapId = qMid; if (!mapData.interactive) mapData.interactive = []; if (!mapData.startGameArea) mapData.startGameArea = []; if (!mapData.quizTrueArea) mapData.quizTrueArea = []; if (!mapData.quizFalseArea) mapData.quizFalseArea = []; if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; ROOM_CZ_SPOT = null; // คำนวณจุดแต่งตัวใหม่ตาม map เกมที่โหลด markRoomCzInteractiveCell(); if (mapData.backgroundImage) { mapBackgroundImg = new Image(); mapBackgroundImg.onload = function () { resizeAndDraw(); }; mapBackgroundImg.src = mapData.backgroundImage; } else { mapBackgroundImg = null; } syncLobbyBUiChrome(); try { var u = new URL(window.location.href); u.searchParams.set('map', qMid); history.replaceState({}, '', u.pathname + u.search); } catch (e2) { /* ignore */ } } else { appendLobbySystemChat('— โหลดไฟล์แผนที่เกมไม่สำเร็จ — รีเฟรชหรือเช็คว่ามีฉาก ' + qMid + ' บนเซิร์ฟ'); } resizeAndDraw(); renderPeers(); redrawLobbyMap(); }) .catch(function () { appendLobbySystemChat('— โหลดแผนที่เกมล้มเหลว'); resizeAndDraw(); renderPeers(); redrawLobbyMap(); }); return; } return; } const mid = (data && data.mapId != null) ? String(data.mapId).trim() : ''; const lobbyLevelStr = (data && data.lobbyLevel != null) ? String(data.lobbyLevel) : ''; const caseIdStr = (data && data.caseId != null) ? String(data.caseId) : ''; const isLobbyBMap = mid === POST_CASE_LOBBY_SPACE_ID; const detectiveMeta = !!(lobbyLevelStr || caseIdStr); /* LobbyB = แผนที่ mn8nx46h — ต้องอยู่ room-lobby เสมอ ห้ามไป play.html (จะโดน joinLocked แล้วเด้ง lobby) */ if (data && (data.stayInRoomLobby || isLobbyBMap || (detectiveMeta && !mid && isCurrentRoomLobbyA()))) { applyRoomLobbyBTransition({ mapId: mid || POST_CASE_LOBBY_SPACE_ID, lobbyLevel: data.lobbyLevel != null ? data.lobbyLevel : lobbyLevelStr, caseId: data.caseId != null ? data.caseId : caseIdStr, peersSnap: (data && data.peersSnap) ? data.peersSnap : [] }); return; } if (data && data.detectiveMinigame) { try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ } } try { var avEl = document.getElementById('room-lobby-profile-avatar'); var cidPlay = getStoredCharacterId(); if (avEl && avEl.src && cidPlay && String(avEl.src).indexOf('data:image/') === 0) { sessionStorage.setItem('justicePlayLobbyAvatar:' + cidPlay, avEl.src); } } catch (eAv) { /* ignore */ } let q = 'play.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(getProfileDisplayName()); if (mid) q += '&map=' + encodeURIComponent(mid); if (lobbyLevelStr) q += '&lobbyLevel=' + encodeURIComponent(lobbyLevelStr); if (caseIdStr) q += '&case=' + encodeURIComponent(caseIdStr); if (data && data.detectiveMinigame) q += '&detectiveReturn=1'; location.href = q; }); document.addEventListener('keydown', (e) => { if (moveCodes.includes(e.code) && !isChatFocused()) { if (suspectPickOverlayOpen) { e.preventDefault(); return; } keys[e.code] = true; e.preventDefault(); } if (e.code === 'Escape' && !e.repeat && !isChatFocused()) { const hostConsoleOv = document.getElementById('host-console-overlay'); if (hostConsoleOv && !hostConsoleOv.classList.contains('is-hidden')) { hostConsoleOverlay.close(); e.preventDefault(); return; } const profileOv = document.getElementById('room-lobby-profile-overlay'); if (profileOv && !profileOv.classList.contains('is-hidden')) { roomLobbyProfileOverlay.close(); e.preventDefault(); return; } const evOv = document.getElementById('lobby-evidence-overlay'); if (evOv && !evOv.classList.contains('is-hidden')) { closeLobbyEvidenceModal(); e.preventDefault(); return; } const rankOv = document.getElementById('lobby-rank-overlay'); if (rankOv && !rankOv.classList.contains('is-hidden')) { closeLobbyRankModal(); e.preventDefault(); return; } if (suspectPickOverlayOpen) { closeSuspectOverlay(); e.preventDefault(); return; } getPreplayEls(); if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) { if (preplayCaseDetailOverlay && !preplayCaseDetailOverlay.classList.contains('is-hidden')) { preplayCaseDetailOverlay.classList.add('is-hidden'); } else { closeLobbyPreplayWizard(); } e.preventDefault(); return; } } if (isLobbyInteractKeyDown(e) && !e.repeat && !isChatFocused()) { if (suspectPickOverlayOpen) return; getPreplayEls(); if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) { return; } const me = peers.get(socket.id); if (!mapData || !me) return; e.preventDefault(); /* ทุก F ในโถง — ต้องกดพร้อมก่อน (รวม Host เลือกระดับ/คดี และช่องเขียว) */ const target = getLobbyInteractTarget(me); if (isRoomCzInteractTarget(target) || isNearRoomCzSpot(me)) { e.preventDefault(); openRoomCustomize(); return; } if (isHostHcInteractTarget(target) || isNearHostHcSpot(me)) { e.preventDefault(); openHostConsoleIfHost(); return; } /* F อื่นในโถง (Host เลือกระดับ/คดี และช่องเขียว) — ต้องกดพร้อมก่อน */ if (!me.ready) { appendLobbySystemChat('— กดพร้อมก่อน แล้วค่อยกด F'); return; } if (hostCanOpenLobbyAPreplayWithF()) { openLobbyPreplayWizard(); return; } if (!target) { if (isCurrentRoomLobbyA() && hostId !== socket.id) { appendLobbySystemChat('— เฉพาะ Host กด F เพื่อเลือกระดับและคดี'); } return; } e.preventDefault(); socket.emit('lobby-interact', { x: target.x, y: target.y }, (res) => { if (res && res.ok) return; if (res && res.error) appendLobbySystemChat('— ' + res.error); }); } }); document.addEventListener('keyup', (e) => { if (moveCodes.includes(e.code)) keys[e.code] = false; }); var lobbyZoomPctEl = document.getElementById('lobby-zoom-pct'); var lobbyZoomPctHideTimer = null; function showZoomPct() { if (!lobbyZoomPctEl) return; var pct = Math.round(lobbyZoom * 100); lobbyZoomPctEl.textContent = pct + '%'; lobbyZoomPctEl.classList.add('lobby-zoom-pct-visible'); if (lobbyZoomPctHideTimer) clearTimeout(lobbyZoomPctHideTimer); lobbyZoomPctHideTimer = setTimeout(function () { lobbyZoomPctEl.classList.remove('lobby-zoom-pct-visible'); lobbyZoomPctHideTimer = null; }, 1500); } if (canvas) { canvas.addEventListener('pointerdown', () => { focusLobbyMapCanvas(); }); canvas.addEventListener('wheel', (e) => { e.preventDefault(); lobbyZoom *= e.deltaY > 0 ? 0.9 : 1.1; lobbyZoom = Math.max(LOBBY_ZOOM_MIN, Math.min(LOBBY_ZOOM_MAX, lobbyZoom)); 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); }); } if (readyCheck) { ensureReadyControlEnabled(); updateReadyLabelVisual(); readyCheck.addEventListener('change', () => { const ready = readyCheck.checked; const me = peers.get(socket.id); if (me) me.ready = ready; updateReadyLabelVisual(); renderPeers(); redrawLobbyMap(); socket.emit('set-ready', { ready }); requestAnimationFrame(focusLobbyMapCanvas); }); } if (btnStart) { btnStart.addEventListener('click', () => { if (isCurrentRoomLobbyA()) return; const mapId = (playMapSelect && playMapSelect.value) ? playMapSelect.value.trim() : ''; socket.emit('start-game', { mapId: mapId || undefined }, (res) => { if (res && res.ok === false && res.error) { try { alert(res.error); } catch (e) { /* ignore */ } } }); }); } const btnLeaveLobby = document.getElementById('btn-leave-lobby'); if (btnLeaveLobby) { btnLeaveLobby.addEventListener('click', () => { window.location.href = CREATE_ROOM_URL; }); } window.addEventListener('resize', resizeAndDraw); const wrapEl = document.getElementById('lobby-map-wrap'); if (wrapEl && typeof ResizeObserver !== 'undefined') { new ResizeObserver(() => resizeAndDraw()).observe(wrapEl); } resizeAndDraw(); setTimeout(resizeAndDraw, 100); updateLobbyProfileAvatar(); setupRoomCustomize(); rlEnsureManifest(function () { rlResolveMyColors(function () { updateRoomProfileAvatarTinted(); preloadMyTintedCharacter(function () { preloadAllLobbyTintedOccupants(function () { roomPreloadReady = true; maybeHideRoomLoading(); }); }); }); }); window.addEventListener('pageshow', function () { syncLobbyAvatarFromStorage(); rlResolveMyColors(); }); document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') syncLobbyAvatarFromStorage(); }); window.addEventListener('storage', function (e) { if (e.key == null || e.key === 'gameCharacterId') syncLobbyAvatarFromStorage(); }); })();