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();
+ });
+})();