From ccda2babd5ffd72201dcab32a11f32178824c10b Mon Sep 17 00:00:00 2001 From: giteaadmin Date: Thu, 21 May 2026 15:06:52 +0000 Subject: [PATCH] design and bot on lobby --- www/html/Game/public/js/room-lobby.js | 38 +- .../js/room-lobby.js.bak-20260521-191647 | 4298 ++++++++++++++++ .../js/room-lobby.js.bak-20260521-203106 | 4302 ++++++++++++++++ .../js/room-lobby.js.bak-20260521-215409 | 4320 ++++++++++++++++ .../js/room-lobby.js.bak-20260521-215937 | 4322 +++++++++++++++++ 5 files changed, 17275 insertions(+), 5 deletions(-) create mode 100644 www/html/Game/public/js/room-lobby.js.bak-20260521-191647 create mode 100644 www/html/Game/public/js/room-lobby.js.bak-20260521-203106 create mode 100644 www/html/Game/public/js/room-lobby.js.bak-20260521-215409 create mode 100644 www/html/Game/public/js/room-lobby.js.bak-20260521-215937 diff --git a/www/html/Game/public/js/room-lobby.js b/www/html/Game/public/js/room-lobby.js index cec0037..a067896 100644 --- a/www/html/Game/public/js/room-lobby.js +++ b/www/html/Game/public/js/room-lobby.js @@ -267,6 +267,8 @@ 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; @@ -549,6 +551,23 @@ 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(); @@ -620,6 +639,8 @@ } } 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; @@ -982,7 +1003,9 @@ const dir = p.direction || 'down'; const isWalking = id === socket.id ? !!(me && me.isWalking) - : !!((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.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) @@ -994,7 +1017,8 @@ } const peerTheme = (id === socket.id) ? myTintTheme : (p.colorTheme || null); const peerSkin = (id === socket.id) ? myTintSkin : (p.colorSkin || null); - const charImg = getAvatarImgColored(p.characterId, peerTheme, peerSkin, dir, timeMs, isWalking); + const cid = p.characterId || rlDefaultCharId; // กันตัวละครหายเป็นวงกลม blob เมื่อ characterId ว่าง + const charImg = getAvatarImgColored(cid, peerTheme, peerSkin, dir, timeMs, isWalking); const iw = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalWidth : 0; const ih = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalHeight : 0; const imgScale = (iw && ih) ? Math.min(boxSize / iw, boxSize / ih, 1) : 1; @@ -1838,10 +1862,10 @@ function getStoredCharacterId() { try { const v = (localStorage.getItem('gameCharacterId') || '').trim(); - if (v === 'Chatest') return ''; // legacy placeholder — ไม่มี sprite จริง - return v; + if (v && v !== 'Chatest') return v; // 'Chatest' = legacy placeholder ไม่มี sprite จริง + return rlDefaultCharId || ''; // ไม่มีตัวที่เลือก → ใช้ตัว default ที่มี sprite จริง } catch (e) { - return ''; + return rlDefaultCharId || ''; } } @@ -3922,6 +3946,8 @@ if (!mapData.quizTrueArea) mapData.quizTrueArea = []; if (!mapData.quizFalseArea) mapData.quizFalseArea = []; if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + ROOM_CZ_SPOT = null; // รีเซ็ตจุดแต่งตัวก่อน แล้วคำนวณใหม่ตาม map ใหม่ (LobbyB ไม่มี customizeSpot → ไม่แสดง icon) + markRoomCzInteractiveCell(); (data.peersSnap || []).forEach(function (row) { if (!row || !row.id) return; const p = peers.get(row.id); @@ -4047,6 +4073,8 @@ 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(); }; diff --git a/www/html/Game/public/js/room-lobby.js.bak-20260521-191647 b/www/html/Game/public/js/room-lobby.js.bak-20260521-191647 new file mode 100644 index 0000000..cec0037 --- /dev/null +++ b/www/html/Game/public/js/room-lobby.js.bak-20260521-191647 @@ -0,0 +1,4298 @@ +(function () { + // Error "message channel closed" มาจาก extension ของเบราว์เซอร์ ไม่ใช่โค้ดเกม — ลองโหมดไม่ระบุตัวตนหรือปิด extension + const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; + const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; + 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; + if (!spaceId) { location.href = BASE + '/lobby.html'; 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 socket = io(typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined, { path: '/Game/socket.io' }); + 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; + 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 = []; + 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); + 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 rlSampleSwatch(group, idx, cb) { + var key = group + '-' + idx; + if (rlSwatchCache[key]) { cb(rlSwatchCache[key]); 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); } + }); + } + + 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; 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 && cb) cb(); + } + + /* ===== 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 preloadMyTintedCharacter(cb) { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) { if (cb) cb(); return; } + var dirs = ['down', 'up', 'left', 'right']; + var suffixes = ['idle', '0', '1', '2', '3']; + var total = dirs.length * suffixes.length, doneCount = 0, finished = false; + function one() { doneCount++; if (!finished && doneCount >= total) { finished = true; if (cb) cb(); } } + setTimeout(function () { if (!finished) { finished = true; if (cb) cb(); } }, 4500); + dirs.forEach(function (dir) { + var ckey = id + '|' + (myTintTheme || '') + '|' + (myTintSkin || '') + '|' + dir; + var anim = tintedCharCache[ckey] || (tintedCharCache[ckey] = { frames: [], fallback: null }); + suffixes.forEach(function (suf) { + rlBuildTintedFrame(id, dir, suf, myTintTheme, myTintSkin, function (img) { + if (img) { if (suf === 'idle') anim.fallback = img; else anim.frames[parseInt(suf, 10)] = img; } + one(); + }); + }); + }); + } + + /* ===== ห้องแต่งตัว (Customize) ในห้อง room-lobby — ปุ่มล่างซ้าย + popup + tint สด ===== */ + var ROOM_CZ_ASSET = SERVER + '/img/03-5-Customize/'; + var ROOM_CZ_FACE = [ + { idx: 1, price: 0 }, { idx: 2, price: 0 }, { idx: 3, price: 50 }, { idx: 4, price: 50 }, + { idx: 5, price: 50 }, { idx: 6, price: 100 }, { idx: 7, price: 100 }, { idx: 8, price: 200 } + ]; + /** จุด "ห้องแต่งตัว" ในฉาก — ตั้งจาก 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(); }; + + 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 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 roomCzInjectStyle() { + if (document.getElementById('room-cz-style')) return; + var st = document.createElement('style'); + st.id = 'room-cz-style'; + st.textContent = [ + '.room-cz-open{position:fixed;left:clamp(10px,1.4vw,22px);bottom:clamp(96px,15vh,150px);z-index:60;width:clamp(54px,6vw,76px);padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-open img{display:block;width:100%;height:auto;filter:drop-shadow(0 0 8px rgba(34,211,238,.5))}', + '.room-cz-overlay{position:fixed;inset:0;z-index:140;display:flex;align-items:center;justify-content:center;padding:16px;font-family:Kanit,Sarabun,system-ui,sans-serif}', + '.room-cz-overlay.hidden{display:none!important}', + '.room-cz-backdrop{position:absolute;inset:0;background:rgba(4,6,18,.78);backdrop-filter:blur(4px)}', + '.room-cz-dialog{position:relative;width:min(92vw,720px);max-height:90vh;overflow:hidden auto;display:flex;flex-direction:column;gap:clamp(.5rem,1.6vh,1rem);padding:clamp(1rem,3vw,1.6rem) clamp(1rem,3vw,1.8rem) clamp(1.2rem,3vw,1.8rem);border-radius:18px;background:linear-gradient(180deg,rgba(14,20,44,.97),rgba(9,13,30,.97));border:2px solid rgba(34,211,238,.7);box-shadow:0 0 22px rgba(34,211,238,.45),0 0 48px rgba(236,72,153,.28),inset 0 0 24px rgba(34,211,238,.1);color:#e8faff}', + '.room-cz-titlebar{display:flex;align-items:center;justify-content:center;position:relative}', + '.room-cz-titlebar h2{margin:0;font-size:clamp(1.1rem,3vw,1.6rem);font-weight:700;text-shadow:0 0 12px rgba(34,211,238,.6)}', + '.room-cz-close{position:absolute;right:0;top:0;width:38px;height:38px;border:2px solid rgba(236,72,153,.6);border-radius:10px;background:rgba(20,16,40,.6);color:#ff8fd0;font-size:1.4rem;line-height:1;cursor:pointer}', + '.room-cz-row{display:flex;align-items:center;gap:clamp(.5rem,2vw,1rem);flex-wrap:wrap}', + '.room-cz-rowlabel{height:clamp(20px,3.4vh,30px);width:auto;flex:0 0 auto}', + '.room-cz-swatches{display:flex;flex-wrap:wrap;gap:clamp(6px,1vw,10px)}', + '.room-cz-swatch{padding:0;border:2px solid transparent;border-radius:8px;background:none;cursor:pointer;line-height:0}', + '.room-cz-swatch img{display:block;width:clamp(28px,4vw,40px);height:auto;border-radius:6px}', + '.room-cz-swatch.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.7)}', + '.room-cz-tabs{position:relative;width:100%;max-width:560px;margin:0 auto;aspect-ratio:776/118}', + '.room-cz-tabbar{display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}', + '.room-cz-tabhit{position:absolute;top:0;height:100%;width:33.34%;padding:0;border:none;background:transparent;cursor:pointer}', + '.room-cz-tabhit[data-tab=face]{left:0}.room-cz-tabhit[data-tab=hair]{left:33.33%}.room-cz-tabhit[data-tab=cloth]{left:66.66%}', + '.room-cz-items{flex:1;min-height:clamp(140px,30vh,280px);max-height:42vh;overflow-y:auto;display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(8px,1.5vw,14px);padding:clamp(8px,1.5vw,14px);border-radius:12px;background:rgba(8,12,28,.6);border:1px solid rgba(34,211,238,.25)}', + '.room-cz-item{position:relative;padding:6px;border:2px solid rgba(34,211,238,.3);border-radius:12px;background:rgba(18,26,52,.7);cursor:pointer;aspect-ratio:1;display:flex;align-items:center;justify-content:center}', + '.room-cz-item.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.6)}', + '.room-cz-item img{width:78%;height:auto;object-fit:contain}', + '.room-cz-item .pr{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);font-size:.7rem;font-weight:700;color:#ffe066;background:rgba(0,0,0,.45);border-radius:8px;padding:1px 8px}', + '.room-cz-empty{grid-column:1/-1;align-self:center;text-align:center;color:rgba(255,255,255,.6);padding:2rem 1rem}', + '.room-cz-confirm{align-self:center;padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-confirm img{display:block;width:min(60vw,240px);height:auto}', + '@media(max-width:560px){.room-cz-items{grid-template-columns:repeat(3,1fr)}}' + ].join(''); + document.head.appendChild(st); + } + + function roomCzBuildDom() { + if (document.getElementById('room-cz-overlay')) return; + var ov = document.createElement('div'); + ov.id = 'room-cz-overlay'; ov.className = 'room-cz-overlay hidden'; + ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'ห้องแต่งตัว'); + ov.innerHTML = + '
' + + '
' + + '

ห้องแต่งตัว

' + + '
ธีมสี
' + + '
สีผิว
' + + '
' + + '
' + + '' + + '
'; + document.body.appendChild(ov); + } + + function roomCzMarkSwatch(group, idx) { + var wrap = document.getElementById(group === 'color' ? 'room-cz-colors' : 'room-cz-skins'); + if (wrap) [].forEach.call(wrap.children, function (c) { c.classList.toggle('sel', c.getAttribute('data-idx') === String(idx)); }); + } + + function roomCzSelectSwatch(group, idx) { + roomCzMarkSwatch(group, idx); + try { localStorage.setItem(group === 'color' ? 'lobbyThemeColor' : 'lobbySkinTone', String(idx)); } catch (e) {} + rlResolveMyColors(function () { updateRoomProfileAvatarTinted(); if (mapData && canvas) drawLobbyMap(); }); + } + + function roomCzMakeSwatch(group, idx) { + var b = document.createElement('button'); + b.type = 'button'; b.className = 'room-cz-swatch'; b.setAttribute('data-idx', String(idx)); + var im = document.createElement('img'); + im.src = ROOM_CZ_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png'; im.alt = ''; + b.appendChild(im); + b.addEventListener('click', function () { roomCzSelectSwatch(group, idx); }); + return b; + } + + function roomCzRenderItems(tab) { + var grid = document.getElementById('room-cz-items'); if (!grid) return; + grid.innerHTML = ''; + if (tab !== 'face') { var e = document.createElement('div'); e.className = 'room-cz-empty'; e.textContent = 'ยังไม่เปิดให้บริการ'; grid.appendChild(e); return; } + var saved = ''; try { saved = localStorage.getItem('lobbyItem_face') || ''; } catch (e2) {} + ROOM_CZ_FACE.forEach(function (it) { + var cell = document.createElement('button'); cell.type = 'button'; + cell.className = 'room-cz-item' + (String(it.idx) === saved ? ' sel' : ''); cell.setAttribute('data-idx', String(it.idx)); + var im = document.createElement('img'); im.src = ROOM_CZ_ASSET + 'face-' + it.idx + '.png'; im.alt = ''; cell.appendChild(im); + if (it.price > 0) { var p = document.createElement('span'); p.className = 'pr'; p.textContent = String(it.price); cell.appendChild(p); } + cell.addEventListener('click', function () { + [].forEach.call(grid.children, function (c) { c.classList.remove('sel'); }); + cell.classList.add('sel'); + try { localStorage.setItem('lobbyItem_face', String(it.idx)); } catch (e3) {} + }); + grid.appendChild(cell); + }); + } + + function roomCzSetTab(tab) { + var bar = document.getElementById('room-cz-tabbar'); + if (bar) { var m = { face: 'tab-1-face.png', hair: 'tab-2-hair.png', cloth: 'tab-3-cloth.png' }; bar.src = ROOM_CZ_ASSET + (m[tab] || m.face); } + roomCzRenderItems(tab); + } + + function openRoomCustomize() { + var ov = document.getElementById('room-cz-overlay'); if (!ov) return; + var cw = document.getElementById('room-cz-colors'), sw = document.getElementById('room-cz-skins'); + if (cw && !cw.childElementCount) { for (var i = 1; i <= 8; i++) cw.appendChild(roomCzMakeSwatch('color', i)); } + if (sw && !sw.childElementCount) { for (var j = 1; j <= 3; j++) sw.appendChild(roomCzMakeSwatch('skin', j)); } + try { + var c = localStorage.getItem('lobbyThemeColor'); if (c) roomCzMarkSwatch('color', c); + var s = localStorage.getItem('lobbySkinTone'); if (s) roomCzMarkSwatch('skin', s); + } catch (e) {} + roomCzSetTab('face'); + ov.classList.remove('hidden'); + } + + function closeRoomCustomize() { var ov = document.getElementById('room-cz-overlay'); if (ov) ov.classList.add('hidden'); } + + function setupRoomCustomize() { + roomCzInjectStyle(); + roomCzBuildDom(); + var ob = document.getElementById('room-cz-open'); + if (ob) ob.addEventListener('click', openRoomCustomize); + var cb = document.getElementById('room-cz-close'); + if (cb) cb.addEventListener('click', closeRoomCustomize); + var bd = document.getElementById('room-cz-backdrop'); + if (bd) bd.addEventListener('click', closeRoomCustomize); + var cf = document.getElementById('room-cz-confirm'); + if (cf) cf.addEventListener('click', closeRoomCustomize); + [].forEach.call(document.querySelectorAll('.room-cz-tabhit'), function (t) { + t.addEventListener('click', function () { roomCzSetTab(t.getAttribute('data-tab')); }); + }); + } + + 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; + + 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); + 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 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); + } + + 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 boxSize = Math.min(tileSize, tileSize * 1.2) * characterCells; + const dir = p.direction || 'down'; + const isWalking = id === socket.id + ? !!(me && me.isWalking) + : !!((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)); + 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 charImg = getAvatarImgColored(p.characterId, peerTheme, peerSkin, dir, timeMs, isWalking); + const iw = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalWidth : 0; + const ih = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalHeight : 0; + const imgScale = (iw && ih) ? Math.min(boxSize / iw, boxSize / ih, 1) : 1; + const drawW = (iw || boxSize) * imgScale; + const drawH = (ih || boxSize) * imgScale; + 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 (charImg.complete && charImg.naturalWidth) { + ctx.drawImage(charImg, 0, 0, iw, ih, 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 showPreplayCaseStep() { + if (preplayStepLevel) preplayStepLevel.classList.add('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.remove('is-hidden'); + } + + 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'); + }); + } + + 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), + }; + // สุ่มสีให้บอท (tint ผ่าน renderer Phase 3a) + var botColorIdx = 1 + Math.floor(Math.random() * 8); + var botSkinIdx = 1 + Math.floor(Math.random() * 3); + rlSampleSwatch('color', botColorIdx, function (rgb) { if (rgb) { bot.colorTheme = rgb; if (mapData && canvas) drawLobbyMap(); } }); + rlSampleSwatch('skin', botSkinIdx, function (rgb) { if (rgb) { bot.colorSkin = rgb; if (mapData && canvas) drawLobbyMap(); } }); + lobbyBots.set(id, bot); + } + while (lobbyBots.size > lobbyBotSlotCount) { + const keys = [...lobbyBots.keys()]; + lobbyBots.delete(keys[keys.length - 1]); + } + } + + 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 === 'Chatest') return ''; // legacy placeholder — ไม่มี sprite จริง + return v; + } catch (e) { + return ''; + } + } + + 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'; + } + + 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.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()); + window.addEventListener('resize', this._boundSyncProfileFrameScale); + } + + _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 dialogWidth = this.dialogEl.clientWidth || 0; + if (dialogWidth > 0) { + const rawDialogScale = dialogWidth / 1701; + dialogScale = Math.max(0.35, Math.min(1, rawDialogScale)); + this.dialogEl.style.setProperty('--rp-scale', String(dialogScale.toFixed(4))); + } + } + if (!this.innerFrameEl) return; + this.innerFrameEl.style.setProperty('--pf-scale', String(dialogScale.toFixed(4))); + } + + _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'; + }); + } + + 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 currentName = this.nameEl ? String(this.nameEl.textContent || '').trim() : getProfileDisplayName(); + const draft = window.prompt('แก้ไขชื่อผู้เล่น', currentName || ''); + if (draft == null) return; + const nextName = String(draft).trim(); + if (!nextName) { + alert('กรุณากรอกชื่อผู้เล่น'); + return; + } + saveProfileDisplayName(nextName); + this.setProfileData({ displayName: nextName }); + updateLobbyProfileAvatar(); + } + + 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.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 currentPlayers = this._getPlayersCount(); + const minAllowed = Math.max(2, currentPlayers); + const maxAllowed = 10; + const next = this.pendingMaxPlayers + delta; + this.pendingMaxPlayers = Math.max(minAllowed, Math.min(maxAllowed, 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; + maxPlayers = this.pendingMaxPlayers; + updatePlayersHud(); + this.close(); + } + + open() { + if (!this.overlay) return; + this.pendingMaxPlayers = Math.max(2, maxPlayers || 10); + 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(); + } + + socket.on('connect', () => { + socket.emit('join-space', { spaceId, nickname: nick, 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 = BASE + '/lobby.html'; + return; + } + mapData = res.mapData; + roomJoinReady = true; + maybeHideRoomLoading(); + markRoomCzInteractiveCell(); + if (ROOM_CZ_SPOT) appendLobbySystemChat('— เดินไปช่องเขียว แล้วกด F เพื่อเปิดห้องแต่งตัว'); + 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); + 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('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', () => { + if (hostId !== socket.id) { + alert('เฉพาะ Host เท่านั้น'); + return; + } + hostConsoleOverlay.open(); + }); + document.getElementById('btn-customize')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + 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; + } + + 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); + 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); + } + + function updateSuspectHostUi() { + if (!suspectPickOverlayOpen) return; + const isHost = hostId === socket.id; + 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 + ? 'เลือกการ์ดผู้ต้องสงสัยก่อน แล้วกดเริ่มสืบสวน' + : 'รอ 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) { + accuseBtn.classList.toggle('is-hidden', !isHost); + accuseBtn.setAttribute('aria-hidden', isHost ? '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); + updateSuspectHostUi(); + updatePlayersHud(); + updateSuspectFloatingOpenBtn(); + } + + function closeSuspectOverlay() { + const ov = document.getElementById('suspect-pick-overlay'); + 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); + 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('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 || hostId !== socket.id) return; + const idx = parseInt(card.getAttribute('data-index'), 10); + if (idx >= 0 && idx <= 2) selectSuspectCard(idx); + }); + + document.getElementById('suspect-btn-start')?.addEventListener('click', () => { + beginSuspectInvestigation('เริ่มสืบสวนไม่สำเร็จ'); + }); + + document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + const pickNumber = Number.isFinite(suspectSelectedIndex) ? (suspectSelectedIndex + 1) : 1; + appendLobbySystemChat('— Host ชี้ตัวคนร้ายหมายเลข ' + pickNumber); + beginSuspectInvestigation('ชี้ตัวคนร้ายไม่สำเร็จ'); + }); + + 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 = []; + (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 (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 && 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 = []; + 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 */ } + } + let q = 'play.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); + 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; + } + /* 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 () { 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(); + }); +})(); diff --git a/www/html/Game/public/js/room-lobby.js.bak-20260521-203106 b/www/html/Game/public/js/room-lobby.js.bak-20260521-203106 new file mode 100644 index 0000000..47c73ec --- /dev/null +++ b/www/html/Game/public/js/room-lobby.js.bak-20260521-203106 @@ -0,0 +1,4302 @@ +(function () { + // Error "message channel closed" มาจาก extension ของเบราว์เซอร์ ไม่ใช่โค้ดเกม — ลองโหมดไม่ระบุตัวตนหรือปิด extension + const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; + const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; + 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; + if (!spaceId) { location.href = BASE + '/lobby.html'; 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 socket = io(typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined, { path: '/Game/socket.io' }); + 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; + 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 = []; + 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); + 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 rlSampleSwatch(group, idx, cb) { + var key = group + '-' + idx; + if (rlSwatchCache[key]) { cb(rlSwatchCache[key]); 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); } + }); + } + + 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; 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 && cb) cb(); + } + + /* ===== 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 preloadMyTintedCharacter(cb) { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) { if (cb) cb(); return; } + var dirs = ['down', 'up', 'left', 'right']; + var suffixes = ['idle', '0', '1', '2', '3']; + var total = dirs.length * suffixes.length, doneCount = 0, finished = false; + function one() { doneCount++; if (!finished && doneCount >= total) { finished = true; if (cb) cb(); } } + setTimeout(function () { if (!finished) { finished = true; if (cb) cb(); } }, 4500); + dirs.forEach(function (dir) { + var ckey = id + '|' + (myTintTheme || '') + '|' + (myTintSkin || '') + '|' + dir; + var anim = tintedCharCache[ckey] || (tintedCharCache[ckey] = { frames: [], fallback: null }); + suffixes.forEach(function (suf) { + rlBuildTintedFrame(id, dir, suf, myTintTheme, myTintSkin, function (img) { + if (img) { if (suf === 'idle') anim.fallback = img; else anim.frames[parseInt(suf, 10)] = img; } + one(); + }); + }); + }); + } + + /* ===== ห้องแต่งตัว (Customize) ในห้อง room-lobby — ปุ่มล่างซ้าย + popup + tint สด ===== */ + var ROOM_CZ_ASSET = SERVER + '/img/03-5-Customize/'; + var ROOM_CZ_FACE = [ + { idx: 1, price: 0 }, { idx: 2, price: 0 }, { idx: 3, price: 50 }, { idx: 4, price: 50 }, + { idx: 5, price: 50 }, { idx: 6, price: 100 }, { idx: 7, price: 100 }, { idx: 8, price: 200 } + ]; + /** จุด "ห้องแต่งตัว" ในฉาก — ตั้งจาก 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(); }; + + 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 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 roomCzInjectStyle() { + if (document.getElementById('room-cz-style')) return; + var st = document.createElement('style'); + st.id = 'room-cz-style'; + st.textContent = [ + '.room-cz-open{position:fixed;left:clamp(10px,1.4vw,22px);bottom:clamp(96px,15vh,150px);z-index:60;width:clamp(54px,6vw,76px);padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-open img{display:block;width:100%;height:auto;filter:drop-shadow(0 0 8px rgba(34,211,238,.5))}', + '.room-cz-overlay{position:fixed;inset:0;z-index:140;display:flex;align-items:center;justify-content:center;padding:16px;font-family:Kanit,Sarabun,system-ui,sans-serif}', + '.room-cz-overlay.hidden{display:none!important}', + '.room-cz-backdrop{position:absolute;inset:0;background:rgba(4,6,18,.78);backdrop-filter:blur(4px)}', + '.room-cz-dialog{position:relative;width:min(92vw,720px);max-height:90vh;overflow:hidden auto;display:flex;flex-direction:column;gap:clamp(.5rem,1.6vh,1rem);padding:clamp(1rem,3vw,1.6rem) clamp(1rem,3vw,1.8rem) clamp(1.2rem,3vw,1.8rem);border-radius:18px;background:linear-gradient(180deg,rgba(14,20,44,.97),rgba(9,13,30,.97));border:2px solid rgba(34,211,238,.7);box-shadow:0 0 22px rgba(34,211,238,.45),0 0 48px rgba(236,72,153,.28),inset 0 0 24px rgba(34,211,238,.1);color:#e8faff}', + '.room-cz-titlebar{display:flex;align-items:center;justify-content:center;position:relative}', + '.room-cz-titlebar h2{margin:0;font-size:clamp(1.1rem,3vw,1.6rem);font-weight:700;text-shadow:0 0 12px rgba(34,211,238,.6)}', + '.room-cz-close{position:absolute;right:0;top:0;width:38px;height:38px;border:2px solid rgba(236,72,153,.6);border-radius:10px;background:rgba(20,16,40,.6);color:#ff8fd0;font-size:1.4rem;line-height:1;cursor:pointer}', + '.room-cz-row{display:flex;align-items:center;gap:clamp(.5rem,2vw,1rem);flex-wrap:wrap}', + '.room-cz-rowlabel{height:clamp(20px,3.4vh,30px);width:auto;flex:0 0 auto}', + '.room-cz-swatches{display:flex;flex-wrap:wrap;gap:clamp(6px,1vw,10px)}', + '.room-cz-swatch{padding:0;border:2px solid transparent;border-radius:8px;background:none;cursor:pointer;line-height:0}', + '.room-cz-swatch img{display:block;width:clamp(28px,4vw,40px);height:auto;border-radius:6px}', + '.room-cz-swatch.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.7)}', + '.room-cz-tabs{position:relative;width:100%;max-width:560px;margin:0 auto;aspect-ratio:776/118}', + '.room-cz-tabbar{display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}', + '.room-cz-tabhit{position:absolute;top:0;height:100%;width:33.34%;padding:0;border:none;background:transparent;cursor:pointer}', + '.room-cz-tabhit[data-tab=face]{left:0}.room-cz-tabhit[data-tab=hair]{left:33.33%}.room-cz-tabhit[data-tab=cloth]{left:66.66%}', + '.room-cz-items{flex:1;min-height:clamp(140px,30vh,280px);max-height:42vh;overflow-y:auto;display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(8px,1.5vw,14px);padding:clamp(8px,1.5vw,14px);border-radius:12px;background:rgba(8,12,28,.6);border:1px solid rgba(34,211,238,.25)}', + '.room-cz-item{position:relative;padding:6px;border:2px solid rgba(34,211,238,.3);border-radius:12px;background:rgba(18,26,52,.7);cursor:pointer;aspect-ratio:1;display:flex;align-items:center;justify-content:center}', + '.room-cz-item.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.6)}', + '.room-cz-item img{width:78%;height:auto;object-fit:contain}', + '.room-cz-item .pr{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);font-size:.7rem;font-weight:700;color:#ffe066;background:rgba(0,0,0,.45);border-radius:8px;padding:1px 8px}', + '.room-cz-empty{grid-column:1/-1;align-self:center;text-align:center;color:rgba(255,255,255,.6);padding:2rem 1rem}', + '.room-cz-confirm{align-self:center;padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-confirm img{display:block;width:min(60vw,240px);height:auto}', + '@media(max-width:560px){.room-cz-items{grid-template-columns:repeat(3,1fr)}}' + ].join(''); + document.head.appendChild(st); + } + + function roomCzBuildDom() { + if (document.getElementById('room-cz-overlay')) return; + var ov = document.createElement('div'); + ov.id = 'room-cz-overlay'; ov.className = 'room-cz-overlay hidden'; + ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'ห้องแต่งตัว'); + ov.innerHTML = + '
' + + '
' + + '

ห้องแต่งตัว

' + + '
ธีมสี
' + + '
สีผิว
' + + '
' + + '
' + + '' + + '
'; + document.body.appendChild(ov); + } + + function roomCzMarkSwatch(group, idx) { + var wrap = document.getElementById(group === 'color' ? 'room-cz-colors' : 'room-cz-skins'); + if (wrap) [].forEach.call(wrap.children, function (c) { c.classList.toggle('sel', c.getAttribute('data-idx') === String(idx)); }); + } + + function roomCzSelectSwatch(group, idx) { + roomCzMarkSwatch(group, idx); + try { localStorage.setItem(group === 'color' ? 'lobbyThemeColor' : 'lobbySkinTone', String(idx)); } catch (e) {} + rlResolveMyColors(function () { updateRoomProfileAvatarTinted(); if (mapData && canvas) drawLobbyMap(); }); + } + + function roomCzMakeSwatch(group, idx) { + var b = document.createElement('button'); + b.type = 'button'; b.className = 'room-cz-swatch'; b.setAttribute('data-idx', String(idx)); + var im = document.createElement('img'); + im.src = ROOM_CZ_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png'; im.alt = ''; + b.appendChild(im); + b.addEventListener('click', function () { roomCzSelectSwatch(group, idx); }); + return b; + } + + function roomCzRenderItems(tab) { + var grid = document.getElementById('room-cz-items'); if (!grid) return; + grid.innerHTML = ''; + if (tab !== 'face') { var e = document.createElement('div'); e.className = 'room-cz-empty'; e.textContent = 'ยังไม่เปิดให้บริการ'; grid.appendChild(e); return; } + var saved = ''; try { saved = localStorage.getItem('lobbyItem_face') || ''; } catch (e2) {} + ROOM_CZ_FACE.forEach(function (it) { + var cell = document.createElement('button'); cell.type = 'button'; + cell.className = 'room-cz-item' + (String(it.idx) === saved ? ' sel' : ''); cell.setAttribute('data-idx', String(it.idx)); + var im = document.createElement('img'); im.src = ROOM_CZ_ASSET + 'face-' + it.idx + '.png'; im.alt = ''; cell.appendChild(im); + if (it.price > 0) { var p = document.createElement('span'); p.className = 'pr'; p.textContent = String(it.price); cell.appendChild(p); } + cell.addEventListener('click', function () { + [].forEach.call(grid.children, function (c) { c.classList.remove('sel'); }); + cell.classList.add('sel'); + try { localStorage.setItem('lobbyItem_face', String(it.idx)); } catch (e3) {} + }); + grid.appendChild(cell); + }); + } + + function roomCzSetTab(tab) { + var bar = document.getElementById('room-cz-tabbar'); + if (bar) { var m = { face: 'tab-1-face.png', hair: 'tab-2-hair.png', cloth: 'tab-3-cloth.png' }; bar.src = ROOM_CZ_ASSET + (m[tab] || m.face); } + roomCzRenderItems(tab); + } + + function openRoomCustomize() { + var ov = document.getElementById('room-cz-overlay'); if (!ov) return; + var cw = document.getElementById('room-cz-colors'), sw = document.getElementById('room-cz-skins'); + if (cw && !cw.childElementCount) { for (var i = 1; i <= 8; i++) cw.appendChild(roomCzMakeSwatch('color', i)); } + if (sw && !sw.childElementCount) { for (var j = 1; j <= 3; j++) sw.appendChild(roomCzMakeSwatch('skin', j)); } + try { + var c = localStorage.getItem('lobbyThemeColor'); if (c) roomCzMarkSwatch('color', c); + var s = localStorage.getItem('lobbySkinTone'); if (s) roomCzMarkSwatch('skin', s); + } catch (e) {} + roomCzSetTab('face'); + ov.classList.remove('hidden'); + } + + function closeRoomCustomize() { var ov = document.getElementById('room-cz-overlay'); if (ov) ov.classList.add('hidden'); } + + function setupRoomCustomize() { + roomCzInjectStyle(); + roomCzBuildDom(); + var ob = document.getElementById('room-cz-open'); + if (ob) ob.addEventListener('click', openRoomCustomize); + var cb = document.getElementById('room-cz-close'); + if (cb) cb.addEventListener('click', closeRoomCustomize); + var bd = document.getElementById('room-cz-backdrop'); + if (bd) bd.addEventListener('click', closeRoomCustomize); + var cf = document.getElementById('room-cz-confirm'); + if (cf) cf.addEventListener('click', closeRoomCustomize); + [].forEach.call(document.querySelectorAll('.room-cz-tabhit'), function (t) { + t.addEventListener('click', function () { roomCzSetTab(t.getAttribute('data-tab')); }); + }); + } + + 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; + + 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); + 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 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); + } + + 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 boxSize = Math.min(tileSize, tileSize * 1.2) * characterCells; + const dir = p.direction || 'down'; + const isWalking = id === socket.id + ? !!(me && me.isWalking) + : !!((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)); + 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 charImg = getAvatarImgColored(p.characterId, peerTheme, peerSkin, dir, timeMs, isWalking); + const iw = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalWidth : 0; + const ih = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalHeight : 0; + const imgScale = (iw && ih) ? Math.min(boxSize / iw, boxSize / ih, 1) : 1; + const drawW = (iw || boxSize) * imgScale; + const drawH = (ih || boxSize) * imgScale; + 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 (charImg.complete && charImg.naturalWidth) { + ctx.drawImage(charImg, 0, 0, iw, ih, 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 showPreplayCaseStep() { + if (preplayStepLevel) preplayStepLevel.classList.add('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.remove('is-hidden'); + } + + 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'); + }); + } + + 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), + }; + // สุ่มสีให้บอท (tint ผ่าน renderer Phase 3a) + var botColorIdx = 1 + Math.floor(Math.random() * 8); + var botSkinIdx = 1 + Math.floor(Math.random() * 3); + rlSampleSwatch('color', botColorIdx, function (rgb) { if (rgb) { bot.colorTheme = rgb; if (mapData && canvas) drawLobbyMap(); } }); + rlSampleSwatch('skin', botSkinIdx, function (rgb) { if (rgb) { bot.colorSkin = rgb; if (mapData && canvas) drawLobbyMap(); } }); + lobbyBots.set(id, bot); + } + while (lobbyBots.size > lobbyBotSlotCount) { + const keys = [...lobbyBots.keys()]; + lobbyBots.delete(keys[keys.length - 1]); + } + } + + 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 === 'Chatest') return ''; // legacy placeholder — ไม่มี sprite จริง + return v; + } catch (e) { + return ''; + } + } + + 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'; + } + + 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.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()); + window.addEventListener('resize', this._boundSyncProfileFrameScale); + } + + _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 dialogWidth = this.dialogEl.clientWidth || 0; + if (dialogWidth > 0) { + const rawDialogScale = dialogWidth / 1701; + dialogScale = Math.max(0.35, Math.min(1, rawDialogScale)); + this.dialogEl.style.setProperty('--rp-scale', String(dialogScale.toFixed(4))); + } + } + if (!this.innerFrameEl) return; + this.innerFrameEl.style.setProperty('--pf-scale', String(dialogScale.toFixed(4))); + } + + _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'; + }); + } + + 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 currentName = this.nameEl ? String(this.nameEl.textContent || '').trim() : getProfileDisplayName(); + const draft = window.prompt('แก้ไขชื่อผู้เล่น', currentName || ''); + if (draft == null) return; + const nextName = String(draft).trim(); + if (!nextName) { + alert('กรุณากรอกชื่อผู้เล่น'); + return; + } + saveProfileDisplayName(nextName); + this.setProfileData({ displayName: nextName }); + updateLobbyProfileAvatar(); + } + + 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.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 currentPlayers = this._getPlayersCount(); + const minAllowed = Math.max(2, currentPlayers); + const maxAllowed = 10; + const next = this.pendingMaxPlayers + delta; + this.pendingMaxPlayers = Math.max(minAllowed, Math.min(maxAllowed, 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; + maxPlayers = this.pendingMaxPlayers; + updatePlayersHud(); + this.close(); + } + + open() { + if (!this.overlay) return; + this.pendingMaxPlayers = Math.max(2, maxPlayers || 10); + 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(); + } + + socket.on('connect', () => { + socket.emit('join-space', { spaceId, nickname: nick, 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 = BASE + '/lobby.html'; + return; + } + mapData = res.mapData; + roomJoinReady = true; + maybeHideRoomLoading(); + markRoomCzInteractiveCell(); + if (ROOM_CZ_SPOT) appendLobbySystemChat('— เดินไปช่องเขียว แล้วกด F เพื่อเปิดห้องแต่งตัว'); + 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); + 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('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', () => { + if (hostId !== socket.id) { + alert('เฉพาะ Host เท่านั้น'); + return; + } + hostConsoleOverlay.open(); + }); + document.getElementById('btn-customize')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + 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; + } + + 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); + 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); + } + + function updateSuspectHostUi() { + if (!suspectPickOverlayOpen) return; + const isHost = hostId === socket.id; + 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 + ? 'เลือกการ์ดผู้ต้องสงสัยก่อน แล้วกดเริ่มสืบสวน' + : 'รอ 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) { + accuseBtn.classList.toggle('is-hidden', !isHost); + accuseBtn.setAttribute('aria-hidden', isHost ? '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); + updateSuspectHostUi(); + updatePlayersHud(); + updateSuspectFloatingOpenBtn(); + } + + function closeSuspectOverlay() { + const ov = document.getElementById('suspect-pick-overlay'); + 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); + 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('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 || hostId !== socket.id) return; + const idx = parseInt(card.getAttribute('data-index'), 10); + if (idx >= 0 && idx <= 2) selectSuspectCard(idx); + }); + + document.getElementById('suspect-btn-start')?.addEventListener('click', () => { + beginSuspectInvestigation('เริ่มสืบสวนไม่สำเร็จ'); + }); + + document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + const pickNumber = Number.isFinite(suspectSelectedIndex) ? (suspectSelectedIndex + 1) : 1; + appendLobbySystemChat('— Host ชี้ตัวคนร้ายหมายเลข ' + pickNumber); + beginSuspectInvestigation('ชี้ตัวคนร้ายไม่สำเร็จ'); + }); + + 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; // รีเซ็ตจุดแต่งตัวก่อน แล้วคำนวณใหม่ตาม map ใหม่ (LobbyB ไม่มี customizeSpot → ไม่แสดง icon) + markRoomCzInteractiveCell(); + (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 (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 && 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 */ } + } + let q = 'play.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); + 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; + } + /* 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 () { 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(); + }); +})(); diff --git a/www/html/Game/public/js/room-lobby.js.bak-20260521-215409 b/www/html/Game/public/js/room-lobby.js.bak-20260521-215409 new file mode 100644 index 0000000..d9db022 --- /dev/null +++ b/www/html/Game/public/js/room-lobby.js.bak-20260521-215409 @@ -0,0 +1,4320 @@ +(function () { + // Error "message channel closed" มาจาก extension ของเบราว์เซอร์ ไม่ใช่โค้ดเกม — ลองโหมดไม่ระบุตัวตนหรือปิด extension + const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; + const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; + 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; + if (!spaceId) { location.href = BASE + '/lobby.html'; 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 socket = io(typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined, { path: '/Game/socket.io' }); + 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; + 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 = []; + 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); + 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 rlSampleSwatch(group, idx, cb) { + var key = group + '-' + idx; + if (rlSwatchCache[key]) { cb(rlSwatchCache[key]); 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); } + }); + } + + 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; 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 && cb) cb(); + } + + /* ===== 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 preloadMyTintedCharacter(cb) { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) { if (cb) cb(); return; } + var dirs = ['down', 'up', 'left', 'right']; + var suffixes = ['idle', '0', '1', '2', '3']; + var total = dirs.length * suffixes.length, doneCount = 0, finished = false; + function one() { doneCount++; if (!finished && doneCount >= total) { finished = true; if (cb) cb(); } } + setTimeout(function () { if (!finished) { finished = true; if (cb) cb(); } }, 4500); + dirs.forEach(function (dir) { + var ckey = id + '|' + (myTintTheme || '') + '|' + (myTintSkin || '') + '|' + dir; + var anim = tintedCharCache[ckey] || (tintedCharCache[ckey] = { frames: [], fallback: null }); + suffixes.forEach(function (suf) { + rlBuildTintedFrame(id, dir, suf, myTintTheme, myTintSkin, function (img) { + if (img) { if (suf === 'idle') anim.fallback = img; else anim.frames[parseInt(suf, 10)] = img; } + one(); + }); + }); + }); + } + + /* ===== ห้องแต่งตัว (Customize) ในห้อง room-lobby — ปุ่มล่างซ้าย + popup + tint สด ===== */ + var ROOM_CZ_ASSET = SERVER + '/img/03-5-Customize/'; + var ROOM_CZ_FACE = [ + { idx: 1, price: 0 }, { idx: 2, price: 0 }, { idx: 3, price: 50 }, { idx: 4, price: 50 }, + { idx: 5, price: 50 }, { idx: 6, price: 100 }, { idx: 7, price: 100 }, { idx: 8, price: 200 } + ]; + /** จุด "ห้องแต่งตัว" ในฉาก — ตั้งจาก 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(); }; + + 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 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 roomCzInjectStyle() { + if (document.getElementById('room-cz-style')) return; + var st = document.createElement('style'); + st.id = 'room-cz-style'; + st.textContent = [ + '.room-cz-open{position:fixed;left:clamp(10px,1.4vw,22px);bottom:clamp(96px,15vh,150px);z-index:60;width:clamp(54px,6vw,76px);padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-open img{display:block;width:100%;height:auto;filter:drop-shadow(0 0 8px rgba(34,211,238,.5))}', + '.room-cz-overlay{position:fixed;inset:0;z-index:140;display:flex;align-items:center;justify-content:center;padding:16px;font-family:Kanit,Sarabun,system-ui,sans-serif}', + '.room-cz-overlay.hidden{display:none!important}', + '.room-cz-backdrop{position:absolute;inset:0;background:rgba(4,6,18,.78);backdrop-filter:blur(4px)}', + '.room-cz-dialog{position:relative;width:min(92vw,720px);max-height:90vh;overflow:hidden auto;display:flex;flex-direction:column;gap:clamp(.5rem,1.6vh,1rem);padding:clamp(1rem,3vw,1.6rem) clamp(1rem,3vw,1.8rem) clamp(1.2rem,3vw,1.8rem);border-radius:18px;background:linear-gradient(180deg,rgba(14,20,44,.97),rgba(9,13,30,.97));border:2px solid rgba(34,211,238,.7);box-shadow:0 0 22px rgba(34,211,238,.45),0 0 48px rgba(236,72,153,.28),inset 0 0 24px rgba(34,211,238,.1);color:#e8faff}', + '.room-cz-titlebar{display:flex;align-items:center;justify-content:center;position:relative}', + '.room-cz-titlebar h2{margin:0;font-size:clamp(1.1rem,3vw,1.6rem);font-weight:700;text-shadow:0 0 12px rgba(34,211,238,.6)}', + '.room-cz-close{position:absolute;right:0;top:0;width:38px;height:38px;border:2px solid rgba(236,72,153,.6);border-radius:10px;background:rgba(20,16,40,.6);color:#ff8fd0;font-size:1.4rem;line-height:1;cursor:pointer}', + '.room-cz-row{display:flex;align-items:center;gap:clamp(.5rem,2vw,1rem);flex-wrap:wrap}', + '.room-cz-rowlabel{height:clamp(20px,3.4vh,30px);width:auto;flex:0 0 auto}', + '.room-cz-swatches{display:flex;flex-wrap:wrap;gap:clamp(6px,1vw,10px)}', + '.room-cz-swatch{padding:0;border:2px solid transparent;border-radius:8px;background:none;cursor:pointer;line-height:0}', + '.room-cz-swatch img{display:block;width:clamp(28px,4vw,40px);height:auto;border-radius:6px}', + '.room-cz-swatch.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.7)}', + '.room-cz-tabs{position:relative;width:100%;max-width:560px;margin:0 auto;aspect-ratio:776/118}', + '.room-cz-tabbar{display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}', + '.room-cz-tabhit{position:absolute;top:0;height:100%;width:33.34%;padding:0;border:none;background:transparent;cursor:pointer}', + '.room-cz-tabhit[data-tab=face]{left:0}.room-cz-tabhit[data-tab=hair]{left:33.33%}.room-cz-tabhit[data-tab=cloth]{left:66.66%}', + '.room-cz-items{flex:1;min-height:clamp(140px,30vh,280px);max-height:42vh;overflow-y:auto;display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(8px,1.5vw,14px);padding:clamp(8px,1.5vw,14px);border-radius:12px;background:rgba(8,12,28,.6);border:1px solid rgba(34,211,238,.25)}', + '.room-cz-item{position:relative;padding:6px;border:2px solid rgba(34,211,238,.3);border-radius:12px;background:rgba(18,26,52,.7);cursor:pointer;aspect-ratio:1;display:flex;align-items:center;justify-content:center}', + '.room-cz-item.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.6)}', + '.room-cz-item img{width:78%;height:auto;object-fit:contain}', + '.room-cz-item .pr{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);font-size:.7rem;font-weight:700;color:#ffe066;background:rgba(0,0,0,.45);border-radius:8px;padding:1px 8px}', + '.room-cz-empty{grid-column:1/-1;align-self:center;text-align:center;color:rgba(255,255,255,.6);padding:2rem 1rem}', + '.room-cz-confirm{align-self:center;padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-confirm img{display:block;width:min(60vw,240px);height:auto}', + '@media(max-width:560px){.room-cz-items{grid-template-columns:repeat(3,1fr)}}' + ].join(''); + document.head.appendChild(st); + } + + function roomCzBuildDom() { + if (document.getElementById('room-cz-overlay')) return; + var ov = document.createElement('div'); + ov.id = 'room-cz-overlay'; ov.className = 'room-cz-overlay hidden'; + ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'ห้องแต่งตัว'); + ov.innerHTML = + '
' + + '
' + + '

ห้องแต่งตัว

' + + '
ธีมสี
' + + '
สีผิว
' + + '
' + + '
' + + '' + + '
'; + document.body.appendChild(ov); + } + + function roomCzMarkSwatch(group, idx) { + var wrap = document.getElementById(group === 'color' ? 'room-cz-colors' : 'room-cz-skins'); + if (wrap) [].forEach.call(wrap.children, function (c) { c.classList.toggle('sel', c.getAttribute('data-idx') === String(idx)); }); + } + + function roomCzSelectSwatch(group, idx) { + roomCzMarkSwatch(group, idx); + try { localStorage.setItem(group === 'color' ? 'lobbyThemeColor' : 'lobbySkinTone', String(idx)); } catch (e) {} + rlResolveMyColors(function () { updateRoomProfileAvatarTinted(); if (mapData && canvas) drawLobbyMap(); }); + } + + function roomCzMakeSwatch(group, idx) { + var b = document.createElement('button'); + b.type = 'button'; b.className = 'room-cz-swatch'; b.setAttribute('data-idx', String(idx)); + var im = document.createElement('img'); + im.src = ROOM_CZ_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png'; im.alt = ''; + b.appendChild(im); + b.addEventListener('click', function () { roomCzSelectSwatch(group, idx); }); + return b; + } + + function roomCzRenderItems(tab) { + var grid = document.getElementById('room-cz-items'); if (!grid) return; + grid.innerHTML = ''; + if (tab !== 'face') { var e = document.createElement('div'); e.className = 'room-cz-empty'; e.textContent = 'ยังไม่เปิดให้บริการ'; grid.appendChild(e); return; } + var saved = ''; try { saved = localStorage.getItem('lobbyItem_face') || ''; } catch (e2) {} + ROOM_CZ_FACE.forEach(function (it) { + var cell = document.createElement('button'); cell.type = 'button'; + cell.className = 'room-cz-item' + (String(it.idx) === saved ? ' sel' : ''); cell.setAttribute('data-idx', String(it.idx)); + var im = document.createElement('img'); im.src = ROOM_CZ_ASSET + 'face-' + it.idx + '.png'; im.alt = ''; cell.appendChild(im); + if (it.price > 0) { var p = document.createElement('span'); p.className = 'pr'; p.textContent = String(it.price); cell.appendChild(p); } + cell.addEventListener('click', function () { + [].forEach.call(grid.children, function (c) { c.classList.remove('sel'); }); + cell.classList.add('sel'); + try { localStorage.setItem('lobbyItem_face', String(it.idx)); } catch (e3) {} + }); + grid.appendChild(cell); + }); + } + + function roomCzSetTab(tab) { + var bar = document.getElementById('room-cz-tabbar'); + if (bar) { var m = { face: 'tab-1-face.png', hair: 'tab-2-hair.png', cloth: 'tab-3-cloth.png' }; bar.src = ROOM_CZ_ASSET + (m[tab] || m.face); } + roomCzRenderItems(tab); + } + + function openRoomCustomize() { + var ov = document.getElementById('room-cz-overlay'); if (!ov) return; + var cw = document.getElementById('room-cz-colors'), sw = document.getElementById('room-cz-skins'); + if (cw && !cw.childElementCount) { for (var i = 1; i <= 8; i++) cw.appendChild(roomCzMakeSwatch('color', i)); } + if (sw && !sw.childElementCount) { for (var j = 1; j <= 3; j++) sw.appendChild(roomCzMakeSwatch('skin', j)); } + try { + var c = localStorage.getItem('lobbyThemeColor'); if (c) roomCzMarkSwatch('color', c); + var s = localStorage.getItem('lobbySkinTone'); if (s) roomCzMarkSwatch('skin', s); + } catch (e) {} + roomCzSetTab('face'); + ov.classList.remove('hidden'); + } + + function closeRoomCustomize() { var ov = document.getElementById('room-cz-overlay'); if (ov) ov.classList.add('hidden'); } + + function setupRoomCustomize() { + roomCzInjectStyle(); + roomCzBuildDom(); + var ob = document.getElementById('room-cz-open'); + if (ob) ob.addEventListener('click', openRoomCustomize); + var cb = document.getElementById('room-cz-close'); + if (cb) cb.addEventListener('click', closeRoomCustomize); + var bd = document.getElementById('room-cz-backdrop'); + if (bd) bd.addEventListener('click', closeRoomCustomize); + var cf = document.getElementById('room-cz-confirm'); + if (cf) cf.addEventListener('click', closeRoomCustomize); + [].forEach.call(document.querySelectorAll('.room-cz-tabhit'), function (t) { + t.addEventListener('click', function () { roomCzSetTab(t.getAttribute('data-tab')); }); + }); + } + + 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); + 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 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); + } + + 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 boxSize = Math.min(tileSize, tileSize * 1.2) * characterCells; + const dir = p.direction || 'down'; + const isWalking = id === socket.id + ? !!(me && me.isWalking) + : !!((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)); + 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 iw = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalWidth : 0; + const ih = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalHeight : 0; + const imgScale = (iw && ih) ? Math.min(boxSize / iw, boxSize / ih, 1) : 1; + const drawW = (iw || boxSize) * imgScale; + const drawH = (ih || boxSize) * imgScale; + 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 (charImg.complete && charImg.naturalWidth) { + ctx.drawImage(charImg, 0, 0, iw, ih, 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 showPreplayCaseStep() { + if (preplayStepLevel) preplayStepLevel.classList.add('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.remove('is-hidden'); + } + + 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'); + }); + } + + 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), + }; + // สุ่มสีให้บอท (tint ผ่าน renderer Phase 3a) + var botColorIdx = 1 + Math.floor(Math.random() * 8); + var botSkinIdx = 1 + Math.floor(Math.random() * 3); + rlSampleSwatch('color', botColorIdx, function (rgb) { if (rgb) { bot.colorTheme = rgb; if (mapData && canvas) drawLobbyMap(); } }); + rlSampleSwatch('skin', botSkinIdx, function (rgb) { if (rgb) { bot.colorSkin = rgb; if (mapData && canvas) drawLobbyMap(); } }); + lobbyBots.set(id, bot); + } + while (lobbyBots.size > lobbyBotSlotCount) { + const keys = [...lobbyBots.keys()]; + lobbyBots.delete(keys[keys.length - 1]); + } + } + + 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'; + } + + 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.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()); + window.addEventListener('resize', this._boundSyncProfileFrameScale); + } + + _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 dialogWidth = this.dialogEl.clientWidth || 0; + if (dialogWidth > 0) { + const rawDialogScale = dialogWidth / 1701; + dialogScale = Math.max(0.35, Math.min(1, rawDialogScale)); + this.dialogEl.style.setProperty('--rp-scale', String(dialogScale.toFixed(4))); + } + } + if (!this.innerFrameEl) return; + this.innerFrameEl.style.setProperty('--pf-scale', String(dialogScale.toFixed(4))); + } + + _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'; + }); + } + + 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 currentName = this.nameEl ? String(this.nameEl.textContent || '').trim() : getProfileDisplayName(); + const draft = window.prompt('แก้ไขชื่อผู้เล่น', currentName || ''); + if (draft == null) return; + const nextName = String(draft).trim(); + if (!nextName) { + alert('กรุณากรอกชื่อผู้เล่น'); + return; + } + saveProfileDisplayName(nextName); + this.setProfileData({ displayName: nextName }); + updateLobbyProfileAvatar(); + } + + 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.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 currentPlayers = this._getPlayersCount(); + const minAllowed = Math.max(2, currentPlayers); + const maxAllowed = 10; + const next = this.pendingMaxPlayers + delta; + this.pendingMaxPlayers = Math.max(minAllowed, Math.min(maxAllowed, 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; + maxPlayers = this.pendingMaxPlayers; + updatePlayersHud(); + this.close(); + } + + open() { + if (!this.overlay) return; + this.pendingMaxPlayers = Math.max(2, maxPlayers || 10); + 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(); + } + + socket.on('connect', () => { + socket.emit('join-space', { spaceId, nickname: nick, 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 = BASE + '/lobby.html'; + return; + } + mapData = res.mapData; + roomJoinReady = true; + maybeHideRoomLoading(); + markRoomCzInteractiveCell(); + if (ROOM_CZ_SPOT) appendLobbySystemChat('— เดินไปช่องเขียว แล้วกด F เพื่อเปิดห้องแต่งตัว'); + 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); + 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('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', () => { + if (hostId !== socket.id) { + alert('เฉพาะ Host เท่านั้น'); + return; + } + hostConsoleOverlay.open(); + }); + document.getElementById('btn-customize')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + 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; + } + + 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); + 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); + } + + function updateSuspectHostUi() { + if (!suspectPickOverlayOpen) return; + const isHost = hostId === socket.id; + 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 + ? 'เลือกการ์ดผู้ต้องสงสัยก่อน แล้วกดเริ่มสืบสวน' + : 'รอ 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) { + accuseBtn.classList.toggle('is-hidden', !isHost); + accuseBtn.setAttribute('aria-hidden', isHost ? '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); + updateSuspectHostUi(); + updatePlayersHud(); + updateSuspectFloatingOpenBtn(); + } + + function closeSuspectOverlay() { + const ov = document.getElementById('suspect-pick-overlay'); + 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); + 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('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 || hostId !== socket.id) return; + const idx = parseInt(card.getAttribute('data-index'), 10); + if (idx >= 0 && idx <= 2) selectSuspectCard(idx); + }); + + document.getElementById('suspect-btn-start')?.addEventListener('click', () => { + beginSuspectInvestigation('เริ่มสืบสวนไม่สำเร็จ'); + }); + + document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + const pickNumber = Number.isFinite(suspectSelectedIndex) ? (suspectSelectedIndex + 1) : 1; + appendLobbySystemChat('— Host ชี้ตัวคนร้ายหมายเลข ' + pickNumber); + beginSuspectInvestigation('ชี้ตัวคนร้ายไม่สำเร็จ'); + }); + + 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; // รีเซ็ตจุดแต่งตัวก่อน แล้วคำนวณใหม่ตาม map ใหม่ (LobbyB ไม่มี customizeSpot → ไม่แสดง icon) + markRoomCzInteractiveCell(); + (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 (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 && 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 */ } + } + let q = 'play.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); + 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; + } + /* 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 () { 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(); + }); +})(); diff --git a/www/html/Game/public/js/room-lobby.js.bak-20260521-215937 b/www/html/Game/public/js/room-lobby.js.bak-20260521-215937 new file mode 100644 index 0000000..01de927 --- /dev/null +++ b/www/html/Game/public/js/room-lobby.js.bak-20260521-215937 @@ -0,0 +1,4322 @@ +(function () { + // Error "message channel closed" มาจาก extension ของเบราว์เซอร์ ไม่ใช่โค้ดเกม — ลองโหมดไม่ระบุตัวตนหรือปิด extension + const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; + const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; + 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; + if (!spaceId) { location.href = BASE + '/lobby.html'; 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 socket = io(typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined, { path: '/Game/socket.io' }); + 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; + 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 = []; + 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); + 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 rlSampleSwatch(group, idx, cb) { + var key = group + '-' + idx; + if (rlSwatchCache[key]) { cb(rlSwatchCache[key]); 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); } + }); + } + + 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; 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 && cb) cb(); + } + + /* ===== 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 preloadMyTintedCharacter(cb) { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) { if (cb) cb(); return; } + var dirs = ['down', 'up', 'left', 'right']; + var suffixes = ['idle', '0', '1', '2', '3']; + var total = dirs.length * suffixes.length, doneCount = 0, finished = false; + function one() { doneCount++; if (!finished && doneCount >= total) { finished = true; if (cb) cb(); } } + setTimeout(function () { if (!finished) { finished = true; if (cb) cb(); } }, 4500); + dirs.forEach(function (dir) { + var ckey = id + '|' + (myTintTheme || '') + '|' + (myTintSkin || '') + '|' + dir; + var anim = tintedCharCache[ckey] || (tintedCharCache[ckey] = { frames: [], fallback: null }); + suffixes.forEach(function (suf) { + rlBuildTintedFrame(id, dir, suf, myTintTheme, myTintSkin, function (img) { + if (img) { if (suf === 'idle') anim.fallback = img; else anim.frames[parseInt(suf, 10)] = img; } + one(); + }); + }); + }); + } + + /* ===== ห้องแต่งตัว (Customize) ในห้อง room-lobby — ปุ่มล่างซ้าย + popup + tint สด ===== */ + var ROOM_CZ_ASSET = SERVER + '/img/03-5-Customize/'; + var ROOM_CZ_FACE = [ + { idx: 1, price: 0 }, { idx: 2, price: 0 }, { idx: 3, price: 50 }, { idx: 4, price: 50 }, + { idx: 5, price: 50 }, { idx: 6, price: 100 }, { idx: 7, price: 100 }, { idx: 8, price: 200 } + ]; + /** จุด "ห้องแต่งตัว" ในฉาก — ตั้งจาก 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(); }; + + 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 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 roomCzInjectStyle() { + if (document.getElementById('room-cz-style')) return; + var st = document.createElement('style'); + st.id = 'room-cz-style'; + st.textContent = [ + '.room-cz-open{position:fixed;left:clamp(10px,1.4vw,22px);bottom:clamp(96px,15vh,150px);z-index:60;width:clamp(54px,6vw,76px);padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-open img{display:block;width:100%;height:auto;filter:drop-shadow(0 0 8px rgba(34,211,238,.5))}', + '.room-cz-overlay{position:fixed;inset:0;z-index:140;display:flex;align-items:center;justify-content:center;padding:16px;font-family:Kanit,Sarabun,system-ui,sans-serif}', + '.room-cz-overlay.hidden{display:none!important}', + '.room-cz-backdrop{position:absolute;inset:0;background:rgba(4,6,18,.78);backdrop-filter:blur(4px)}', + '.room-cz-dialog{position:relative;width:min(92vw,720px);max-height:90vh;overflow:hidden auto;display:flex;flex-direction:column;gap:clamp(.5rem,1.6vh,1rem);padding:clamp(1rem,3vw,1.6rem) clamp(1rem,3vw,1.8rem) clamp(1.2rem,3vw,1.8rem);border-radius:18px;background:linear-gradient(180deg,rgba(14,20,44,.97),rgba(9,13,30,.97));border:2px solid rgba(34,211,238,.7);box-shadow:0 0 22px rgba(34,211,238,.45),0 0 48px rgba(236,72,153,.28),inset 0 0 24px rgba(34,211,238,.1);color:#e8faff}', + '.room-cz-titlebar{display:flex;align-items:center;justify-content:center;position:relative}', + '.room-cz-titlebar h2{margin:0;font-size:clamp(1.1rem,3vw,1.6rem);font-weight:700;text-shadow:0 0 12px rgba(34,211,238,.6)}', + '.room-cz-close{position:absolute;right:0;top:0;width:38px;height:38px;border:2px solid rgba(236,72,153,.6);border-radius:10px;background:rgba(20,16,40,.6);color:#ff8fd0;font-size:1.4rem;line-height:1;cursor:pointer}', + '.room-cz-row{display:flex;align-items:center;gap:clamp(.5rem,2vw,1rem);flex-wrap:wrap}', + '.room-cz-rowlabel{height:clamp(20px,3.4vh,30px);width:auto;flex:0 0 auto}', + '.room-cz-swatches{display:flex;flex-wrap:wrap;gap:clamp(6px,1vw,10px)}', + '.room-cz-swatch{padding:0;border:2px solid transparent;border-radius:8px;background:none;cursor:pointer;line-height:0}', + '.room-cz-swatch img{display:block;width:clamp(28px,4vw,40px);height:auto;border-radius:6px}', + '.room-cz-swatch.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.7)}', + '.room-cz-tabs{position:relative;width:100%;max-width:560px;margin:0 auto;aspect-ratio:776/118}', + '.room-cz-tabbar{display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}', + '.room-cz-tabhit{position:absolute;top:0;height:100%;width:33.34%;padding:0;border:none;background:transparent;cursor:pointer}', + '.room-cz-tabhit[data-tab=face]{left:0}.room-cz-tabhit[data-tab=hair]{left:33.33%}.room-cz-tabhit[data-tab=cloth]{left:66.66%}', + '.room-cz-items{flex:1;min-height:clamp(140px,30vh,280px);max-height:42vh;overflow-y:auto;display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(8px,1.5vw,14px);padding:clamp(8px,1.5vw,14px);border-radius:12px;background:rgba(8,12,28,.6);border:1px solid rgba(34,211,238,.25)}', + '.room-cz-item{position:relative;padding:6px;border:2px solid rgba(34,211,238,.3);border-radius:12px;background:rgba(18,26,52,.7);cursor:pointer;aspect-ratio:1;display:flex;align-items:center;justify-content:center}', + '.room-cz-item.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.6)}', + '.room-cz-item img{width:78%;height:auto;object-fit:contain}', + '.room-cz-item .pr{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);font-size:.7rem;font-weight:700;color:#ffe066;background:rgba(0,0,0,.45);border-radius:8px;padding:1px 8px}', + '.room-cz-empty{grid-column:1/-1;align-self:center;text-align:center;color:rgba(255,255,255,.6);padding:2rem 1rem}', + '.room-cz-confirm{align-self:center;padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-confirm img{display:block;width:min(60vw,240px);height:auto}', + '@media(max-width:560px){.room-cz-items{grid-template-columns:repeat(3,1fr)}}' + ].join(''); + document.head.appendChild(st); + } + + function roomCzBuildDom() { + if (document.getElementById('room-cz-overlay')) return; + var ov = document.createElement('div'); + ov.id = 'room-cz-overlay'; ov.className = 'room-cz-overlay hidden'; + ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'ห้องแต่งตัว'); + ov.innerHTML = + '
' + + '
' + + '

ห้องแต่งตัว

' + + '
ธีมสี
' + + '
สีผิว
' + + '
' + + '
' + + '' + + '
'; + document.body.appendChild(ov); + } + + function roomCzMarkSwatch(group, idx) { + var wrap = document.getElementById(group === 'color' ? 'room-cz-colors' : 'room-cz-skins'); + if (wrap) [].forEach.call(wrap.children, function (c) { c.classList.toggle('sel', c.getAttribute('data-idx') === String(idx)); }); + } + + function roomCzSelectSwatch(group, idx) { + roomCzMarkSwatch(group, idx); + try { localStorage.setItem(group === 'color' ? 'lobbyThemeColor' : 'lobbySkinTone', String(idx)); } catch (e) {} + rlResolveMyColors(function () { updateRoomProfileAvatarTinted(); if (mapData && canvas) drawLobbyMap(); }); + } + + function roomCzMakeSwatch(group, idx) { + var b = document.createElement('button'); + b.type = 'button'; b.className = 'room-cz-swatch'; b.setAttribute('data-idx', String(idx)); + var im = document.createElement('img'); + im.src = ROOM_CZ_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png'; im.alt = ''; + b.appendChild(im); + b.addEventListener('click', function () { roomCzSelectSwatch(group, idx); }); + return b; + } + + function roomCzRenderItems(tab) { + var grid = document.getElementById('room-cz-items'); if (!grid) return; + grid.innerHTML = ''; + if (tab !== 'face') { var e = document.createElement('div'); e.className = 'room-cz-empty'; e.textContent = 'ยังไม่เปิดให้บริการ'; grid.appendChild(e); return; } + var saved = ''; try { saved = localStorage.getItem('lobbyItem_face') || ''; } catch (e2) {} + ROOM_CZ_FACE.forEach(function (it) { + var cell = document.createElement('button'); cell.type = 'button'; + cell.className = 'room-cz-item' + (String(it.idx) === saved ? ' sel' : ''); cell.setAttribute('data-idx', String(it.idx)); + var im = document.createElement('img'); im.src = ROOM_CZ_ASSET + 'face-' + it.idx + '.png'; im.alt = ''; cell.appendChild(im); + if (it.price > 0) { var p = document.createElement('span'); p.className = 'pr'; p.textContent = String(it.price); cell.appendChild(p); } + cell.addEventListener('click', function () { + [].forEach.call(grid.children, function (c) { c.classList.remove('sel'); }); + cell.classList.add('sel'); + try { localStorage.setItem('lobbyItem_face', String(it.idx)); } catch (e3) {} + }); + grid.appendChild(cell); + }); + } + + function roomCzSetTab(tab) { + var bar = document.getElementById('room-cz-tabbar'); + if (bar) { var m = { face: 'tab-1-face.png', hair: 'tab-2-hair.png', cloth: 'tab-3-cloth.png' }; bar.src = ROOM_CZ_ASSET + (m[tab] || m.face); } + roomCzRenderItems(tab); + } + + function openRoomCustomize() { + var ov = document.getElementById('room-cz-overlay'); if (!ov) return; + var cw = document.getElementById('room-cz-colors'), sw = document.getElementById('room-cz-skins'); + if (cw && !cw.childElementCount) { for (var i = 1; i <= 8; i++) cw.appendChild(roomCzMakeSwatch('color', i)); } + if (sw && !sw.childElementCount) { for (var j = 1; j <= 3; j++) sw.appendChild(roomCzMakeSwatch('skin', j)); } + try { + var c = localStorage.getItem('lobbyThemeColor'); if (c) roomCzMarkSwatch('color', c); + var s = localStorage.getItem('lobbySkinTone'); if (s) roomCzMarkSwatch('skin', s); + } catch (e) {} + roomCzSetTab('face'); + ov.classList.remove('hidden'); + } + + function closeRoomCustomize() { var ov = document.getElementById('room-cz-overlay'); if (ov) ov.classList.add('hidden'); } + + function setupRoomCustomize() { + roomCzInjectStyle(); + roomCzBuildDom(); + var ob = document.getElementById('room-cz-open'); + if (ob) ob.addEventListener('click', openRoomCustomize); + var cb = document.getElementById('room-cz-close'); + if (cb) cb.addEventListener('click', closeRoomCustomize); + var bd = document.getElementById('room-cz-backdrop'); + if (bd) bd.addEventListener('click', closeRoomCustomize); + var cf = document.getElementById('room-cz-confirm'); + if (cf) cf.addEventListener('click', closeRoomCustomize); + [].forEach.call(document.querySelectorAll('.room-cz-tabhit'), function (t) { + t.addEventListener('click', function () { roomCzSetTab(t.getAttribute('data-tab')); }); + }); + } + + 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); + 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 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); + } + + 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 boxSize = Math.min(tileSize, tileSize * 1.2) * characterCells; + 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 iw = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalWidth : 0; + const ih = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalHeight : 0; + const imgScale = (iw && ih) ? Math.min(boxSize / iw, boxSize / ih, 1) : 1; + const drawW = (iw || boxSize) * imgScale; + const drawH = (ih || boxSize) * imgScale; + 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 (charImg.complete && charImg.naturalWidth) { + ctx.drawImage(charImg, 0, 0, iw, ih, 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 showPreplayCaseStep() { + if (preplayStepLevel) preplayStepLevel.classList.add('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.remove('is-hidden'); + } + + 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'); + }); + } + + 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), + }; + // สุ่มสีให้บอท (tint ผ่าน renderer Phase 3a) + var botColorIdx = 1 + Math.floor(Math.random() * 8); + var botSkinIdx = 1 + Math.floor(Math.random() * 3); + rlSampleSwatch('color', botColorIdx, function (rgb) { if (rgb) { bot.colorTheme = rgb; if (mapData && canvas) drawLobbyMap(); } }); + rlSampleSwatch('skin', botSkinIdx, function (rgb) { if (rgb) { bot.colorSkin = rgb; if (mapData && canvas) drawLobbyMap(); } }); + lobbyBots.set(id, bot); + } + while (lobbyBots.size > lobbyBotSlotCount) { + const keys = [...lobbyBots.keys()]; + lobbyBots.delete(keys[keys.length - 1]); + } + } + + 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'; + } + + 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.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()); + window.addEventListener('resize', this._boundSyncProfileFrameScale); + } + + _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 dialogWidth = this.dialogEl.clientWidth || 0; + if (dialogWidth > 0) { + const rawDialogScale = dialogWidth / 1701; + dialogScale = Math.max(0.35, Math.min(1, rawDialogScale)); + this.dialogEl.style.setProperty('--rp-scale', String(dialogScale.toFixed(4))); + } + } + if (!this.innerFrameEl) return; + this.innerFrameEl.style.setProperty('--pf-scale', String(dialogScale.toFixed(4))); + } + + _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'; + }); + } + + 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 currentName = this.nameEl ? String(this.nameEl.textContent || '').trim() : getProfileDisplayName(); + const draft = window.prompt('แก้ไขชื่อผู้เล่น', currentName || ''); + if (draft == null) return; + const nextName = String(draft).trim(); + if (!nextName) { + alert('กรุณากรอกชื่อผู้เล่น'); + return; + } + saveProfileDisplayName(nextName); + this.setProfileData({ displayName: nextName }); + updateLobbyProfileAvatar(); + } + + 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.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 currentPlayers = this._getPlayersCount(); + const minAllowed = Math.max(2, currentPlayers); + const maxAllowed = 10; + const next = this.pendingMaxPlayers + delta; + this.pendingMaxPlayers = Math.max(minAllowed, Math.min(maxAllowed, 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; + maxPlayers = this.pendingMaxPlayers; + updatePlayersHud(); + this.close(); + } + + open() { + if (!this.overlay) return; + this.pendingMaxPlayers = Math.max(2, maxPlayers || 10); + 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(); + } + + socket.on('connect', () => { + socket.emit('join-space', { spaceId, nickname: nick, 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 = BASE + '/lobby.html'; + return; + } + mapData = res.mapData; + roomJoinReady = true; + maybeHideRoomLoading(); + markRoomCzInteractiveCell(); + if (ROOM_CZ_SPOT) appendLobbySystemChat('— เดินไปช่องเขียว แล้วกด F เพื่อเปิดห้องแต่งตัว'); + 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); + 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('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', () => { + if (hostId !== socket.id) { + alert('เฉพาะ Host เท่านั้น'); + return; + } + hostConsoleOverlay.open(); + }); + document.getElementById('btn-customize')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + 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; + } + + 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); + 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); + } + + function updateSuspectHostUi() { + if (!suspectPickOverlayOpen) return; + const isHost = hostId === socket.id; + 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 + ? 'เลือกการ์ดผู้ต้องสงสัยก่อน แล้วกดเริ่มสืบสวน' + : 'รอ 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) { + accuseBtn.classList.toggle('is-hidden', !isHost); + accuseBtn.setAttribute('aria-hidden', isHost ? '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); + updateSuspectHostUi(); + updatePlayersHud(); + updateSuspectFloatingOpenBtn(); + } + + function closeSuspectOverlay() { + const ov = document.getElementById('suspect-pick-overlay'); + 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); + 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('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 || hostId !== socket.id) return; + const idx = parseInt(card.getAttribute('data-index'), 10); + if (idx >= 0 && idx <= 2) selectSuspectCard(idx); + }); + + document.getElementById('suspect-btn-start')?.addEventListener('click', () => { + beginSuspectInvestigation('เริ่มสืบสวนไม่สำเร็จ'); + }); + + document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + const pickNumber = Number.isFinite(suspectSelectedIndex) ? (suspectSelectedIndex + 1) : 1; + appendLobbySystemChat('— Host ชี้ตัวคนร้ายหมายเลข ' + pickNumber); + beginSuspectInvestigation('ชี้ตัวคนร้ายไม่สำเร็จ'); + }); + + 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; // รีเซ็ตจุดแต่งตัวก่อน แล้วคำนวณใหม่ตาม map ใหม่ (LobbyB ไม่มี customizeSpot → ไม่แสดง icon) + markRoomCzInteractiveCell(); + (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 (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 && 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 */ } + } + let q = 'play.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); + 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; + } + /* 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 () { 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(); + }); +})();