Files
justice/www/html/Game/public/TryChangeCSS/js/room-lobby.js
T
2026-05-06 20:55:11 +00:00

3542 lines
147 KiB
JavaScript

(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');
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 suspectPickOverlayOpen = false;
let suspectSelectedIndex = 0;
/** ตรงกับเซิร์ฟ — เฟสเลือกผู้ต้องสงสัยยังไม่จบ (ปิด overlay แล้วยังเปิดได้จากปุ่มโถง) */
let serverSuspectPhaseActive = false;
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;
}
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) {
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 };
}
const peerList = [...peers.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 charImg = getAvatarImg(p.characterId, 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 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;
}
function hostStandingInStartGameArea() {
var me = peers.get(socket.id);
if (!me || !mapData) return false;
var tx = Math.floor(me.x), ty = Math.floor(me.y);
var row = mapData.startGameArea && mapData.startGameArea[ty];
return !!(row && row[tx] === 1);
}
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 */
const LOBBY_A_MAP_NAME = 'LobbyA';
function isCurrentRoomLobbyA() {
if (!mapData) return false;
return String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase();
}
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;
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;
}
});
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 updatePlayersHud() {
if (roomPlayersHud) roomPlayersHud.textContent = 'PLAYERS : ' + peers.size + '/' + maxPlayers;
const sph = document.getElementById('suspect-players-hud');
if (sph) sph.textContent = 'PLAYERS : ' + peers.size + '/' + maxPlayers;
}
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();
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;
}
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;
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;
updatePlayersHud();
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();
serverSuspectPhaseActive = !!res.suspectPhaseActive;
if (res.suspectPhaseActive) {
openSuspectOverlay(res.suspectPickIndex != null ? res.suspectPickIndex : 0);
} else {
updateSuspectFloatingOpenBtn();
}
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 = '<option value="">— เลือกฉากสำหรับเล่น —</option>';
(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 = '<option value="">โหลดฉากไม่ได้</option>'; });
}
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 : 'จบเกมตอบคำถาม'));
redrawLobbyMap();
});
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 = '<span class="ai-typing-dot"></span><span class="ai-typing-dot"></span><span class="ai-typing-dot"></span>';
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 = '<option value="">— เลือกฉากสำหรับเล่น —</option>';
(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 = '<option value="">โหลดฉากไม่ได้</option>'; });
}
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 = '<option value="">ไม่มีไมค์</option>';
return;
}
voiceDeviceSelect.appendChild(new Option('ไมค์เริ่มต้น (default)', ''));
inputs.forEach(d => {
voiceDeviceSelect.appendChild(new Option(d.label || 'ไมค์ ' + (voiceDeviceSelect.options.length), d.deviceId));
});
} catch (e) {
voiceDeviceSelect.innerHTML = '<option value="">โหลดไม่สำเร็จ</option>';
}
}
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 = '<span class="lobby-rank-crown-ico" aria-hidden="true">👑</span><span class="lobby-rank-crown-n">1</span>';
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 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);
});
}
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');
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;
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);
appendLobbySystemChat('— เริ่มสืบสวน · ผู้ต้องสงสัยหมายเลข ' + n);
});
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) socket.emit('suspect-pick-select', { index: idx });
});
document.getElementById('suspect-btn-start')?.addEventListener('click', () => {
if (!suspectPickOverlayOpen || hostId !== socket.id) return;
socket.emit('suspect-pick-start', {}, (res) => {
if (res && res.ok) return;
const err = (res && res.error) ? String(res.error) : 'เริ่มสืบสวนไม่สำเร็จ';
appendLobbySystemChat('— ' + err);
try { console.warn('suspect-pick-start', res); } catch (e2) { /* ignore */ }
});
});
document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => {
if (!suspectPickOverlayOpen || hostId !== socket.id) return;
const pickNumber = Number.isFinite(suspectSelectedIndex) ? (suspectSelectedIndex + 1) : 1;
appendLobbySystemChat('— Host ชี้ตัวคนร้ายหมายเลข ' + pickNumber);
socket.emit('suspect-pick-start', {}, (res) => {
if (res && res.ok) return;
const err = (res && res.error) ? String(res.error) : 'ชี้ตัวคนร้ายไม่สำเร็จ';
appendLobbySystemChat('— ' + err);
try { console.warn('suspect-btn-accuse', res); } catch (e2) { /* ignore */ }
});
});
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();
appendLobbySystemChat('— ย้ายไป LobbyB แล้ว · ห้องนี้ไม่รับผู้เล่นใหม่');
tryTroublesomeLobbyGate();
updateSuspectFloatingOpenBtn();
syncLobbyBUiChrome();
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) => {
serverSuspectPhaseActive = false;
closeTroublesomeOverlay();
closeSuspectOverlay();
closeLobbyPreplayWizard();
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;
}
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);
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;
if (hostCanOpenLobbyAPreplayWithF()) {
e.preventDefault();
openLobbyPreplayWizard();
return;
}
const target = getLobbyInteractTarget(me);
if (!target) {
e.preventDefault();
return;
}
/* ทุกแบบแผนที่ในโถง (รวม quiz) — ต้องพร้อมก่อน F โต้ตอบช่องเขียว */
if (!me.ready) {
e.preventDefault();
appendLobbySystemChat('— กดพร้อมก่อน แล้วค่อยกด 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();
window.addEventListener('pageshow', function () {
syncLobbyAvatarFromStorage();
});
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') syncLobbyAvatarFromStorage();
});
window.addEventListener('storage', function (e) {
if (e.key == null || e.key === 'gameCharacterId') syncLobbyAvatarFromStorage();
});
})();