Files
justice/www/html/Game/public/js/room-lobby.js
T

7368 lines
355 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
// Error "message channel closed" มาจาก extension ของเบราว์เซอร์ ไม่ใช่โค้ดเกม — ลองโหมดไม่ระบุตัวตนหรือปิด extension
const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game';
const SERVER = BASE;
const MAIN_LOBBY_AI_BTN_ICON = typeof appPath === 'function'
? appPath('/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png')
: '/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png';
const params = new URLSearchParams(location.search);
const spaceId = params.get('space');
const nick = (params.get('nick') || '').trim() || 'ผู้เล่น';
const DISPLAY_NAME_STORAGE_KEY = 'roomLobbyDisplayName';
let profileDisplayName = nick;
const DESIGN_MENU = typeof designMainMenuHref === 'function' ? designMainMenuHref() : '/Main-Menu/';
if (!spaceId) { location.href = DESIGN_MENU; return; }
const roomIdValueEl = document.getElementById('room-id-value');
const displayRoomParam = (params.get('displayRoom') || '').trim();
/** ตัด suffix timestamp ที่ต่อท้าย spaceId ตอนสร้างห้อง (เช่น "asdasd-1782187315578" → "asdasd")
→ client ที่ join (ไม่มี ?displayRoom) จะโชว์ชื่อห้องเหมือน host */
function cleanRoomIdForDisplay(sid) {
return String(sid || '').replace(/-\d{10,}$/, '');
}
if (displayRoomParam) {
try { localStorage.setItem('lastCreatedSpaceName', displayRoomParam); } catch (e) { /* ignore */ }
if (roomIdValueEl) roomIdValueEl.textContent = displayRoomParam;
} else if (roomIdValueEl) {
roomIdValueEl.textContent = cleanRoomIdForDisplay(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' };
const LOBBY_EVIDENCE_CARD_ICONS = ['🚬', '👓', '🔪'];
/** ข้อมูลตัวอย่างตาม 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 }
] }
]
}
};
/* การ์ดหลักฐานจัดการผ่าน Admin (server) — โหลดทับ default ด้านบนเมื่อพร้อม */
var lobbyEvidenceCasesRemote = null;
function loadEvidenceCardsConfig() {
try {
fetch(SERVER + '/api/evidence-cards', { cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (j) {
if (j && j.cases) {
lobbyEvidenceCasesRemote = j.cases;
var ov = document.getElementById('lobby-evidence-overlay');
if (ov && typeof lobbyEvidenceSuspectIdx === 'number') {
try { renderLobbyEvidenceCards(lobbyEvidenceSuspectIdx); } catch (e) { /* ignore */ }
}
}
})
.catch(function () { /* ใช้ default */ });
} catch (e) { /* ignore */ }
}
loadEvidenceCardsConfig();
const socketOrigin = (typeof GAME_NODE_ORIGIN !== 'undefined' && GAME_NODE_ORIGIN)
? GAME_NODE_ORIGIN
: (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined);
if (typeof io !== 'function') {
var rlErr = document.createElement('div');
rlErr.setAttribute('role', 'alert');
rlErr.style.cssText = 'position:fixed;inset:0;z-index:999999;display:flex;align-items:center;justify-content:center;padding:24px;background:#0a0e1a;color:#e8f4ff;font-family:Kanit,system-ui,sans-serif;text-align:center;line-height:1.6';
rlErr.innerHTML = '<div><p style="font-size:1.15rem;margin:0 0 12px">โหลด Socket ไม่สำเร็จ (localhost)</p><p style="opacity:.85;margin:0 0 16px">เพิ่มในไฟล์ <code>room-lobby.html</code> บนเครื่องคุณเท่านั้น:<br><code>&lt;script src=&quot;https://srv1361159.hstgr.cloud/Game/dev-local/bootstrap.js&quot;&gt;&lt;/script&gt;</code><br>ดู Game/dev-local/INSTALL-WINDOWS.txt</p><a href="' + (typeof designMainMenuHref === 'function' ? designMainMenuHref() : '/Main-Menu/') + '" style="color:#67e8f9">กลับเมนูหลัก</a></div>';
(document.body || document.documentElement).appendChild(rlErr);
throw new Error('socket.io not loaded');
}
const socket = io(socketOrigin, { path: '/Game/socket.io' });
window.__jdGameSocket = socket;
const roomPlayersHud = document.getElementById('room-players-hud');
const peersList = document.getElementById('peers-list');
const readyCheck = document.getElementById('ready-check');
const btnStart = document.getElementById('btn-start');
const hostOnly = document.getElementById('host-only');
const nonHostMsg = document.getElementById('non-host-msg');
const playMapSelect = document.getElementById('play-map-select');
const canvas = document.getElementById('lobby-map-canvas');
const READY_IMG_IDLE = SERVER + '/img/btn-ready-idle.png?v=1';
const READY_IMG_ACTIVE = SERVER + '/img/btn-ready-active.png?v=1';
let mapData = null;
let quizModeActive = false;
let quizPhaseLocal = null;
let quizPhaseEndsAt = 0;
let quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
let quizTimerInterval = null;
let lastQuizQuestionText = '';
let lastQuizScores = {};
/** ผู้เล่นที่ตอบผิดแล้ว — วาดแบบ ghost ให้คนอื่นเห็น (สถานะของตัวเองมาจาก quizPlayerLocal) */
let quizPeersLocked = {};
let lastQuizQIdx = 0;
let lastQuizQTotal = 0;
/** mapId ฉากปัจจุบันจากเซิร์ฟ (เช่น mn8nx46h = LobbyB) — ใช้ตรวจ UI LobbyB ให้แม่น */
let clientLobbyMapId = null;
let hostId = null;
let spaceName = '';
let maxPlayers = 10;
let lobbyBotSlotCount = 0;
/** จำนวนช่องรวมในโถง (คน + บอท) — ตรง server LOBBY_SLOT_TOTAL */
const LOBBY_SLOT_TOTAL = 6;
const LOBBY_BOT_PREFIX = '__lobby_bot_';
const lobbyBots = new Map();
let suspectPickOverlayOpen = false;
let suspectSelectedIndex = 0;
/** ตรงกับเซิร์ฟ — เฟสเลือกผู้ต้องสงสัยยังไม่จบ (ปิด overlay แล้วยังเปิดได้จากปุ่มโถง) */
let serverSuspectPhaseActive = false;
/** จำนวนผู้เล่นที่กลับเข้า LobbyB แล้ว/ทั้งหมด — host เริ่มมินิเกมรอบถัดไปได้เมื่อครบ (allHere) */
let lobbyBPresentCount = 0;
let lobbyBTotalCount = 0;
let lobbyBAllHere = true;
/** มินิเกม 3 แบบที่สุ่มแล้ว — ล็อกกับการ์ด 0–2 จนกว่าสร้างห้องใหม่ */
let suspectCardMinigames = [];
/** หลักฐาน (ติกถูก) ที่เก็บได้ต่อผู้ต้องสงสัย 0–2 (เล่นมินิเกมจบ +1 ต่อช่อง สูงสุด 3) */
let suspectProgress = [0, 0, 0];
/** การ์ดหลักฐานที่ผู้เล่นคนนี้เก็บได้ — [suspectIdx][cardIdx 02] จากเซิร์ฟ */
let myPlayerEvidence = [[], [], []];
/** ขั้นพิจารณาคดี: null | 'voting' | 'revealed' */
let trialMode = null;
let myTrialVote = null;
let trialExcludedSuspect = null; /* รอบจับผิดตัว: ผู้ต้องสงสัยที่โหวตผิดไปแล้ว — ห้ามเลือกซ้ำ */
let trialSilencedMe = false; /* ฉันถูกปิดปาก (Silence) — ห้ามโหวตรอบนี้ */
let trialVoteCounts = [0, 0, 0];
let mapBackgroundImg = null;
let hostIconImg = null;
const peers = new Map();
/** รองรับ x/y string จาก Socket — ไม่งั้นตำแหน่งจากสุ่มจุดเกิดจะถูกทิ้ง */
function normalizeLobbyPeerFromServer(p, mapRef) {
const sp = mapRef && mapRef.spawn;
const sx = Number(sp && sp.x);
const sy = Number(sp && sp.y);
const fx = Number.isFinite(sx) ? sx : 1;
const fy = Number.isFinite(sy) ? sy : 1;
const x = Number(p.x);
const y = Number(p.y);
const nx = Number.isFinite(x) ? x : fx;
const ny = Number.isFinite(y) ? y : fy;
return { ...p, x: nx, y: ny, tx: nx, ty: ny };
}
const characterImages = {};
/** ลำดับโหลดรูปยืนเฉย/เดิน ต่อทิศ — idle ก่อน (ตรงกับเซิร์ฟ upload) */
function characterSpriteUrlCandidates(id, dir) {
const d = dir || 'down';
const enc = encodeURIComponent(id);
const q = '?ch=' + enc;
const base = SERVER + '/img/characters/' + enc + '_' + d;
return [
base + '_idle.png' + q,
base + '_idle_0.png' + q,
base + '.png' + q,
base + '_0.png' + q,
];
}
function createDefaultAvatarImg() {
const c = document.createElement('canvas');
c.width = 64; c.height = 64;
const ctx = c.getContext('2d');
ctx.fillStyle = '#7aa2f7';
ctx.beginPath();
ctx.arc(32, 22, 14, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#9ece6a';
ctx.beginPath();
ctx.arc(32, 48, 18, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#1a1b26';
ctx.beginPath();
ctx.arc(28, 20, 3, 0, Math.PI * 2);
ctx.arc(36, 20, 3, 0, Math.PI * 2);
ctx.fill();
const img = new Image();
img.src = c.toDataURL('image/png');
return img;
}
const defaultAvatarImg = createDefaultAvatarImg();
const characterAnimations = {};
const CHARACTER_ANIM_FRAMES = 4;
const CHARACTER_ANIM_FRAME_MS = 200;
function walkAnimPhaseIndex(now, isWalking) {
const t = isWalking ? (typeof now === 'number' ? now : Date.now()) : 0;
return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES;
}
function pickLoadedWalkFrameIndex(anim, phase) {
if (!anim || !anim.frames || !anim.frames.length) return -1;
const maxK = Math.min(phase, anim.frames.length - 1, CHARACTER_ANIM_FRAMES - 1);
for (var k = maxK; k >= 0; k--) {
var f = anim.frames[k];
if (f && f.complete && f.naturalWidth) return k;
}
return -1;
}
function getCharacterImg(id, direction) {
if (!id) return null;
const dir = direction || 'down';
const key = id + '_' + dir;
if (characterImages[key]) return characterImages[key];
const img = new Image();
const urls = characterSpriteUrlCandidates(id, dir);
let uidx = 0;
img.onerror = function () {
uidx += 1;
if (uidx >= urls.length) {
img.onerror = null;
return;
}
img.src = urls[uidx];
};
img.src = urls[0];
characterImages[key] = img;
return img;
}
function getCharacterFrame(id, direction, now, isWalking) {
if (!id) return null;
const dir = direction || 'down';
const key = id + '_' + dir;
let anim = characterAnimations[key];
if (!anim) {
anim = { frames: [], fallback: null };
characterAnimations[key] = anim;
anim.fallback = getCharacterImg(id, dir);
var q = '?ch=' + encodeURIComponent(id);
var tryIdleWalk = characterSpriteUrlCandidates(id, dir);
var frame0 = new Image();
frame0.onload = function () {
for (var i = 1; i < CHARACTER_ANIM_FRAMES; i++) {
var img = new Image();
img.src = SERVER + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png' + q;
anim.frames.push(img);
}
if (mapData && canvas) drawLobbyMap();
};
var tix = 0;
frame0.onerror = function () {
tix += 1;
if (tix >= tryIdleWalk.length) {
if (mapData && canvas) drawLobbyMap();
return;
}
frame0.src = tryIdleWalk[tix];
};
frame0.src = tryIdleWalk[0];
anim.frames.push(frame0);
}
var phase = walkAnimPhaseIndex(now, isWalking);
// ยืนนิ่ง → ใช้รูป idle (_idle.png) ก่อน
if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback;
var fi = pickLoadedWalkFrameIndex(anim, phase);
if (fi >= 0) return anim.frames[fi];
const fb = anim.fallback;
if (fb && fb.complete && fb.naturalWidth) return fb;
return null;
}
function getAvatarImg(characterId, direction, now, isWalking) {
const img = characterId ? getCharacterFrame(characterId, direction, now, isWalking) : null;
if (img) return img;
return defaultAvatarImg;
}
/* ===== Phase 3a: tint ตัวละคร (composite layer + ทาสี) — ตอนนี้ใช้กับตัวผู้เล่นเอง ===== */
var RL_CUSTOMIZE_ASSET = SERVER + '/img/03-5-Customize/';
var RL_LAYER_NAMES = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face'];
var myTintTheme = null;
var myTintSkin = null;
var rlSwatchCache = {};
var tintedCharCache = {};
var rlLayerMissing = {};
function rlLoadImg(src, cb) {
if (rlLayerMissing[src]) { cb(null); return; } // เคย 404 (ล้ม 2 ครั้ง) แล้ว ไม่ขอซ้ำ (กัน console spam)
var img = new Image();
img.onload = function () { cb(img); };
img.onerror = function () {
// retry 1 ครั้งพร้อม cache-bust ก่อนมาร์ค missing — กัน layer หลุดชั่วคราวตอน burst โหลดหน้าแรก
// (ถ้ามาร์ค missing ทันทีจากการล้มครั้งเดียว → color layer หาย → ตัวละครขาวทั้ง session)
var img2 = new Image();
img2.onload = function () { cb(img2); };
img2.onerror = function () { rlLayerMissing[src] = true; cb(null); };
img2.src = src + (src.indexOf('?') === -1 ? '?' : '&') + 'r=' + Date.now();
};
img.src = src;
}
function rlHexToRgbCss(hex) {
var h = String(hex || '').replace('#', '').trim();
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
var n = parseInt(h, 16);
if (!Number.isFinite(n)) return null;
return 'rgb(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ')';
}
function rlSampleSwatch(group, idx, cb) {
var key = group + '-' + idx;
if (rlSwatchCache[key]) { cb(rlSwatchCache[key]); return; }
var hexArr = group === 'color' ? ROOM_CZ_THEME_HEX : ROOM_CZ_SKIN_HEX;
var hi = parseInt(idx, 10) - 1;
if (hexArr && hexArr[hi]) {
var fromHex = rlHexToRgbCss(hexArr[hi]);
if (fromHex) { rlSwatchCache[key] = fromHex; cb(fromHex); return; }
}
// ไม่ sample สีจากรูปด้วย getImageData อีกต่อไป — การย่อรูป/decode ต่าง browser
// ทำให้ค่าสีเพี้ยนไม่ตรงกัน ใช้ palette hex (ด้านบน) เป็นแหล่งความจริงเดียว
cb(null);
}
var LOBBY_PLAYER_TINT_KEY = 'justiceLobbyPlayerTint';
function rgbCssToHexLobby(rgb) {
if (!rgb) return '';
var s = String(rgb).trim();
if (s.charAt(0) === '#') return s;
var m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
if (!m) return '';
return '#' + [m[1], m[2], m[3]].map(function (n) {
var v = Math.max(0, Math.min(255, parseInt(n, 10)));
return v.toString(16).padStart(2, '0');
}).join('');
}
function lobbyBotTintsStorageKey() {
return 'justiceLobbyBotTints:' + (spaceId || 'default');
}
function loadLobbyBotTintsMap() {
try {
var raw = sessionStorage.getItem(lobbyBotTintsStorageKey());
return raw ? JSON.parse(raw) : {};
} catch (e) {
return {};
}
}
function saveLobbyBotTint(botId, bot) {
if (!botId || !bot) return;
var map = loadLobbyBotTintsMap();
map[String(botId)] = {
colorTheme: bot.colorTheme || '',
colorSkin: bot.colorSkin || '',
};
try { sessionStorage.setItem(lobbyBotTintsStorageKey(), JSON.stringify(map)); } catch (e) { /* ignore */ }
}
function applySavedLobbyBotColors(botId, bot) {
var saved = loadLobbyBotTintsMap()[String(botId)];
if (!saved) return false;
if (saved.colorTheme) bot.colorTheme = saved.colorTheme;
if (saved.colorSkin) bot.colorSkin = saved.colorSkin;
return !!(saved.colorTheme || saved.colorSkin);
}
function persistLobbyPlayerTintJson() {
if (!myTintTheme && !myTintSkin) return;
var bodyHex = rgbCssToHexLobby(myTintTheme);
var headHex = rgbCssToHexLobby(myTintSkin);
try {
localStorage.setItem(LOBBY_PLAYER_TINT_KEY, JSON.stringify({
themeRgb: myTintTheme || '',
skinRgb: myTintSkin || '',
head: headHex || '',
hair: bodyHex || '',
body: bodyHex || '',
savedAt: Date.now(),
}));
} catch (e) { /* ignore */ }
}
function rlResolveMyColors(cb) {
var me = peers.get(socket.id);
var serverTi = me && parseLobbyThemeIndex(me.lobbyColorThemeIndex);
var serverSi = me && parseLobbySkinIndex(me.lobbySkinToneIndex);
if (serverTi) {
applyPeerThemeFromServer(me, serverTi, serverSi, function () {
myTintTheme = me.colorTheme || myTintTheme;
myTintSkin = me.colorSkin || myTintSkin;
try {
localStorage.setItem('lobbyThemeColor', String(serverTi));
if (serverSi) localStorage.setItem('lobbySkinTone', String(serverSi));
} catch (eLs) { /* ignore */ }
persistLobbyPlayerTintJson();
if (mapData && canvas) drawLobbyMap();
if (cb) cb();
});
return;
}
var c = '', s = '';
try { c = localStorage.getItem('lobbyThemeColor') || ''; s = localStorage.getItem('lobbySkinTone') || ''; } catch (e) {}
var need = 0, done = false;
function go() {
if (done) return;
if (need <= 0) {
done = true;
persistLobbyPlayerTintJson();
if (cb) cb();
}
}
if (c) { need++; rlSampleSwatch('color', c, function (rgb) { myTintTheme = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); }
if (s) { need++; rlSampleSwatch('skin', s, function (rgb) { myTintSkin = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); }
if (need === 0) go();
}
/* ===== Preload + Loading overlay ก่อนเข้า room-lobby (กันสีตัวละครกระตุก) ===== */
var roomJoinReady = false, roomPreloadReady = false, roomLoadingHidden = false;
function injectRoomLoadingOverlay() {
if (document.getElementById('room-loading-overlay')) return;
var style = document.createElement('style');
style.textContent = '#room-loading-overlay{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:18px;background:radial-gradient(circle at 50% 38%, #0b1430, #060818);color:#cfe9ff;font-family:Kanit,Sarabun,system-ui,sans-serif;transition:opacity .45s ease}#room-loading-overlay.is-hidden{opacity:0;pointer-events:none}#room-loading-spinner{width:56px;height:56px;border:5px solid rgba(34,211,238,.22);border-top-color:#22d3ee;border-radius:50%;animation:rlspin 1s linear infinite}@keyframes rlspin{to{transform:rotate(360deg)}}#room-loading-overlay .rl-txt{font-size:1.1rem;letter-spacing:.04em;opacity:.9}';
document.head.appendChild(style);
var ov = document.createElement('div');
ov.id = 'room-loading-overlay';
ov.innerHTML = '<div id="room-loading-spinner"></div><div class="rl-txt">กำลังโหลดตัวละคร…</div>';
(document.body || document.documentElement).appendChild(ov);
}
function hideRoomLoading() {
if (roomLoadingHidden) return;
roomLoadingHidden = true;
var ov = document.getElementById('room-loading-overlay');
if (ov) { ov.classList.add('is-hidden'); setTimeout(function () { if (ov.parentNode) ov.parentNode.removeChild(ov); }, 600); }
}
function maybeHideRoomLoading() { if (roomJoinReady && roomPreloadReady) hideRoomLoading(); }
function preloadLobbyTintedCharacter(id, theme, skin, cb) {
if (!id || (!theme && !skin)) { if (cb) cb(); return; }
var dirs = ['down', 'up', 'left', 'right'];
var suffixes = ['idle', '0', '1', '2', '3'];
var total = dirs.length * suffixes.length;
var doneCount = 0;
var finished = false;
function one() {
doneCount++;
if (!finished && doneCount >= total) {
finished = true;
if (cb) cb();
}
}
setTimeout(function () {
if (!finished) {
finished = true;
if (cb) cb();
}
}, 6000);
dirs.forEach(function (dir) {
var ckey = id + '|' + (theme || '') + '|' + (skin || '') + '|' + dir;
var anim = tintedCharCache[ckey] || (tintedCharCache[ckey] = { frames: [], fallback: null });
suffixes.forEach(function (suf) {
rlBuildTintedFrame(id, dir, suf, theme, skin, function (img) {
if (img) {
if (suf === 'idle') anim.fallback = img;
else anim.frames[parseInt(suf, 10)] = img;
}
one();
});
});
});
}
function preloadMyTintedCharacter(cb) {
preloadLobbyTintedCharacter(getStoredCharacterId(), myTintTheme, myTintSkin, cb);
}
/** อุ่น tint บอท + peer ที่มีสี — กันกระตุกตอนบอทเดิน/idle ครั้งแรก */
function preloadAllLobbyTintedOccupants(cb) {
var pending = 1;
function doneOne() {
pending--;
if (pending <= 0 && cb) cb();
}
var pid = getStoredCharacterId();
if (pid && (myTintTheme || myTintSkin)) {
pending++;
preloadLobbyTintedCharacter(pid, myTintTheme, myTintSkin, doneOne);
}
lobbyBots.forEach(function (b, id) {
if (!b || !b.characterId) return;
if (!b.colorTheme && !b.colorSkin) return;
saveLobbyBotTint(id, b); /* sync สีบอท lobby → เกม (เกมอ่าน sessionStorage นี้ ให้สีตรงกัน) */
pending++;
preloadLobbyTintedCharacter(b.characterId, b.colorTheme, b.colorSkin, doneOne);
});
peers.forEach(function (p) {
if (!p || !p.characterId) return;
if (!p.colorTheme && !p.colorSkin) return;
pending++;
preloadLobbyTintedCharacter(p.characterId, p.colorTheme, p.colorSkin, doneOne);
});
doneOne();
}
function readLobbyCharImgSize(img) {
if (!img) return { iw: 0, ih: 0 };
if (img.tagName === 'CANVAS' && img.width > 0 && img.height > 0) {
return { iw: img.width, ih: img.height };
}
if (img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) {
return { iw: img.naturalWidth, ih: img.naturalHeight };
}
return { iw: 0, ih: 0 };
}
/* ===== ห้องแต่งตัว (Customize) ในห้อง room-lobby — ปุ่มล่างซ้าย + popup + tint สด ===== */
/** ธีมสี 8 ช่อง — ตรงกับ customize-popup / play.js */
var ROOM_CZ_THEME_HEX = [
'#d72520', '#ef8508', '#efe237', '#5bb443',
'#2585cb', '#3f4ead', '#b53fd6', '#fec1fe'
];
/** สีผิว 3 ช่อง */
var ROOM_CZ_SKIN_HEX = ['#eaa78a', '#fbd5c4', '#fae9e1'];
/** สี bot จากเซิร์ฟเวอร์ (ลำดับช่อง) — [{ themeIndex, skinToneIndex }, ...] */
var lobbyBotThemesFromServer = [];
function parseLobbyThemeIndex(n) {
var v = Math.floor(Number(n));
return (v >= 1 && v <= 8) ? v : null;
}
function parseLobbySkinIndex(n) {
var v = Math.floor(Number(n));
return (v >= 1 && v <= 3) ? v : null;
}
function getMyLobbyThemeIndex() {
var me = peers.get(socket.id);
return parseLobbyThemeIndex(me && me.lobbyColorThemeIndex) || parseLobbyThemeIndex(localStorage.getItem('lobbyThemeColor'));
}
function peerLobbyThemeIndex(p) {
if (!p) return null;
return parseLobbyThemeIndex(p.lobbyColorThemeIndex) || parseLobbyThemeIndex(p.colorThemeIndex);
}
function getLobbyTakenThemeIndices(excludePeerId, humanOnly) {
var taken = [];
var seen = {};
peers.forEach(function (p, id) {
if (excludePeerId && id === excludePeerId) return;
var ti = peerLobbyThemeIndex(p);
if (ti && !seen[ti]) { seen[ti] = true; taken.push(ti); }
});
if (!humanOnly) {
lobbyBots.forEach(function (b) {
var ti = peerLobbyThemeIndex(b);
if (ti && !seen[ti]) { seen[ti] = true; taken.push(ti); }
});
}
return taken;
}
function isLobbyThemeIndexAvailable(themeIndex, forSelfChange) {
var ti = parseLobbyThemeIndex(themeIndex);
if (!ti) return false;
var myIdx = getMyLobbyThemeIndex();
if (forSelfChange && myIdx === ti) return true;
/* บล็อกสีที่ "ผู้ใช้คนอื่นหรือบอท" ใช้อยู่ทั้งหมด — ห้ามเลือกซ้ำกับใครเลย (มี 8 สี ผู้เล่นสูงสุด 6 จึงเหลือว่างเสมอ) */
return getLobbyTakenThemeIndices(forSelfChange ? socket.id : null, false).indexOf(ti) < 0;
}
function applyThemeIndexToOccupant(target, themeIndex, skinIndex, done) {
if (!target) { if (done) done(); return; }
var ti = parseLobbyThemeIndex(themeIndex) || 1;
var si = parseLobbySkinIndex(skinIndex) || 1;
target.colorThemeIndex = ti;
target.colorSkinIndex = si;
var need = 2;
function tick() {
need--;
if (need <= 0 && done) done();
}
rlSampleSwatch('color', ti, function (rgb) {
if (rgb) target.colorTheme = rgb;
tick();
});
rlSampleSwatch('skin', si, function (rgb) {
if (rgb) target.colorSkin = rgb;
tick();
});
}
function applyPeerThemeFromServer(peer, themeIndex, skinIndex, done) {
applyThemeIndexToOccupant(peer, themeIndex, skinIndex, done);
}
/** อัปเดตสีให้ "บอทที่มีอยู่แล้ว" ตาม lobbyBotThemesFromServer — กันบอทค้างสีเดิมแล้วซ้ำกับผู้เล่น
* (ensureLobbyBots ลงสีเฉพาะตอนสร้างบอทใหม่ ไม่แตะบอทเดิมเวลา server ดันบอทเปลี่ยนสี) */
function reapplyLobbyBotThemesFromServer() {
if (!Array.isArray(lobbyBotThemesFromServer) || !lobbyBots.size) return;
lobbyBots.forEach(function (bot, id) {
if (!bot) return;
var key = String(id);
if (key.indexOf(LOBBY_BOT_PREFIX) !== 0) return;
var slot = parseInt(key.slice(LOBBY_BOT_PREFIX.length), 10);
if (!(slot >= 0)) return;
var srv = lobbyBotThemesFromServer[slot];
var ti = srv && parseLobbyThemeIndex(srv.themeIndex);
if (!ti) return;
if (parseLobbyThemeIndex(bot.colorThemeIndex) === ti) return; // สีไม่เปลี่ยน → ข้าม
var si = parseLobbySkinIndex(srv.skinToneIndex) || parseLobbySkinIndex(bot.colorSkinIndex) || ((slot % 3) + 1);
applyThemeIndexToOccupant(bot, ti, si, function () {
if (bot.characterId) preloadLobbyTintedCharacter(bot.characterId, bot.colorTheme, bot.colorSkin, function () {});
if (mapData && canvas) drawLobbyMap();
});
});
}
function applyAllPeerThemesFromServer(done) {
var pending = 1;
function tick() {
pending--;
if (pending <= 0 && done) done();
}
peers.forEach(function (p, id) {
var ti = parseLobbyThemeIndex(p.lobbyColorThemeIndex);
if (!ti) return;
pending++;
applyPeerThemeFromServer(p, ti, p.lobbySkinToneIndex, tick);
if (id === socket.id) {
try {
localStorage.setItem('lobbyThemeColor', String(ti));
if (p.lobbySkinToneIndex) localStorage.setItem('lobbySkinTone', String(p.lobbySkinToneIndex));
} catch (e) { /* ignore */ }
}
});
tick();
}
/** วินิจฉัยสี: รันใน console บนแต่ละเครื่อง → เทียบ index/สี ของทุกตัว เพื่อหาจุดที่ไม่ตรง */
window.__colorDebug = function () {
var rows = [];
peers.forEach(function (p, id) {
rows.push({
who: id === socket.id ? '*ME*' : 'peer',
id: String(id).slice(0, 6),
name: p.nickname || '',
serverIdx: p.lobbyColorThemeIndex || null,
skinIdx: p.lobbySkinToneIndex || null,
renderRGB: (id === socket.id ? (myTintTheme || '') : (p.colorTheme || '(none)')),
});
});
(lobbyBots || []).forEach(function (b, i) {
rows.push({ who: 'BOT', id: String(b.id || ('bot' + i)).slice(0, 10), name: b.nickname || '', serverIdx: peerLobbyThemeIndex(b), skinIdx: b.lobbySkinToneIndex || null, renderRGB: b.colorTheme || '(none)' });
});
try { console.table(rows); } catch (e) { console.log(JSON.stringify(rows, null, 2)); }
console.log('[color-dbg/lobby] mySocket=' + socket.id + ' myTintTheme=' + myTintTheme + ' myTintSkin=' + myTintSkin +
' botThemesFromServer=' + JSON.stringify(lobbyBotThemesFromServer) +
' LS.lobbyThemeColor=' + (function () { try { return localStorage.getItem('lobbyThemeColor'); } catch (e) { return '?'; } })());
return rows;
};
function applyLobbyTintSyncPayload(data) {
if (!data) return;
if (Array.isArray(data.lobbyBotThemes)) lobbyBotThemesFromServer = data.lobbyBotThemes;
try { sessionStorage.removeItem(lobbyBotTintsStorageKey()); } catch (eClr) { /* ignore */ }
(data.peers || []).forEach(function (row) {
if (!row || !row.id) return;
var p = peers.get(row.id);
if (!p) return;
if (parseLobbyThemeIndex(row.lobbyColorThemeIndex)) {
p.lobbyColorThemeIndex = row.lobbyColorThemeIndex;
}
if (parseLobbySkinIndex(row.lobbySkinToneIndex)) {
p.lobbySkinToneIndex = row.lobbySkinToneIndex;
}
});
applyAllPeerThemesFromServer(function () {
if (peers.get(socket.id)) {
var me = peers.get(socket.id);
if (me && parseLobbyThemeIndex(me.lobbyColorThemeIndex)) {
myTintTheme = me.colorTheme || myTintTheme;
myTintSkin = me.colorSkin || myTintSkin;
}
}
syncLobbyCaseBots();
reapplyLobbyBotThemesFromServer();
if (mapData && canvas) drawLobbyMap();
if (typeof window.refreshCustomizeColorRow === 'function') window.refreshCustomizeColorRow();
});
}
/** จุด "ห้องแต่งตัว" ในฉาก — ตั้งจาก mapData.customizeSpot (วางใน editor) ถ้าไม่มี = null = ไม่แสดง */
var ROOM_CZ_SPOT = null;
var roomCzIcon = new Image();
roomCzIcon.src = '/Main-Lobby/IMAGE/btn-cloth.png';
roomCzIcon.onload = function () { if (typeof mapData !== 'undefined' && mapData && canvas) drawLobbyMap(); };
/** จุด "ตั้งค่าโฮสต์" — mapData.hostConsoleSpot (วางใน editor) */
var ROOM_HC_SPOT = null;
var roomHcIcon = new Image();
roomHcIcon.src = SERVER + '/img/btn-setting-host.png';
roomHcIcon.onload = function () { if (typeof mapData !== 'undefined' && mapData && canvas) drawLobbyMap(); };
function markRoomCzInteractiveCell() {
if (!mapData) return;
if (mapData.customizeSpot && Number.isFinite(Number(mapData.customizeSpot.x)) && Number.isFinite(Number(mapData.customizeSpot.y))) {
ROOM_CZ_SPOT = { x: Math.floor(Number(mapData.customizeSpot.x)), y: Math.floor(Number(mapData.customizeSpot.y)) };
} else {
ROOM_CZ_SPOT = null; // ไม่มีจุดแต่งตัวใน map นี้ → ไม่แสดง icon / ไม่มี interact
return;
}
var w = mapData.width || 20, h = mapData.height || 15;
if (ROOM_CZ_SPOT.x < 0 || ROOM_CZ_SPOT.x >= w || ROOM_CZ_SPOT.y < 0 || ROOM_CZ_SPOT.y >= h) { ROOM_CZ_SPOT = null; return; }
if (!Array.isArray(mapData.interactive)) mapData.interactive = [];
for (var y = 0; y < h; y++) { if (!Array.isArray(mapData.interactive[y])) mapData.interactive[y] = []; }
mapData.interactive[ROOM_CZ_SPOT.y][ROOM_CZ_SPOT.x] = 1;
}
function markHostHcInteractiveCell() {
if (!mapData) return;
if (mapData.hostConsoleSpot && Number.isFinite(Number(mapData.hostConsoleSpot.x)) && Number.isFinite(Number(mapData.hostConsoleSpot.y))) {
ROOM_HC_SPOT = { x: Math.floor(Number(mapData.hostConsoleSpot.x)), y: Math.floor(Number(mapData.hostConsoleSpot.y)) };
} else {
ROOM_HC_SPOT = null;
return;
}
var w = mapData.width || 20, h = mapData.height || 15;
if (ROOM_HC_SPOT.x < 0 || ROOM_HC_SPOT.x >= w || ROOM_HC_SPOT.y < 0 || ROOM_HC_SPOT.y >= h) { ROOM_HC_SPOT = null; return; }
if (!Array.isArray(mapData.interactive)) mapData.interactive = [];
for (var y = 0; y < h; y++) { if (!Array.isArray(mapData.interactive[y])) mapData.interactive[y] = []; }
mapData.interactive[ROOM_HC_SPOT.y][ROOM_HC_SPOT.x] = 1;
}
function isRoomCzInteractTarget(t) {
return !!(t && ROOM_CZ_SPOT && t.x === ROOM_CZ_SPOT.x && t.y === ROOM_CZ_SPOT.y);
}
function isNearRoomCzSpot(me) {
if (!me || !ROOM_CZ_SPOT || typeof me.x !== 'number' || typeof me.y !== 'number') return false;
var dx = me.x - ROOM_CZ_SPOT.x, dy = me.y - ROOM_CZ_SPOT.y;
return (dx * dx + dy * dy) <= 2.25; // ภายในรัศมี ~1.5 ช่อง (ยืนใกล้ตู้ก็กด F ได้)
}
function isHostHcInteractTarget(t) {
return !!(t && ROOM_HC_SPOT && t.x === ROOM_HC_SPOT.x && t.y === ROOM_HC_SPOT.y);
}
function isNearHostHcSpot(me) {
if (!me || !ROOM_HC_SPOT || typeof me.x !== 'number' || typeof me.y !== 'number') return false;
var dx = me.x - ROOM_HC_SPOT.x, dy = me.y - ROOM_HC_SPOT.y;
return (dx * dx + dy * dy) <= 2.25;
}
function openHostConsoleIfHost() {
if (hostId !== socket.id) {
appendLobbySystemChat('— เฉพาะ Host เท่านั้น');
return;
}
if (hostConsoleOverlay && typeof hostConsoleOverlay.open === 'function') hostConsoleOverlay.open();
}
/** เปิดห้องแต่งตัว — production ใช้ #room-cz-overlay + CSS จาก customize-popup.css */
function openRoomCustomize() {
if (typeof window.openCustomizePopup === 'function') {
window.openCustomizePopup();
}
}
function closeRoomCustomize() {
if (typeof window.closeCustomizePopup === 'function') {
window.closeCustomizePopup();
}
}
function setupRoomCustomize() {
window.openRoomCustomize = openRoomCustomize;
window.closeRoomCustomize = closeRoomCustomize;
window.isLobbyThemeIndexAvailable = isLobbyThemeIndexAvailable;
window.getLobbyTakenThemeIndices = function (forSelf) {
return getLobbyTakenThemeIndices(forSelf ? socket.id : null);
};
window.validateLobbyThemeColorChange = function (themeIndex, skinIndex, cb) {
var ti = parseLobbyThemeIndex(themeIndex);
if (!ti) { if (cb) cb(false, 'เลือกสีเสื้อไม่ถูกต้อง'); return; }
if (!isLobbyThemeIndexAvailable(ti, true)) {
if (cb) cb(false, 'สีเสื้อนี้มีคนใช้แล้ว — เลือกสีอื่น');
return;
}
socket.emit('lobby-tint-update', {
themeIndex: ti,
skinToneIndex: parseLobbySkinIndex(skinIndex) || 1,
}, function (res) {
if (!(res && res.ok)) {
if (cb) cb(false, (res && res.error) ? res.error : 'เปลี่ยนสีไม่สำเร็จ');
return;
}
try {
localStorage.setItem('lobbyThemeColor', String(res.lobbyColorThemeIndex || ti));
localStorage.setItem('lobbySkinTone', String(res.lobbySkinToneIndex || skinIndex || 1));
} catch (e) { /* ignore */ }
var me = peers.get(socket.id);
if (me) {
me.lobbyColorThemeIndex = res.lobbyColorThemeIndex || ti;
me.lobbySkinToneIndex = res.lobbySkinToneIndex || skinIndex;
}
if (cb) cb(true);
});
};
window.addEventListener('customize-popup:applied', function () {
rlResolveMyColors(function () {
persistLobbyPlayerTintJson();
updateRoomProfileAvatarTinted();
if (mapData && canvas) drawLobbyMap();
renderPeers();
});
});
try { document.dispatchEvent(new CustomEvent('room-lobby-ready')); } catch (e) { /* ignore */ }
}
function rlTintMask(img, color) {
var c = document.createElement('canvas');
c.width = img.naturalWidth; c.height = img.naturalHeight;
var x = c.getContext('2d');
x.drawImage(img, 0, 0);
x.globalCompositeOperation = 'source-in';
x.fillStyle = color;
x.fillRect(0, 0, c.width, c.height);
return c;
}
/** ตัวละคร default — ใช้เมื่อผู้เล่น/บอทไม่มี characterId (กันตัวละครหายเป็นวงกลม blob) */
var rlDefaultCharId = '';
(function rlLoadDefaultChar() {
try {
fetch(SERVER + '/api/characters', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (list) {
if (!Array.isArray(list) || !list.length) return;
var pick = list.filter(function (x) { return x && x.hasLayerFiles; })[0] || list[0];
if (pick && pick.id) {
rlDefaultCharId = pick.id;
if (typeof mapData !== 'undefined' && mapData && typeof canvas !== 'undefined' && canvas) drawLobbyMap();
}
})
.catch(function () { /* ignore */ });
} catch (e) { /* ignore */ }
})();
/** เก็บ layerManifest แบบ per-id (เดิมเก็บ slot เดียวผูกกับ getStoredCharacterId เท่านั้น
* → ถ้า localStorage ว่าง/ตัวที่วาดเป็น default char คนละ id แมนิเฟสต์ไม่โหลด tint เลยไม่ทำงาน = ตัวขาว) */
var rlManifestById = Object.create(null);
var rlManifestFetching = false;
var rlManifestWaiters = [];
function rlEnsureManifestFor(id, cb) {
if (!id) { if (cb) cb(null); return; }
if (rlManifestById[id]) { if (cb) cb(rlManifestById[id]); return; }
if (cb) rlManifestWaiters.push({ id: id, cb: cb });
if (rlManifestFetching) return;
rlManifestFetching = true;
fetch(SERVER + '/api/characters', { cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (list) {
if (Array.isArray(list)) {
list.forEach(function (c) {
if (c && c.id && c.layerManifest) rlManifestById[c.id] = c.layerManifest;
});
}
})
.catch(function () { /* ignore */ })
.then(function () {
rlManifestFetching = false;
var waiters = rlManifestWaiters.splice(0);
waiters.forEach(function (w) { try { w.cb(rlManifestById[w.id] || null); } catch (e) { /* ignore */ } });
});
}
/** wrapper เดิม — โหลด manifest ของตัวละครที่ผู้เล่นเลือกไว้ (ใช้ตอน init) */
function rlEnsureManifest(cb) {
rlEnsureManifestFor(getStoredCharacterId() || rlDefaultCharId, cb);
}
function rlFrameLayerMap(id, dir, frameSuffix) {
var manifest = rlManifestById[id];
if (!manifest) return null;
var entry = (frameSuffix === 'idle')
? (manifest.byDirIdle && manifest.byDirIdle[dir])
: (manifest.byDir && manifest.byDir[dir]);
if (!entry || !entry.frames || !entry.frames.length) return null;
if (frameSuffix === 'idle') return entry.frames[0] || null;
var fi = parseInt(frameSuffix, 10);
return entry.frames[fi] || entry.frames[0] || null;
}
function rlBuildTintedFrame(id, dir, frameSuffix, theme, skin, cb) {
// โหลด manifest ของ id นี้ให้พร้อมก่อน (คืนทันทีถ้า cache แล้ว) — กันตัวขาวเมื่อ id ที่วาด
// ไม่ตรงกับตัวที่ผู้เล่นเลือก (เช่น default char / peer ที่ใช้ตัวละครคนละตัว)
rlEnsureManifestFor(id, function () {
rlBuildTintedFrameInner(id, dir, frameSuffix, theme, skin, cb);
});
}
function rlBuildTintedFrameInner(id, dir, frameSuffix, theme, skin, cb) {
var map = rlFrameLayerMap(id, dir, frameSuffix);
if (!map) { cb(null); return; }
var names = RL_LAYER_NAMES.filter(function (n) { return map[n]; });
if (!names.length) { cb(null); return; }
var loaded = {}, pending = names.length;
names.forEach(function (name) {
rlLoadImg(SERVER + '/img/characters/' + map[name], function (img) {
loaded[name] = img;
if (--pending === 0) done();
});
});
function done() {
var ref = null, i;
for (i = 0; i < RL_LAYER_NAMES.length; i++) { if (loaded[RL_LAYER_NAMES[i]]) { ref = loaded[RL_LAYER_NAMES[i]]; break; } }
if (!ref) { cb(null); return; }
var c = document.createElement('canvas'); c.width = ref.naturalWidth; c.height = ref.naturalHeight;
var x = c.getContext('2d');
RL_LAYER_NAMES.forEach(function (name) {
var img = loaded[name];
if (!img || !img.naturalWidth) return;
if (theme && (name === 'bodyColor' || name === 'hairColor')) x.drawImage(rlTintMask(img, theme), 0, 0);
else if (skin && name === 'headColor') x.drawImage(rlTintMask(img, skin), 0, 0);
else x.drawImage(img, 0, 0);
});
var out = new Image();
try { out.src = c.toDataURL('image/png'); } catch (e) { cb(null); return; }
cb(out);
}
}
function getTintedFrame(id, theme, skin, dir, now, isWalking) {
var ckey = id + '|' + (theme || '') + '|' + (skin || '') + '|' + dir;
var anim = tintedCharCache[ckey];
if (!anim) {
anim = { frames: [], fallback: null };
tintedCharCache[ckey] = anim;
rlBuildTintedFrame(id, dir, 'idle', theme, skin, function (img) { if (img) anim.fallback = img; if (mapData && canvas) drawLobbyMap(); });
for (var i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
(function (idx) {
rlBuildTintedFrame(id, dir, String(idx), theme, skin, function (img) { if (img) anim.frames[idx] = img; if (mapData && canvas) drawLobbyMap(); });
})(i);
}
}
var phase = walkAnimPhaseIndex(now, isWalking);
// ยืนนิ่ง → ใช้เฟรม idle (byDirIdle) ก่อน ไม่ใช่เฟรมเดินเฟรมแรก
if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback;
for (var k = Math.min(phase, CHARACTER_ANIM_FRAMES - 1); k >= 0; k--) {
var f = anim.frames[k];
if (f && f.complete && f.naturalWidth) return f;
}
if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback;
return null;
}
function getAvatarImgColored(characterId, theme, skin, direction, now, isWalking) {
if (characterId && (theme || skin)) {
var t = getTintedFrame(characterId, theme || null, skin || null, direction || 'down', now, isWalking);
if (t) return t;
}
return getAvatarImg(characterId, direction, now, isWalking);
}
function updateRoomProfileAvatarTinted() {
var id = getStoredCharacterId();
if (!id || (!myTintTheme && !myTintSkin)) return;
rlBuildTintedFrame(id, 'down', 'idle', myTintTheme, myTintSkin, function (img) {
if (!img) return;
var av = document.getElementById('room-lobby-profile-avatar');
if (av) av.src = img.src;
});
}
injectRoomLoadingOverlay();
setTimeout(hideRoomLoading, 8000); // safety: ไม่บัง overlay เกิน 8 วิ
const keys = {};
const MOVE_SPEED = 0.15;
let lastMoveSend = 0;
const moveCodes = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
let lobbyInteractPulse = null;
let lobbyZoom = 1.2;
const LOBBY_ZOOM_MIN = 0.4;
const LOBBY_ZOOM_MAX = 2.5;
let mapTransform = { offsetX: 0, offsetY: 0, ts: 32, w: 20, h: 15 };
/** true เฉพาะช่องพิมพ์ข้อความ — ไม่รวม READY / แผนที่ (ให้กด F โต้ตอบได้โดยไม่ต้องกดพร้อมก่อน) */
function isChatFocused() {
const el = document.activeElement;
if (!el) return false;
if (el.id === 'ready-check') return false;
if (el.closest && el.closest('.room-lobby-ready-fixed')) return false;
if (el.id === 'lobby-map-canvas') return false;
if (el.id === 'chat-input' || el.id === 'ai-chat-input') return true;
if (el.tagName === 'TEXTAREA') return true;
if (el.tagName === 'INPUT') {
const t = (el.type || '').toLowerCase();
return t === 'text' || t === 'search' || t === 'tel' || t === 'url' || t === 'email' || t === 'password' || t === '';
}
if (el.isContentEditable) return true;
return false;
}
/** ปุ่มเดียวกับ F บนแป้น US + แป้นไทยมาตรฐาน (ช่องเดียวกับ F → ด) + ยังทำงานระหว่างสลับภาษา */
function isLobbyInteractKeyDown(e) {
if (e.isComposing || e.keyCode === 229) return false;
if (e.code === 'KeyF') return true;
const k = e.key;
if (k === 'f' || k === 'F') return true;
if (k === 'ด') return true;
return false;
}
hostIconImg = new Image();
hostIconImg.src = SERVER + '/img/host-icon.png';
hostIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); };
const lobbyReadyIconImg = new Image();
lobbyReadyIconImg.src = SERVER + '/img/icon-ready.png?v=1';
lobbyReadyIconImg.onerror = function () {
lobbyReadyIconImg.src = SERVER + '/img/lobby-icon-ready.png?v=1';
};
lobbyReadyIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); };
const readyLabelImg = document.getElementById('ready-label-img');
const readyLabelEl = document.getElementById('ready-label');
/** พร้อม/ไม่พร้อม ใช้ได้ทุกจำนวนคนในห้อง — ไม่ล็อกตาม peers.size */
function ensureReadyControlEnabled() {
if (readyCheck) readyCheck.disabled = false;
}
function updateReadyLabelVisual() {
if (!readyCheck || !readyLabelImg) return;
var on = readyCheck.checked;
readyLabelImg.src = on ? READY_IMG_ACTIVE : READY_IMG_IDLE;
readyLabelImg.alt = on ? 'พร้อมแล้ว — กดเพื่อยกเลิก' : 'ยังไม่พร้อม — กดเพื่อพร้อม';
if (readyLabelEl) readyLabelEl.classList.toggle('ready-label--active', on);
}
const defaultShadowImgs = { up: new Image(), down: new Image(), left: new Image(), right: new Image() };
['up', 'down', 'left', 'right'].forEach(function (d) {
defaultShadowImgs[d].src = SERVER + '/img/default-shadow-' + d + '.png';
defaultShadowImgs[d].onload = function () { if (mapData && canvas) drawLobbyMap(); };
});
function getDefaultShadowImg(dir) {
const d = dir || 'down';
return defaultShadowImgs[d] && defaultShadowImgs[d].complete && defaultShadowImgs[d].naturalWidth ? defaultShadowImgs[d] : null;
}
const speakingBubbleFrames = [];
const SPEAKING_BUBBLE_FRAME_MS = 120;
for (var sb = 0; sb < 4; sb++) {
var img = new Image();
img.src = SERVER + '/img/speaking-bubble-0' + sb + '.png';
img.onload = function () { if (mapData && canvas) drawLobbyMap(); };
speakingBubbleFrames.push(img);
}
function getSpeakingBubbleFrame() {
var idx = Math.floor((typeof Date !== 'undefined' ? Date.now() : 0) / SPEAKING_BUBBLE_FRAME_MS) % 4;
var img = speakingBubbleFrames[idx];
return img && img.complete && img.naturalWidth ? img : (speakingBubbleFrames[0] && speakingBubbleFrames[0].complete ? speakingBubbleFrames[0] : null);
}
function getQuizQuestionAreaTileBounds(md) {
if (!md || md.gameType !== 'quiz') return null;
const grid = md.quizQuestionArea;
if (!grid || !grid.length) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (let yy = 0; yy < grid.length; yy++) {
const row = grid[yy];
if (!row) continue;
for (let xx = 0; xx < row.length; xx++) {
if (row[xx] === 1) {
if (xx < minX) minX = xx;
if (yy < minY) minY = yy;
if (xx > maxX) maxX = xx;
if (yy > maxY) maxY = yy;
}
}
}
if (minX === Infinity) return null;
return { minX, minY, maxX, maxY };
}
function syncQuizMapQuestionPanel() {
const panel = document.getElementById('quiz-map-question-panel');
const textEl = document.getElementById('quiz-map-question-text');
if (!panel || !textEl) return;
const bounds = (quizModeActive && mapData && mapData.gameType === 'quiz' && (lastQuizQuestionText || '').trim())
? getQuizQuestionAreaTileBounds(mapData)
: null;
if (!bounds || !mapTransform || mapTransform.cw == null) {
panel.classList.add('is-hidden');
panel.setAttribute('aria-hidden', 'true');
return;
}
const cw = mapTransform.cw;
const ch = mapTransform.ch;
const z = mapTransform.lobbyZoom;
const ts = mapTransform.tileSize;
const meX = mapTransform.meX;
const meY = mapTransform.meY;
const leftPx = cw / 2 + z * (bounds.minX * ts - meX * ts);
const topPx = ch / 2 + z * (bounds.minY * ts - meY * ts);
const wPx = z * (bounds.maxX - bounds.minX + 1) * ts;
const hPx = z * (bounds.maxY - bounds.minY + 1) * ts;
textEl.textContent = lastQuizQuestionText || '';
panel.style.left = Math.round(leftPx) + 'px';
panel.style.top = Math.round(topPx) + 'px';
panel.style.width = Math.round(Math.max(48, wPx)) + 'px';
panel.style.height = Math.round(Math.max(40, hPx)) + 'px';
panel.classList.remove('is-hidden');
panel.setAttribute('aria-hidden', 'false');
}
function drawLobbyMap() {
if (!canvas || !mapData) return;
const ctx = canvas.getContext('2d');
const w = mapData.width || 20;
const h = mapData.height || 15;
const tileSize = mapData.tileSize || 32;
const mapWpx = w * tileSize;
const mapHpx = h * tileSize;
const cw = canvas.width;
const ch = canvas.height;
const characterCells = mapData.characterCells || 1;
const cellsW = Math.max(1, Math.min(4, Number(mapData.characterCellsW) || characterCells || 1));
const cellsH = Math.max(1, Math.min(4, Number(mapData.characterCellsH) || characterCells || 1));
const me = peers.get(socket.id);
const meX = (me && typeof me.x === 'number') ? me.x : 1;
const meY = (me && typeof me.y === 'number') ? me.y : 1;
const ts = tileSize * lobbyZoom;
mapTransform = { cw, ch, lobbyZoom, tileSize, meX, meY, w, h };
ctx.fillStyle = '#303770';
ctx.fillRect(0, 0, cw, ch);
ctx.save();
ctx.translate(cw / 2, ch / 2);
ctx.scale(lobbyZoom, lobbyZoom);
ctx.translate(-meX * tileSize, -meY * tileSize);
if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) {
const nw = mapBackgroundImg.naturalWidth;
const nh = mapBackgroundImg.naturalHeight;
ctx.drawImage(mapBackgroundImg, 0, 0, nw, nh, 0, 0, mapWpx, mapHpx);
}
const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false';
const timeMs = Date.now();
if (showGrid) {
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const sx = x * tileSize;
const sy = y * tileSize;
const ob = mapData.objects?.[y]?.[x] ?? 0;
const cellColor = mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x];
if (ob === 1) {
ctx.fillStyle = 'rgba(65,72,104,0.92)';
ctx.fillRect(sx, sy, tileSize, tileSize);
ctx.strokeStyle = '#565f89';
ctx.strokeRect(sx, sy, tileSize, tileSize);
} else if (cellColor) {
ctx.fillStyle = cellColor;
ctx.fillRect(sx, sy, tileSize, tileSize);
} else if (!mapBackgroundImg || !mapBackgroundImg.complete) {
ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335';
ctx.fillRect(sx, sy, tileSize, tileSize);
}
const isInter = mapData.interactive && mapData.interactive[y] && mapData.interactive[y][x] === 1;
if (isInter) {
ctx.fillStyle = 'rgba(158,206,106,0.35)';
ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4);
ctx.strokeStyle = 'rgba(158,206,106,0.8)';
ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4);
const pulse = lobbyInteractPulse;
if (pulse && pulse.x === x && pulse.y === y && timeMs < pulse.until) {
const t = (pulse.until - timeMs) / 700;
ctx.fillStyle = 'rgba(255, 214, 102,' + (0.25 + 0.35 * t) + ')';
ctx.fillRect(sx, sy, tileSize, tileSize);
}
}
const isStartArea = mapData.gameType === 'lobby' && mapData.startGameArea && mapData.startGameArea[y] && mapData.startGameArea[y][x] === 1;
if (isStartArea) {
ctx.fillStyle = 'rgba(255, 158, 100, 0.4)';
ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4);
ctx.strokeStyle = 'rgba(255, 120, 60, 0.92)';
ctx.lineWidth = 2;
ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4);
ctx.lineWidth = 1;
}
const isQuizQ = mapData.gameType === 'quiz' && mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1;
if (isQuizQ) {
ctx.fillStyle = 'rgba(255, 214, 102, 0.32)';
ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4);
ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)';
ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4);
}
const isQuizT = mapData.gameType === 'quiz' && mapData.quizTrueArea && mapData.quizTrueArea[y] && mapData.quizTrueArea[y][x] === 1;
if (isQuizT) {
ctx.fillStyle = 'rgba(86, 202, 255, 0.38)';
ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4);
ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)';
ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4);
}
const isQuizF = mapData.gameType === 'quiz' && mapData.quizFalseArea && mapData.quizFalseArea[y] && mapData.quizFalseArea[y][x] === 1;
if (isQuizF) {
ctx.fillStyle = 'rgba(247, 118, 190, 0.38)';
ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4);
ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)';
ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4);
}
}
}
}
if (mapData.gameType === 'quiz' && showGrid) {
function tileBoundsForGrid(gr) {
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (let yy = 0; yy < h; yy++) {
for (let xx = 0; xx < w; xx++) {
if (gr && gr[yy] && gr[yy][xx] === 1) {
minX = Math.min(minX, xx);
maxX = Math.max(maxX, xx);
minY = Math.min(minY, yy);
maxY = Math.max(maxY, yy);
}
}
}
return minX === Infinity ? null : { minX, maxX, minY, maxY };
}
function drawQuizZoneLabel(gr, en, th, icon, stroke, fill) {
const b = tileBoundsForGrid(gr);
if (!b) return;
const sx = b.minX * tileSize;
const sy = b.minY * tileSize;
const sw = (b.maxX - b.minX + 1) * tileSize;
const sh = (b.maxY - b.minY + 1) * tileSize;
const mcx = sx + sw / 2;
const mcy = sy + sh / 2;
ctx.save();
ctx.shadowColor = stroke;
ctx.shadowBlur = 16;
ctx.strokeStyle = stroke;
ctx.lineWidth = 3;
ctx.strokeRect(sx + 1, sy + 1, sw - 2, sh - 2);
ctx.shadowBlur = 0;
const iconSz = Math.min(sw, sh) * 0.4;
ctx.font = 'bold ' + Math.round(iconSz) + 'px NotoSansThai, Kanit, system-ui, sans-serif';
ctx.fillStyle = fill;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(icon, mcx, mcy - sh * 0.1);
ctx.font = 'bold ' + Math.max(11, Math.round(tileSize * 0.44)) + 'px NotoSansThai, Kanit, system-ui, sans-serif';
ctx.fillStyle = '#e2e8f0';
ctx.fillText(en, mcx, mcy + sh * 0.08);
ctx.font = Math.max(10, Math.round(tileSize * 0.32)) + 'px NotoSansThai, Kanit, system-ui, sans-serif';
ctx.fillStyle = 'rgba(226,232,240,0.92)';
ctx.fillText(th, mcx, mcy + sh * 0.2);
ctx.restore();
}
drawQuizZoneLabel(mapData.quizTrueArea, 'SAFE', 'ปลอดภัย', '✓', 'rgba(122,220,255,0.95)', 'rgba(200,240,255,0.95)');
drawQuizZoneLabel(mapData.quizFalseArea, 'SCAM', 'อันตราย', '✕', 'rgba(255,130,200,0.95)', 'rgba(255,210,225,0.95)');
}
function peerVisualOffset(id) {
if (mapData && mapData.lobbySpawnMode === 'slots6') return { ax: 0, ay: 0 };
let h = 0;
for (let i = 0; i < (id || '').length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
const ax = ((h % 5) - 2) * 0.1;
const ay = ((Math.floor(h / 5) % 5) - 2) * 0.1;
return { ax, ay };
}
// ไอคอน "ห้องแต่งตัว" ในฉาก (จุด interact — เดินไปกด F)
if (ROOM_CZ_SPOT && roomCzIcon && roomCzIcon.complete && roomCzIcon.naturalWidth) {
const _czx = ROOM_CZ_SPOT.x * tileSize;
const _czy = ROOM_CZ_SPOT.y * tileSize;
const _czS = tileSize * 1.5;
ctx.save();
ctx.globalAlpha = 0.5;
ctx.fillStyle = 'rgba(34,211,238,0.35)';
ctx.beginPath();
ctx.ellipse(_czx + tileSize / 2, _czy + tileSize - 3, tileSize * 0.55, tileSize * 0.22, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.drawImage(roomCzIcon, _czx + tileSize / 2 - _czS / 2, _czy + tileSize - _czS, _czS, _czS);
}
if (ROOM_HC_SPOT && roomHcIcon && hostId === socket.id && roomHcIcon.complete && roomHcIcon.naturalWidth) {
const _hcx = ROOM_HC_SPOT.x * tileSize;
const _hcy = ROOM_HC_SPOT.y * tileSize;
const _hcS = tileSize * 1.5;
ctx.save();
ctx.globalAlpha = 0.5;
ctx.fillStyle = 'rgba(236,72,153,0.35)';
ctx.beginPath();
ctx.ellipse(_hcx + tileSize / 2, _hcy + tileSize - 3, tileSize * 0.55, tileSize * 0.22, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.drawImage(roomHcIcon, _hcx + tileSize / 2 - _hcS / 2, _hcy + tileSize - _hcS, _hcS, _hcS);
}
const peerList = [...peers.entries(), ...lobbyBots.entries()].sort(function (a, b) {
const pa = a[1], pb = b[1];
const ya = pa.y != null ? pa.y : 1, yb = pb.y != null ? pb.y : 1;
if (Math.abs(ya - yb) > 0.01) return ya - yb;
return (pa.x != null ? pa.x : 1) - (pb.x != null ? pb.x : 1);
});
peerList.forEach(function (entry) {
const id = entry[0], p = entry[1];
const off = peerVisualOffset(id);
const px = ((p.x != null ? p.x : 1) + off.ax) * tileSize;
const py = ((p.y != null ? p.y : 1) + off.ay) * tileSize;
const screenX = px;
const screenY = py;
const cx = screenX + tileSize / 2;
const cellBottom = screenY + tileSize;
const boxW = tileSize * cellsW * 1.15;
const boxH = tileSize * cellsH * 1.35;
const dir = p.direction || 'down';
const isWalking = id === socket.id
? !!(me && me.isWalking)
: (p.tx != null || p.ty != null)
? !!((p.tx != null && Math.abs((p.tx || p.x) - p.x) > 0.02) || (p.ty != null && Math.abs((p.ty || p.y) - p.y) > 0.02))
: !!p.isWalking; /* บอท (ไม่มี tx/ty) ใช้ flag isWalking ที่ stepLobbyCaseBots ตั้ง */
const peerLockedOut = quizModeActive && (
quizPeersLocked[id] ||
(id === socket.id && quizPlayerLocal && quizPlayerLocal.cannotTrue && quizPlayerLocal.cannotFalse)
);
if (peerLockedOut) ctx.save();
if (peerLockedOut) {
ctx.globalAlpha = 0.45;
ctx.filter = 'grayscale(1) brightness(1.25)';
}
const peerTheme = (id === socket.id) ? myTintTheme : (p.colorTheme || null);
const peerSkin = (id === socket.id) ? myTintSkin : (p.colorSkin || null);
const cid = p.characterId || rlDefaultCharId; // กันตัวละครหายเป็นวงกลม blob เมื่อ characterId ว่าง
const charImg = getAvatarImgColored(cid, peerTheme, peerSkin, dir, timeMs, isWalking);
const sz = readLobbyCharImgSize(charImg);
const iw = sz.iw;
const ih = sz.ih;
const imgScale = (iw > 0 && ih > 0) ? Math.min(boxW / iw, boxH / ih, 1) : 0;
const drawW = imgScale > 0 ? iw * imgScale : 0;
const drawH = imgScale > 0 ? ih * imgScale : 0;
const drawY = cellBottom - drawH;
const shadowImg = getDefaultShadowImg(dir);
if (shadowImg) {
const sw = shadowImg.naturalWidth;
const sh = shadowImg.naturalHeight;
const shadowScale = Math.min(drawW / sw, drawH / sh, 1.2);
const shadowW = sw * shadowScale;
const shadowH = sh * shadowScale;
const shadowY = cellBottom - shadowH + (tileSize * 0.08);
ctx.globalAlpha = 0.7;
ctx.drawImage(shadowImg, 0, 0, sw, sh, cx - shadowW / 2, shadowY, shadowW, shadowH);
ctx.globalAlpha = 1;
}
if (drawW > 0 && drawH > 0 && charImg) {
var sIw = iw;
var sIh = ih;
if (charImg.tagName === 'CANVAS') {
sIw = charImg.width;
sIh = charImg.height;
} else if (charImg.naturalWidth > 0 && charImg.naturalHeight > 0) {
sIw = charImg.naturalWidth;
sIh = charImg.naturalHeight;
}
ctx.drawImage(charImg, 0, 0, sIw, sIh, cx - drawW / 2, drawY, drawW, drawH);
} else {
const r = Math.max(8, ts / 2 - 2);
const cy = screenY + ts / 2;
ctx.fillStyle = id === socket.id ? '#7aa2f7' : '#9ece6a';
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#c0caf5';
ctx.lineWidth = 2;
ctx.stroke();
}
const nameFontSize = Math.max(10, Math.round(tileSize * 0.4));
const labelY = cellBottom - drawH - (tileSize * 0.2);
const headTop = cellBottom - drawH - 4;
const now = Date.now();
if (p.speakingUntil > now) {
var bubbleImg = getSpeakingBubbleFrame();
if (bubbleImg) {
var bw = tileSize * 0.9;
var bh = (bubbleImg.naturalHeight / bubbleImg.naturalWidth) * bw;
var bubbleY = headTop - bh - tileSize * 0.08;
ctx.drawImage(bubbleImg, 0, 0, bubbleImg.naturalWidth, bubbleImg.naturalHeight, cx - bw / 2, bubbleY, bw, bh);
} else {
var level = (p.speakingLevel != null ? p.speakingLevel : 0.5);
var r0 = tileSize * (0.2 + level * 0.25);
ctx.strokeStyle = 'rgba(158, 206, 106, 0.85)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, headTop, r0, 0, Math.PI * 2);
ctx.stroke();
}
}
const nameText = (p.nickname || id.slice(0, 6));
const isHost = id === hostId;
const nameY = labelY + (tileSize * 0.125);
const voiceIconY = labelY - tileSize * 0.12;
if (p.voiceMicOn === false) {
const iconSize = Math.max(10, Math.round(tileSize * 0.35));
ctx.font = iconSize + 'px NotoSansThai, Kanit, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#f7768e';
ctx.fillText('🔇', cx, voiceIconY);
ctx.textBaseline = 'alphabetic';
}
if (p.ready && lobbyReadyIconImg && lobbyReadyIconImg.complete && lobbyReadyIconImg.naturalWidth) {
var nwI = lobbyReadyIconImg.naturalWidth;
var nhI = lobbyReadyIconImg.naturalHeight;
if (nwI > 0 && nhI > 0) {
var rih = 82;
var riw = 89;
var iconBottom = Math.round(nameY - nameFontSize * 1.0);
var iconTop = iconBottom - rih;
var iconLeft = Math.round(cx - riw / 2);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(lobbyReadyIconImg, 0, 0, nwI, nhI, iconLeft, iconTop, riw, rih);
ctx.restore();
}
}
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#111827';
ctx.lineWidth = Math.max(2, Math.round(nameFontSize * 0.22));
ctx.lineJoin = 'round';
ctx.font = '400 ' + nameFontSize + 'px NotoSansThai, Kanit, sans-serif';
ctx.textAlign = 'center';
if (isHost && hostIconImg && hostIconImg.complete && hostIconImg.naturalWidth) {
const iconW = 53;
const iconH = 40;
const gap = Math.max(2, Math.round(tileSize * 0.06));
const textW = ctx.measureText(nameText).width;
const totalW = iconW + gap + textW;
const startX = cx - totalW / 2;
ctx.drawImage(hostIconImg, 0, 0, hostIconImg.naturalWidth, hostIconImg.naturalHeight, startX, labelY - iconH / 2, iconW, iconH);
ctx.textAlign = 'left';
ctx.strokeText(nameText, startX + iconW + gap, nameY);
ctx.fillText(nameText, startX + iconW + gap, nameY);
ctx.textAlign = 'center';
} else {
ctx.strokeText(nameText, cx, nameY);
ctx.fillText(nameText, cx, nameY);
}
if (peerLockedOut) ctx.restore();
});
ctx.restore();
syncQuizMapQuestionPanel();
}
function resizeAndDraw() {
if (!canvas) return;
const vp = window.visualViewport;
const w = Math.max(vp ? vp.width : 0, window.innerWidth || 0, document.documentElement.clientWidth || 0) || 800;
const h = Math.max(vp ? vp.height : 0, window.innerHeight || 0, document.documentElement.clientHeight || 0) || 600;
const pw = Math.floor(w);
const ph = Math.floor(h);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
drawLobbyMap();
}
function redrawLobbyMap() {
if (canvas && mapData) drawLobbyMap();
}
function getFacingCellOffset(direction) {
const d = direction || 'down';
if (d === 'up') return { dx: 0, dy: -1 };
if (d === 'down') return { dx: 0, dy: 1 };
if (d === 'left') return { dx: -1, dy: 0 };
return { dx: 1, dy: 0 };
}
function cellIsInteractiveLobby(tx, ty) {
if (!mapData || !mapData.interactive) return false;
const row = mapData.interactive[ty];
return !!(row && row[tx] === 1);
}
/** ช่องที่กด F ได้: ช่องหน้าทิศทางก่อน แล้วค่อยช่องที่ยืนอยู่ */
function getLobbyInteractTarget(me) {
if (!mapData || !me) return null;
const w = mapData.width || 20, h = mapData.height || 15;
const px = Math.floor(me.x), py = Math.floor(me.y);
const { dx, dy } = getFacingCellOffset(me.direction);
const fx = px + dx, fy = py + dy;
if (fx >= 0 && fx < w && fy >= 0 && fy < h && cellIsInteractiveLobby(fx, fy)) return { x: fx, y: fy };
if (cellIsInteractiveLobby(px, py)) return { x: px, y: py };
return null;
}
function appendLobbySystemChat(text) {
const el = document.getElementById('chat-messages');
if (!el) return;
const div = document.createElement('div');
div.className = 'chat-msg chat-msg-system';
div.textContent = text;
el.appendChild(div);
el.scrollTop = 1e9;
}
function quizCellOnLobby(grid, tx, ty) {
return !!(grid && grid[ty] && grid[ty][tx] === 1);
}
function quizTilesFootprintLobby(px, py) {
const s = new Set();
if (!mapData) return s;
const cells = Math.max(1, Math.min(4, mapData.characterCells || 1));
const w = mapData.width || 20, h = mapData.height || 15;
const minTx = Math.floor(px);
const minTy = Math.floor(py);
const maxTx = Math.min(w - 1, minTx + cells - 1);
const maxTy = Math.min(h - 1, minTy + cells - 1);
for (let ty = minTy; ty <= maxTy; ty++) {
for (let tx = minTx; tx <= maxTx; tx++) {
if (tx >= 0 && ty >= 0) s.add(tx + ',' + ty);
}
}
return s;
}
function quizAnswerTileForbiddenLobby(tx, ty) {
if (!quizPlayerLocal || quizPlayerLocal.eliminated) return false;
if (quizPlayerLocal.cannotTrue && quizCellOnLobby(mapData.quizTrueArea, tx, ty)) return true;
if (quizPlayerLocal.cannotFalse && quizCellOnLobby(mapData.quizFalseArea, tx, ty)) return true;
return false;
}
function quizLockFootprintBlocksLobby(px, py) {
if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false;
for (const k of quizTilesFootprintLobby(px, py)) {
const p = k.split(',');
const txi = +p[0], tyi = +p[1];
if (quizAnswerTileForbiddenLobby(txi, tyi)) return true;
}
return false;
}
function quizLockWouldEnterForbiddenLobby(ox, oy, nx, ny) {
if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false;
const fromS = quizTilesFootprintLobby(ox, oy);
const toS = quizTilesFootprintLobby(nx, ny);
for (const k of toS) {
if (fromS.has(k)) continue;
const p = k.split(',');
const txi = +p[0], tyi = +p[1];
if (quizAnswerTileForbiddenLobby(txi, tyi)) return true;
}
return false;
}
function canWalkLobbyBot(x, y, fromX, fromY, botId) {
if (!mapData) return false;
const w = mapData.width || 20, h = mapData.height || 15;
const tx = Math.floor(x), ty = Math.floor(y);
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
const ob = mapData.objects?.[ty]?.[tx] ?? 0;
if (ob === 1) return false;
const bp = mapData.blockPlayer;
if (bp && bp[ty] && bp[ty][tx] === 1) {
for (const [, p] of peers) {
if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false;
}
for (const [id, b] of lobbyBots) {
if (id === botId) continue;
if (Math.floor(b.x) === tx && Math.floor(b.y) === ty) return false;
}
}
return true;
}
function canWalkLobby(x, y, fromX, fromY) {
if (!mapData) return false;
const w = mapData.width || 20, h = mapData.height || 15;
const tx = Math.floor(x), ty = Math.floor(y);
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
const ob = mapData.objects?.[ty]?.[tx] ?? 0;
if (ob === 1) return false;
const bp = mapData.blockPlayer;
if (bp && bp[ty] && bp[ty][tx] === 1) {
for (const [id, p] of peers) {
if (id === socket.id) continue;
if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false;
}
}
if (mapData.gameType === 'quiz' && quizModeActive && quizPlayerLocal && !quizPlayerLocal.eliminated) {
const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY);
if (hasFrom) {
if (quizLockWouldEnterForbiddenLobby(fromX, fromY, x, y)) return false;
} else if (quizLockFootprintBlocksLobby(x, y)) {
return false;
}
}
return true;
}
/** A* pathfinding on lobby grid. Returns array of { x, y } (cell centers). */
function pathfindLobby(fromX, fromY, toX, toY) {
if (!mapData) return [];
const w = mapData.width || 20, h = mapData.height || 15;
const fx = Math.floor(fromX), fy = Math.floor(fromY);
const tx = Math.floor(toX), ty = Math.floor(toY);
if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLobby(tx + 0.5, ty + 0.5)) return [];
if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }];
const key = (gx, gy) => gx + ',' + gy;
const open = [{ gx: fx, gy: fy, f: 0, g: 0 }];
const closed = new Set();
const cameFrom = {};
const gScore = { [key(fx, fy)]: 0 };
const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty);
const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }];
while (open.length) {
open.sort((a, b) => a.f - b.f);
const cur = open.shift();
const ck = key(cur.gx, cur.gy);
if (closed.has(ck)) continue;
closed.add(ck);
if (cur.gx === tx && cur.gy === ty) {
const path = [];
let u = cur;
while (u) {
path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 });
u = cameFrom[key(u.gx, u.gy)];
}
return path;
}
for (const d of dirs) {
const nx = cur.gx + d.dx, ny = cur.gy + d.dy;
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
if (!canWalkLobby(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5)) continue;
const nk = key(nx, ny);
if (closed.has(nk)) continue;
const g = (gScore[ck] ?? Infinity) + 1;
if (g >= (gScore[nk] ?? Infinity)) continue;
gScore[nk] = g;
cameFrom[nk] = cur;
open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g });
}
}
return [];
}
let lobbyPath = [];
function lobbyMapRequiresStartGameArea() {
if (!mapData) return false;
var nameOk = String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase();
if (mapData.gameType !== 'lobby' && !nameOk) return false;
var a = mapData.startGameArea;
if (!a || !a.length) return false;
for (var y = 0; y < a.length; y++) {
var row = a[y];
if (!row) continue;
for (var x = 0; x < row.length; x++) if (row[x] === 1) return true;
}
return false;
}
/** ช่องกริดที่ตัวละครครอบคลุม (สูง×กว้าง จากแมป) — ใช้ตรวจพื้นที่ส้ม/เขียว ไม่ใช่แค่มุม anchor */
function lobbyCharacterFootprintTiles(px, py) {
const tiles = new Set();
if (!mapData) return tiles;
const w = mapData.width || 20;
const h = mapData.height || 15;
const cellsW = Math.max(1, Math.min(4, mapData.characterCellsW || mapData.characterCells || 1));
const cellsH = Math.max(1, Math.min(4, mapData.characterCellsH || mapData.characterCells || 1));
const minTx = Math.floor(px);
const minTy = Math.floor(py);
const maxTx = Math.min(w - 1, minTx + cellsW - 1);
const maxTy = Math.min(h - 1, minTy + cellsH - 1);
for (let ty = minTy; ty <= maxTy; ty++) {
for (let tx = minTx; tx <= maxTx; tx++) {
if (tx >= 0 && ty >= 0) tiles.add(tx + ',' + ty);
}
}
return tiles;
}
function peerStandingInStartGameArea(me) {
if (!me || !mapData) return false;
const area = mapData.startGameArea;
if (!area || !area.length) return false;
for (const key of lobbyCharacterFootprintTiles(me.x, me.y)) {
const parts = key.split(',');
const tx = +parts[0];
const ty = +parts[1];
if (area[ty] && area[ty][tx] === 1) return true;
}
return false;
}
function hostStandingInStartGameArea() {
return peerStandingInStartGameArea(peers.get(socket.id));
}
function updateHostStartGameButton() {
if (!btnStart) return;
if (hostId !== socket.id) return;
var hint = document.getElementById('host-start-area-hint');
if (isCurrentRoomLobbyA()) {
btnStart.disabled = true;
btnStart.setAttribute('hidden', '');
btnStart.setAttribute('aria-hidden', 'true');
if (hint) {
hint.hidden = false;
var reqA = lobbyMapRequiresStartGameArea();
var okA = !reqA || hostStandingInStartGameArea();
if (reqA && !okA) {
hint.textContent = 'LobbyA: กดพร้อมก่อน · ยืนพื้นส้ม แล้วกด F / ด เพื่อเลือกระดับและคดี';
} else if (reqA) {
hint.textContent = 'LobbyA: กดพร้อมก่อน · ในพื้นส้ม กด F / ด เพื่อเลือกระดับและคดี';
} else {
hint.textContent = 'LobbyA: กดพร้อมก่อน · กด F / ด เพื่อเลือกระดับและคดี';
}
}
return;
}
btnStart.removeAttribute('hidden');
btnStart.removeAttribute('aria-hidden');
var req = lobbyMapRequiresStartGameArea();
var ok = !req || hostStandingInStartGameArea();
btnStart.disabled = !ok;
btnStart.title = req ? (ok ? 'เริ่มเกม' : 'ยืนในพื้นที่สีส้ม (เริ่มเกม) ก่อน') : 'เริ่มเกม';
if (hint) {
if (req && !ok) {
hint.hidden = false;
hint.textContent = 'ยืนในพื้นที่สีส้มบนแผนที่ (จุดเริ่มเกม) แล้วค่อยกดเริ่ม';
} else {
hint.hidden = true;
hint.textContent = '';
}
}
}
/** โถง LobbyA — host กดเริ่มแล้วให้เลือกระดับแล้วคดีก่อนเข้า play (ตรง Create Room → mlsbbxfe) */
const LOBBY_A_MAP_NAME = 'LobbyA';
const LOBBY_A_MAP_ID = 'mlsbbxfe';
function isCurrentRoomLobbyA() {
if (!mapData) return false;
if (String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase()) return true;
if (clientLobbyMapId === LOBBY_A_MAP_ID) return true;
if (String(mapData.id || '').trim() === LOBBY_A_MAP_ID) return true;
return false;
}
/** Host บน LobbyA — หลังกดพร้อมแล้ว กด F ในพื้นที่ส้ม (หรือทั้งแมปถ้าไม่มีส้ม) เพื่อเลือกระดับ/คดี */
function hostCanOpenLobbyAPreplayWithF() {
if (!isCurrentRoomLobbyA() || hostId !== socket.id) return false;
var req = lobbyMapRequiresStartGameArea();
return !req || hostStandingInStartGameArea();
}
let preplaySelectedLevel = null;
let preplayOverlay = null;
let preplayStepLevel = null;
let preplayStepCase = null;
let preplayCaseDetailOverlay = null;
function getPreplayEls() {
if (!preplayOverlay) {
preplayOverlay = document.getElementById('lobby-preplay-overlay');
preplayStepLevel = document.getElementById('lobby-preplay-step-level');
preplayStepCase = document.getElementById('lobby-preplay-step-case');
preplayCaseDetailOverlay = document.getElementById('lobby-preplay-case-detail-overlay');
}
return !!preplayOverlay;
}
function closeLobbyPreplayWizard() {
getPreplayEls();
if (!preplayOverlay) return;
preplayOverlay.classList.add('is-hidden');
preplayOverlay.setAttribute('aria-hidden', 'true');
try { document.body.classList.remove('room-lobby--preplay-open'); } catch (e) { /* ignore */ }
preplaySelectedLevel = null;
delete preplayOverlay.dataset.preplayLevel;
if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden');
preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); });
}
function openLobbyPreplayWizard() {
if (!getPreplayEls()) return;
initLobbyPreplayWizard();
ensureCaseMediaLoaded();
preplaySelectedLevel = null;
delete preplayOverlay.dataset.preplayLevel;
preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); });
preplayOverlay.classList.remove('is-hidden');
preplayOverlay.setAttribute('aria-hidden', 'false');
try { document.body.classList.add('room-lobby--preplay-open'); } catch (e) { /* ignore */ }
if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden');
if (preplayStepCase) preplayStepCase.classList.add('is-hidden');
if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden');
}
function syncPreplayCaseStepScale() {
if (!getPreplayEls() || !preplayStepCase || !preplayOverlay) return;
if (preplayOverlay.classList.contains('is-hidden')) return;
if (preplayStepCase.classList.contains('is-hidden')) return;
var rect = preplayStepCase.getBoundingClientRect();
if (!rect.width || !rect.height) return;
var baseWidth = 1700;
var baseHeight = 900;
var byWidth = rect.width / baseWidth;
var byHeight = rect.height / baseHeight;
var targetScale = 1.08;
var scale = Math.max(0.56, Math.min(targetScale, Math.min(byWidth, byHeight) * targetScale));
preplayStepCase.style.setProperty('--lobby-preplay-case-scale', scale.toFixed(4));
}
/* ===== รูปคดี + Cutscene (โหลดจาก /api/case-media — แก้ใน Admin ได้) ===== */
var caseMediaData = null;
var caseMediaFetchPromise = null;
function ensureCaseMediaLoaded() {
if (caseMediaData) return Promise.resolve(caseMediaData);
if (caseMediaFetchPromise) return caseMediaFetchPromise;
caseMediaFetchPromise = fetch(SERVER + '/api/case-media', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { caseMediaData = (j && j.cases) ? j : { cases: {} }; return caseMediaData; })
.catch(function () { caseMediaFetchPromise = null; return { cases: {} }; });
return caseMediaFetchPromise;
}
function getDetectiveCaseId() {
try {
var m = window.__detectiveLobbyMeta;
var cid = m && m.caseId != null ? parseInt(m.caseId, 10) : 1;
if (cid >= 1 && cid <= 15) return cid;
} catch (e) { /* ignore */ }
return 1;
}
function getCaseMedia(cid) {
var n = parseInt(cid, 10);
if (!(n >= 1 && n <= 15)) n = 1;
var defSuspects = [1, 2, 3].map(function (s) {
return '/Main-Lobby/IMAGE/suspect-pick/case-' + n + '-suspect-' + s + '.png';
});
var defCulprits = [1, 2, 3].map(function (s) {
return '/Main-Lobby/IMAGE/culprit-prison/case-' + n + '-suspect-' + s + '.png';
});
var def = {
name: 'คดีที่ ' + n,
art: '/Main-Lobby/IMAGE/case/case-' + n + '.png',
detail: '/Main-Lobby/IMAGE/case/case-detail-' + n + '.png',
story: ['/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-1.png', '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-2.png'],
suspects: defSuspects,
culprits: defCulprits,
};
var c = (caseMediaData && caseMediaData.cases) ? (caseMediaData.cases[n] || caseMediaData.cases[String(n)]) : null;
if (!c) return def;
var suspects = (Array.isArray(c.suspects) && c.suspects.length >= 3) ? c.suspects.slice(0, 3) : def.suspects;
var culprits = (Array.isArray(c.culprits) && c.culprits.length >= 3) ? c.culprits.slice(0, 3) : def.culprits;
return {
name: c.name || def.name,
art: c.art || def.art,
detail: c.detail || def.detail,
story: (Array.isArray(c.story) && c.story.length) ? c.story : def.story,
suspects: suspects,
culprits: culprits,
};
}
function getSuspectPickImageUrl(suspectIndex) {
var i = Math.max(0, Math.min(2, Math.floor(Number(suspectIndex)) || 0));
var m = getCaseMedia(getDetectiveCaseId());
return (m.suspects && m.suspects[i]) || ('/Main-Lobby/IMAGE/Choose%20a%20suspect/suspect-' + (i + 1) + '.png');
}
function getCulpritPrisonImageUrl(suspectIndex) {
var i = Math.max(0, Math.min(2, Math.floor(Number(suspectIndex)) || 0));
var m = getCaseMedia(getDetectiveCaseId());
return (m.culprits && m.culprits[i]) || ('/Main-Lobby/IMAGE/Choose%20a%20suspect/suspect-' + (i + 1) + '.png');
}
/** อัปเดตรูปผู้ต้องสงสัย 3 ใบตามคดีปัจจุบัน (เลือกผู้ต้องสงสัย + โหวตพิจารณาคดี) */
function applySuspectPickImages() {
var cards = document.querySelectorAll('#suspect-cards-row .suspect-card');
if (!cards.length) return;
function paint() {
cards.forEach(function (card) {
var idx = parseInt(card.getAttribute('data-index'), 10);
if (Number.isNaN(idx) || idx < 0 || idx > 2) return;
var img = card.querySelector(':scope > img');
if (img) img.src = getSuspectPickImageUrl(idx);
/* ปุ่มแว่นขยาย — ดูการ์ดเต็ม + ปิดได้ (ไม่ชนการเลือก/โหวต) */
if (!card.querySelector('.suspect-zoom-btn')) {
if (getComputedStyle(card).position === 'static') card.style.position = 'relative';
var zb = document.createElement('button');
zb.type = 'button';
zb.className = 'suspect-zoom-btn';
zb.setAttribute('aria-label', 'ดูการ์ดเต็ม');
zb.textContent = '🔍';
zb.style.cssText = 'position:absolute;top:9px;right:9px;z-index:8;width:34px;height:34px;border:none;border-radius:50%;cursor:pointer;font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center;background:rgba(10,16,30,.74);color:#9fe9ff;box-shadow:0 2px 8px rgba(0,0,0,.45)';
zb.addEventListener('click', function (e) {
e.stopPropagation();
var i = parseInt(card.getAttribute('data-index'), 10);
openEvidenceCardLightbox(getSuspectPickImageUrl(i), '', 'common');
});
card.appendChild(zb);
}
});
}
if (caseMediaData) paint();
else ensureCaseMediaLoaded().then(paint);
}
/** สร้างการ์ดเลือกคดีตามระดับที่เลือก — 5 ระดับ × 3 คดี (L1=1-3, L2=4-6 … L5=13-15) */
/** คดี (1-15) → โฟลเดอร์/กลุ่มผู้เสียหาย (1-5) ตามตารางออกแบบเกม
1=ประถม 2=มัธยมต้น 3=มัธยมปลาย 4=นักศึกษานิติ 5=ประชาชนทั่วไป */
var CASE_FOLDER = { 1: 1, 2: 5, 3: 4, 4: 5, 5: 3, 6: 5, 7: 2, 8: 4, 9: 5, 10: 3, 11: 5, 12: 4, 13: 3, 14: 5, 15: 5 };
function casesForFolder(folder) {
var out = [];
for (var n = 1; n <= 15; n++) if (CASE_FOLDER[n] === folder) out.push(n);
return out;
}
function renderPreplayCaseCards() {
var row = preplayOverlay ? preplayOverlay.querySelector('.lobby-preplay-case-row') : null;
if (!row) return;
var lvl = parseInt(preplaySelectedLevel || (preplayOverlay && preplayOverlay.dataset.preplayLevel) || '1', 10);
if (!(lvl >= 1 && lvl <= 5)) lvl = 1;
var caseList = casesForFolder(lvl);
var html = '';
for (var ci = 0; ci < caseList.length; ci++) {
var n = caseList[ci];
var m = getCaseMedia(n);
var nm = String(m.name || ('คดีที่ ' + n)).replace(/"/g, '&quot;');
html += '<div class="lobby-case-card-wrap">' +
'<img class="lobby-case-art" src="' + m.art + '" alt="' + nm + '" decoding="async">' +
'<div class="lobby-case-actions">' +
'<button type="button" class="lobby-case-detail" data-case="' + n + '" title="รายละเอียด"><img src="/Main-Lobby/IMAGE/case/btn-detail.png" alt="รายละเอียด" decoding="async"></button>' +
'<button type="button" class="lobby-case-start" data-case="' + n + '" title="เริ่มคดี"><img src="/Main-Lobby/IMAGE/case/btn-startcase.png" alt="เริ่มคดี" decoding="async"></button>' +
'</div>' +
'</div>';
}
row.innerHTML = html;
}
/** Cutscene ก่อนเข้า LobbyB — โชว์ภาพเรื่องราว 2 รูปของคดี ต้องกด «ไปต่อ» ทีละรูป */
function showCaseCutscene(cid, onDone) {
var media = getCaseMedia(cid);
var imgs = (media.story || []).filter(function (s) { return typeof s === 'string' && s; });
var finished = false;
var done = function () { if (finished) return; finished = true; if (typeof onDone === 'function') onDone(); };
if (!imgs.length) { done(); return; }
var idx = 0;
var ov = document.getElementById('case-cutscene-overlay');
if (!ov) {
ov = document.createElement('div');
ov.id = 'case-cutscene-overlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:99990;overflow:hidden;background:#000';
ov.innerHTML =
'<img id="case-cutscene-img" alt="" decoding="async" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center;display:block">' +
'<div style="position:absolute;left:0;right:0;bottom:24px;z-index:2;display:flex;align-items:center;justify-content:center;gap:16px;padding:0 24px;pointer-events:none">' +
'<span id="case-cutscene-progress" style="font:800 15px/1 Kanit,system-ui,sans-serif;color:#cdd6ff;background:rgba(8,12,26,.6);padding:8px 14px;border-radius:999px"></span>' +
'<button type="button" id="case-cutscene-next" style="pointer-events:auto;cursor:pointer;border:none;border-radius:999px;padding:13px 38px;font:800 17px/1 Kanit,system-ui,sans-serif;color:#0a0e1a;background:linear-gradient(180deg,#ffd666,#f0b429);box-shadow:0 10px 28px rgba(240,180,41,.5)">ไปต่อ ▶</button>' +
'</div>';
document.body.appendChild(ov);
}
ov.classList.remove('is-hidden');
ov.style.cssText = 'position:fixed;inset:0;z-index:99990;overflow:hidden;background:#000;display:block';
var imgEl = document.getElementById('case-cutscene-img');
var progEl = document.getElementById('case-cutscene-progress');
var nextBtn = document.getElementById('case-cutscene-next');
if (imgEl) {
imgEl.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center;display:block';
}
function render() {
if (imgEl) imgEl.src = imgs[idx];
if (progEl) progEl.textContent = (idx + 1) + ' / ' + imgs.length;
if (nextBtn) nextBtn.textContent = (idx + 1 >= imgs.length) ? 'เริ่มคดี ▶' : 'ไปต่อ ▶';
}
function finish() { ov.classList.add('is-hidden'); ov.style.display = 'none'; done(); }
if (nextBtn) nextBtn.onclick = function () { idx++; if (idx >= imgs.length) finish(); else render(); };
/* preload รูปถัดไป */
if (imgs[1]) { var pre = new Image(); pre.src = imgs[1]; }
render();
}
function showPreplayCaseStep() {
if (preplayStepLevel) preplayStepLevel.classList.add('is-hidden');
if (preplayStepCase) preplayStepCase.classList.remove('is-hidden');
ensureCaseMediaLoaded().then(function () {
renderPreplayCaseCards();
syncPreplayCaseStepScale();
requestAnimationFrame(syncPreplayCaseStepScale);
});
syncPreplayCaseStepScale();
requestAnimationFrame(syncPreplayCaseStepScale);
}
function showPreplayLevelStep() {
if (preplayStepCase) preplayStepCase.classList.add('is-hidden');
if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden');
if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden');
}
function initLobbyPreplayWizard() {
if (!getPreplayEls() || !preplayOverlay || preplayOverlay.dataset.bound === '1') return;
preplayOverlay.dataset.bound = '1';
preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (btn) {
btn.addEventListener('click', function () {
preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (b) { b.classList.remove('is-selected'); });
btn.classList.add('is-selected');
preplaySelectedLevel = btn.getAttribute('data-level');
if (preplaySelectedLevel) {
preplayOverlay.dataset.preplayLevel = preplaySelectedLevel;
showPreplayCaseStep();
}
});
});
var btnCloseLevel = document.getElementById('lobby-preplay-close-level');
if (btnCloseLevel) btnCloseLevel.addEventListener('click', function () { closeLobbyPreplayWizard(); });
var btnCloseCase = document.getElementById('lobby-preplay-close-case');
if (btnCloseCase) btnCloseCase.addEventListener('click', function () { closeLobbyPreplayWizard(); });
var btnBackCase = document.getElementById('lobby-preplay-back-case');
if (btnBackCase) btnBackCase.addEventListener('click', function () { showPreplayLevelStep(); });
var caseRow = preplayOverlay.querySelector('.lobby-preplay-case-row');
var casePrev = document.getElementById('lobby-preplay-case-prev');
var caseNext = document.getElementById('lobby-preplay-case-next');
function scrollCaseRow(dir) {
if (!caseRow) return;
var firstCard = caseRow.querySelector('.lobby-case-card-wrap');
var step = firstCard ? Math.max(120, Math.round(firstCard.getBoundingClientRect().width * 0.92)) : Math.max(140, Math.round(caseRow.clientWidth * 0.65));
caseRow.scrollBy({ left: dir * step, behavior: 'smooth' });
}
if (casePrev) casePrev.addEventListener('click', function () { scrollCaseRow(-1); });
if (caseNext) caseNext.addEventListener('click', function () { scrollCaseRow(1); });
preplayOverlay.addEventListener('click', function (ev) {
var startBtn = ev.target.closest('.lobby-case-start');
if (!startBtn || !preplayOverlay.contains(startBtn)) return;
/* กันกดซ้ำ — ถ้าปุ่มไหน loading อยู่ (รวมปุ่มอื่นในกลุ่ม) → ไม่รับ */
if (startBtn.classList.contains('is-loading') || startBtn.disabled) return;
var allCaseBtns = preplayOverlay.querySelectorAll('.lobby-case-start');
var anyLoading = false;
allCaseBtns.forEach(function (b) { if (b.classList.contains('is-loading')) anyLoading = true; });
if (anyLoading) 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;
}
/* loading state: lock ทุกปุ่มเริ่มคดี + ใส่ class is-loading */
allCaseBtns.forEach(function (b) { b.disabled = true; });
startBtn.classList.add('is-loading');
function clearLoadingState() {
allCaseBtns.forEach(function (b) {
b.disabled = false;
b.classList.remove('is-loading');
});
}
var acked = false;
var t = setTimeout(function () {
if (!acked) {
clearLoadingState();
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) {
clearLoadingState();
try { alert(res.error); } catch (e2) { /* ignore */ }
}
/* ถ้าสำเร็จ — คงสถานะ loading ไว้จนกว่าเซิร์ฟจะส่ง event นำเข้าฉาก (กันกดซ้ำระหว่างรอเปลี่ยน scene) */
});
});
/* delegation — การ์ดถูก render ใหม่ 15 ใบ; ตั้งรูปรายละเอียดตามคดีที่กด */
preplayOverlay.addEventListener('click', function (ev) {
var detailBtn = ev.target.closest('.lobby-case-detail');
if (!detailBtn || !preplayOverlay.contains(detailBtn)) return;
var cid = (detailBtn.getAttribute('data-case') || '').trim();
var o = document.getElementById('lobby-preplay-case-detail-overlay');
if (!o) return;
var img = o.querySelector('img');
if (img && cid) img.src = getCaseMedia(cid).detail;
o.classList.remove('is-hidden');
});
var detailClose = document.getElementById('lobby-preplay-case-detail-close');
if (detailClose) detailClose.addEventListener('click', function () {
var o = document.getElementById('lobby-preplay-case-detail-overlay');
if (o) o.classList.add('is-hidden');
});
window.addEventListener('resize', syncPreplayCaseStepScale);
window.addEventListener('orientationchange', function () {
setTimeout(syncPreplayCaseStepScale, 180);
});
}
const PATH_ARRIVE_THRESH = 0.15;
const LOBBY_BOT_WANDER_DIRS = [[0, -1], [0, 1], [-1, 0], [1, 0]];
function stepLobbyCaseBots() {
if (!lobbyBotSlotCount || !mapData || lobbyBots.size === 0) return;
const w = mapData.width || 20, h = mapData.height || 15;
const now = Date.now();
lobbyBots.forEach((b, id) => {
/* เดินแบบมีจุดหมาย: สุ่มจุดเป้าในแมป เดินเข้าหาแบบทแยงลื่น แล้วค่อยสุ่มใหม่เมื่อถึง/ติด/หมดเวลา + พักยืนเป็นช่วง — ดูเป็นธรรมชาติ ไม่เร่ร่อนสุ่มทิศ */
const arrived = (typeof b.botWanderTX === 'number' && typeof b.botWanderTY === 'number')
&& Math.hypot(b.botWanderTX - b.x, b.botWanderTY - b.y) < 0.5;
const timedOut = typeof b.botWanderRetargetAt === 'number' && now >= b.botWanderRetargetAt;
if (typeof b.botWanderTX !== 'number' || typeof b.botWanderTY !== 'number' || arrived || timedOut) {
b.botWanderTX = 1 + Math.random() * Math.max(1, w - 2);
b.botWanderTY = 1 + Math.random() * Math.max(1, h - 2);
b.botWanderRetargetAt = now + 3000 + Math.floor(Math.random() * 4500);
b.botWanderPauseUntil = (arrived && Math.random() < 0.45) ? now + 500 + Math.floor(Math.random() * 1600) : 0;
}
if (b.botWanderPauseUntil && now < b.botWanderPauseUntil) { b.isWalking = false; return; }
let dx = b.botWanderTX - b.x;
let dy = b.botWanderTY - b.y;
const dist = Math.hypot(dx, dy) || 1;
dx /= dist; dy /= dist;
if (Math.abs(dy) > Math.abs(dx)) b.direction = dy > 0 ? 'down' : 'up';
else b.direction = dx > 0 ? 'right' : 'left';
const ox = b.x, oy = b.y;
const step = MOVE_SPEED;
const nx = b.x + dx * step;
const ny = b.y + dy * step;
if (canWalkLobbyBot(nx, ny, b.x, b.y, id)) {
b.x = nx;
b.y = ny;
} else if (canWalkLobbyBot(nx, b.y, b.x, b.y, id)) {
b.x = nx;
} else if (canWalkLobbyBot(b.x, ny, b.x, b.y, id)) {
b.y = ny;
} else {
b.botWanderTX = undefined;
b.botWanderTY = undefined; // ติดผนัง → หาเป้าใหม่ (ไม่กระตุกชนซ้ำ)
}
b.x = Math.max(0, Math.min(w - 0.01, b.x));
b.y = Math.max(0, Math.min(h - 0.01, b.y));
b.isWalking = Math.abs(b.x - ox) > 1e-5 || Math.abs(b.y - oy) > 1e-5;
});
}
/* บอท lobby ต้องเดิน "ตรงกันทุก client": host เป็นเจ้าของตำแหน่ง (เดินจริงด้วย stepLobbyCaseBots)
แล้ว broadcast ให้คนอื่น — client ที่ไม่ใช่ host จะไม่สุ่มเดินเอง แต่ lerp ตามตำแหน่งที่ได้รับ
(เดิม: ทุกเครื่องสุ่มจุดเป้าเอง → บอทตัวเดียวกันอยู่คนละที่บนจอแต่ละคน) */
let lobbyBotBroadcastAt = 0;
function broadcastLobbyBotPositions() {
if (lobbyBots.size === 0) return;
const now = Date.now();
if (now - lobbyBotBroadcastAt < 100) return; /* ~10Hz พอให้ลื่นและไม่กินแบนด์วิดท์ */
lobbyBotBroadcastAt = now;
const arr = [];
lobbyBots.forEach((b, id) => {
arr.push({
id: id,
x: Math.round(b.x * 1000) / 1000,
y: Math.round(b.y * 1000) / 1000,
direction: b.direction || 'down',
isWalking: !!b.isWalking,
});
});
if (arr.length) socket.emit('lobby-bot-move', { bots: arr });
}
function lerpLobbyBots() {
lobbyBots.forEach((b) => {
if (typeof b.botSyncTX === 'number') b.x += (b.botSyncTX - b.x) * LERP;
if (typeof b.botSyncTY === 'number') b.y += (b.botSyncTY - b.y) * LERP;
});
}
function lobbyTick() {
const me = peers.get(socket.id);
if (!mapData || !me) { requestAnimationFrame(lobbyTick); return; }
peers.forEach((p, id) => {
if (id !== socket.id && (p.tx != null || p.ty != null)) {
if (p.tx != null) p.x += (p.tx - p.x) * LERP;
if (p.ty != null) p.y += (p.ty - p.y) * LERP;
}
});
if (lobbyBotSlotCount > 0) {
if (hostId === socket.id) {
if (!suspectPickOverlayOpen) stepLobbyCaseBots();
broadcastLobbyBotPositions(); /* host เดินจริง → ส่งตำแหน่งให้ทุกคน */
} else {
lerpLobbyBots(); /* client อื่น: ตามตำแหน่งจาก host (ไม่สุ่มเดินเอง) */
}
}
const w = mapData.width || 20, h = mapData.height || 15;
const preWalkX = me.x, preWalkY = me.y;
let accX = 0, accY = 0;
let usePath = false;
if (!suspectPickOverlayOpen) {
const keyPressed = !isChatFocused() && (keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD']);
if (lobbyPath.length > 0 && keyPressed) lobbyPath = [];
if (lobbyPath.length > 0) {
const way = lobbyPath[0];
const dx = way.x - me.x, dy = way.y - me.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= PATH_ARRIVE_THRESH) {
lobbyPath.shift();
while (lobbyPath.length > 0) {
const w2 = lobbyPath[0];
const ux = w2.x - me.x, uy = w2.y - me.y;
if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break;
lobbyPath.shift();
}
if (lobbyPath.length === 0) { me.isWalking = false; redrawLobbyMap(); requestAnimationFrame(lobbyTick); return; }
usePath = true;
const next = lobbyPath[0];
accX = next.x - me.x;
accY = next.y - me.y;
} else {
usePath = true;
accX = dx;
accY = dy;
}
if (usePath && (accX !== 0 || accY !== 0)) {
if (Math.abs(accY) > Math.abs(accX)) me.direction = accY > 0 ? 'down' : 'up';
else me.direction = accX > 0 ? 'right' : 'left';
}
}
if (!usePath && !isChatFocused()) {
if (keys['ArrowUp'] || keys['KeyW']) { accY = -1; me.direction = 'up'; }
if (keys['ArrowDown'] || keys['KeyS']) { accY = 1; me.direction = 'down'; }
if (keys['ArrowLeft'] || keys['KeyA']) { accX = -1; me.direction = 'left'; }
if (keys['ArrowRight'] || keys['KeyD']) { accX = 1; me.direction = 'right'; }
}
if (accX !== 0 || accY !== 0) {
const len = Math.sqrt(accX * accX + accY * accY) || 1;
const step = Math.min(MOVE_SPEED, len);
const nx = me.x + (accX / len) * step;
const ny = me.y + (accY / len) * step;
if (canWalkLobby(nx, ny, me.x, me.y)) {
me.x = nx;
me.y = ny;
} else if (canWalkLobby(nx, me.y, me.x, me.y)) {
me.x = nx;
} else if (canWalkLobby(me.x, ny, me.x, me.y)) {
me.y = ny;
}
me.x = Math.max(0, Math.min(w - 0.01, me.x));
me.y = Math.max(0, Math.min(h - 0.01, me.y));
const now = Date.now();
if (now - lastMoveSend > 80) {
lastMoveSend = now;
socket.emit('move', { x: me.x, y: me.y, direction: me.direction || 'down' });
}
}
} /* !suspectPickOverlayOpen */
const movedThisTick = !suspectPickOverlayOpen && (Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5);
me.isWalking = suspectPickOverlayOpen ? false : (!!(accX !== 0 || accY !== 0) || lobbyPath.length > 0 || movedThisTick);
updateHostStartGameButton();
redrawLobbyMap();
requestAnimationFrame(lobbyTick);
}
function lobbyOccupantCount() {
return peers.size + lobbyBots.size;
}
function lobbyOccupantSlots() {
return maxPlayers + lobbyBotSlotCount;
}
function lobbySpawnTileWalkable(tx, ty) {
if (!mapData) return false;
const w = mapData.width || 20;
const h = mapData.height || 15;
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
const row = mapData.objects && mapData.objects[ty];
return !(row && row[tx] === 1);
}
function lobbySpawnFootprintFits(anchorX, anchorY) {
if (!mapData) return false;
const cellsW = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsW) || Number(mapData.characterCells) || 1)));
const cellsH = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsH) || Number(mapData.characterCells) || 1)));
const w = mapData.width || 20;
const h = mapData.height || 15;
const maxX = Math.min(w, anchorX + cellsW);
const maxY = Math.min(h, anchorY + cellsH);
for (let ty = anchorY; ty < maxY; ty++) {
for (let tx = anchorX; tx < maxX; tx++) {
if (!lobbySpawnTileWalkable(tx, ty)) return false;
}
}
return true;
}
function parseLobbyPlayerSpawnsFromMapLobby(md) {
const w = md.width || 20;
const h = md.height || 15;
const out = [null, null, null, null, null, null];
const raw = md && md.lobbyPlayerSpawns;
if (!Array.isArray(raw)) return out;
for (let i = 0; i < 6 && i < raw.length; i++) {
const cell = raw[i];
if (!cell || typeof cell !== 'object') continue;
const x = Math.floor(Number(cell.x));
const y = Math.floor(Number(cell.y));
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
if (x < 0 || x >= w || y < 0 || y >= h) continue;
out[i] = { x, y };
}
return out;
}
function pickRandomLobbySpawnFromMap() {
const fb = mapData.spawn || { x: 1, y: 1 };
const fx = Number.isFinite(Number(fb.x)) ? Number(fb.x) : 1;
const fy = Number.isFinite(Number(fb.y)) ? Number(fb.y) : 1;
const grid = mapData.spawnArea;
if (!grid || !Array.isArray(grid)) return { x: fx, y: fy };
const w = mapData.width || 20;
const h = mapData.height || 15;
const pool = [];
for (let yy = 0; yy < h; yy++) {
const row = grid[yy];
if (!row) continue;
for (let xx = 0; xx < w; xx++) {
if (Number(row[xx]) === 1 && lobbySpawnFootprintFits(xx, yy)) pool.push({ x: xx, y: yy });
}
}
if (!pool.length) return { x: fx, y: fy };
const pick = pool[Math.floor(Math.random() * pool.length)];
return { x: pick.x, y: pick.y };
}
/** สอดคล้อง server pickSpawnForJoin — P1…P6 ตามลำดับเข้า */
function pickLobbySpawnForJoin(joinOrderIndex) {
if (!mapData) return { x: 1, y: 1 };
const mode = mapData.lobbySpawnMode;
const ord = joinOrderIndex | 0;
if (mode === 'slots6' && ord >= 6) return pickRandomLobbySpawnFromMap();
const j = Math.min(Math.max(0, ord), 5);
if (mode === 'fixed' && mapData.spawn) {
const fx = Number.isFinite(Number(mapData.spawn.x)) ? Math.floor(Number(mapData.spawn.x)) : 1;
const fy = Number.isFinite(Number(mapData.spawn.y)) ? Math.floor(Number(mapData.spawn.y)) : 1;
const w = mapData.width || 20;
const h = mapData.height || 15;
const x = Math.max(0, Math.min(w - 1, fx));
const y = Math.max(0, Math.min(h - 1, fy));
if (lobbySpawnFootprintFits(x, y)) return { x, y };
return pickRandomLobbySpawnFromMap();
}
if (mode === 'slots6') {
const slots = parseLobbyPlayerSpawnsFromMapLobby(mapData);
const pick = slots[j];
if (pick && lobbySpawnFootprintFits(pick.x, pick.y)) return { x: pick.x, y: pick.y };
return pickRandomLobbySpawnFromMap();
}
return pickRandomLobbySpawnFromMap();
}
function pickLobbyBotSpawn(index) {
const sp = pickLobbySpawnForJoin(index);
return { x: sp.x, y: sp.y };
}
function syncLobbyCaseBots() {
if (!lobbyBotSlotCount || !mapData) {
lobbyBots.clear();
return;
}
while (lobbyBots.size < lobbyBotSlotCount) {
const i = lobbyBots.size;
const id = LOBBY_BOT_PREFIX + i;
const sp = pickLobbyBotSpawn(peers.size + i);
const wanderDirs = [[0, -1], [0, 1], [-1, 0], [1, 0]];
const wd = wanderDirs[Math.floor(Math.random() * wanderDirs.length)];
const bot = {
x: sp.x,
y: sp.y,
direction: 'down',
nickname: 'บอท ' + (i + 1),
ready: true,
characterId: getStoredCharacterId(),
isWalking: false,
botWanderDx: wd[0],
botWanderDy: wd[1],
botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 900),
};
lobbyBots.set(id, bot);
var serverSlot = lobbyBotThemesFromServer[i];
var serverTheme = serverSlot && parseLobbyThemeIndex(serverSlot.themeIndex);
var serverSkin = serverSlot && parseLobbySkinIndex(serverSlot.skinToneIndex);
if (serverTheme) {
applyThemeIndexToOccupant(bot, serverTheme, serverSkin || ((i % 3) + 1), function () {
if (bot.characterId) preloadLobbyTintedCharacter(bot.characterId, bot.colorTheme, bot.colorSkin, function () {});
if (mapData && canvas) drawLobbyMap();
});
} else {
var taken = {};
getLobbyTakenThemeIndices(null).forEach(function (t) { taken[t] = true; });
var botColorIdx = null;
for (var cTry = 1; cTry <= 8; cTry++) {
if (!taken[cTry]) { botColorIdx = cTry; break; }
}
if (!botColorIdx) botColorIdx = ((i % 8) + 1);
var botSkinIdx = (i % 3) + 1;
applyThemeIndexToOccupant(bot, botColorIdx, botSkinIdx, function () {
if (bot.characterId) preloadLobbyTintedCharacter(bot.characterId, bot.colorTheme, bot.colorSkin, function () {});
if (mapData && canvas) drawLobbyMap();
});
}
}
while (lobbyBots.size > lobbyBotSlotCount) {
const keys = [...lobbyBots.keys()];
lobbyBots.delete(keys[keys.length - 1]);
}
setTimeout(function () {
try { preloadAllLobbyTintedOccupants(function () {}); } catch (e) { /* ignore */ }
}, 400);
}
function updatePlayersHud() {
const hudText = 'PLAYERS : ' + lobbyOccupantCount() + '/' + lobbyOccupantSlots();
if (roomPlayersHud) roomPlayersHud.textContent = hudText;
const sph = document.getElementById('suspect-players-hud');
if (sph) sph.textContent = hudText;
}
function renderPeers() {
if (!peersList) return;
peersList.innerHTML = '';
const arr = [...peers.values()];
arr.forEach(p => {
const div = document.createElement('div');
div.className = 'room-lobby-peer';
const name = p.nickname || p.id.slice(0, 8);
const readyText = p.ready ? ' ✓ พร้อม' : ' ยังไม่พร้อม';
div.textContent = name + readyText;
peersList.appendChild(div);
});
if (quizModeActive) renderQuizScoreboard(lastQuizScores);
}
function getStoredCharacterId() {
try {
const v = (localStorage.getItem('gameCharacterId') || '').trim();
if (v && v !== 'Chatest') return v; // 'Chatest' = legacy placeholder ไม่มี sprite จริง
return rlDefaultCharId || ''; // ไม่มีตัวที่เลือก → ใช้ตัว default ที่มี sprite จริง
} catch (e) {
return rlDefaultCharId || '';
}
}
function updateLobbyProfileAvatar() {
const img = document.getElementById('room-lobby-profile-avatar');
if (!img) return;
const id = getStoredCharacterId();
if (!id) {
img.removeAttribute('src');
img.alt = (profileDisplayName || nick || 'ผู้เล่น');
return;
}
if (myTintTheme || myTintSkin) { updateRoomProfileAvatarTinted(); return; } // ใช้รูป tint สี กันถูกเขียนทับเป็นรูป baked
try {
const du = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id);
if (du && typeof du === 'string' && du.indexOf('data:image/') === 0) {
img.onload = null;
img.onerror = null;
img.src = du;
img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร';
return;
}
} catch (e) { /* ignore */ }
const urls = characterSpriteUrlCandidates(id, 'down');
let uidx = 0;
img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร';
img.onerror = function () {
uidx += 1;
if (uidx >= urls.length) {
img.onerror = null;
img.removeAttribute('src');
return;
}
img.src = urls[uidx];
};
img.onload = function () {
img.onerror = null;
};
img.src = urls[0];
}
function loadProfileDisplayName() {
try {
const saved = (localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) || '').trim();
if (saved) profileDisplayName = saved;
} catch (e) { /* ignore */ }
}
function saveProfileDisplayName(nextName) {
const safeName = String(nextName || '').trim();
if (!safeName) return;
profileDisplayName = safeName;
try { localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, safeName); } catch (e) { /* ignore */ }
}
function getProfileDisplayName() {
const me = peers.get(socket.id);
const serverName = me && typeof me.nickname === 'string' ? me.nickname.trim() : '';
if (serverName) return serverName;
return profileDisplayName || nick || 'PLAYER';
}
window.getProfileDisplayName = getProfileDisplayName;
loadProfileDisplayName();
function padAgentId(n) {
let s = String(n || '');
while (s.length < 6) s = '0' + s;
return s;
}
function ensureAgentDisplayId() {
const key = 'agentDisplayId';
try {
let v = (localStorage.getItem(key) || '').trim();
if (!/^\d{6}$/.test(v)) {
v = padAgentId(100000 + Math.floor(Math.random() * 899999));
localStorage.setItem(key, v);
}
return v;
} catch (e) {
return padAgentId(100000 + Math.floor(Math.random() * 899999));
}
}
function ensurePlayerKey() {
try {
let k = (localStorage.getItem(PLAYER_KEY) || '').trim();
if (!k || k.length < 8) {
k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14);
localStorage.setItem(PLAYER_KEY, k);
}
return k;
} catch (e) {
return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14);
}
}
function getStoredCoins() {
try {
const raw = localStorage.getItem('jdCoins');
const n = Math.max(0, parseInt(raw, 10) || 0);
return n;
} catch (e) {
return 0;
}
}
function syncLobbyAvatarFromStorage() {
updateLobbyProfileAvatar();
if (typeof redrawLobbyMap === 'function') redrawLobbyMap();
}
class RoomLobbyProfileOverlay {
constructor() {
this.overlay = document.getElementById('room-lobby-profile-overlay');
this.backdrop = document.getElementById('room-lobby-profile-backdrop');
this.closeBtn = document.getElementById('room-lobby-profile-close');
this.dialogEl = document.querySelector('.room-lobby-profile-dialog');
this.innerFrameEl = document.querySelector('.room-lobby-profile-inner-frame');
this.coinsRowEl = document.querySelector('.room-lobby-profile-coins');
this.coinsLabelEl = document.querySelector('.room-lobby-profile-coins-label');
this.coinsIconEl = document.querySelector('.room-lobby-profile-coins-icon');
this.avatarEl = document.getElementById('room-lobby-profile-overlay-avatar');
this.nameEl = document.getElementById('room-lobby-profile-overlay-name');
this.agentEl = document.getElementById('room-lobby-profile-overlay-agent');
this.coinsEl = document.getElementById('room-lobby-profile-coins-val');
this.musicBtn = document.getElementById('room-lobby-profile-music');
this.sfxBtn = document.getElementById('room-lobby-profile-sfx');
this.archivGroupBtns = Array.from(document.querySelectorAll('.room-lobby-profile-archiv-group-btn'));
this.archivListEl = document.getElementById('room-lobby-profile-achievements-list');
this.archivScrollEl = document.querySelector('.room-lobby-profile-archiv-scroll');
this.archivScrollBarEl = document.querySelector('.room-lobby-profile-archiv-scroll-bar');
this.editBtn = document.getElementById('room-lobby-profile-edit-btn');
this.activeArchivGroup = 1;
this._boundSyncProfileFrameScale = () => this._syncProfileFrameScale();
this._profileFrameScaleObserver = null;
this._bindEvents();
this._initProfileFrameScaleObserver();
this._syncProfileFrameScale();
this._applySwitchVisual(this.musicBtn, true);
this._applySwitchVisual(this.sfxBtn, false);
this._setArchivGroup(1);
this._syncCoinsFromServer();
if (window.jdAchievements) window.jdAchievements.load();
}
_bindEvents() {
this.backdrop?.addEventListener('click', () => this.close());
this.closeBtn?.addEventListener('click', () => this.close());
this.musicBtn?.addEventListener('click', () => this._toggleChip(this.musicBtn));
this.sfxBtn?.addEventListener('click', () => this._toggleChip(this.sfxBtn));
this.archivGroupBtns.forEach((btn) => {
btn.addEventListener('click', () => {
const raw = parseInt(btn.getAttribute('data-group'), 10);
this._setArchivGroup(Number.isFinite(raw) ? raw : 1);
});
});
this.editBtn?.addEventListener('click', () => this._editDisplayName());
this.archivListEl?.addEventListener('scroll', () => this._syncArchivScroll(), { passive: true });
window.addEventListener('resize', this._boundSyncProfileFrameScale);
}
_syncArchivScroll() {
if (!this.archivListEl || !this.archivScrollEl || !this.archivScrollBarEl) return;
const total = this.archivListEl.scrollHeight || 0;
const visible = this.archivListEl.clientHeight || 0;
const overflow = total > visible + 1;
this.archivScrollEl.classList.toggle('is-hidden', !overflow);
this.archivScrollEl.setAttribute('aria-hidden', overflow ? 'false' : 'true');
if (!overflow) return;
const max = Math.max(1, total - visible);
const ratio = Math.max(0, Math.min(1, this.archivListEl.scrollTop / max));
const barPct = Math.max(18, Math.min(42, (visible / Math.max(1, total)) * 100));
this.archivScrollBarEl.style.height = barPct.toFixed(2) + '%';
this.archivScrollBarEl.style.top = ((100 - barPct) * ratio).toFixed(2) + '%';
}
_initProfileFrameScaleObserver() {
if (!this.innerFrameEl) return;
if (typeof ResizeObserver === 'undefined') return;
this._profileFrameScaleObserver = new ResizeObserver(() => this._syncProfileFrameScale());
this._profileFrameScaleObserver.observe(this.innerFrameEl);
}
_syncProfileFrameScale() {
let dialogScale = 1;
if (this.dialogEl) {
const rect = this.dialogEl.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const scaleW = rect.width / 1701;
const scaleH = rect.height / 951;
dialogScale = Math.max(0.35, Math.min(1, Math.min(scaleW, scaleH)));
this.dialogEl.style.setProperty('--rp-scale', String(dialogScale.toFixed(4)));
}
}
if (!this.innerFrameEl) return;
this.innerFrameEl.style.setProperty('--pf-scale', String(dialogScale.toFixed(4)));
this._syncArchivScroll();
}
_toggleChip(btn) {
if (!btn) return;
const current = String(btn.getAttribute('data-on') || '').toLowerCase() === 'true';
this._applySwitchVisual(btn, !current);
}
_applySwitchVisual(btn, isOn) {
if (!btn) return;
const on = !!isOn;
btn.setAttribute('data-on', on ? 'true' : 'false');
const img = btn.querySelector('img');
if (!img) return;
const nextSrc = on ? 'img/03-6-Profile/btn-on.png' : 'img/03-6-Profile/btn-off.png';
img.src = nextSrc;
img.alt = on ? 'เปิด' : 'ปิด';
}
_setArchivGroup(groupIndex) {
let idx = parseInt(groupIndex, 10);
if (!Number.isFinite(idx) || idx < 1 || idx > 5) idx = 1;
this.activeArchivGroup = idx;
this.archivGroupBtns.forEach((btn) => {
const raw = parseInt(btn.getAttribute('data-group'), 10);
const id = Number.isFinite(raw) ? raw : 1;
const active = id === idx;
btn.classList.toggle('is-active', active);
const img = btn.querySelector('img');
if (!img) return;
img.src = active
? 'img/03-6-Profile/archiv-group-0' + id + '-a.png'
: 'img/03-6-Profile/archiv-group-0' + id + '.png';
});
if (window.jdAchievements && this.archivListEl) {
window.jdAchievements.renderGroup(this.archivListEl, idx);
}
this._syncArchivScroll();
}
setProfileData(data) {
const payload = data || {};
const avatarSrc = payload.avatarSrc || document.getElementById('room-lobby-profile-avatar')?.src || '';
if (this.avatarEl && avatarSrc) this.avatarEl.src = avatarSrc;
if (this.nameEl) this.nameEl.textContent = payload.displayName || getProfileDisplayName();
const agentId = payload.agentIdLabel || ('AGENT ID : ' + ensureAgentDisplayId());
if (this.agentEl) this.agentEl.textContent = agentId;
const coinsVal = payload.coins != null ? payload.coins : getStoredCoins();
if (this.coinsEl) this.coinsEl.textContent = String(Math.max(0, parseInt(coinsVal, 10) || 0));
}
_syncCoinsFromServer() {
const key = ensurePlayerKey();
const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins.php') : '/Admin/api/player-coins.php')
+ '?playerKey=' + encodeURIComponent(key);
fetch(url, { credentials: 'omit' })
.then((r) => r.json())
.then((d) => {
if (!d || !d.ok) return;
const coins = Math.max(0, parseInt(d.coins, 10) || 0);
try { localStorage.setItem('jdCoins', String(coins)); } catch (e) { /* ignore */ }
if (this.coinsEl) this.coinsEl.textContent = String(coins);
})
.catch(() => { /* ignore */ });
}
_editDisplayName() {
const self = this;
const currentName = getProfileDisplayName();
function afterRename(nextName) {
if (!nextName) return;
saveProfileDisplayName(nextName);
self.setProfileData({ displayName: nextName });
updateLobbyProfileAvatar();
renderPeers();
updatePlayersHud();
refreshHostConsoleOverlayIfOpen();
}
if (window.jdDisplayName && typeof window.jdDisplayName.openEditor === 'function') {
window.jdDisplayName.openEditor({ initialValue: currentName }).then(function (draft) {
if (draft == null) return;
if (typeof window.jdApplyDisplayNameChange === 'function') {
const result = window.jdApplyDisplayNameChange(draft, { socket });
if (result.ok) afterRename(result.displayName);
return;
}
afterRename(draft);
});
return;
}
const draft = window.prompt('แก้ไขชื่อผู้เล่น', currentName || '');
if (draft == null) return;
const nextName = String(draft).trim();
if (!nextName) {
alert('กรุณากรอกชื่อผู้เล่น');
return;
}
if (typeof window.jdApplyDisplayNameChange === 'function') {
const result = window.jdApplyDisplayNameChange(nextName, { socket });
if (!result.ok) return;
afterRename(result.displayName);
return;
}
afterRename(nextName);
}
open(data) {
if (!this.overlay) return;
this.setProfileData(data);
this._syncCoinsFromServer();
if (window.jdAchievements) window.jdAchievements.load();
this.overlay.classList.remove('is-hidden');
this.overlay.setAttribute('aria-hidden', 'false');
this._syncProfileFrameScale();
requestAnimationFrame(() => this._syncProfileFrameScale());
requestAnimationFrame(() => requestAnimationFrame(() => this._syncProfileFrameScale()));
this._syncArchivScroll();
this.closeBtn?.focus();
}
close() {
if (!this.overlay) return;
this.overlay.classList.add('is-hidden');
this.overlay.setAttribute('aria-hidden', 'true');
}
toggle(force, data) {
if (!this.overlay) return;
const shouldOpen = typeof force === 'boolean' ? force : this.overlay.classList.contains('is-hidden');
if (shouldOpen) this.open(data);
else this.close();
}
}
const roomLobbyProfileOverlay = new RoomLobbyProfileOverlay();
window.RoomLobbyProfileOverlay = RoomLobbyProfileOverlay;
window.roomLobbyProfileOverlay = roomLobbyProfileOverlay;
class HostConsoleOverlay {
constructor() {
this.overlay = document.getElementById('host-console-overlay');
this.backdrop = document.getElementById('host-console-backdrop');
this.dialogEl = document.querySelector('.room-lobby-host-console-dialog');
this.closeBtn = document.getElementById('host-console-close');
this.confirmBtn = document.getElementById('host-console-confirm');
this.decBtn = document.getElementById('host-console-max-dec');
this.incBtn = document.getElementById('host-console-max-inc');
this.maxValueEl = document.getElementById('host-console-max-value');
this.summaryEl = document.getElementById('host-console-summary');
this.membersListEl = document.getElementById('host-console-members-list');
this.pendingMaxPlayers = maxPlayers;
this._boundSyncScale = () => this._syncScale();
this._scaleObserver = null;
this._bindEvents();
this._initScaleObserver();
this._syncScale();
}
_bindEvents() {
// Close only via the X hit area button (and Esc key handler).
this.closeBtn?.addEventListener('click', () => this.close());
this.decBtn?.addEventListener('click', () => this._changeMax(-1));
this.incBtn?.addEventListener('click', () => this._changeMax(1));
this.confirmBtn?.addEventListener('click', () => this._confirm());
window.addEventListener('resize', this._boundSyncScale);
}
_initScaleObserver() {
if (!this.dialogEl) return;
if (typeof ResizeObserver === 'undefined') return;
this._scaleObserver = new ResizeObserver(() => this._syncScale());
this._scaleObserver.observe(this.dialogEl);
}
_syncScale() {
if (!this.dialogEl) return;
const w = this.dialogEl.clientWidth || 0;
const h = this.dialogEl.clientHeight || 0;
if (!w || !h) return;
const scaleW = w / 990;
const scaleH = h / 881;
const scale = Math.max(0.5, Math.min(1.08, Math.min(scaleW, scaleH)));
this.dialogEl.style.setProperty('--hc-scale', String(scale.toFixed(4)));
}
_isHost() {
return hostId === socket.id;
}
_getPlayersCount() {
return Math.max(0, peers.size);
}
_changeMax(delta) {
if (!this._isHost()) return;
const humans = this._getPlayersCount();
const minAllowed = Math.max(humans, 2);
const next = this.pendingMaxPlayers + delta;
this.pendingMaxPlayers = Math.max(minAllowed, Math.min(LOBBY_SLOT_TOTAL, next));
this._render();
}
_kickMember(row) {
if (!this._isHost()) return;
if (!row || !row.id || row.isHost) return;
socket.emit('host-console-kick-peer', { targetId: row.id }, (res) => {
if (!res || !res.ok) {
var msg = (res && res.error) ? res.error : 'ลบสมาชิกไม่สำเร็จ';
try { alert(msg); } catch (e) { /* ignore */ }
return;
}
});
}
_renderMembers() {
if (!this.membersListEl) return;
this.membersListEl.textContent = '';
const rows = [...peers.entries()].map(([id, p]) => ({
id,
isHost: id === hostId,
name: (p && p.nickname) ? String(p.nickname).trim() : id.slice(0, 8)
}));
rows.sort((a, b) => {
if (a.isHost && !b.isHost) return -1;
if (!a.isHost && b.isHost) return 1;
return a.name.localeCompare(b.name, 'th');
});
const frag = document.createDocumentFragment();
rows.forEach((row) => {
const li = document.createElement('li');
li.className = 'room-lobby-host-console-member-item' + (row.isHost ? ' is-host' : '');
const name = document.createElement('span');
name.className = 'room-lobby-host-console-member-name';
name.textContent = row.isHost ? (row.name + ' (Host)') : row.name;
li.appendChild(name);
if (!row.isHost) {
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'room-lobby-host-console-delete-btn';
delBtn.disabled = !this._isHost();
delBtn.setAttribute('aria-label', 'ลบสมาชิก ' + row.name);
const delImg = document.createElement('img');
delImg.src = 'img/03-4-Host-Console/host-console-delete.png';
delImg.alt = '';
delImg.decoding = 'async';
delBtn.appendChild(delImg);
delBtn.addEventListener('click', () => this._kickMember(row));
li.appendChild(delBtn);
}
frag.appendChild(li);
});
this.membersListEl.appendChild(frag);
}
_render() {
const players = this._getPlayersCount();
const bots = Math.max(0, this.pendingMaxPlayers - players);
if (this.maxValueEl) this.maxValueEl.textContent = String(this.pendingMaxPlayers);
if (this.summaryEl) this.summaryEl.textContent = 'สรุปจำนวน : ' + players + ' ผู้เล่น + ' + bots + ' Bot';
this._renderMembers();
const canEdit = this._isHost();
if (this.decBtn) this.decBtn.disabled = !canEdit;
if (this.incBtn) this.incBtn.disabled = !canEdit;
if (this.confirmBtn) this.confirmBtn.disabled = !canEdit;
}
_confirm() {
if (!this._isHost()) return;
const humans = this._getPlayersCount();
const total = Math.min(LOBBY_SLOT_TOTAL, Math.max(humans, this.pendingMaxPlayers));
const bots = total - humans;
lobbyBotSlotCount = bots;
maxPlayers = Math.max(humans, total - bots);
this.pendingMaxPlayers = total;
syncLobbyCaseBots();
updatePlayersHud();
if (mapData && canvas) drawLobbyMap();
const self = this;
socket.emit('host-console-apply', {
totalSlots: total,
botSlotCount: bots,
maxPlayers: maxPlayers
}, function (res) {
if (!res || !res.ok) {
var msg = (res && res.error) ? res.error : 'บันทึกการตั้งค่าไม่สำเร็จ';
try { alert(msg); } catch (e) { /* ignore */ }
return;
}
if (res.maxPlayers != null) maxPlayers = res.maxPlayers;
if (res.botSlotCount != null) {
lobbyBotSlotCount = Math.max(0, parseInt(res.botSlotCount, 10) || 0);
syncLobbyCaseBots();
updatePlayersHud();
if (mapData && canvas) drawLobbyMap();
}
self.close();
});
}
open() {
if (!this.overlay) return;
if (!this._isHost()) return;
const totalNow = Math.min(LOBBY_SLOT_TOTAL, Math.max(peers.size, lobbyOccupantCount()));
this.pendingMaxPlayers = totalNow > 0 ? totalNow : Math.min(LOBBY_SLOT_TOTAL, Math.max(2, (maxPlayers || 2) + (lobbyBotSlotCount || 0)));
this._render();
this.overlay.classList.remove('is-hidden');
this.overlay.setAttribute('aria-hidden', 'false');
this._syncScale();
requestAnimationFrame(() => this._syncScale());
this.closeBtn?.focus();
}
close() {
if (!this.overlay) return;
this.overlay.classList.add('is-hidden');
this.overlay.setAttribute('aria-hidden', 'true');
}
}
const hostConsoleOverlay = new HostConsoleOverlay();
window.hostConsoleOverlay = hostConsoleOverlay;
function refreshHostConsoleOverlayIfOpen() {
if (!hostConsoleOverlay || !hostConsoleOverlay.overlay) return;
if (hostConsoleOverlay.overlay.classList.contains('is-hidden')) return;
hostConsoleOverlay._render();
}
function applyPeerDisplayNameUpdate(data) {
if (!data || !data.id) return;
const p = peers.get(data.id);
if (p) p.nickname = data.nickname;
if (data.id === socket.id) {
saveProfileDisplayName(data.nickname);
try { localStorage.setItem('playerName', data.nickname); } catch (e) { /* ignore */ }
if (window.jdDisplayName && typeof window.jdDisplayName.syncDom === 'function') {
window.jdDisplayName.syncDom(data.nickname);
}
}
renderPeers();
updatePlayersHud();
refreshHostConsoleOverlayIfOpen();
}
socket.on('peer-display-name', applyPeerDisplayNameUpdate);
socket.on('connect', () => {
/* ส่งค่าสีที่เลือกใน Main-Lobby ติดไปด้วย — server จะใช้ค่านี้ถ้ายังว่าง (กันโชว์สีแดง default ตอนเข้าใหม่) */
var savedThemeIdx = null;
var savedSkinIdx = null;
try {
var t = parseInt(localStorage.getItem('lobbyThemeColor'), 10);
var s = parseInt(localStorage.getItem('lobbySkinTone'), 10);
if (t >= 1 && t <= 8) savedThemeIdx = t;
if (s >= 1 && s <= 3) savedSkinIdx = s;
} catch (e) { /* ignore */ }
socket.emit('join-space', {
spaceId,
nickname: getProfileDisplayName(),
characterId: getStoredCharacterId(),
playerKey: ensurePlayerKey(),
desiredLobbyColorThemeIndex: savedThemeIdx,
desiredLobbySkinToneIndex: savedSkinIdx,
roomPassword: (params.get('pass') || '').trim(), /* ห้องส่วนตัว: รหัสผ่านจาก ?pass (host/joiner) */
}, (res) => {
if (!res || !res.ok) {
var joinErr = (res && res.error) || 'เข้าไม่ได้';
if (/เริ่มคดี|ไม่รับผู้เล่น/.test(joinErr)) {
alert(joinErr + '\n\nถ้าคุณเคยอยู่ในห้องนี้แล้ว: ให้ใช้ nick ในลิงก์ให้ตรงกับชื่อที่ใช้ตอนเข้าห้องครั้งแรก แล้วรีเฟรชหน้า');
} else {
alert(joinErr);
}
location.href = DESIGN_MENU;
return;
}
mapData = res.mapData;
roomJoinReady = true;
/* ไอคอนกุญแจ 🔒 โชว์เฉพาะห้อง private (จาก res.isPrivate) — public ไม่โชว์ */
try {
const lockEl = document.getElementById('room-id-lock');
if (lockEl) lockEl.style.display = res.isPrivate ? '' : 'none';
} catch (eLk) { /* ignore */ }
maybeHideRoomLoading();
markRoomCzInteractiveCell();
markHostHcInteractiveCell();
if (ROOM_CZ_SPOT) appendLobbySystemChat('— เดินไปช่องเขียว แล้วกด F เพื่อเปิดห้องแต่งตัว');
if (ROOM_HC_SPOT && hostId === socket.id) appendLobbySystemChat('— เดินไปจุดโฮสต์ (ไอคอนชมพู) แล้วกด F เพื่อเปิด Host Console');
clientLobbyMapId = res.mapId != null ? res.mapId : null;
if (res.detectiveLobbyCaseId != null) {
try {
window.__detectiveLobbyMeta = {
level: res.detectiveLobbyLevel != null ? res.detectiveLobbyLevel : null,
caseId: res.detectiveLobbyCaseId,
};
} catch (eMeta) { /* ignore */ }
ensureCaseMediaLoaded().then(function () {
applySuspectPickImages();
refreshSuspectCaseLabel();
});
}
hostId = res.hostId || null;
spaceName = (res.spaceName || '').trim() || spaceId;
(res.peers || []).forEach(p => { peers.set(p.id, normalizeLobbyPeerFromServer(p, mapData)); });
if (mapData && mapData.backgroundImage) {
mapBackgroundImg = new Image();
mapBackgroundImg.src = mapData.backgroundImage;
mapBackgroundImg.onload = () => { resizeAndDraw(); };
}
if (!mapData.interactive) mapData.interactive = [];
if (!mapData.startGameArea) mapData.startGameArea = [];
if (!mapData.quizTrueArea) mapData.quizTrueArea = [];
if (!mapData.quizFalseArea) mapData.quizFalseArea = [];
if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
maxPlayers = res.maxPlayers != null ? res.maxPlayers : 10;
lobbyBotSlotCount = res.botSlotCount != null ? Math.max(0, parseInt(res.botSlotCount, 10) || 0) : 0;
if (lobbyBotSlotCount === 0 && maxPlayers > 0 && maxPlayers < 6) {
lobbyBotSlotCount = 6 - maxPlayers;
}
if (Array.isArray(res.lobbyBotThemes)) lobbyBotThemesFromServer = res.lobbyBotThemes;
applyAllPeerThemesFromServer(function () {
rlResolveMyColors(function () {
if (lobbyBotSlotCount > 0) syncLobbyCaseBots();
if (mapData && canvas) drawLobbyMap();
});
});
updatePlayersHud();
syncLobbyBUiChrome();
if (wasPageReload() && (hostId === socket.id || peers.size <= 1)) {
window.location.replace(CREATE_ROOM_URL);
return;
}
renderPeers();
updateLobbyProfileAvatar();
var meAfterJoin = peers.get(socket.id);
if (readyCheck && meAfterJoin) {
readyCheck.checked = !!meAfterJoin.ready;
updateReadyLabelVisual();
}
ensureReadyControlEnabled();
if (res.cardMinigames) setSuspectCardMinigames(res.cardMinigames);
applyPersonalSuspectState(res);
serverSuspectPhaseActive = !!res.suspectPhaseActive;
/* กลับ LobbyB ด้วยการ reload (full navigation) — โชว์การ์ดหลักฐานที่เพิ่งได้จาก sessionStorage */
var revealedOnJoin = false;
var hasPendingReveal = false;
try { hasPendingReveal = !!sessionStorage.getItem('justiceEvidenceReveal'); } catch (eRev) { hasPendingReveal = false; }
if (hasPendingReveal && (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID || res.suspectPhaseActive)) {
revealedOnJoin = showEvidenceRevealOnReturn();
}
if (res.suspectPhaseActive) {
if (revealedOnJoin) {
setTimeout(function () { openSuspectOverlay(res.suspectPickIndex != null ? res.suspectPickIndex : 0); }, 1600);
} else {
openSuspectOverlay(res.suspectPickIndex != null ? res.suspectPickIndex : 0);
}
} else {
updateSuspectFloatingOpenBtn();
}
if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID || res.suspectPhaseActive) {
try {
var uLobbyB = new URL(window.location.href);
uLobbyB.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID);
history.replaceState({}, '', uLobbyB.pathname + uLobbyB.search);
} catch (eMap) { /* ignore */ }
}
const isHost = hostId === socket.id;
if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none';
if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline';
if (isHost && playMapSelect) {
fetch(SERVER + '/api/maps')
.then(r => r.json())
.then(list => {
playMapSelect.innerHTML = '<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));
applyPeerThemeFromServer(peers.get(data.id), data.lobbyColorThemeIndex, data.lobbySkinToneIndex, function () {
updatePlayersHud();
renderPeers();
redrawLobbyMap();
if (typeof window.refreshCustomizeColorRow === 'function') window.refreshCustomizeColorRow();
});
});
socket.on('lobby-tint-sync', function (data) {
applyLobbyTintSyncPayload(data || {});
});
socket.on('user-left', (data) => {
if (typeof closePeer === 'function') closePeer(data.id);
peers.delete(data.id);
updatePlayersHud();
renderPeers();
redrawLobbyMap();
if (typeof window.refreshCustomizeColorRow === 'function') window.refreshCustomizeColorRow();
});
socket.on('host-console-kicked', (data) => {
var msg = (data && data.message) ? String(data.message) : 'คุณถูก Host นำออกจากห้อง';
try { alert(msg); } catch (e) { /* ignore */ }
window.location.href = CREATE_ROOM_URL;
});
socket.on('host-console-settings', (data) => {
if (!data || typeof data !== 'object') return;
if (data.maxPlayers != null) maxPlayers = Math.max(peers.size, parseInt(data.maxPlayers, 10) || peers.size);
if (Array.isArray(data.lobbyBotThemes)) lobbyBotThemesFromServer = data.lobbyBotThemes;
if (data.botSlotCount != null) {
lobbyBotSlotCount = Math.max(0, parseInt(data.botSlotCount, 10) || 0);
syncLobbyCaseBots();
}
updatePlayersHud();
refreshHostConsoleOverlayIfOpen();
if (mapData && canvas) drawLobbyMap();
if (typeof window.refreshCustomizeColorRow === 'function') window.refreshCustomizeColorRow();
});
socket.on('peer-ready', (data) => {
const p = peers.get(data.id);
if (p) { p.ready = data.ready; renderPeers(); redrawLobbyMap(); }
if (data && data.id === socket.id && readyCheck) {
readyCheck.checked = !!data.ready;
updateReadyLabelVisual();
}
ensureReadyControlEnabled();
});
socket.on('user-move', (data) => {
const p = peers.get(data.id);
if (p) {
if (data.id === socket.id) {
if (data.x != null) {
const x = Number(data.x);
if (Number.isFinite(x)) { p.x = x; p.tx = x; }
}
if (data.y != null) {
const y = Number(data.y);
if (Number.isFinite(y)) { p.y = y; p.ty = y; }
}
} else {
if (data.x != null) p.tx = data.x;
if (data.y != null) p.ty = data.y;
}
if (data.direction) p.direction = data.direction;
if (data.characterId != null) p.characterId = data.characterId;
redrawLobbyMap();
}
});
function hideQuizScoreboardAndFeedback() {
var fb = document.getElementById('quiz-feedback-banner');
if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; }
}
function renderQuizScoreboard(scores) {
var ov = document.getElementById('quiz-game-overlay');
var ul = document.getElementById('quiz-scoreboard-list');
if (!ul) return;
if (!quizModeActive) {
if (ov) ov.classList.add('is-hidden');
return;
}
if (ov) ov.classList.remove('is-hidden');
var merged = scores && typeof scores === 'object' ? Object.assign({}, scores) : {};
peers.forEach(function (_, id) {
if (merged[id] == null) merged[id] = 0;
});
ul.textContent = '';
var rows = [];
peers.forEach(function (p, id) {
rows.push({
id: id,
nick: (p && p.nickname) ? String(p.nickname) : id,
sc: merged[id] != null ? merged[id] : 0,
characterId: p && p.characterId ? String(p.characterId) : '',
});
});
rows.sort(function (a, b) {
if (b.sc !== a.sc) return b.sc - a.sc;
return a.nick.localeCompare(b.nick, 'th');
});
rows.forEach(function (row) {
var li = document.createElement('li');
if (row.id === socket.id) li.className = 'quiz-scoreboard-me';
var av = document.createElement(row.characterId ? 'img' : 'div');
av.className = 'quiz-sb-avatar';
if (row.characterId) {
av.alt = '';
var duSb = '';
try {
duSb = localStorage.getItem(LOBBY_IDLE_DOWN_LS + row.characterId) || '';
} catch (eDu) { duSb = ''; }
if (duSb && duSb.indexOf('data:image/') === 0) {
av.src = duSb;
} else {
var urlsSb = characterSpriteUrlCandidates(row.characterId, 'down');
var qi = 0;
av.onerror = function () {
qi += 1;
if (qi >= urlsSb.length) {
av.onerror = null;
av.removeAttribute('src');
return;
}
av.src = urlsSb[qi];
};
av.src = urlsSb[0];
}
}
var meta = document.createElement('div');
meta.className = 'quiz-sb-meta';
var spName = document.createElement('span');
spName.className = 'quiz-scoreboard-name';
spName.textContent = row.nick;
var spVal = document.createElement('span');
spVal.className = 'quiz-scoreboard-val';
spVal.textContent = String(row.sc);
meta.appendChild(spName);
meta.appendChild(spVal);
li.appendChild(av);
li.appendChild(meta);
ul.appendChild(li);
});
}
function initQuizScoreboardZeros() {
if (!quizModeActive) return;
lastQuizScores = {};
peers.forEach(function (_, id) { lastQuizScores[id] = 0; });
renderQuizScoreboard(lastQuizScores);
}
function showQuizRoundFeedback(r) {
var el = document.getElementById('quiz-feedback-banner');
if (!el || !r || !r.results) return;
var mine = null;
for (var i = 0; i < r.results.length; i++) {
if (r.results[i].id === socket.id) { mine = r.results[i]; break; }
}
if (!mine) return;
el.classList.remove('is-hidden');
if (mine.right) {
el.className = 'quiz-feedback-banner quiz-feedback-ok';
el.textContent = 'คุณตอบถูก · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม';
} else {
el.className = 'quiz-feedback-banner quiz-feedback-bad';
if (mine.choice == null) {
el.textContent = 'คุณไม่ได้ยืนในโซนตอบ (จริง/เท็จ) — นับเป็นผิด';
} else {
el.textContent = 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีกในเกมนี้';
}
}
if (typeof window.__quizFeedbackHideT === 'number') clearTimeout(window.__quizFeedbackHideT);
window.__quizFeedbackHideT = setTimeout(function () {
el.classList.add('is-hidden');
}, 4200);
}
function showQuizOverlay() {
var ov = document.getElementById('quiz-game-overlay');
if (ov) ov.classList.remove('is-hidden');
}
function hideQuizOverlay() {
var ov = document.getElementById('quiz-game-overlay');
if (ov) ov.classList.add('is-hidden');
if (quizTimerInterval) { clearInterval(quizTimerInterval); quizTimerInterval = null; }
var panel = document.getElementById('quiz-map-question-panel');
if (panel) {
panel.classList.add('is-hidden');
panel.setAttribute('aria-hidden', 'true');
}
hideQuizScoreboardAndFeedback();
}
function updateQuizTimerDisplay() {
var el = document.getElementById('quiz-game-timer');
if (!el || !quizPhaseEndsAt) return;
var s = Math.max(0, Math.ceil((quizPhaseEndsAt - Date.now()) / 1000));
el.textContent = String(s);
}
socket.on('quiz-phase', function (p) {
if (!p) return;
if (p.text) lastQuizQuestionText = p.text;
quizPhaseLocal = p.phase;
quizPhaseEndsAt = p.endsAt || 0;
lastQuizQIdx = typeof p.questionIndex === 'number' ? p.questionIndex : 0;
lastQuizQTotal = typeof p.questionTotal === 'number' ? p.questionTotal : 0;
var phaseEl = document.getElementById('quiz-game-phase-label');
var qEl = document.getElementById('quiz-game-question');
var numEl = document.getElementById('quiz-hud-quiz-num');
if (numEl) {
numEl.textContent = '- Quiz ' + (p.questionIndex || '—') + ' / ' + (p.questionTotal || '—') + ' -';
}
if (phaseEl) {
phaseEl.textContent = p.phase === 'read'
? 'อ่านคำถาม'
: 'เดินเข้าโซน SAFE (จริง) หรือ SCAM (เท็จ)';
}
if (qEl) qEl.textContent = lastQuizQuestionText || '';
showQuizOverlay();
if (quizTimerInterval) clearInterval(quizTimerInterval);
quizTimerInterval = setInterval(updateQuizTimerDisplay, 200);
updateQuizTimerDisplay();
if (Object.keys(lastQuizScores).length) renderQuizScoreboard(lastQuizScores);
redrawLobbyMap();
});
socket.on('quiz-player-state', function (st) {
if (!st) return;
quizPlayerLocal = {
cannotTrue: !!st.cannotTrue,
cannotFalse: !!st.cannotFalse,
eliminated: !!st.eliminated,
score: typeof st.score === 'number' ? st.score : (quizPlayerLocal.score || 0),
};
redrawLobbyMap();
});
socket.on('quiz-result', function (r) {
if (!r) return;
var correct = r.correctTrue ? 'เฉลยข้อนี้: ถูก = จริง' : 'เฉลยข้อนี้: ถูก = เท็จ';
var line = '— ข้อ ' + (r.questionIndex || '') + ' ' + correct;
if (r.allWrong) line += ' · ทุกคนผิด — จบเกม';
appendLobbySystemChat(line);
if (r.results) {
r.results.forEach(function (row) {
if (!row.right) quizPeersLocked[row.id] = true;
});
}
if (r.scores) {
lastQuizScores = r.scores;
renderQuizScoreboard(lastQuizScores);
}
showQuizRoundFeedback(r);
if (r.results) {
r.results.forEach(function (row) {
if (row.id === socket.id) {
var det = row.right ? 'ถูก' : 'ผิด';
if (!row.right && row.choice == null) det += ' (ไม่ได้ยืนในโซน)';
appendLobbySystemChat('— คุณตอบ' + det + ' · คะแนนรวม ' + (typeof row.score === 'number' ? row.score : 0));
}
});
}
redrawLobbyMap();
});
socket.on('quiz-ended', function (d) {
quizModeActive = false;
quizPhaseLocal = null;
quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
quizPeersLocked = {};
lastQuizQuestionText = '';
lastQuizScores = {};
try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ }
hideQuizOverlay();
appendLobbySystemChat('— ' + (d && d.message ? d.message : 'จบเกมตอบคำถาม'));
if (d && d.returnToLobbyB) {
serverSuspectPhaseActive = true;
updateSuspectFloatingOpenBtn();
}
redrawLobbyMap();
});
socket.on('detective-minigame-ended', function (data) {
applyDetectiveReturnToLobbyB(data || {});
});
socket.on('lobby-evidence-sync', function (data) {
applyPersonalSuspectState(data || {});
applySuspectProgressVisual();
if (lobbyEvidenceSuspectIdx != null) renderLobbyEvidenceCards(lobbyEvidenceSuspectIdx);
});
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); }
});
/* รับตำแหน่งบอทจาก host (เฉพาะ client ที่ไม่ใช่ host) → เก็บเป็นเป้า lerp ใน lobbyTick */
socket.on('lobby-bot-move', (data) => {
if (hostId === socket.id) return; /* host เป็นเจ้าของเอง ไม่ต้องรับ */
if (!data || !Array.isArray(data.bots)) return;
data.bots.forEach((rb) => {
if (!rb || typeof rb.id !== 'string') return;
const b = lobbyBots.get(rb.id);
if (!b) return;
if (Number.isFinite(rb.x)) b.botSyncTX = rb.x;
if (Number.isFinite(rb.y)) b.botSyncTY = rb.y;
if (rb.direction) b.direction = rb.direction;
b.isWalking = !!rb.isWalking;
});
});
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';
var n = parseInt(cid, 10);
if (!(n >= 1 && n <= 15)) cid = '1'; else cid = String(n);
if (lobbyEvidenceCasesRemote && lobbyEvidenceCasesRemote[cid]) return lobbyEvidenceCasesRemote[cid];
return LOBBY_EVIDENCE_CASES[cid] || LOBBY_EVIDENCE_CASES['1'];
} catch (e) {
return (lobbyEvidenceCasesRemote && lobbyEvidenceCasesRemote['1']) || LOBBY_EVIDENCE_CASES['1'];
}
}
var evidenceScaleBound = false;
function syncLobbyEvidenceScale() {
var wrap = document.querySelector('#lobby-evidence-overlay .stage-wrap');
var stage = document.querySelector('#lobby-evidence-overlay .ev-stage');
if (!wrap || !stage) return;
var dw = 1920;
var dh = 1080;
var w = wrap.clientWidth || window.innerWidth;
var h = wrap.clientHeight || window.innerHeight;
var scale = Math.min(w / dw, h / dh);
if (!Number.isFinite(scale) || scale <= 0) scale = 1;
var scaledW = dw * scale;
var scaledH = dh * scale;
var tx = Math.max(0, (w - scaledW) / 2);
var ty = Math.max(0, (h - scaledH) / 2);
stage.style.transform = 'translate(' + tx + 'px, ' + ty + 'px) scale(' + scale + ')';
stage.style.marginBottom = '0';
}
function bindLobbyEvidenceScale() {
if (evidenceScaleBound) return;
evidenceScaleBound = true;
window.addEventListener('resize', syncLobbyEvidenceScale);
window.addEventListener('orientationchange', syncLobbyEvidenceScale);
}
function ensureLobbyEvidenceEvLayout() {
var ov = document.getElementById('lobby-evidence-overlay');
if (!ov || ov.querySelector('.ev-stage')) return ov;
var base = LOBBY_EVIDENCE_ASSET_BASE;
var backdrop = ov.querySelector('.lobby-evidence-backdrop');
ov.textContent = '';
if (backdrop) ov.appendChild(backdrop);
var wrap = document.createElement('div');
wrap.className = 'stage-wrap';
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-modal', 'true');
wrap.setAttribute('aria-labelledby', 'lobby-evidence-sr-title');
wrap.innerHTML =
'<h2 id="lobby-evidence-sr-title" class="visually-hidden">แฟ้มหลักฐาน</h2>' +
'<div class="stage ev-stage">' +
'<div class="layer ev-bg-fallback" aria-hidden="true"></div>' +
'<div class="layer ev-popup"><img src="' + base + '/evidence-bg.png" alt="" width="1684" height="963" decoding="async"></div>' +
'<button type="button" class="layer ev-close" id="lobby-evidence-close" title="ปิด" aria-label="ปิดแฟ้มหลักฐาน"></button>' +
'<div class="layer ev-tabs" id="lobby-evidence-tabs" role="tablist" aria-label="เลือกผู้ต้องสงสัย">' +
'<img class="ev-tab is-active" data-state="1" src="' + base + '/evidence-tab-1.png" alt="" decoding="async">' +
'<img class="ev-tab" data-state="2" src="' + base + '/evidence-tab-2.png" alt="" decoding="async">' +
'<img class="ev-tab" data-state="3" src="' + base + '/evidence-tab-3.png" alt="" decoding="async">' +
'<button type="button" class="ev-tab-click" role="tab" data-evidence-tab="0" aria-selected="true" aria-label="ผู้ต้องสงสัย 1"></button>' +
'<button type="button" class="ev-tab-click" role="tab" data-evidence-tab="1" aria-selected="false" aria-label="ผู้ต้องสงสัย 2"></button>' +
'<button type="button" class="ev-tab-click" role="tab" data-evidence-tab="2" aria-selected="false" aria-label="ผู้ต้องสงสัย 3"></button>' +
'</div>' +
'<div class="layer ev-cards" id="lobby-evidence-cards-root"></div>' +
'</div>';
ov.appendChild(wrap);
return ov;
}
function formatLobbyEvCardDescHtml(body) {
var text = String(body || '').trim();
var lines = text ? text.split(/\n+/).map(function (s) { return s.trim(); }).filter(Boolean) : [];
if (!lines.length) lines.push('—');
if (!lines.some(function (ln) { return ln.indexOf('เครื่องมือสืบ') >= 0; })) {
lines.push('เครื่องมือสืบเสาะเคยถือไว้ครอบครอง');
}
return lines.join('<br>');
}
function buildEvHtmlCard(card, c, linkName, cardIdx) {
var rar = String(c.rarity || 'common').toLowerCase();
if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common';
var nStar = Math.max(1, Math.min(3, Number(c.stars) || 1));
var stars = '';
for (var st = 0; st < nStar; st++) stars += '★';
var nm = linkName || '?';
var icon = LOBBY_EVIDENCE_CARD_ICONS[cardIdx] || '🔎';
card.classList.remove('ev-card--img');
card.innerHTML =
'<div class="ev-card-head">' +
'<div class="icon" aria-hidden="true"></div>' +
'<div class="name">' +
'<div class="th"></div>' +
'<div class="en"></div>' +
'</div>' +
'</div>' +
'<div class="ev-card-art" aria-hidden="true">[การ์ดยังไม่มี PNG]</div>' +
'<div class="ev-card-desc"></div>' +
'<div class="ev-card-link-wrap">' +
'<div class="ev-card-link">' +
'<span class="label">Suspect Link</span>' +
'<span class="stars"></span>' +
'</div>' +
'<div class="ev-card-link">' +
'<div class="suspect">' +
'<div class="avatar"></div>' +
'<div class="sname"></div>' +
'</div>' +
'</div>' +
'</div>';
card.querySelector('.icon').textContent = icon;
card.querySelector('.th').textContent = c.titleTh || '';
card.querySelector('.en').textContent = c.titleEn ? '(' + c.titleEn + ')' : '';
card.querySelector('.ev-card-desc').innerHTML = formatLobbyEvCardDescHtml(c.body);
card.querySelector('.stars').textContent = stars + ' (' + LOBBY_EVIDENCE_RARITY[rar] + ')';
card.querySelector('.avatar').textContent = nm.charAt(0);
card.querySelector('.sname').textContent = nm;
}
function evLobbyEvidenceCardEl(c, linkName, cardIdx) {
var rar = String(c.rarity || 'common').toLowerCase();
if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common';
var card = document.createElement('article');
card.className = 'ev-card rarity-' + rar;
/* การ์ดออกแบบเป็น PNG เต็มใบ — แสดงรูปแทน HTML chrome (มี fallback ถ้ารูปโหลดไม่ได้) */
if (c && c.imageUrl) {
card.classList.add('ev-card--img');
var fullImg = document.createElement('img');
fullImg.className = 'ev-card-full-img';
fullImg.alt = (c.titleTh || '') + (c.titleEn ? ' (' + c.titleEn + ')' : '');
fullImg.decoding = 'async';
fullImg.onerror = function () {
this.onerror = null;
buildEvHtmlCard(card, c, linkName, cardIdx);
};
fullImg.src = c.imageUrl;
card.appendChild(fullImg);
makeEvidenceCardZoomable(card, c, linkName);
return card;
}
buildEvHtmlCard(card, c, linkName, cardIdx);
return card;
}
function evLobbyEvidenceCardElLegacy(c, linkName, cardIdx) {
var rar = String(c.rarity || 'common').toLowerCase();
if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common';
var nStar = Math.max(1, Math.min(3, Number(c.stars) || 1));
var stars = '';
for (var st = 0; st < nStar; st++) stars += '★';
var nm = linkName || '?';
var icon = LOBBY_EVIDENCE_CARD_ICONS[cardIdx] || '🔎';
var card = document.createElement('article');
card.className = 'ev-card rarity-' + rar;
card.innerHTML =
'<div class="ev-card-head">' +
'<div class="icon" aria-hidden="true"></div>' +
'<div class="name">' +
'<div class="th"></div>' +
'<div class="en"></div>' +
'</div>' +
'</div>' +
'<div class="ev-card-art" aria-hidden="true">[การ์ดยังไม่มี PNG]</div>' +
'<div class="ev-card-desc"></div>' +
'<div class="ev-card-link-wrap">' +
'<div class="ev-card-link">' +
'<span class="label">Suspect Link</span>' +
'<span class="stars"></span>' +
'</div>' +
'<div class="ev-card-link">' +
'<div class="suspect">' +
'<div class="avatar"></div>' +
'<div class="sname"></div>' +
'</div>' +
'</div>' +
'</div>';
card.querySelector('.icon').textContent = icon;
card.querySelector('.th').textContent = c.titleTh || '';
card.querySelector('.en').textContent = c.titleEn ? '(' + c.titleEn + ')' : '';
card.querySelector('.ev-card-desc').innerHTML = formatLobbyEvCardDescHtml(c.body);
card.querySelector('.stars').textContent = stars + ' (' + LOBBY_EVIDENCE_RARITY[rar] + ')';
card.querySelector('.avatar').textContent = nm.charAt(0);
card.querySelector('.sname').textContent = nm;
return card;
}
var DETECTIVE_EVIDENCE_STORAGE_KEY = 'justiceDetectiveMyEvidence';
/* แถวหลักฐาน = array ของ card object {rarity, stars, num, imageUrl} (ไม่จำกัดจำนวน) */
function normalizeEvidenceSlotList(cards) {
if (!Array.isArray(cards)) return [];
var out = [];
for (var i = 0; i < cards.length; i++) {
var c = cards[i];
if (c && typeof c === 'object' && c.imageUrl) out.push(c);
}
return out;
}
function getSuspectEvidenceCount(si) {
var i = Math.floor(Number(si));
if (Number.isNaN(i) || i < 0 || i > 2) return 0;
return (myPlayerEvidence[i] || []).length;
}
function canInvestigateSuspect(si) {
return true; // เก็บได้ไม่จำกัด
}
function persistMyPlayerEvidenceSession() {
try {
var hasAny = (myPlayerEvidence || []).some(function (r) { return r && r.length; });
if (hasAny) sessionStorage.setItem(DETECTIVE_EVIDENCE_STORAGE_KEY, JSON.stringify(myPlayerEvidence));
else sessionStorage.removeItem(DETECTIVE_EVIDENCE_STORAGE_KEY);
} catch (e) { /* ignore */ }
}
function applyMyPlayerEvidence(data) {
if (!Array.isArray(data)) return;
myPlayerEvidence = [0, 1, 2].map(function (si) {
return normalizeEvidenceSlotList(data[si]);
});
persistMyPlayerEvidenceSession();
}
/** ใช้เฉพาะตอน server ไม่ได้ส่ง myPlayerEvidence มาเลย (เช่น event ทั่วไป) — server เป็น source of truth */
function restoreMyPlayerEvidenceFromSession() {
try {
var raw = sessionStorage.getItem(DETECTIVE_EVIDENCE_STORAGE_KEY);
if (!raw) return false;
var parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return false;
applyMyPlayerEvidence(parsed);
return true;
} catch (e) { return false; }
}
function applyPersonalSuspectState(data) {
/* server เป็นความจริงเสมอ — ห้าม merge กับ session (กันเครื่องหมายถูกตกค้าง) */
if (data && Array.isArray(data.myPlayerEvidence)) {
applyMyPlayerEvidence(data.myPlayerEvidence);
} else if (!myPlayerEvidence || !myPlayerEvidence.some(function (r) { return r && r.length; })) {
restoreMyPlayerEvidenceFromSession();
}
if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress);
else if (myPlayerEvidence && myPlayerEvidence.some(function (row) { return row && row.length; })) {
setSuspectProgress([0, 1, 2].map(function (i) { return (myPlayerEvidence[i] || []).length; }));
}
applySuspectProgressVisual();
/* อัปเดต UI host เร็วทันที — ปุ่ม "ชี้ตัวคนร้าย" ต้องโผล่ทันทีเมื่อสืบครบ */
if (suspectPickOverlayOpen) updateSuspectHostUi();
if (typeof lobbyEvidenceSuspectIdx === 'number') renderLobbyEvidenceCards(lobbyEvidenceSuspectIdx);
}
/* ล้าง session เก่าเมื่อโหลดหน้านี้ — กันเครื่องหมายตกค้างข้ามคดี/รีเฟรช (server จะเติมให้เองถ้ามี) */
try { sessionStorage.removeItem(DETECTIVE_EVIDENCE_STORAGE_KEY); } catch (e) { /* ignore */ }
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 = '';
var owned = (myPlayerEvidence[si] || []);
for (var slot = 0; slot < owned.length; slot++) {
if (!owned[slot]) continue;
root.appendChild(evLobbyEvidenceCardEl(owned[slot], s.linkName, slot));
}
if (!root.children.length) {
const empty = document.createElement('p');
empty.className = 'ev-empty-hint';
empty.textContent = 'ยังไม่มีหลักฐาน — เล่นมินิเกมสืบสวน 1 ครั้งเพื่อรับการ์ด 1 ใบ';
root.appendChild(empty);
}
}
let lobbyEvidenceSuspectIdx = 0;
function syncLobbyEvidenceTabUi(idx) {
lobbyEvidenceSuspectIdx = Math.max(0, Math.min(2, idx));
var tabsEl = document.getElementById('lobby-evidence-tabs');
if (tabsEl) {
tabsEl.querySelectorAll('.ev-tab').forEach(function (img) {
var state = parseInt(img.getAttribute('data-state'), 10);
img.classList.toggle('is-active', state === lobbyEvidenceSuspectIdx + 1);
});
}
document.querySelectorAll('#lobby-evidence-overlay [data-evidence-tab]').forEach((b) => {
const i = parseInt(b.getAttribute('data-evidence-tab'), 10);
b.setAttribute('aria-selected', i === lobbyEvidenceSuspectIdx ? 'true' : 'false');
});
renderLobbyEvidenceCards(lobbyEvidenceSuspectIdx);
syncLobbyEvidenceScale();
}
function openLobbyEvidenceModal() {
ensureLobbyEvidenceEvLayout();
bindLobbyEvidenceScale();
const ov = document.getElementById('lobby-evidence-overlay');
if (!ov) return;
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
syncLobbyEvidenceTabUi(0);
requestAnimationFrame(syncLobbyEvidenceScale);
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();
});
ensureLobbyEvidenceEvLayout();
bindLobbyEvidenceScale();
document.getElementById('lobby-evidence-overlay')?.addEventListener('click', (e) => {
if (e.target.id === 'lobby-evidence-backdrop' || e.target.closest('#lobby-evidence-close')) {
closeLobbyEvidenceModal();
return;
}
const hit = e.target.closest('.ev-tab-click[data-evidence-tab]');
if (!hit) return;
const i = parseInt(hit.getAttribute('data-evidence-tab'), 10);
if (!Number.isNaN(i)) syncLobbyEvidenceTabUi(i);
});
const LOBBY_RANK_ASSET_BASE = BASE + '/img/Leaderboard';
const LOBBY_RANK_BASE_WIDTH = 1107;
const LOBBY_RANK_BASE_HEIGHT = 952;
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 {
return getCaseMedia(getDetectiveCaseId()).name || 'คดี';
} catch (e) {
return 'คดี';
}
}
/* กระดานผู้นำในเกม — คะแนนจริง "แยกต่อคดี" จาก server (ว่าง → fallback mock) */
var lobbyRankRealLeaders = null;
function fetchLobbyRankLeaders() {
var caseId = '';
try { caseId = String(getDetectiveCaseId() || ''); } catch (e) { caseId = ''; }
var base = (typeof appPath === 'function' ? appPath('/Admin/api/leaderboard.php') : '/Admin/api/leaderboard.php');
var url = base + '?limit=10&caseId=' + encodeURIComponent(caseId) + '&playerKey=' + encodeURIComponent(ensurePlayerKey());
fetch(url, { credentials: 'omit' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d || !d.ok || !Array.isArray(d.top)) return;
lobbyRankRealLeaders = d.top.map(function (e) {
return { name: e.name || 'ผู้เล่น', score: Math.max(0, parseInt(e.score, 10) || 0) };
});
var ov = document.getElementById('lobby-rank-overlay');
if (ov && !ov.classList.contains('is-hidden')) renderLobbyRankModalContent();
})
.catch(function () { /* ออฟไลน์ → ใช้ mock */ });
}
function getLobbyPlayerAvatarSrc() {
try {
const composed = (localStorage.getItem('jdCharLobbyIdleDown') || '').trim();
if (composed) return composed;
} catch (e) { /* ignore */ }
const topRight = document.getElementById('room-lobby-profile-avatar');
if (topRight && topRight.getAttribute('src')) {
return topRight.getAttribute('src');
}
return (typeof appPath === 'function' ? appPath('/Game/public/img/characters/char_01_idle_down.png') : (BASE + '/img/characters/char_01_idle_down.png'));
}
function createAvatarWithTone(src, toneIndex, className) {
const img = document.createElement('img');
img.className = className || 'lobby-rank-row-avatar';
img.src = src;
img.alt = '';
img.decoding = 'async';
if (toneIndex > 0) {
const hue = (toneIndex * 42) % 360;
img.style.filter = `hue-rotate(${hue}deg) saturate(1.08)`;
}
return img;
}
function buildRankPedestal(rank, entry, pedClass, toneIndex, avatarSrc) {
const wrap = document.createElement('div');
wrap.className = `lobby-rank-ped ${pedClass}`;
const pic = document.createElement('img');
pic.className = 'lobby-rank-ped-pic';
pic.src = `${LOBBY_RANK_ASSET_BASE}/${rank}st-pic.png`.replace('/2st-pic', '/2nd-pic').replace('/3st-pic', '/3rd-pic');
pic.alt = '';
const picWrap = document.createElement('div');
picWrap.className = 'lobby-rank-ped-pic-wrap';
const badge = document.createElement('img');
badge.className = 'lobby-rank-ped-badge';
badge.src = `${LOBBY_RANK_ASSET_BASE}/${rank}st.png`.replace('/2st', '/2nd').replace('/3st', '/3rd');
badge.alt = `อันดับ ${rank}`;
badge.decoding = 'async';
picWrap.appendChild(pic);
picWrap.appendChild(badge);
const pedAvatar = createAvatarWithTone(avatarSrc, toneIndex, 'lobby-rank-ped-avatar');
pedAvatar.style.zIndex = '1';
picWrap.appendChild(pedAvatar);
wrap.appendChild(picWrap);
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 rankSrc = (lobbyRankRealLeaders && lobbyRankRealLeaders.length) ? lobbyRankRealLeaders : LOBBY_RANK_MOCK_LEADERS;
const sorted = [...rankSrc].sort((a, b) => b.score - a.score);
const podium = document.getElementById('lobby-rank-podium');
const list = document.getElementById('lobby-rank-list');
const selfFoot = document.getElementById('lobby-rank-self');
if (!podium || !list || !selfFoot) return;
const playerAvatarSrc = getLobbyPlayerAvatarSrc();
podium.textContent = '';
const first = sorted[0];
const second = sorted[1];
const third = sorted[2];
if (second) podium.appendChild(buildRankPedestal(2, second, 'lobby-rank-ped--2', 1, playerAvatarSrc));
if (first) podium.appendChild(buildRankPedestal(1, first, 'lobby-rank-ped--1', 0, playerAvatarSrc));
if (third) podium.appendChild(buildRankPedestal(3, third, 'lobby-rank-ped--3', 2, playerAvatarSrc));
list.textContent = '';
for (let i = 3; i < sorted.length && i < 10; i++) {
const row = sorted[i];
const rowEl = document.createElement('div');
rowEl.className = 'lobby-rank-row';
const rankEl = document.createElement('div');
rankEl.className = 'lobby-rank-row-rank';
rankEl.textContent = String(i + 1);
const playerEl = document.createElement('div');
playerEl.className = 'lobby-rank-row-player';
const picBg = document.createElement('div');
picBg.className = 'lobby-rank-row-pic-bg';
const picFrame = document.createElement('img');
picFrame.src = `${LOBBY_RANK_ASSET_BASE}/rank-pic.png`;
picFrame.alt = '';
picFrame.decoding = 'async';
picBg.appendChild(picFrame);
picBg.appendChild(createAvatarWithTone(playerAvatarSrc, i, 'lobby-rank-row-avatar'));
const nameEl = document.createElement('div');
nameEl.className = 'lobby-rank-row-name';
nameEl.textContent = row.name || '';
nameEl.title = row.name || '';
playerEl.appendChild(picBg);
playerEl.appendChild(nameEl);
const scoreEl = document.createElement('div');
scoreEl.className = 'lobby-rank-row-score';
scoreEl.textContent = String(row.score);
const line = document.createElement('img');
line.className = 'lobby-rank-row-line';
line.src = `${LOBBY_RANK_ASSET_BASE}/rank-line.png`;
line.alt = '';
line.decoding = 'async';
rowEl.appendChild(rankEl);
rowEl.appendChild(playerEl);
rowEl.appendChild(scoreEl);
rowEl.appendChild(line);
list.appendChild(rowEl);
}
const selfRank = selfFoot.querySelector('.lobby-rank-self-rank');
const selfName = selfFoot.querySelector('.lobby-rank-self-name');
const selfScore = selfFoot.querySelector('.lobby-rank-self-score');
const selfAvatar = selfFoot.querySelector('.lobby-rank-self-avatar');
if (selfRank) selfRank.textContent = '28';
if (selfName) selfName.textContent = nick || 'ผู้เล่น';
if (selfScore) selfScore.textContent = '7250';
if (selfAvatar) {
selfAvatar.src = playerAvatarSrc;
selfAvatar.alt = nick || 'ผู้เล่น';
}
}
function syncLobbyRankScale() {
const dialog = document.querySelector('#lobby-rank-overlay .lobby-rank-dialog');
if (!dialog) return;
const rect = dialog.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const scaleW = rect.width / LOBBY_RANK_BASE_WIDTH;
const scaleH = rect.height / LOBBY_RANK_BASE_HEIGHT;
const scale = Math.max(0.28, Math.min(1, Math.min(scaleW, scaleH)));
dialog.style.setProperty('--lrb-scale', String(scale.toFixed(4)));
}
function openLobbyRankModal() {
const ov = document.getElementById('lobby-rank-overlay');
if (!ov) return;
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
fetchLobbyRankLeaders();
syncLobbyRankScale();
renderLobbyRankModalContent();
requestAnimationFrame(() => {
syncLobbyRankScale();
renderLobbyRankModalContent();
});
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');
}
window.addEventListener('resize', syncLobbyRankScale);
window.addEventListener('orientationchange', syncLobbyRankScale);
document.getElementById('lobby-b-btn-rank')?.addEventListener('click', () => {
if (!isPostCaseLobbyRoom()) return;
openLobbyRankModal();
});
document.getElementById('btn-profile-set')?.addEventListener('click', () => {
roomLobbyProfileOverlay.open();
});
document.getElementById('btn-setting-host')?.addEventListener('click', () => {
openHostConsoleIfHost();
});
document.getElementById('btn-customize')?.addEventListener('click', () => {
openRoomCustomize();
});
document.getElementById('room-lobby-profile-logout')?.addEventListener('click', () => {
window.location.href = CREATE_ROOM_URL;
});
document.getElementById('lobby-rank-backdrop')?.addEventListener('click', () => {
closeLobbyRankModal();
});
document.getElementById('lobby-rank-close')?.addEventListener('click', () => {
closeLobbyRankModal();
});
socket.off('user-joined');
socket.on('user-joined', async (data) => {
peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData));
/* แก้สีชุด peer ใหม่ไม่ขึ้นบนเครื่องคนอื่น: resolve RGB ของธีมสีทันที
(lobby-tint-sync ถูก emit ก่อน user-joined จึงข้าม peer นี้เพราะยังไม่อยู่ใน map) */
applyPeerThemeFromServer(peers.get(data.id), data.lobbyColorThemeIndex, data.lobbySkinToneIndex, function () {
updatePlayersHud();
renderPeers();
redrawLobbyMap();
if (typeof window.refreshCustomizeColorRow === 'function') window.refreshCustomizeColorRow();
});
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;
el.textContent = getCaseMedia(getDetectiveCaseId()).name || 'คดี';
}
function setSuspectCardMinigames(cards) {
if (!cards || !cards.length) return;
suspectCardMinigames = cards;
}
var SUSPECT_EVID_FULL = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card.png'; // เก็บหลักฐานแล้ว (ติกถูก)
var SUSPECT_EVID_EMPTY = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card-bg.png'; // ช่องว่าง (?)
/** อัปเดต suspectProgress จากเซิร์ฟ แล้ววาดติกถูกใหม่ */
function setSuspectProgress(arr) {
if (!Array.isArray(arr)) return;
suspectProgress = [0, 1, 2].map(function (i) {
var v = Math.floor(Number(arr[i]));
return (Number.isFinite(v) && v > 0) ? Math.min(3, v) : 0;
});
applySuspectProgressVisual();
if (suspectPickOverlayOpen) updateSuspectHostUi(); // อัปเดตปุ่มชี้ตัว (โชว์เมื่อสืบครบ)
}
/** วาดช่องหลักฐานใต้การ์ดผู้ต้องสงสัย — โชว์ bg 3 ใบเสมอ; เก็บได้กี่ใบ เปลี่ยนเป็น symbol-card.png ซ้าย→ขวา */
function applySuspectProgressVisual() {
for (var i = 0; i <= 2; i++) {
var card = document.querySelector('.suspect-card[data-index="' + i + '"]');
if (!card) continue;
var count = getSuspectEvidenceCount(i);
if (!count && suspectProgress && suspectProgress[i] > 0) count = suspectProgress[i];
card.classList.toggle('suspect-card--evidence-full', count >= 3);
var list = card.querySelector('.suspect-symbol-list');
if (!list) continue;
list.classList.remove('suspect-symbol-list--mini-cards');
list.textContent = '';
for (var slot = 0; slot < 3; slot++) {
var sym = document.createElement('img');
var collected = slot < count;
sym.src = collected ? SUSPECT_EVID_FULL : SUSPECT_EVID_EMPTY;
sym.alt = '';
sym.width = 47;
sym.height = 61;
sym.decoding = 'async';
sym.setAttribute('title', collected ? ('หลักฐานชิ้นที่ ' + (slot + 1)) : 'ยังไม่มีหลักฐาน');
list.appendChild(sym);
}
}
}
/* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย (ใช้ overlay เดิม) ===== */
function injectTrialStyle() {
if (document.getElementById('trial-style')) return;
var st = document.createElement('style');
st.id = 'trial-style';
var RV = '/Main-Lobby/IMAGE/RoomVote/';
st.textContent =
'.suspect-pick--trial{background:linear-gradient(rgba(4,8,18,.35),rgba(4,8,18,.55)),url(' + RV + 'roomvote-bg.png) center/cover no-repeat,#04081a !important}' +
'.suspect-pick--trial .suspect-pick-actions{display:none!important}' +
'.suspect-pick--trial #suspect-btn-accuse{display:none!important}' + /* #1 ปุ่มชี้ตัวต้องหายตอนหน้าโหวต */
'#trial-vote-counter{position:absolute;right:22px;top:14px;color:#7fe9ff;font:800 24px/1 Kanit,system-ui,sans-serif;display:none;z-index:62;text-shadow:0 0 12px rgba(34,211,238,.7)}' +
'#trial-vote-counter b{color:#fff}' +
'.suspect-pick--trial #trial-vote-counter{display:block}' +
'#trial-vote-timer{position:absolute;right:22px;top:50px;color:#9ece6a;font:900 34px/1 Kanit,system-ui,sans-serif;display:none;z-index:62;text-shadow:0 0 14px rgba(158,206,106,.6)}' +
'.suspect-pick--trial:not(.trial-revealed) #trial-vote-timer{display:block}' +
'#trial-vote-timer.low{color:#f7768e;text-shadow:0 0 14px rgba(247,118,142,.7)}' +
'#trial-card-notice{position:absolute;left:50%;top:9%;transform:translateX(-50%);z-index:63;background:linear-gradient(180deg,#1f2747,#141a33);border:1px solid rgba(255,214,102,.75);color:#ffe7b3;font:800 18px/1.3 Kanit;padding:10px 22px;border-radius:999px;box-shadow:0 10px 30px rgba(0,0,0,.5);display:none;text-align:center}' +
'#pick-vote-overlay{position:fixed;inset:0;z-index:120;background:rgba(6,9,20,.86);display:none;align-items:center;justify-content:center;backdrop-filter:blur(3px)}' +
'#pick-vote-overlay.is-open{display:flex}' +
'.pv-shell{width:min(620px,92vw);background:linear-gradient(180deg,#161d38,#0e1326);border:1px solid rgba(124,154,255,.35);border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.6);padding:22px 24px;text-align:center}' +
'.pv-badge{display:inline-block;font:900 13px/1 Kanit;letter-spacing:1px;padding:6px 14px;border-radius:999px;background:rgba(255,214,102,.16);color:#ffd666;border:1px solid rgba(255,214,102,.5)}' +
'.pv-title{margin:12px 0 2px;font:900 26px/1.2 Kanit;color:#fff}' +
'.pv-sub{margin:0 0 6px;font:600 14px/1.4 Kanit;color:#9fb0d8}' +
'.pv-timer{font:900 30px/1 Kanit;color:#9ece6a;margin:4px 0 12px;text-shadow:0 0 12px rgba(158,206,106,.5)}' +
'.pv-timer.low{color:#f7768e;text-shadow:0 0 12px rgba(247,118,142,.6)}' +
'.pv-list{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin:0 0 14px}' +
'.pv-cand{position:relative;min-width:120px;padding:12px 14px;border-radius:14px;background:#1b2240;border:2px solid rgba(124,154,255,.25);color:#dfe6ff;font:800 16px/1.2 Kanit;cursor:pointer;transition:transform .12s,border-color .12s,background .12s}' +
'.pv-cand:hover{transform:translateY(-2px);border-color:rgba(124,154,255,.6)}' +
'.pv-cand.is-picked{border-color:#9ece6a;background:#1f3326;box-shadow:0 0 16px rgba(158,206,106,.35)}' +
'.pv-cand .pv-me{font:700 11px/1 Kanit;color:#7fe9ff;display:block;margin-top:4px}' +
'.pv-cand .pv-bot{font:700 11px/1 Kanit;color:#9fb0d8;display:block;margin-top:4px}' +
'.pv-cand .pv-count{position:absolute;top:-8px;right:-8px;min-width:24px;height:24px;border-radius:999px;background:#f7768e;color:#fff;font:900 13px/24px Kanit;display:none}' +
'.pv-cand .pv-count.show{display:block}' +
'.pv-foot{font:700 14px/1.3 Kanit;color:#9fb0d8}' +
'.pv-result{font:900 22px/1.3 Kanit;color:#ffd666;margin:6px 0}' +
'.suspect-pick--trial .suspect-pick-title-img{display:none!important}' +
'#trial-title{display:none;text-align:center;margin:0 0 4px}' +
'#trial-title img{display:block;margin:0 auto;max-width:90vw}' +
'#trial-title-main{height:92px;width:auto}' +
'#trial-title-sub{height:46px;width:auto;margin-top:6px}' +
'.suspect-pick--trial #trial-title{display:block}' +
'.suspect-pick--trial .suspect-card{cursor:pointer;position:relative}' +
// ตอนโหวต: ใบที่เราเลือก = ปุ่มภาพ "เลือกแล้ว" (btn-selected.png)
'.suspect-card .trial-vote-badge{position:absolute;left:50%;bottom:10px;transform:translateX(-50%);display:none;z-index:7;white-space:nowrap}' +
'.suspect-pick--trial .suspect-card--myvote .trial-vote-badge{display:block;width:204px;height:83px;max-width:80%;background:url(' + RV + 'btn-selected.png) center/contain no-repeat;color:transparent;font-size:0;padding:0;box-shadow:none}' +
// ตอนเปิดเผยผล: โชว์คะแนนโหวตทุกใบ (ป้ายฟ้า)
'.suspect-pick--trial.trial-revealed .trial-vote-badge{display:block;background:rgba(34,211,238,.97);color:#06121f;font:700 15px/1 Kanit;padding:7px 16px;border-radius:999px;box-shadow:0 2px 10px rgba(0,0,0,.45)}' +
'.suspect-card--myvote{outline:3px solid #22c55e;outline-offset:-3px;border-radius:16px}' +
'.suspect-card--culprit{outline:4px solid #f7768e;outline-offset:-4px;border-radius:16px;box-shadow:0 0 34px rgba(247,118,142,.85)}' +
// แสตมป์ "ผู้บริสุทธิ์" ทับใบที่โหวตผิด + เทาใบนั้น
'.suspect-card .trial-stamp{position:absolute;left:50%;top:44%;transform:translate(-50%,-50%) rotate(-8deg);width:78%;max-width:300px;height:auto;z-index:9;display:none;pointer-events:none}' +
'.suspect-card--innocent .trial-stamp{display:block}' +
'.suspect-card--innocent{filter:grayscale(1) brightness(.8)}' +
'#trial-lose-txt{position:absolute;left:50%;top:4%;transform:translateX(-50%);width:712px;max-width:88vw;height:auto;z-index:61;display:none}' +
'#trial-result-banner{position:absolute;left:50%;top:5%;transform:translateX(-50%);background:rgba(6,12,24,.96);border:2px solid #22d3ee;border-radius:16px;padding:14px 26px;color:#e7ecff;font:600 20px/1.5 Kanit,system-ui,sans-serif;text-align:center;z-index:60;max-width:82%;box-shadow:0 8px 40px rgba(0,0,0,.6)}' +
'#trial-reveal-btn{position:absolute;left:50%;bottom:5%;transform:translateX(-50%);background:linear-gradient(180deg,#22d3ee,#0e7490);color:#06121f;border:none;border-radius:14px;padding:14px 30px;font:700 20px/1 Kanit,system-ui,sans-serif;cursor:pointer;z-index:60;box-shadow:0 6px 22px rgba(34,211,238,.5)}' +
// รอบจับผิดตัว: ใบที่โหวตผิดไปแล้ว — หรี่ + กันคลิก + ป้าย "โหวตผิดไปแล้ว"
'.suspect-card--excluded{filter:grayscale(1) brightness(.55);pointer-events:none;position:relative}' +
'.suspect-card--excluded .trial-vote-badge{display:none!important}' +
'.trial-excluded-badge{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(-12deg);background:rgba(180,30,40,.92);color:#fff;border:3px solid #fff;border-radius:12px;padding:8px 18px;font:800 22px/1 Kanit,system-ui,sans-serif;white-space:nowrap;z-index:62;box-shadow:0 4px 18px rgba(0,0,0,.6);pointer-events:none}' +
// ถูกปิดปาก (Silence): บังหน้าโหวต โชว์แค่ตัวละครเรา + ข้อความห้ามโหวต
'#trial-silenced-layer{position:absolute;inset:0;z-index:70;display:none;flex-direction:column;align-items:center;justify-content:center;gap:18px;background:radial-gradient(ellipse at center,rgba(10,14,28,.86),rgba(6,8,18,.96))}' +
'#trial-silenced-layer .tsl-char{width:auto;height:42%;max-height:340px;object-fit:contain;filter:grayscale(.6) brightness(.9) drop-shadow(0 8px 20px rgba(0,0,0,.6))}' +
'#trial-silenced-layer .tsl-msg{color:#ffd6e0;font:700 26px/1.5 Kanit,system-ui,sans-serif;text-align:center;text-shadow:0 2px 10px rgba(0,0,0,.8)}' +
'#trial-silenced-layer .tsl-msg b{color:#ff6b81;font-size:30px}' +
'#trial-silenced-layer .tsl-icon{font-size:30px}';
document.head.appendChild(st);
}
function ensureTrialBadges() {
for (var i = 0; i <= 2; i++) {
var card = document.querySelector('.suspect-card[data-index="' + i + '"]');
if (!card) continue;
if (!card.querySelector('.trial-vote-badge')) {
var b = document.createElement('span');
b.className = 'trial-vote-badge';
b.textContent = '';
card.appendChild(b);
}
}
}
function applyTrialBadges() {
for (var i = 0; i <= 2; i++) {
var card = document.querySelector('.suspect-card[data-index="' + i + '"]');
if (!card) continue;
var b = card.querySelector('.trial-vote-badge');
if (b) b.textContent = 'โหวต ' + ((trialVoteCounts && trialVoteCounts[i]) || 0);
}
}
function ensureTrialBanner() {
var ov = document.getElementById('suspect-pick-overlay');
var el = document.getElementById('trial-result-banner');
if (!el && ov) {
el = document.createElement('div');
el.id = 'trial-result-banner';
el.style.display = 'none';
ov.appendChild(el);
}
return el;
}
function ensureTrialCounter() {
var ov = document.getElementById('suspect-pick-overlay');
var el = document.getElementById('trial-vote-counter');
if (!el && ov) {
el = document.createElement('div');
el.id = 'trial-vote-counter';
ov.appendChild(el);
}
return el;
}
function setTrialCounter(voted, total) {
var el = ensureTrialCounter();
if (el) el.innerHTML = 'ลงติกแล้ว : <b>' + (voted || 0) + '/' + (total || 0) + '</b>';
}
var trialVoteCountdownRaf = 0;
function ensureTrialVoteTimerEl() {
var ov = document.getElementById('suspect-pick-overlay');
var el = document.getElementById('trial-vote-timer');
if (!el && ov) { el = document.createElement('div'); el.id = 'trial-vote-timer'; ov.appendChild(el); }
return el;
}
function stopTrialVoteCountdown() {
if (trialVoteCountdownRaf) { cancelAnimationFrame(trialVoteCountdownRaf); trialVoteCountdownRaf = 0; }
}
function startTrialVoteCountdown(endsAt) {
stopTrialVoteCountdown();
var el = ensureTrialVoteTimerEl();
if (!el || !endsAt) { if (el) el.textContent = ''; return; }
function tick() {
if (trialMode !== 'voting') { stopTrialVoteCountdown(); return; }
var ms = endsAt - Date.now();
var s = Math.max(0, Math.ceil(ms / 1000));
el.textContent = '⏱ ' + s;
el.classList.toggle('low', s <= 5);
if (ms > 0) trialVoteCountdownRaf = requestAnimationFrame(tick);
}
tick();
}
function showTrialCardNotice(text) {
var ov = document.getElementById('suspect-pick-overlay');
if (!ov) return;
var el = document.getElementById('trial-card-notice');
if (!el) { el = document.createElement('div'); el.id = 'trial-card-notice'; ov.appendChild(el); }
el.textContent = text;
el.style.display = 'block';
clearTimeout(showTrialCardNotice._t);
showTrialCardNotice._t = setTimeout(function () { if (el) el.style.display = 'none'; }, 4200);
}
function ensureTrialRevealBtn() {
var ov = document.getElementById('suspect-pick-overlay');
var btn = document.getElementById('trial-reveal-btn');
if (!btn && ov) {
btn = document.createElement('button');
btn.id = 'trial-reveal-btn';
btn.type = 'button';
btn.textContent = 'เปิดเผยผล';
btn.addEventListener('click', function () {
socket.emit('trial-reveal', {}, function (res) {
if (!(res && res.ok)) appendLobbySystemChat('— เปิดเผยผลไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : ''));
});
});
ov.appendChild(btn);
}
if (btn) btn.style.display = (trialMode === 'voting' && hostId === socket.id) ? 'block' : 'none';
}
/** หัวข้อ "พิจารณาคดี" (แทนรูป title เลือกผู้ต้องสงสัยตอนอยู่โหมดโหวต) */
function ensureTrialTitle() {
var RV = '/Main-Lobby/IMAGE/RoomVote/';
var row = document.getElementById('suspect-cards-row');
var t = document.getElementById('trial-title');
if (!t && row && row.parentNode) {
t = document.createElement('div');
t.id = 'trial-title';
t.innerHTML = '<img id="trial-title-main" src="' + RV + 'roomvote-txt.png" alt="พิจารณาคดี">' +
'<img id="trial-title-sub" src="' + RV + 'roomvote-txt-2.png" alt="เลือกผู้ร้ายตัวจริง">';
row.parentNode.insertBefore(t, row);
}
return t;
}
// mode: 'vote' = roomvote-txt-2, 'result' = roomvote-txt-3
function setTrialSubtitle(mode) {
var RV = '/Main-Lobby/IMAGE/RoomVote/';
var sub = document.getElementById('trial-title-sub');
if (sub) sub.src = (mode === 'result') ? (RV + 'roomvote-txt-3.png') : (RV + 'roomvote-txt-2.png');
}
function castTrialVote(idx) {
if (trialMode !== 'voting') return;
if (trialSilencedMe) { appendLobbySystemChat('— คุณถูกปิดปาก (Silence) · โหวตรอบนี้ไม่ได้'); return; }
if (trialExcludedSuspect != null && idx === trialExcludedSuspect) {
appendLobbySystemChat('— ผู้ต้องสงสัยหมายเลข ' + (idx + 1) + ' ถูกโหวตไปแล้ว (ผิด) · เลือกคนอื่น');
return;
}
myTrialVote = idx;
document.querySelectorAll('.suspect-card').forEach(function (c) {
var sel = parseInt(c.getAttribute('data-index'), 10) === idx;
c.classList.toggle('suspect-card--myvote', sel);
var b = c.querySelector('.trial-vote-badge');
if (b && sel) b.textContent = '✓ เลือกแล้ว';
});
socket.emit('trial-vote', { index: idx }, function (res) {
if (!(res && res.ok)) appendLobbySystemChat('— โหวตไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : ''));
});
}
function openTrialVoteUI(data) {
trialMode = 'voting';
myTrialVote = null;
trialExcludedSuspect = (data && data.revote && Number.isInteger(data.excludedSuspect)) ? data.excludedSuspect : null;
trialSilencedMe = !!(data && data.silenced);
trialVoteCounts = [0, 0, 0];
if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames);
applyPersonalSuspectState(data);
injectTrialStyle();
var ov = document.getElementById('suspect-pick-overlay');
if (!ov) return;
applySuspectPickImages();
bindSuspectPickScaleListeners();
suspectPickOverlayOpen = true;
serverSuspectPhaseActive = false;
ov.classList.remove('is-hidden', 'trial-revealed');
ov.classList.add('suspect-pick--trial');
ov.setAttribute('aria-hidden', 'false');
document.querySelectorAll('.suspect-card').forEach(function (c) {
c.classList.remove('suspect-card--myvote', 'suspect-card--culprit', 'suspect-card--selected', 'suspect-card--innocent', 'suspect-card--excluded');
var xb = c.querySelector('.trial-excluded-badge'); if (xb) xb.remove();
});
/* รอบจับผิดตัว: ตีตราใบที่โหวตผิดไปแล้ว + ห้ามเลือกซ้ำ */
if (trialExcludedSuspect != null) {
document.querySelectorAll('.suspect-card').forEach(function (c) {
if (parseInt(c.getAttribute('data-index'), 10) !== trialExcludedSuspect) return;
c.classList.add('suspect-card--excluded');
if (!c.querySelector('.trial-excluded-badge')) {
var xb = document.createElement('div');
xb.className = 'trial-excluded-badge';
xb.textContent = 'โหวตผิดไปแล้ว';
c.appendChild(xb);
}
});
}
var loseEl = document.getElementById('trial-lose-txt'); if (loseEl) loseEl.style.display = 'none';
applySuspectProgressVisual();
ensureTrialTitle();
var ttl = document.getElementById('trial-title'); if (ttl) ttl.style.display = 'block';
setTrialSubtitle('vote');
ensureTrialBadges();
document.querySelectorAll('.trial-vote-badge').forEach(function (b) { b.textContent = ''; });
setTrialCounter(0, (data && data.totalPlayers) || (peers ? peers.size : 0) || 1);
// กันปุ่มชี้ตัวค้าง: ใส่ is-hidden ตอนเข้าหน้าโหวต
var accuseBtn0 = document.getElementById('suspect-btn-accuse');
if (accuseBtn0) accuseBtn0.classList.add('is-hidden');
ensureTrialRevealBtn();
var banner = ensureTrialBanner();
if (banner) banner.style.display = 'none';
var actions = document.getElementById('suspect-pick-actions');
if (actions) actions.classList.remove('suspect-pick-actions--visible');
var hint = document.getElementById('suspect-pick-hint');
if (hint) { hint.classList.remove('is-hidden'); hint.textContent = trialSilencedMe ? 'คุณถูกปิดปาก — โหวตรอบนี้ไม่ได้' : 'คลิกการ์ดที่คิดว่าเป็นคนร้าย'; }
applyTrialSilencedLayer();
/* ตัวจับเวลาโหวต + แจ้งการ์ดพิเศษ (Extension / Bail re-vote) */
startTrialVoteCountdown((data && data.voteEndsAt) || 0);
if (data && data.revote) {
showTrialCardNotice('ใช้การ์ด Bail Coin — จับผิดตัว! โหวตใหม่อีกครั้ง');
} else if (data && data.extensionCard) {
showTrialCardNotice('ใช้การ์ด Extension — เพิ่มเวลาโหวต +' + ((data.extensionSec) || 10) + ' วินาที');
}
updateSuspectFloatingOpenBtn();
scheduleSuspectPickScale();
}
/** ถ้าฉันถูกปิดปาก (Silence) — บังหน้าโหวตด้วยตัวละครของฉัน + ข้อความ "คุณห้ามโหวต" */
function applyTrialSilencedLayer() {
var ov = document.getElementById('suspect-pick-overlay');
if (!ov) return;
var layer = document.getElementById('trial-silenced-layer');
if (!trialSilencedMe) {
if (layer) layer.style.display = 'none';
ov.classList.remove('suspect-pick--silenced');
return;
}
ov.classList.add('suspect-pick--silenced');
if (!layer) {
layer = document.createElement('div');
layer.id = 'trial-silenced-layer';
ov.appendChild(layer);
}
layer.innerHTML = '';
layer.style.display = 'flex';
var info = resolveOccupantCharInfo(socket && socket.id);
var charImg = buildPodiumCharImg(info.characterId, info.colorTheme, info.colorSkin);
charImg.className = 'tsl-char';
var msg = document.createElement('div');
msg.className = 'tsl-msg';
msg.innerHTML = '<span class="tsl-icon">🔇</span> คุณถูกปิดปาก (Silence)<br><b>ห้ามโหวตรอบนี้</b>';
layer.appendChild(charImg);
layer.appendChild(msg);
}
function ensureTrialLoseTxt() {
var ov = document.getElementById('suspect-pick-overlay');
var el = document.getElementById('trial-lose-txt');
if (!el && ov) {
el = document.createElement('img');
el.id = 'trial-lose-txt';
el.src = '/Main-Lobby/IMAGE/RoomVote/txt-lose.png';
el.alt = 'ไม่นะ..คุณจับแพะ!!';
ov.appendChild(el);
}
return el;
}
function showTrialResult(data) {
trialMode = 'revealed';
trialSilencedMe = false;
var silLayer = document.getElementById('trial-silenced-layer'); if (silLayer) silLayer.style.display = 'none';
var sov = document.getElementById('suspect-pick-overlay'); if (sov) sov.classList.remove('suspect-pick--silenced');
stopTrialVoteCountdown();
var culprit = (data && typeof data.culpritIndex === 'number') ? data.culpritIndex : 0;
if (data && Array.isArray(data.counts)) trialVoteCounts = data.counts;
var counts = trialVoteCounts || [0, 0, 0];
var ov = document.getElementById('suspect-pick-overlay');
if (ov) ov.classList.add('trial-revealed');
setTrialSubtitle('result');
// หาผู้ต้องสงสัยที่ถูกโหวตมากสุด
var mostVoted = 0; for (var k = 1; k <= 2; k++) { if ((counts[k] || 0) > (counts[mostVoted] || 0)) mostVoted = k; }
var anyVotes = ((counts[0] || 0) + (counts[1] || 0) + (counts[2] || 0)) > 0;
var correct = anyVotes && (mostVoted === culprit);
document.querySelectorAll('.suspect-card').forEach(function (c) {
var ci = parseInt(c.getAttribute('data-index'), 10);
c.classList.remove('suspect-card--myvote', 'suspect-card--innocent');
c.classList.toggle('suspect-card--culprit', ci === culprit);
var b = c.querySelector('.trial-vote-badge');
if (b) b.textContent = 'โหวต ' + (counts[ci] || 0);
var stamp = c.querySelector('.trial-stamp');
if (!stamp) { stamp = document.createElement('img'); stamp.className = 'trial-stamp'; stamp.src = '/Main-Lobby/IMAGE/RoomVote/stamp-innocent.png'; stamp.alt = 'ผู้บริสุทธิ์'; c.appendChild(stamp); }
// โหวตผิด: ตีตรา "ผู้บริสุทธิ์" บนใบที่ถูกโหวตมากสุด (ไม่ใช่คนร้าย)
if (!correct && anyVotes && ci === mostVoted && ci !== culprit) c.classList.add('suspect-card--innocent');
});
// โหวตผิด → โชว์ "ไม่นะ..คุณจับแพะ!!" + ซ่อนหัวข้อปกติ
var lose = ensureTrialLoseTxt();
var titleEl = document.getElementById('trial-title');
if (!correct && anyVotes) {
if (lose) lose.style.display = 'block';
if (titleEl) titleEl.style.display = 'none';
} else {
if (lose) lose.style.display = 'none';
if (titleEl) titleEl.style.display = 'block';
}
var names = (data && data.winnerNames && data.winnerNames.length) ? data.winnerNames.join(', ') : 'ไม่มีใครตอบถูก';
_lastTrialWinners = (data && Array.isArray(data.winners)) ? data.winners.slice() : [];
_lastDisruptorId = (data && data.disruptorId) ? data.disruptorId : null;
_lastHasDisruptor = !!(data && data.hasDisruptor && data.disruptorId);
var iWon = !!(data && Array.isArray(data.winners) && data.winners.indexOf(socket.id) >= 0);
var banner = ensureTrialBanner();
if (banner) {
banner.style.top = (!correct && anyVotes) ? '17%' : '5%';
banner.innerHTML = 'คนร้ายตัวจริงคือ <b>ผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + '</b><br>' +
'ผู้ตอบถูก: ' + names + (iWon ? '<br>🎉 คุณตอบถูก! +100' : '');
banner.style.display = 'block';
}
var rb = document.getElementById('trial-reveal-btn');
if (rb) rb.style.display = 'none';
var hint = document.getElementById('suspect-pick-hint');
if (hint) hint.textContent = 'กำลังสรุปผล...';
appendLobbySystemChat('— เปิดเผยผล: คนร้ายคือผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + ' · ผู้ตอบถูก: ' + names);
// โชว์คะแนนโหวตสักครู่ แล้วไปหน้าผลเต็มเสมอ (ถูก = เรือนจำไซเบอร์ / ผิด/ไม่โหวต = ภารกิจล้มเหลว)
if (trialFinalTimer) clearTimeout(trialFinalTimer);
trialFinalTimer = setTimeout(function () { showFinalResult(correct, culprit, anyVotes); }, 2600);
}
var trialFinalTimer = null;
var winPodiumTimer = null;
var _lastTrialWinners = [];
var _lastDisruptorId = null;
var _lastHasDisruptor = false;
/** id ผู้เล่น/บอททั้งหมดในห้อง (เรียง peers ก่อน bot) — ใช้กรณีตัวป่วน: โชว์ทุกคนยกเว้นตัวป่วน */
function allOccupantIds() {
var out = [];
if (peers) peers.forEach(function (_p, id) { out.push(id); });
if (lobbyBots) lobbyBots.forEach(function (_b, id) { out.push(id); });
return out;
}
/** ข้อมูลตัวละคร/ชื่อ/สี ของผู้เล่นหรือบอท ตาม id (ใช้วางบนโพเดียมผู้ชนะ)
สี = colorTheme/colorSkin (rgb/hex) สำหรับระบบ tint — ไม่ใช่ index */
function resolveOccupantCharInfo(id) {
var isMe = !!(socket && id === socket.id);
var p = (peers && peers.get(id)) || (lobbyBots && lobbyBots.get(id));
if (p) {
return {
characterId: p.characterId || (isMe ? getStoredCharacterId() : null),
name: (p.nickname || '').trim() || (isMe ? 'คุณ' : String(id).slice(0, 6)),
colorTheme: (isMe ? (myTintTheme || p.colorTheme) : p.colorTheme) || '',
colorSkin: (isMe ? (myTintSkin || p.colorSkin) : p.colorSkin) || '',
};
}
if (isMe) {
return { characterId: getStoredCharacterId(), name: 'คุณ', colorTheme: myTintTheme || '', colorSkin: myTintSkin || '' };
}
if (typeof id === 'string' && id.indexOf('__lobby_bot_') === 0) {
var slot = parseInt(id.slice('__lobby_bot_'.length), 10);
return { characterId: null, name: 'บอท ' + ((Number.isFinite(slot) ? slot : 0) + 1), colorTheme: '', colorSkin: '' };
}
return { characterId: null, name: String(id).slice(0, 6), colorTheme: '', colorSkin: '' };
}
/** img ตัวละครเต็มตัว (down idle) — โหลด sprite ฐานก่อน แล้ว swap เป็นตัวที่ "ทาสีแล้ว" (tint) เมื่อพร้อม */
function buildPodiumCharImg(characterId, colorTheme, colorSkin) {
var img = document.createElement('img');
img.className = 'av';
img.alt = '';
img.decoding = 'async';
img.style.filter = 'drop-shadow(0 6px 12px rgba(0,0,0,.5))';
if (!characterId) { img.src = defaultAvatarImg.src; return img; }
var cands = characterSpriteUrlCandidates(characterId, 'down');
var ci = 0;
img.src = cands[0];
img.onerror = function () {
ci++;
if (ci < cands.length) { img.src = cands[ci]; }
else { img.onerror = null; img.src = defaultAvatarImg.src; }
};
if ((colorTheme || colorSkin) && typeof preloadLobbyTintedCharacter === 'function') {
preloadLobbyTintedCharacter(characterId, colorTheme, colorSkin, function () {
var ckey = characterId + '|' + (colorTheme || '') + '|' + (colorSkin || '') + '|down';
var anim = tintedCharCache[ckey];
var tinted = anim && anim.fallback;
if (tinted && tinted.complete && tinted.naturalWidth) { img.onerror = null; img.src = tinted.src; }
});
}
return img;
}
/** หน้า2 ของผลชนะ — โพเดียมผู้ชนะบน win-bg + มงกุฎ · memberIds = รายชื่อที่จะโชว์ (ดีฟอลต์ = ผู้โหวตถูก) */
function renderWinPodium(memberIds) {
var R = '/Main-Lobby/IMAGE/Result/';
var ov = document.getElementById('trial-final');
if (!ov) return;
ov.classList.add('trial-final--podium');
ov.style.backgroundImage = 'url(' + R + 'win-bg.png)';
var culpEl = ov.querySelector('#trial-final-culprit');
if (culpEl) culpEl.style.display = 'none';
var txtEl = ov.querySelector('#trial-final-txt');
if (txtEl) txtEl.style.display = 'none';
var congrats = ov.querySelector('#trial-final-congrats');
if (!congrats) { congrats = document.createElement('img'); congrats.id = 'trial-final-congrats'; congrats.alt = 'ยินดีด้วย ทีมนักสืบ ชนะ'; ov.appendChild(congrats); }
congrats.src = R + 'txt-congrats.png';
var pod = ov.querySelector('#trial-final-podium');
if (!pod) { pod = document.createElement('div'); pod.id = 'trial-final-podium'; ov.appendChild(pod); }
pod.innerHTML = '';
var left = document.createElement('div'); left.className = 'tf-pod-side tf-pod-left';
var right = document.createElement('div'); right.className = 'tf-pod-side tf-pod-right';
var ids = (Array.isArray(memberIds) && memberIds.length) ? memberIds.slice(0, 8)
: ((_lastTrialWinners && _lastTrialWinners.length) ? _lastTrialWinners.slice(0, 8) : []);
if (!ids.length && socket && socket.id) ids = [socket.id];
ids.forEach(function (id, i) {
var info = resolveOccupantCharInfo(id);
var cell = document.createElement('div'); cell.className = 'tf-pod-char';
if (i === 0) { var cr = document.createElement('div'); cr.className = 'crown'; cr.textContent = '👑'; cell.appendChild(cr); }
var nm = document.createElement('div'); nm.className = 'nm'; nm.textContent = info.name || '';
cell.appendChild(nm);
cell.appendChild(buildPodiumCharImg(info.characterId, info.colorTheme, info.colorSkin));
(i % 2 === 0 ? left : right).appendChild(cell);
});
pod.appendChild(left);
pod.appendChild(right);
var home = ov.querySelector('#trial-final-home');
if (home) home.style.display = '';
}
/** หน้าผลโหวตผิด "ตัวป่วนชนะ" (กรณีมีตัวป่วน) — โชว์เฉพาะตัวป่วน 1 ตัว บน troll-win-bg */
function renderTrollWinDisruptor(disruptorId) {
var R = '/Main-Lobby/IMAGE/Result/';
var ov = document.getElementById('trial-final');
if (!ov) return;
ov.classList.remove('trial-final--podium');
ov.classList.add('trial-final--trollwin');
ov.style.backgroundImage = 'url(' + R + 'troll-win-bg.png)';
var culpEl = ov.querySelector('#trial-final-culprit');
if (culpEl) culpEl.style.display = 'none';
var txtEl = ov.querySelector('#trial-final-txt');
if (txtEl) { txtEl.src = R + 'txt-troll-win.png'; txtEl.className = ''; txtEl.style.display = 'block'; }
var dWrap = ov.querySelector('#trial-final-disruptor');
if (!dWrap) { dWrap = document.createElement('div'); dWrap.id = 'trial-final-disruptor'; ov.appendChild(dWrap); }
dWrap.innerHTML = '';
var info = resolveOccupantCharInfo(disruptorId);
var nm = document.createElement('div'); nm.className = 'nm'; nm.textContent = info.name || '';
dWrap.appendChild(nm);
dWrap.appendChild(buildPodiumCharImg(info.characterId, info.colorTheme, info.colorSkin));
var home = ov.querySelector('#trial-final-home');
if (home) home.style.display = '';
}
function injectFinalResultStyle() {
if (document.getElementById('trial-final-style')) return;
var R = '/Main-Lobby/IMAGE/Result/';
var st = document.createElement('style');
st.id = 'trial-final-style';
st.textContent =
'#trial-final{position:fixed;inset:0;z-index:9500;background:#04060c center/cover no-repeat}' +
'#trial-final.is-hidden{display:none}' +
// ตัวคนร้ายในช่องคุก (ตอนตอบถูก)
'#trial-final-culprit{position:absolute;left:50%;top:55%;transform:translate(-50%,-50%);height:52%;width:auto;z-index:2;display:none;filter:drop-shadow(0 0 20px rgba(255,200,80,.55))}' +
// หัวข้อ "ภารกิจล้มเหลว" (ตอนตอบผิด) วางทับ lose-bg
'#trial-final-txt{position:absolute;left:50%;top:7%;transform:translateX(-50%);width:780px;max-width:90vw;height:auto;z-index:2;display:none}' +
/* ชนะ: ข้อความ "ยินดีด้วย ชนะ" วางช่วงล่างเหนือปุ่มกลับ (เลี่ยงป้าย "เรือนจำไซเบอร์" บนสุด) */
'#trial-final-txt.final-txt-congrats{top:auto;bottom:21%;width:420px;filter:drop-shadow(0 2px 10px rgba(0,0,0,.7))}' +
'#trial-final-home{position:absolute;left:50%;bottom:5%;transform:translateX(-50%);width:300px;height:104px;max-width:70vw;background:url(' + R + 'btn-gohome.png) center/contain no-repeat;border:none;cursor:pointer;font-size:0;color:transparent;z-index:5;filter:drop-shadow(0 4px 14px rgba(0,0,0,.6))}' +
/* ===== หน้า2 (ชนะ): โพเดียมผู้ชนะ win-bg + ตัวละคร + มงกุฎ ===== */
'#trial-final.trial-final--podium{background-image:url(' + R + 'win-bg.png)!important}' +
'#trial-final-congrats{position:absolute;left:50%;top:5%;transform:translateX(-50%);width:560px;max-width:82vw;height:auto;z-index:4;display:none;filter:drop-shadow(0 3px 14px rgba(0,0,0,.6))}' +
'#trial-final.trial-final--podium #trial-final-congrats{display:block}' +
'#trial-final-podium{position:absolute;left:0;right:0;bottom:9%;height:58%;display:none;z-index:3;pointer-events:none}' +
'#trial-final.trial-final--podium #trial-final-podium{display:block}' +
'.tf-pod-side{position:absolute;bottom:0;display:flex;align-items:flex-end;justify-content:center;gap:min(3vw,52px)}' +
'.tf-pod-left{left:2%;right:56%}' +
'.tf-pod-right{left:56%;right:2%}' +
'.tf-pod-char{position:relative;display:flex;flex-direction:column;align-items:center;width:min(17vw,230px)}' +
'.tf-pod-char .crown{font-size:min(7vw,62px);line-height:1;margin-bottom:-2px;filter:drop-shadow(0 0 10px rgba(255,200,60,.95))}' +
'.tf-pod-char .nm{margin-bottom:8px;color:#eafcff;font:800 clamp(14px,1.7vw,26px)/1 Kanit,system-ui,sans-serif;text-shadow:0 0 10px rgba(0,0,0,.85),0 2px 4px rgba(0,0,0,.9);white-space:nowrap}' +
'.tf-pod-char img.av{width:100%;height:auto;image-rendering:auto}' +
/* ===== ตัวป่วนชนะ (โหวตผิด) — โชว์ตัวป่วนเดี่ยวกลางเวที ===== */
'#trial-final-disruptor{position:absolute;left:50%;bottom:17%;transform:translateX(-50%);display:none;flex-direction:column;align-items:center;width:min(21vw,280px);z-index:3;pointer-events:none}' +
'#trial-final.trial-final--trollwin #trial-final-disruptor{display:flex}' +
'#trial-final-disruptor .nm{margin-bottom:10px;color:#ffd6e6;font:800 clamp(15px,2vw,30px)/1 Kanit,system-ui,sans-serif;text-shadow:0 0 12px rgba(0,0,0,.9),0 2px 5px rgba(0,0,0,.95);white-space:nowrap}' +
'#trial-final-disruptor img.av{width:100%;height:auto}';
document.head.appendChild(st);
}
/* ===== หน้าให้ความรู้ "รู้ทันภัยไซเบอร์" — โผล่หลังโชว์ผลคดี ก่อนกลับ Main-Lobby =====
mock ตามดีไซน์ (ยังไม่ตัด asset) — ใช้ CSS approximate + ตัวละครเทพความรู้ AI-chat-char.png */
function injectCyberEduStyle() {
if (document.getElementById('cyber-edu-style')) return;
var st = document.createElement('style');
st.id = 'cyber-edu-style';
st.textContent =
'#cyber-edu{position:fixed;inset:0;z-index:9600;display:flex;align-items:center;justify-content:center;padding:2vmin;box-sizing:border-box;font-family:Kanit,"NotoSansThai",system-ui,sans-serif;' +
'background:radial-gradient(circle at 28% 38%,rgba(20,60,110,.55),rgba(4,6,14,.96) 68%),repeating-linear-gradient(115deg,rgba(0,200,255,.05) 0 2px,transparent 2px 60px),#04060c}' +
'#cyber-edu.is-hidden{display:none}' +
'#cyber-edu .cedu-stage{display:flex;align-items:center;justify-content:center;gap:min(3vw,40px);width:100%;max-width:1180px;max-height:96vh}' +
'#cyber-edu .cedu-char-col{position:relative;flex:0 0 auto;display:flex;flex-direction:column;align-items:center;align-self:flex-end}' +
'#cyber-edu .cedu-char{width:min(30vw,360px);height:auto;filter:drop-shadow(0 10px 30px rgba(0,180,255,.35))}' +
'#cyber-edu .cedu-bubble{position:relative;margin-bottom:10px;padding:10px 18px;border-radius:16px;background:#eaf6ff;color:#0c3a5e;font-weight:800;font-size:clamp(14px,1.7vw,22px);box-shadow:0 4px 16px rgba(0,0,0,.4);white-space:nowrap}' +
'#cyber-edu .cedu-bubble::after{content:"";position:absolute;left:34px;bottom:-12px;border:8px solid transparent;border-top-color:#eaf6ff;border-bottom:0}' +
'#cyber-edu .cedu-main{flex:1 1 auto;min-width:0;max-width:640px;display:flex;flex-direction:column;align-items:center;gap:min(2.4vh,22px)}' +
'#cyber-edu .cedu-panel{position:relative;width:100%;box-sizing:border-box;padding:clamp(18px,3vw,34px) clamp(16px,3vw,38px);text-align:center;' +
'background:linear-gradient(160deg,rgba(10,28,54,.94),rgba(6,14,32,.96));border:2px solid rgba(94,234,255,.6);border-radius:18px;' +
'box-shadow:0 0 0 1px rgba(94,234,255,.15) inset,0 0 34px rgba(0,200,255,.18),0 18px 50px rgba(0,0,0,.55)}' +
'#cyber-edu .cedu-title{display:flex;align-items:center;justify-content:center;gap:10px;margin:0 0 16px;color:#7df0ff;font-weight:800;font-size:clamp(17px,2.2vw,28px);text-shadow:0 0 14px rgba(94,234,255,.5)}' +
'#cyber-edu .cedu-hacker{display:flex;align-items:center;justify-content:center;gap:14px;height:clamp(120px,20vh,200px);margin:0 0 16px;border-radius:12px;' +
'background:radial-gradient(circle at 60% 50%,rgba(255,40,60,.28),rgba(8,10,22,.9));border:1px solid rgba(255,80,100,.4);font-size:clamp(48px,9vh,90px);filter:drop-shadow(0 0 18px rgba(255,60,80,.4))}' +
'#cyber-edu .cedu-lead{color:#ffd54a;font-weight:800;font-size:clamp(16px,2.1vw,26px);margin:0 0 10px;text-shadow:0 1px 8px rgba(0,0,0,.6)}' +
'#cyber-edu .cedu-body{color:#e6f2ff;font-size:clamp(13px,1.6vw,19px);line-height:1.6;margin:0 0 12px}' +
'#cyber-edu .cedu-penalty{display:flex;align-items:center;justify-content:center;gap:8px;color:#ffd54a;font-weight:800;font-size:clamp(13px,1.7vw,20px);line-height:1.5;margin:0 0 14px}' +
'#cyber-edu .cedu-link{display:inline-block;color:#9be7ff;font-weight:700;font-size:clamp(13px,1.5vw,18px);text-decoration:underline;text-underline-offset:3px;cursor:pointer}' +
'#cyber-edu .cedu-ok{padding:14px 40px;border-radius:12px;cursor:pointer;font-family:inherit;font-weight:800;font-size:clamp(16px,2vw,24px);letter-spacing:.5px;' +
'color:#3a2c05;background:linear-gradient(180deg,#ffe27a,#f3b51e);border:3px solid #ffefb0;box-shadow:0 6px 20px rgba(243,181,30,.45),0 0 0 1px rgba(0,0,0,.2);transition:transform .12s ease,filter .12s ease}' +
'#cyber-edu .cedu-ok:hover{filter:brightness(1.07)}#cyber-edu .cedu-ok:active{transform:scale(.96)}' +
'@media(max-width:780px){#cyber-edu .cedu-stage{flex-direction:column;gap:12px}#cyber-edu .cedu-char-col{align-self:center}#cyber-edu .cedu-char{width:min(40vw,180px)}}';
document.head.appendChild(st);
}
function showCyberAwarenessInterstitial(onContinue) {
injectCyberEduStyle();
var ov = document.getElementById('cyber-edu');
if (!ov) {
ov = document.createElement('div');
ov.id = 'cyber-edu';
ov.className = 'is-hidden';
ov.setAttribute('role', 'dialog');
ov.setAttribute('aria-modal', 'true');
ov.innerHTML =
'<div class="cedu-stage">' +
'<div class="cedu-char-col">' +
'<div class="cedu-bubble">เรื่องนี้สำคัญนะ!</div>' +
'<img class="cedu-char" src="/Main-Lobby/IMAGE/AI-chat-char.png" alt="" decoding="async">' +
'</div>' +
'<div class="cedu-main">' +
'<div class="cedu-panel">' +
'<div class="cedu-title">⚠️ รู้ทันภัยไซเบอร์: การเข้าถึงระบบโดยมิชอบ</div>' +
'<div class="cedu-hacker" aria-hidden="true">🖥️ 🕵️‍♂️🔒</div>' +
'<div class="cedu-lead">แค่เดารหัสผ่านเล่นๆ ก็ผิดกฎหมาย!</div>' +
'<p class="cedu-body">ตาม พ.ร.บ. คอมพิวเตอร์ฯ การแอบ Log-in เข้าใช้งานระบบ<br>หรือข้อมูลของผู้อื่นโดยไม่ได้รับอนุญาต (แม้จะไม่ได้ขโมยเงิน)</p>' +
'<div class="cedu-penalty">⚠️ มีโทษจำคุกสูงสุด 2 ปี หรือปรับไม่เกิน 40,000 บาท หรือทั้งจำทั้งปรับ!</div>' +
'<a class="cedu-link" href="#" role="button">ดูข้อมูลกฎหมายเพิ่มเติม &gt;</a>' +
'</div>' +
'<button type="button" id="cedu-ok" class="cedu-ok">🛡 เข้าใจแล้ว!</button>' +
'</div>' +
'</div>';
document.body.appendChild(ov);
var lk = ov.querySelector('.cedu-link');
if (lk) lk.addEventListener('click', function (e) { e.preventDefault(); }); /* mock — ยังไม่มีหน้ากฎหมาย */
}
var okBtn = ov.querySelector('#cedu-ok');
if (okBtn) okBtn.onclick = function () {
ov.classList.add('is-hidden');
if (typeof onContinue === 'function') onContinue();
};
ov.classList.remove('is-hidden');
}
function showFinalResult(correct, culprit, anyVotes) {
injectFinalResultStyle();
var R = '/Main-Lobby/IMAGE/Result/';
var ov = document.getElementById('trial-final');
if (!ov) {
ov = document.createElement('div');
ov.id = 'trial-final';
ov.className = 'is-hidden';
ov.innerHTML = '<img id="trial-final-culprit" alt="คนร้าย"><img id="trial-final-txt" alt="ภารกิจล้มเหลว">' +
'<button type="button" id="trial-final-home" aria-label="กลับหน้าหลัก">กลับหน้าหลัก</button>';
document.body.appendChild(ov);
ov.querySelector('#trial-final-home').addEventListener('click', function () {
/* โชว์หน้าให้ความรู้ "รู้ทันภัยไซเบอร์" ก่อน แล้วค่อยกลับ Main-Lobby */
showCyberAwarenessInterstitial(function () { window.location.href = '/Main-Lobby/'; });
});
}
var culpEl = ov.querySelector('#trial-final-culprit');
var txtEl = ov.querySelector('#trial-final-txt');
var homeEl = ov.querySelector('#trial-final-home');
/* รีเซ็ตสถานะหน้าก่อน (เผื่อรอบก่อนเป็นหน้าโพเดียม/ตัวป่วน) */
if (winPodiumTimer) { clearTimeout(winPodiumTimer); winPodiumTimer = null; }
ov.classList.remove('trial-final--podium', 'trial-final--trollwin');
var dWrap0 = ov.querySelector('#trial-final-disruptor'); if (dWrap0) dWrap0.innerHTML = '';
if (homeEl) homeEl.style.display = '';
if (correct) {
/* ชนะ — 2 หน้า: (หน้า1) เรือนจำไซเบอร์ + คนร้ายในคุก → (หน้า2) โพเดียมผู้ชนะ + ตัวละคร
มีตัวป่วน → โพเดียมโชว์ "ทุกคนยกเว้นตัวป่วน" · ไม่มี → โชว์ผู้โหวตถูก (ดีฟอลต์) */
ov.style.backgroundImage = 'url(' + R + 'cyber-prison.png)';
var culpIdx = (typeof culprit === 'number') ? culprit : 0;
if (culpEl) {
culpEl.src = getCulpritPrisonImageUrl(culpIdx);
culpEl.style.display = 'block';
}
if (txtEl) txtEl.style.display = 'none'; /* หน้า1 ไม่มีข้อความ congrats (อยู่หน้า2) */
if (homeEl) homeEl.style.display = 'none'; /* หน้า1 ยังกลับไม่ได้ — auto เข้าหน้า2 */
/* มีตัวป่วน → โพเดียมโชว์ "ทุกคนยกเว้นตัวป่วน" · ไม่มีตัวป่วน → โชว์ทุกคน */
var podMembers = _lastHasDisruptor
? allOccupantIds().filter(function (id) { return id !== _lastDisruptorId; })
: allOccupantIds();
winPodiumTimer = setTimeout(function () { renderWinPodium(podMembers); }, 3500);
} else if (anyVotes && _lastHasDisruptor && _lastDisruptorId) {
/* โหวตผิด + มีตัวป่วน → "ตัวป่วนชนะ" โชว์เฉพาะตัวป่วน บน troll-win-bg */
renderTrollWinDisruptor(_lastDisruptorId);
} else {
/* โหวตผิด (ไม่มีตัวป่วน) หรือ ไม่มีใครโหวต → ภารกิจล้มเหลว: lose-bg + txt-your-lose */
ov.style.backgroundImage = 'url(' + R + 'lose-bg.png)';
if (txtEl) { txtEl.src = R + 'txt-your-lose.png'; txtEl.className = ''; txtEl.style.display = 'block'; }
if (culpEl) culpEl.style.display = 'none';
}
ov.classList.remove('is-hidden');
try { closeSuspectOverlay(); } catch (e) { /* ignore */ }
}
/* ===== Key ลัด (เฉพาะ Test Mode) — พรีวิวหน้าผลจบโดยไม่ต้องเล่นจบจริง =====
Ctrl+Alt+1 = โหวตถูก · Ctrl+Alt+2 = โหวตผิด · Ctrl+Alt+3 = สลับมี/ไม่มีตัวป่วน */
var _testDisruptorOn = true;
function __previewTrialFinal(correct, anyVotes) {
var occ = allOccupantIds();
var disruptor = (_testDisruptorOn && occ.length) ? occ[0] : null;
_lastDisruptorId = disruptor;
_lastHasDisruptor = !!disruptor;
_lastTrialWinners = occ.slice(0, Math.min(4, occ.length)); /* ผู้โหวตถูก (ใช้กรณีไม่มีตัวป่วน) */
showFinalResult(correct, 0, anyVotes);
}
document.addEventListener('keydown', function (e) {
if (!e.ctrlKey || !e.altKey || e.repeat) return;
if (!__isJusticeTestModeOn()) return;
if (e.code === 'Digit1') { e.preventDefault(); __previewTrialFinal(true, true); }
else if (e.code === 'Digit2') { e.preventDefault(); __previewTrialFinal(false, true); }
else if (e.code === 'Digit3') {
e.preventDefault();
_testDisruptorOn = !_testDisruptorOn;
try { appendLobbySystemChat('— [Test] ตัวป่วน: ' + (_testDisruptorOn ? 'มี' : 'ไม่มี') + ' (Ctrl+Alt+1 ถูก / Ctrl+Alt+2 ผิด)'); } catch (eC) { /* ignore */ }
}
else if (e.code === 'KeyW') {
/* บังคับให้รอบโหวตชี้คนร้ายถัดไป "นับเป็นโหวตผิด" → ทริกการ์ด 5 (จับผิดตัว/โหวตใหม่) */
e.preventDefault();
try { socket.emit('test-force-wrong-vote'); } catch (eW) { /* ignore */ }
try { appendLobbySystemChat('— [Test] บังคับโหวตรอบถัดไป = ผิด (ถ้ามีการ์ด 5 จะได้โหวตใหม่)'); } catch (eW2) { /* ignore */ }
}
});
/* ===== ห้องสรุปหลักฐาน — การไต่สวน (เลือกหลักฐาน 2 ใบ/ปากคำ ก่อนโหวต) ===== */
var tmState = { round: 0, members: [], picks: [], submitted: false, mode: 'select', submittedPicksById: {}, ownedSlots: [], requiredPicks: 2, hostId: '', submittedIds: [], readyIds: [], iReadied: false };
/** ตำแหน่งการ์ดที่ผู้เล่นเก็บได้สำหรับ suspect ตามรอบ — index เข้าลิสต์ owned (0..n-1) */
function tmOwnedSlotsForRound(round) {
var i = Math.floor(Number(round));
if (Number.isNaN(i) || i < 0 || i > 2) i = 0;
var row = (myPlayerEvidence && myPlayerEvidence[i]) || [];
var out = [];
for (var k = 0; k < row.length; k++) if (row[k] && row[k].imageUrl) out.push(k);
return out;
}
var SUSPECT_NAMES_EN = ['SOMCHAI', 'ARIN', 'JIRAPHA'];
function ensureTestimonyStyle() {
var link = document.getElementById('testimony-style');
if (!link) {
link = document.createElement('link');
link.id = 'testimony-style';
link.rel = 'stylesheet';
document.head.appendChild(link);
}
link.href = 'css/testimony-overlay.css?v=9';
}
var testimonyScaleBound = false;
function syncTestimonyScale() {
var wrap = document.querySelector('#testimony-overlay .stage-wrap');
var stage = document.querySelector('#testimony-overlay .es3-stage');
if (!wrap || !stage) return;
var dw = 1920;
var dh = 1080;
var w = wrap.clientWidth || window.innerWidth;
var h = wrap.clientHeight || window.innerHeight;
var scale = Math.min(w / dw, h / dh);
if (!Number.isFinite(scale) || scale <= 0) scale = 1;
var scaledW = dw * scale;
var scaledH = dh * scale;
var tx = Math.max(0, (w - scaledW) / 2);
var ty = Math.max(0, (h - scaledH) / 2);
stage.style.transform = 'translate(' + tx + 'px, ' + ty + 'px) scale(' + scale + ')';
stage.style.marginBottom = '0';
}
function bindTestimonyScale() {
if (testimonyScaleBound) return;
testimonyScaleBound = true;
window.addEventListener('resize', syncTestimonyScale);
window.addEventListener('orientationchange', syncTestimonyScale);
}
function buildEs3HtmlCard(card, c, linkName, cardIndex) {
var rar = String(c.rarity || 'common').toLowerCase();
if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common';
var nStar = Math.max(1, Math.min(3, Number(c.stars) || 1));
var stars = '';
for (var st = 0; st < nStar; st++) stars += '★';
var nm = linkName || '?';
var icon = LOBBY_EVIDENCE_CARD_ICONS[cardIndex] || '🔎';
card.classList.remove('es3-card--img');
card.innerHTML =
'<div class="es3-card-head"><div class="ico" aria-hidden="true"></div>' +
'<div class="nm"><div class="th"></div><div class="en"></div></div></div>' +
'<div class="es3-card-art" aria-hidden="true">◆</div>' +
'<p class="es3-card-desc"></p>' +
'<div class="es3-card-link"><div class="avatar"></div><div class="sname"></div><div class="stars"></div></div>';
card.querySelector('.ico').textContent = icon;
card.querySelector('.th').textContent = c.titleTh || '';
card.querySelector('.en').textContent = c.titleEn ? '(' + c.titleEn + ')' : '';
card.querySelector('.es3-card-desc').textContent = c.body || '';
card.querySelector('.avatar').textContent = nm.charAt(0);
card.querySelector('.sname').textContent = nm;
card.querySelector('.stars').textContent = stars + ' (' + LOBBY_EVIDENCE_RARITY[rar] + ')';
}
function es3EvidenceCardEl(c, linkName, cardIndex, selectable) {
var card = document.createElement('article');
card.className = 'es3-card';
if (selectable) card.setAttribute('data-card-index', String(cardIndex));
if (c && c.imageUrl) {
card.classList.add('es3-card--img');
var img = document.createElement('img');
img.className = 'es3-card-full-img';
img.alt = (c.titleTh || '') + (c.titleEn ? ' (' + c.titleEn + ')' : '');
img.decoding = 'async';
img.onerror = function () { this.onerror = null; buildEs3HtmlCard(card, c, linkName, cardIndex); };
img.src = c.imageUrl;
card.appendChild(img);
/* ปุ่มแว่นขยายมุมการ์ด — ดูใหญ่ได้โดยไม่ชนกับการเลือกการ์ด (stopPropagation) */
var zb = document.createElement('button');
zb.type = 'button';
zb.className = 'es3-card-zoom';
zb.setAttribute('aria-label', 'ดูใหญ่');
zb.textContent = '🔍';
zb.style.cssText = 'position:absolute;top:6px;right:6px;z-index:3;width:30px;height:30px;border:none;border-radius:50%;cursor:zoom-in;font-size:15px;line-height:1;background:rgba(8,12,28,.78);color:#ffd666;box-shadow:0 2px 8px rgba(0,0,0,.5)';
zb.addEventListener('click', function (e) {
e.stopPropagation();
var label = (c.titleTh || '') + (c.titleEn ? ' · ' + c.titleEn : '');
if (!label && linkName) label = linkName;
openEvidenceCardLightbox(c.imageUrl, label, c.rarity);
});
card.style.position = 'relative';
card.appendChild(zb);
return card;
}
buildEs3HtmlCard(card, c, linkName, cardIndex);
return card;
}
function tmSuspectData(round) {
var payload = getLobbyEvidenceCasePayload();
var suspects = payload.suspects || [];
var si = Math.max(0, Math.min(suspects.length - 1, round));
return suspects[si] || { linkName: '?', cards: [] };
}
function ensureTestimonyOverlay() {
var ov = document.getElementById('testimony-overlay');
if (ov && (!ov.querySelector('.es3-bottom-box') || !ov.querySelector('.es-reveal-wrap'))) {
ov.remove();
ov = null;
}
if (ov) return ov;
ensureTestimonyStyle();
bindTestimonyScale();
ov = document.createElement('div');
ov.id = 'testimony-overlay';
ov.className = 'is-hidden';
ov.setAttribute('aria-hidden', 'true');
var SC = '/Main-Lobby/IMAGE/Showcard/';
ov.innerHTML =
'<div class="stage-wrap">' +
'<div class="stage es3-stage">' +
'<div class="layer es3-bg" aria-hidden="true"><img src="' + SC + 'bg-showcard.png" alt=""></div>' +
'<div class="layer es3-top-popup es3-mode-select">' +
'<span class="corner c-tl"></span><span class="corner c-tr"></span><span class="corner c-bl"></span><span class="corner c-br"></span>' +
'<div class="es3-popup-tab">ห้องสรุปหลักฐาน</div>' +
'</div>' +
'<div class="layer es3-suspect es3-mode-select">' +
'<div class="es3-suspect-frame"><img src="' + SC + 'suspect-bg.png" alt=""></div>' +
'<div class="es3-suspect-portrait"><img id="es3-sus-img" alt=""></div>' +
'<div class="es3-suspect-name" id="es3-sus-name"></div>' +
'</div>' +
'<div class="layer es3-title-wrap es3-mode-select"><h2 class="es3-title" id="es3-title"></h2></div>' +
'<div class="layer es3-ps-row es3-mode-select" id="es3-ps-row"></div>' +
'<div class="layer es3-bottom-box es3-mode-select" id="es3-bottom-box"><img src="' + SC + 'tab-01.png" alt=""></div>' +
'<div class="layer es3-evidence-text es3-mode-select"><img src="' + SC + 'txt-2.png" alt=""></div>' +
'<div class="layer es3-counter es3-mode-select" id="es3-count">เลือกหลักฐาน 0/2</div>' +
'<div class="layer es3-cards es3-mode-select" id="es3-cards"></div>' +
'<div class="layer es3-status-text es3-mode-select" id="es3-status">Status : กำลังรอ..ผู้เล่นเลือกหลักฐาน</div>' +
/* ปุ่มหลักที่ bottom-right — สลับรูปตามสถานะ: btn-send (ทุกคน) → btn-open-card (host เมื่อทุกคนส่งครบ) */
'<div class="layer es3-btn-action es3-mode-select" id="es3BtnAction" role="button" tabindex="0" aria-label="ส่งหลักฐาน" data-state="send">' +
'<img id="es3BtnActionImg" src="' + SC + 'btn-send.png" alt="">' +
'</div>' +
/* legacy hidden — เก็บไว้กันโค้ดเก่าอ้างอิง id (ไม่แสดงผล) */
'<button type="button" class="es3-btn-submit-legacy" id="es3BtnSubmit" hidden disabled aria-hidden="true"></button>' +
'<div class="es3-btn-open-legacy" id="es3BtnOpen" hidden aria-hidden="true"></div>' +
'<div class="es-reveal-wrap" id="es-reveal-wrap" hidden>' +
'<div class="layer es-bg" aria-hidden="true"><img src="' + SC + 'bg-showcard.png" alt=""></div>' +
'<div class="layer es-popup"><img src="' + SC + 'popup-0.png" alt=""></div>' +
'<div class="layer es-suspect">' +
'<div class="es-suspect-frame"><img src="' + SC + 'suspect-bg.png" alt=""></div>' +
'<div class="es-suspect-portrait"><img id="es-popup-sus-img" alt=""></div>' +
'<div class="es-suspect-name" id="es-popup-sus-name"></div>' +
'</div>' +
'<div class="layer es-title-wrap">' +
'<h2 class="es-title" id="es-popup-title"></h2>' +
'<p class="es-subtitle" id="es-popup-sub">[วิเคราะห์พยานหลักฐานกับทีม ก่อนไปปากคำถัดไป]</p>' +
'</div>' +
'<div class="layer es-players" id="es-players"></div>' +
'<button type="button" class="layer es-ready btn" id="esBtnReady" aria-label="READY" data-state="ready">' +
'<img class="ready-default" src="' + SC + 'btn-ready.png" alt="">' +
'<img class="ready-active" src="' + SC + 'btn-ready-active.png" alt="">' +
'<img class="ready-start-vote" src="' + SC + 'btn-start-vote.png" alt="">' +
'</button>' +
/* ปุ่ม host แยกต่างหาก (ขวา) — ไปปากคำถัดไป / เริ่มพิจารณาคดี */
'<button type="button" class="layer es-proceed btn" id="esBtnProceed" hidden disabled aria-label="ไปต่อ">' +
'<img class="es-proceed-next" src="' + SC + 'btn-next-suspect.png" alt="ผู้ต้องสงสัยถัดไป">' +
'<img class="es-proceed-trial" src="' + SC + 'btn-start-vote.png" alt="เริ่มพิจารณาคดี">' +
'</button>' +
'</div>' +
'<button type="button" class="es3-mic btn" id="es3-mic" aria-label="ไมค์">🎤</button>' +
'</div>' +
'</div>';
document.body.appendChild(ov);
var cardsEl = ov.querySelector('#es3-cards');
if (cardsEl) cardsEl.addEventListener('click', function (e) {
var card = e.target.closest('.es3-card'); if (!card || tmState.submitted) return;
var idx = parseInt(card.getAttribute('data-card-index'), 10);
if (!(idx >= 0)) return;
var maxPicks = Math.max(1, Math.min(2, tmState.requiredPicks || 2));
var pos = tmState.picks.indexOf(idx);
if (pos >= 0) { tmState.picks.splice(pos, 1); card.classList.remove('is-selected'); }
else { if (tmState.picks.length >= maxPicks) return; tmState.picks.push(idx); card.classList.add('is-selected'); }
es3UpdateCount();
});
/* ปุ่มเดียวจัดการทั้ง submit (ทุกคน) และ reveal (host เมื่อทุกคนส่งครบ) */
function doSubmitMyPicks() {
var req = Math.max(1, Math.min(2, tmState.requiredPicks || 2));
if (tmState.picks.length !== req || tmState.submitted) return;
socket.emit('testimony-submit', { picks: tmState.picks.slice(0, req) }, function (res) {
if (res && res.ok) {
tmState.submitted = true;
if (!tmState.submittedPicksById) tmState.submittedPicksById = {};
/* เก็บเป็น card object (สอดคล้องกับ data.picks จาก server) ไม่ใช่ index */
var ownedRow = (myPlayerEvidence[tmState.round] || []);
tmState.submittedPicksById[socket.id] = tmState.picks.slice(0, req)
.map(function (slot) { return ownedRow[slot]; })
.filter(function (c) { return c && typeof c === 'object' && c.imageUrl; });
if (tmState.submittedIds.indexOf(socket.id) < 0) tmState.submittedIds.push(socket.id);
es3SetStatus('ส่งหลักฐานแล้ว — รอผู้เล่นอื่น...');
es3RenderMembers(tmState.submittedIds, [], false);
es3UpdateActionButton();
} else {
appendLobbySystemChat('— ส่งหลักฐานไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : ''));
}
});
}
function doRevealAll() {
socket.emit('testimony-reveal', {}, function (res) {
if (!(res && res.ok)) appendLobbySystemChat('— เปิดเผยไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : ''));
});
}
function onActionBtnTrigger() {
var btn = document.getElementById('es3BtnAction');
if (!btn || btn.classList.contains('is-disabled')) return;
var state = btn.getAttribute('data-state');
if (state === 'open') doRevealAll();
else doSubmitMyPicks();
}
var actionEl = ov.querySelector('#es3BtnAction');
if (actionEl) {
actionEl.addEventListener('click', onActionBtnTrigger);
actionEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onActionBtnTrigger(); }
});
}
/* ปุ่ม READY — ทุกคนกดยืนยันของตัวเอง (ไม่ทำหน้าที่ไปต่อแล้ว) */
var readyEl = ov.querySelector('#esBtnReady');
if (readyEl) readyEl.addEventListener('click', function () {
if (this.disabled) return;
this.classList.add('is-active');
this.disabled = true;
tmState.iReadied = true;
socket.emit('testimony-ready-next', {}, function (res) {
if (!(res && res.ok)) {
tmState.iReadied = false;
var rd = document.getElementById('esBtnReady');
if (rd) { rd.disabled = false; rd.classList.remove('is-active'); }
appendLobbySystemChat('— READY ไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : ''));
}
});
});
/* ปุ่ม host แยก — ไปปากคำถัดไป / เริ่มพิจารณาคดี (เปิดเมื่อทุกคน READY) */
var proceedEl = ov.querySelector('#esBtnProceed');
if (proceedEl) proceedEl.addEventListener('click', function () {
if (this.disabled || this.hidden) return;
this.disabled = true;
socket.emit('testimony-start-vote', {}, function (res) {
if (!(res && res.ok)) {
var pe = document.getElementById('esBtnProceed');
if (pe) pe.disabled = false;
appendLobbySystemChat('— ไปต่อไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : ''));
}
});
});
return ov;
}
function es3SetStatus(txt) {
var el = document.getElementById('es3-status');
if (el) el.textContent = 'Status : ' + (txt || 'กำลังรอ..ผู้เล่นเลือกหลักฐาน');
}
function es3UpdateCount() {
var req = Math.max(1, Math.min(2, tmState.requiredPicks || 2));
var el = document.getElementById('es3-count'); if (el) el.textContent = 'เลือกหลักฐาน ' + tmState.picks.length + '/' + req;
es3UpdateActionButton();
}
/* คุมรูป + สถานะของปุ่มเดียวที่ bottom-right
- state="send" → btn-send.png (ทุกคนเห็น; กดส่งหลักฐานของตัวเอง)
- state="open" → btn-open-card.png (เฉพาะ host เห็น/กดได้; กดเปิดเผยทั้งหมด) */
function es3UpdateActionButton() {
var btn = document.getElementById('es3BtnAction');
var img = document.getElementById('es3BtnActionImg');
if (!btn || !img) return;
var SC = '/Main-Lobby/IMAGE/Showcard/';
var req = Math.max(1, Math.min(2, tmState.requiredPicks || 2));
var totalMembers = (tmState.members || []).length;
var submittedCount = (tmState.submittedIds || []).length;
var allDone = totalMembers > 0 && submittedCount >= totalMembers;
var isHost = !!(tmState.hostId && tmState.hostId === socket.id);
if (allDone) {
btn.setAttribute('data-state', 'open');
btn.setAttribute('aria-label', 'เปิดหลักฐานทั้งหมด');
img.src = SC + 'btn-open-card.png';
/* host เท่านั้นที่เห็น/กดได้ */
if (isHost) {
btn.classList.remove('is-hidden', 'is-disabled');
btn.style.display = '';
btn.removeAttribute('aria-hidden');
} else {
btn.classList.add('is-hidden');
btn.style.display = 'none';
btn.setAttribute('aria-hidden', 'true');
}
} else {
btn.setAttribute('data-state', 'send');
btn.setAttribute('aria-label', 'ส่งหลักฐาน');
img.src = SC + 'btn-send.png';
btn.classList.remove('is-hidden');
btn.style.display = '';
btn.removeAttribute('aria-hidden');
/* ใช้ได้เฉพาะเมื่อเลือกครบและยังไม่เคยส่ง */
var canSend = (tmState.picks.length === req && !tmState.submitted && tmState.ownedSlots.length > 0);
btn.classList.toggle('is-disabled', !canSend);
}
}
function buildEsRevealHtmlCard(card, c, linkName, cardIdx) {
var nm = linkName || '?';
var icon = LOBBY_EVIDENCE_CARD_ICONS[cardIdx] || '🔎';
card.classList.remove('es-card--img');
card.innerHTML =
'<div class="es-card-head"><div class="ico" aria-hidden="true"></div>' +
'<div class="nm"><div class="th"></div><div class="en"></div></div></div>' +
'<div class="es-card-art" aria-hidden="true">◆</div>' +
'<p class="es-card-desc"></p>' +
'<div class="es-card-link"><div class="avatar"></div><div class="sname"></div><div class="label">Link</div></div>';
card.querySelector('.ico').textContent = icon;
card.querySelector('.th').textContent = c.titleTh || '';
card.querySelector('.en').textContent = c.titleEn ? '(' + c.titleEn + ')' : '';
card.querySelector('.es-card-desc').textContent = c.body || '';
card.querySelector('.avatar').textContent = nm.charAt(0);
card.querySelector('.sname').textContent = nm;
}
var EVIDENCE_LIGHTBOX_GLOW = { common: 'rgba(120,160,255,.55)', rare: 'rgba(150,120,255,.6)', legendary: 'rgba(255,196,90,.65)' };
/** เปิดดูการ์ดหลักฐานแบบเต็มจอ + ปุ่มปิด (คลิก backdrop / กากบาท / Esc เพื่อปิด) */
function openEvidenceCardLightbox(imageUrl, label, rarity) {
if (!imageUrl) return;
var rar = String(rarity || 'common').toLowerCase();
if (!EVIDENCE_LIGHTBOX_GLOW[rar]) rar = 'common';
var ov = document.getElementById('evidence-card-lightbox');
if (!ov) {
ov = document.createElement('div');
ov.id = 'evidence-card-lightbox';
ov.style.cssText = 'position:fixed;inset:0;z-index:100000;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(5,8,18,.86);backdrop-filter:blur(4px)';
ov.innerHTML =
'<button type="button" class="ecl-close" aria-label="ปิด" ' +
'style="position:absolute;top:22px;right:26px;width:46px;height:46px;display:flex;align-items:center;justify-content:center;padding:0;border:none;border-radius:50%;cursor:pointer;font:900 26px/1 system-ui,sans-serif;color:#0a0e1a;background:linear-gradient(180deg,#ffd666,#f0b429);box-shadow:0 8px 22px rgba(0,0,0,.45)"><span style="display:block;margin-top:-2px">&times;</span></button>' +
'<div class="ecl-stage" style="display:flex;flex-direction:column;align-items:center;gap:14px;max-width:92vw">' +
'<img class="ecl-img" alt="" style="display:block;width:auto;height:auto;max-width:min(86vw,520px);max-height:78vh;border-radius:16px;animation:eclPop .3s cubic-bezier(.2,.9,.3,1.35)" />' +
'<div class="ecl-cap" style="font:800 16px/1.3 Kanit,system-ui,sans-serif;color:#e7ecff;text-align:center;max-width:90vw"></div>' +
'</div>';
document.body.appendChild(ov);
if (!document.getElementById('ecl-anim-style')) {
var st = document.createElement('style');
st.id = 'ecl-anim-style';
st.textContent = '@keyframes eclPop{from{opacity:0;transform:scale(.85)}to{opacity:1;transform:none}}';
document.head.appendChild(st);
}
var closeFn = function () { ov.classList.add('is-hidden'); ov.style.display = 'none'; };
ov.addEventListener('click', function (e) { if (e.target === ov || e.target.classList.contains('ecl-close')) closeFn(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && ov.style.display !== 'none') closeFn();
});
}
var img = ov.querySelector('.ecl-img');
var cap = ov.querySelector('.ecl-cap');
if (img) {
img.style.boxShadow = '0 0 44px ' + EVIDENCE_LIGHTBOX_GLOW[rar] + ',0 22px 60px rgba(0,0,0,.6)';
img.src = imageUrl;
}
if (cap) cap.textContent = label || '';
ov.classList.remove('is-hidden');
ov.style.display = 'flex';
}
/** ทำให้การ์ด (PNG เต็มใบ) คลิกเพื่อดูใหญ่ได้ */
function makeEvidenceCardZoomable(cardEl, c, linkName) {
if (!cardEl || !c || !c.imageUrl) return;
cardEl.style.cursor = 'zoom-in';
cardEl.addEventListener('click', function () {
var label = (c.titleTh || '') + (c.titleEn ? ' · ' + c.titleEn : '');
if (!label && linkName) label = linkName;
openEvidenceCardLightbox(c.imageUrl, label, c.rarity);
});
}
function esRevealCardEl(c, linkName, cardIdx) {
var rar = String(c.rarity || 'common').toLowerCase();
if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common';
var card = document.createElement('div');
card.className = 'es-card rarity-' + rar;
if (c && c.imageUrl) {
card.classList.add('es-card--img');
var img = document.createElement('img');
img.className = 'es-card-full-img';
img.alt = (c.titleTh || '') + (c.titleEn ? ' (' + c.titleEn + ')' : '');
img.decoding = 'async';
img.onerror = function () { this.onerror = null; buildEsRevealHtmlCard(card, c, linkName, cardIdx); };
img.src = c.imageUrl;
card.appendChild(img);
makeEvidenceCardZoomable(card, c, linkName);
return card;
}
buildEsRevealHtmlCard(card, c, linkName, cardIdx);
return card;
}
function testimonyPicksForMember(picksMap, memberId) {
if (!picksMap || memberId == null || memberId === '') return [];
var sel = picksMap[memberId];
if (!sel) sel = picksMap[String(memberId)];
if (!sel && typeof memberId === 'string') {
Object.keys(picksMap).forEach(function (k) {
if (sel && sel.length) return;
if (k === memberId || String(k) === String(memberId)) sel = picksMap[k];
});
}
if (!Array.isArray(sel)) return [];
/* picks = array ของ card object (server เก็บเป็น object หลัง refactor) — คืน object ตามเดิม */
var out = [];
sel.forEach(function (c) {
if (c && typeof c === 'object' && c.imageUrl) out.push(c);
});
return out;
}
function esRenderRevealPlayers(members, picks, suspectData) {
var root = document.getElementById('es-players');
if (!root) return;
root.textContent = '';
var sd = suspectData || { linkName: '?', cards: [] };
var cards = sd.cards || [];
(members || []).forEach(function (m) {
var col = document.createElement('div');
col.className = 'es-player';
col.setAttribute('data-member-id', m.id || '');
var crown = m.isHost ? '<span class="host-icon"></span>' : '';
var nick = String(m.nickname || '').toUpperCase();
var nameEl = document.createElement('div');
nameEl.className = 'es-player-name';
nameEl.innerHTML = crown + nick;
col.appendChild(nameEl);
var sel = testimonyPicksForMember(picks, m.id);
if (!sel.length) {
var none = document.createElement('div');
none.className = 'es-rev-none';
none.textContent = '(ไม่ได้ส่ง)';
col.appendChild(none);
} else {
sel.forEach(function (c, ci) {
if (c && typeof c === 'object' && c.imageUrl) col.appendChild(esRevealCardEl(c, sd.linkName, ci));
});
if (!col.querySelector('.es-card')) {
var bad = document.createElement('div');
bad.className = 'es-rev-none';
bad.textContent = '(หลักฐานไม่ตรงรอบ)';
col.appendChild(bad);
}
}
root.appendChild(col);
});
}
function es3RenderMembers(submittedIds, readyIds, revealMode) {
if (revealMode) {
var ready = readyIds || [];
document.querySelectorAll('#es-players .es-player').forEach(function (col) {
var mid = col.getAttribute('data-member-id');
var watching = !!(mid && ready.indexOf(mid) < 0);
col.classList.toggle('is-watching', watching);
/* ลบ badge "กำลังดู.." ทั้งหมด — ไม่โชว์ใน reveal */
var badge = col.querySelector('.es-watch-badge');
if (badge) badge.remove();
});
return;
}
var root = document.getElementById('es3-ps-row'); if (!root) return;
root.textContent = '';
var SC = '/Main-Lobby/IMAGE/Showcard/';
(tmState.members || []).forEach(function (m) {
var wrap = document.createElement('div');
wrap.className = 'es3-ps' + (m.id === socket.id ? ' is-me' : '');
var done = revealMode ? (readyIds && readyIds.indexOf(m.id) >= 0) : (submittedIds && submittedIds.indexOf(m.id) >= 0);
var crown = m.isHost ? '<span class="host-icon"></span>' : '';
var nick = String(m.nickname || '').toUpperCase();
if (done) {
wrap.innerHTML =
'<div class="es3-ps-name">' + crown + nick + '</div>' +
'<div class="es3-ps-card is-ready">' +
'<img class="card-bg" src="' + SC + 'card-bg.png" alt="">' +
'<img class="card-back" src="' + SC + 'card-back.png" alt="">' +
'<img class="status-icon" src="' + SC + 'ready-icon.png" alt="พร้อม">' +
'</div>';
} else {
wrap.innerHTML =
'<div class="es3-ps-name">' + crown + nick + '</div>' +
'<div class="es3-ps-card is-loading">' +
'<img class="card-bg" src="' + SC + 'card-bg.png" alt="">' +
'<img class="loading-icon" src="' + SC + 'loading.png" alt="">' +
'<img class="loading-text" src="' + SC + 'loading-txt.png" alt="กำลังเลือก">' +
'</div>';
}
root.appendChild(wrap);
});
}
function openTestimony(data) {
if (typeof closeSuspectOverlay === 'function') closeSuspectOverlay();
/* ซิงค์หลักฐานของตัวเองจาก server ก่อน (กันรอบที่ play จบใหม่ๆ แล้ว state ใน room-lobby ยังไม่อัปเดต) */
if (data && Array.isArray(data.myPlayerEvidence)) applyMyPlayerEvidence(data.myPlayerEvidence);
var ov = ensureTestimonyOverlay();
tmState.round = (data && typeof data.round === 'number') ? data.round : 0;
tmState.members = (data && Array.isArray(data.members)) ? data.members : [];
tmState.picks = [];
tmState.submitted = false;
tmState.submittedPicksById = {};
tmState.submittedIds = (data && Array.isArray(data.submitted)) ? data.submitted.slice() : [];
tmState.hostId = (data && data.hostId) ? data.hostId : '';
tmState.readyIds = [];
tmState.iReadied = false;
tmState.mode = 'select';
tmState.ownedSlots = tmOwnedSlotsForRound(tmState.round);
/* ต้องเลือกหลักฐาน = min(2, จำนวนที่เก็บได้) — ถ้าได้ 1 ใบ ส่ง 1 ใบพอ */
tmState.requiredPicks = Math.max(1, Math.min(2, tmState.ownedSlots.length));
var SC = '/Main-Lobby/IMAGE/Showcard/';
var sd = tmSuspectData(tmState.round);
document.getElementById('es3-title').textContent = 'การไต่สวน : ปากคำที่ ' + (tmState.round + 1) + ' - ' + (sd.linkName || ('ผู้ต้องสงสัย ' + (tmState.round + 1)));
var susImg = document.getElementById('es3-sus-img');
if (susImg) susImg.src = getSuspectPickImageUrl(tmState.round);
var susName = document.getElementById('es3-sus-name'); if (susName) susName.textContent = sd.linkName || '';
var stage = ov.querySelector('.es3-stage');
if (stage) stage.classList.remove('es3-stage--reveal');
var revealWrap = document.getElementById('es-reveal-wrap'); if (revealWrap) revealWrap.hidden = true;
var readyBtn = document.getElementById('esBtnReady');
if (readyBtn) { readyBtn.disabled = false; readyBtn.classList.remove('is-active'); }
var tabImg = document.querySelector('#es3-bottom-box img');
if (tabImg) tabImg.src = SC + 'tab-0' + (tmState.round + 1) + '.png';
es3SetStatus('กำลังรอ..ผู้เล่นเลือกหลักฐาน');
var cardsRoot = document.getElementById('es3-cards'); cardsRoot.textContent = '';
/* แสดงเฉพาะการ์ดที่ผู้เล่นเก็บได้จริง (ตามสล็อตจาก myPlayerEvidence) */
if (tmState.ownedSlots.length === 0) {
var empty = document.createElement('div');
empty.className = 'es3-empty-hint';
empty.textContent = 'ยังไม่มีหลักฐานของ ' + (sd.linkName || ('ผู้ต้องสงสัย ' + (tmState.round + 1))) + ' — รอเพื่อนเลือกหลักฐาน';
cardsRoot.appendChild(empty);
/* auto-submit ว่างเพื่อไม่บล็อกรอบ */
socket.emit('testimony-submit', { picks: [] }, function (res) {
if (res && res.ok) {
tmState.submitted = true;
tmState.submittedPicksById[socket.id] = [];
if (tmState.submittedIds.indexOf(socket.id) < 0) tmState.submittedIds.push(socket.id);
es3SetStatus('ไม่มีหลักฐาน — ข้ามอัตโนมัติ');
es3UpdateActionButton();
}
});
} else {
var ownedRow = (myPlayerEvidence[tmState.round] || []);
tmState.ownedSlots.forEach(function (slot) {
var c = ownedRow[slot];
if (c) cardsRoot.appendChild(es3EvidenceCardEl(c, sd.linkName, slot, true));
});
}
es3UpdateCount();
es3RenderMembers(tmState.submittedIds || [], [], false);
es3UpdateActionButton();
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
requestAnimationFrame(syncTestimonyScale);
}
function testimonyStatusUpdate(data) {
if (!data) return;
tmState.submittedIds = Array.isArray(data.submitted) ? data.submitted.slice() : [];
if (data.hostId) tmState.hostId = data.hostId;
es3RenderMembers(tmState.submittedIds, [], false);
es3UpdateActionButton();
/* อัปเดต status text */
var total = (tmState.members || []).length;
if (total > 0 && tmState.submittedIds.length >= total) {
es3SetStatus('ทุกคนเลือกหลักฐานแล้ว — รอ host เปิดเผยผล');
} else if (tmState.submittedIds.length > 0) {
es3SetStatus('กำลังรอ..ผู้เล่นเลือกหลักฐาน (' + tmState.submittedIds.length + '/' + total + ')');
} else {
es3SetStatus('กำลังรอ..ผู้เล่นเลือกหลักฐาน');
}
}
function testimonyReveal(data) {
tmState.mode = 'reveal';
var ov = ensureTestimonyOverlay();
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
if (data && typeof data.round === 'number') tmState.round = data.round;
if (data && Array.isArray(data.members)) tmState.members = data.members;
var SC = '/Main-Lobby/IMAGE/Showcard/';
var sd = tmSuspectData(tmState.round);
var stage = ov.querySelector('.es3-stage');
if (stage) stage.classList.add('es3-stage--reveal');
var revealWrap = document.getElementById('es-reveal-wrap');
if (revealWrap) revealWrap.hidden = false;
var titleEl = document.getElementById('es-popup-title');
if (titleEl) titleEl.textContent = 'การไต่สวน : ปากคำที่ ' + (tmState.round + 1) + ' - ' + (sd.linkName || ('ผู้ต้องสงสัย ' + (tmState.round + 1)));
var susImg = document.getElementById('es-popup-sus-img');
if (susImg) susImg.src = getSuspectPickImageUrl(tmState.round);
var susName = document.getElementById('es-popup-sus-name');
if (susName) susName.textContent = sd.linkName || '';
var rd = document.getElementById('esBtnReady');
if (rd) { rd.disabled = false; rd.classList.remove('is-active', 'is-start-vote'); rd.setAttribute('data-state', 'ready'); }
tmState.readyIds = (data && Array.isArray(data.ready)) ? data.ready.slice() : [];
tmState.iReadied = false;
if (data && data.hostId) tmState.hostId = data.hostId;
var picks = Object.assign({}, tmState.submittedPicksById || {}, (data && data.picks) || {});
esRenderRevealPlayers(tmState.members, picks, sd);
es3RenderMembers([], tmState.readyIds, true);
esUpdateRevealActionButton();
requestAnimationFrame(syncTestimonyScale);
}
function testimonyReadyUpdate(data) {
var readyArr = (data && data.ready) || [];
tmState.readyIds = readyArr.slice();
if (data && data.hostId) tmState.hostId = data.hostId;
es3RenderMembers([], readyArr, true);
esUpdateRevealActionButton();
}
/* คุมสถานะปุ่มใน reveal mode:
- ก่อนผู้เล่นกด READY: btn-ready.png (ทุกคน)
- หลังกด READY แต่ยังรอคนอื่น: btn-ready-active.png (disabled)
- ทุกคน READY แล้ว: host เห็น btn-start-vote.png (กดได้); คนอื่น disabled */
function esUpdateRevealActionButton() {
var btn = document.getElementById('esBtnReady');
var proceed = document.getElementById('esBtnProceed');
var isHost = !!(tmState.hostId && tmState.hostId === socket.id);
var total = (tmState.members || []).length;
var readyCount = (tmState.readyIds || []).length;
var allReady = total > 0 && readyCount >= total;
var meReadied = !!tmState.iReadied || ((tmState.readyIds || []).indexOf(socket.id) >= 0);
/* ปุ่ม READY (กลาง) — แค่ยืนยันของตัวเอง */
if (btn) {
btn.classList.remove('is-start-vote', 'is-active');
btn.setAttribute('data-state', 'ready');
if (meReadied) {
btn.classList.add('is-active');
btn.disabled = true;
btn.setAttribute('aria-label', 'READY แล้ว');
} else {
btn.disabled = false;
btn.setAttribute('aria-label', 'READY');
}
}
/* ปุ่มไปต่อ (ขวา) — host เท่านั้น, เปิดเมื่อทุกคน READY; ป้ายเปลี่ยนตามปากคำสุดท้าย */
if (proceed) {
var isLast = (Number(tmState.round) || 0) >= 2;
proceed.classList.toggle('is-last', isLast);
proceed.setAttribute('aria-label', isLast ? 'เริ่มพิจารณาคดี' : 'ผู้ต้องสงสัยถัดไป');
if (isHost) {
proceed.hidden = false;
proceed.disabled = !allReady;
proceed.classList.toggle('is-enabled', allReady);
} else {
proceed.hidden = true;
proceed.disabled = true;
}
}
}
function hideTestimonyOverlay() {
var ov = document.getElementById('testimony-overlay');
if (ov) {
ov.classList.add('is-hidden');
ov.setAttribute('aria-hidden', 'true');
}
}
function applySuspectSelectionVisual(idx) {
let i = Math.floor(Number(idx));
if (Number.isNaN(i) || i < 0 || i > 2) i = 0;
suspectSelectedIndex = i;
document.querySelectorAll('.suspect-card').forEach((btn) => {
const j = parseInt(btn.getAttribute('data-index'), 10);
btn.classList.toggle('suspect-card--selected', j === i);
});
}
function ensureEvidenceRevealStyle() {
if (document.getElementById('evidence-reveal-style')) return;
var st = document.createElement('style');
st.id = 'evidence-reveal-style';
st.textContent =
'.ev-card--new{animation:evNewPulse 1.4s ease-out 2;box-shadow:0 0 0 3px #ffd666,0 0 28px rgba(255,214,102,.7)!important;border-radius:10px;}' +
'@keyframes evNewPulse{0%{transform:scale(1)}30%{transform:scale(1.06)}100%{transform:scale(1)}}' +
'#evidence-reveal-banner{position:absolute;top:14px;left:50%;transform:translateX(-50%);z-index:30;background:linear-gradient(180deg,#1f2747,#141a33);border:1px solid rgba(255,214,102,.7);color:#ffe7b3;font-weight:800;font-size:18px;padding:10px 22px;border-radius:999px;box-shadow:0 10px 30px rgba(0,0,0,.5);pointer-events:none;animation:evBannerIn .4s ease-out;}' +
'@keyframes evBannerIn{from{opacity:0;transform:translate(-50%,-12px)}to{opacity:1;transform:translateX(-50%)}}';
document.head.appendChild(st);
}
function showEvidenceRevealBanner(n) {
var ov = document.getElementById('lobby-evidence-overlay');
if (!ov) return;
var old = document.getElementById('evidence-reveal-banner');
if (old) old.remove();
var b = document.createElement('div');
b.id = 'evidence-reveal-banner';
b.textContent = 'ได้รับหลักฐานใหม่ +' + Math.max(1, n) + ' ใบ';
ov.appendChild(b);
setTimeout(function () { if (b && b.parentNode) b.remove(); }, 4200);
}
/** โชว์การ์ดหลักฐานที่เพิ่งได้ตอนกลับถึง LobbyB (เปิดแฟ้ม + ไฮไลต์ใบใหม่) */
function showEvidenceRevealOnReturn() {
var raw = null;
try { raw = sessionStorage.getItem('justiceEvidenceReveal'); } catch (e) { raw = null; }
if (!raw) return false;
try { sessionStorage.removeItem('justiceEvidenceReveal'); } catch (e) { /* ignore */ }
var info = null;
try { info = JSON.parse(raw); } catch (e) { return false; }
if (!info || typeof info.suspectIndex !== 'number' || info.suspectIndex < 0) return false;
ensureEvidenceRevealStyle();
setTimeout(function () {
openLobbyEvidenceModal();
syncLobbyEvidenceTabUi(info.suspectIndex);
var root = document.getElementById('lobby-evidence-cards-root');
var newN = 1 + (parseInt(info.freeEvidenceCount, 10) || 0);
if (root && root.children.length) {
var kids = root.children;
for (var i = Math.max(0, kids.length - newN); i < kids.length; i++) {
kids[i].classList.add('ev-card--new');
}
}
showEvidenceRevealBanner(newN);
}, 350);
return true;
}
function applyDetectiveReturnToLobbyB(data) {
quizModeActive = false;
quizPhaseLocal = null;
quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
quizPeersLocked = {};
try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ }
hideQuizOverlay();
const mid = (data && data.mapId) ? String(data.mapId).trim() : POST_CASE_LOBBY_SPACE_ID;
const reopenSuspect = !!(data && data.suspectPhaseActive);
const pickIdx = (data && typeof data.suspectPickIndex === 'number') ? data.suspectPickIndex : suspectSelectedIndex;
if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames);
applyPersonalSuspectState(data);
applyRoomLobbyBTransition({
mapId: mid,
peersSnap: (data && data.peersSnap) ? data.peersSnap : [],
lobbyLevel: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.level) || null,
caseId: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.caseId) || null,
});
const revealedEvidence = showEvidenceRevealOnReturn();
if (reopenSuspect) {
serverSuspectPhaseActive = true;
/* ถ้ามีการโชว์การ์ดหลักฐานใหม่ ให้เลื่อนเปิดหน้าเลือกผู้ต้องสงสัยช้าลง (ผู้เล่นได้ดูการ์ดก่อน) */
setTimeout(function () {
openSuspectOverlay(pickIdx);
updateSuspectFloatingOpenBtn();
syncLobbyBUiChrome();
updatePlayersHud();
}, revealedEvidence ? 1600 : 500);
}
syncLobbyBUiChrome();
updatePlayersHud();
appendLobbySystemChat('— จบมินิเกม · กลับ LobbyB แล้ว');
}
const SUSPECT_DESIGN_WIDTH = 1200;
let suspectPickScaleListenersBound = false;
function scheduleSuspectPickScale() {
requestAnimationFrame(function () {
requestAnimationFrame(layoutSuspectPickScale);
});
}
function bindSuspectPickScaleListeners() {
if (suspectPickScaleListenersBound) return;
suspectPickScaleListenersBound = true;
window.addEventListener('resize', function () {
if (suspectPickOverlayOpen) scheduleSuspectPickScale();
});
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', function () {
if (suspectPickOverlayOpen) scheduleSuspectPickScale();
});
}
document.querySelectorAll('#suspect-cards-row img, .suspect-pick-title-img, #suspect-btn-start img, #suspect-btn-accuse img').forEach(function (img) {
img.addEventListener('load', function () {
if (suspectPickOverlayOpen) scheduleSuspectPickScale();
});
});
}
function layoutSuspectPickScale() {
const ov = document.getElementById('suspect-pick-overlay');
const wrap = document.getElementById('suspect-pick-scale-wrap');
const inner = wrap && wrap.querySelector('.suspect-pick-inner');
if (!ov || !wrap || !inner || ov.classList.contains('is-hidden') || !suspectPickOverlayOpen) return;
inner.style.width = SUSPECT_DESIGN_WIDTH + 'px';
inner.style.transform = 'none';
const naturalH = inner.offsetHeight;
const padX = 24;
const padY = 40;
const availW = window.innerWidth - padX * 2;
const availH = window.innerHeight - padY;
const sW = availW / SUSPECT_DESIGN_WIDTH;
const sH = availH / Math.max(1, naturalH);
const s = Math.min(1, sW, sH);
inner.style.transformOrigin = 'top center';
inner.style.transform = 'scale(' + s + ')';
wrap.style.height = Math.ceil(naturalH * s) + 'px';
wrap.style.overflow = 'hidden';
}
function lobbyMapQueryParam() {
try {
return (new URLSearchParams(location.search).get('map') || '').trim();
} catch (e) {
return '';
}
}
function isPostCaseLobbyRoom() {
if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID) return true;
if (lobbyMapQueryParam() === POST_CASE_LOBBY_SPACE_ID) return true;
if (mapData) {
const nm = String(mapData.name || '').trim().toLowerCase();
if (nm === 'lobbyb') return true;
}
if (spaceId === POST_CASE_LOBBY_SPACE_ID) return true;
return false;
}
function syncLobbyBUiChrome() {
const on = isPostCaseLobbyRoom();
try {
document.body.classList.toggle('room-lobby--lobby-b', on);
} catch (e) { /* ignore */ }
const row = document.getElementById('lobby-b-extra-row');
if (row) {
row.classList.toggle('is-hidden', !on);
row.setAttribute('aria-hidden', on ? 'false' : 'true');
}
}
function updateSuspectFloatingOpenBtn() {
const btn = document.getElementById('btn-suspect-reopen');
if (!btn) return;
const show = !!serverSuspectPhaseActive && !suspectPickOverlayOpen && isPostCaseLobbyRoom();
btn.classList.toggle('is-hidden', !show);
}
/** ชี้ตัวคนร้ายได้ต่อเมื่อสืบครบทั้ง 3 ผู้ต้องสงสัย (เล่นมินิเกมอย่างน้อยการ์ดละ 1 ครั้ง) */
function allSuspectsInvestigated() {
return [0, 1, 2].every(function (i) { return ((suspectProgress && suspectProgress[i]) || 0) >= 1; });
}
function updateSuspectHostUi() {
if (!suspectPickOverlayOpen) return;
if (trialMode) { ensureTrialRevealBtn(); return; } // โหมดพิจารณาคดีคุม UI เอง
const isHost = hostId === socket.id;
const canAccuse = isHost && allSuspectsInvestigated();
const actions = document.getElementById('suspect-pick-actions');
const accuseBtn = document.getElementById('suspect-btn-accuse');
const startBtn = document.getElementById('suspect-btn-start');
const hint = document.getElementById('suspect-pick-hint');
/* ต้องรอผู้เล่นทุกคนกลับเข้า LobbyB ครบก่อน host ถึงเริ่มได้ (กันคนยังโหลดไม่เสร็จหลุดรอบ) */
const waitingForPlayers = !lobbyBAllHere;
const canStartInvestigation = isHost && canInvestigateSuspect(suspectSelectedIndex) && !waitingForPlayers;
if (startBtn) {
startBtn.disabled = !canStartInvestigation;
startBtn.classList.toggle('suspect-btn-start--disabled', !canStartInvestigation);
startBtn.setAttribute('aria-disabled', canStartInvestigation ? 'false' : 'true');
}
if (hint) {
if (!isHost) {
hint.textContent = 'รอ Host เลือกการ์ดและกดเริ่มสืบสวน';
} else if (waitingForPlayers) {
hint.textContent = 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + lobbyBPresentCount + '/' + lobbyBTotalCount + ') แล้วจึงเริ่มได้';
} else if (!canInvestigateSuspect(suspectSelectedIndex)) {
hint.textContent = 'สืบผู้ต้องสงสัยคนนี้ครบ 3 หลักฐานแล้ว — เลือกคนอื่นเพื่อสืบต่อ';
} else if (allSuspectsInvestigated()) {
hint.textContent = 'สืบครบทุกผู้ต้องสงสัยแล้ว — กด "ชี้ตัวคนร้าย" เพื่อเข้าสู่การพิจารณาคดี';
} else {
hint.textContent = 'เลือกการ์ดผู้ต้องสงสัยแล้วกดเริ่มสืบสวน (สืบคนละ 3 ครั้ง · การ์ดเล็กเติมซ้ายไปขวา)';
}
hint.classList.toggle('is-hidden', false);
}
if (actions) {
actions.classList.toggle('suspect-pick-actions--visible', isHost);
actions.setAttribute('aria-hidden', isHost ? 'false' : 'true');
}
if (accuseBtn) {
// โชว์ปุ่มชี้ตัวเฉพาะเมื่อ host + สืบครบ 3 ผู้ต้องสงสัยแล้ว
accuseBtn.classList.toggle('is-hidden', !canAccuse);
accuseBtn.setAttribute('aria-hidden', canAccuse ? 'false' : 'true');
}
document.querySelectorAll('.suspect-card').forEach((c) => {
c.classList.toggle('suspect-card--host', isHost);
});
scheduleSuspectPickScale();
}
function openSuspectOverlay(selectedIndex) {
const ov = document.getElementById('suspect-pick-overlay');
if (!ov) return;
bindSuspectPickScaleListeners();
refreshSuspectCaseLabel();
applySuspectPickImages();
suspectPickOverlayOpen = true;
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
applySuspectSelectionVisual(typeof selectedIndex === 'number' ? selectedIndex : 0);
applySuspectProgressVisual();
updateSuspectHostUi();
updatePlayersHud();
updateSuspectFloatingOpenBtn();
}
function closeSuspectOverlay() {
const ov = document.getElementById('suspect-pick-overlay');
// รีเซ็ตสถานะพิจารณาคดี
if (trialMode) {
trialMode = null; myTrialVote = null; trialExcludedSuspect = null; trialSilencedMe = false; trialVoteCounts = [0, 0, 0];
var sl = document.getElementById('trial-silenced-layer'); if (sl) sl.style.display = 'none';
if (ov) ov.classList.remove('suspect-pick--trial', 'trial-revealed', 'suspect-pick--silenced');
document.querySelectorAll('.suspect-card').forEach(function (c) { c.classList.remove('suspect-card--myvote', 'suspect-card--culprit', 'suspect-card--innocent', 'suspect-card--excluded'); var xb = c.querySelector('.trial-excluded-badge'); if (xb) xb.remove(); });
document.querySelectorAll('.trial-vote-badge').forEach(function (b) { b.textContent = ''; });
var banner = document.getElementById('trial-result-banner'); if (banner) banner.style.display = 'none';
var rb = document.getElementById('trial-reveal-btn'); if (rb) rb.style.display = 'none';
var loseEl = document.getElementById('trial-lose-txt'); if (loseEl) loseEl.style.display = 'none';
}
const wrap = document.getElementById('suspect-pick-scale-wrap');
const inner = wrap && wrap.querySelector('.suspect-pick-inner');
if (inner) {
inner.style.transform = '';
inner.style.width = '';
}
if (wrap) {
wrap.style.height = '';
wrap.style.overflow = '';
}
if (ov) {
ov.classList.add('is-hidden');
ov.setAttribute('aria-hidden', 'true');
}
suspectPickOverlayOpen = false;
syncLobbyBUiChrome();
updateSuspectFloatingOpenBtn();
}
socket.on('troublesome-offer', (data) => {
const seconds = (data && Number(data.seconds)) > 0 ? Number(data.seconds) : 15;
const ov = document.getElementById('troublesome-overlay');
const secEl = document.getElementById('troublesome-sec');
if (!ov) return;
ov.classList.remove('is-hidden');
if (troublesomeTickTimer) clearInterval(troublesomeTickTimer);
let left = seconds;
if (secEl) secEl.textContent = String(left);
if (troublesomeTimerPausedForTuning) {
troublesomeTickTimer = null;
return;
}
troublesomeTickTimer = setInterval(() => {
left -= 1;
if (secEl) secEl.textContent = String(Math.max(0, left));
if (left <= 0) {
if (troublesomeTickTimer) clearInterval(troublesomeTickTimer);
troublesomeTickTimer = null;
socket.emit('troublesome-response', { accept: false });
closeTroublesomeOverlay();
}
}, 1000);
});
const troublesomeDecline = document.getElementById('troublesome-decline');
const troublesomeAccept = document.getElementById('troublesome-accept');
if (troublesomeDecline) {
troublesomeDecline.addEventListener('click', () => {
socket.emit('troublesome-response', { accept: false });
closeTroublesomeOverlay();
});
}
if (troublesomeAccept) {
troublesomeAccept.addEventListener('click', () => {
socket.emit('troublesome-response', { accept: true });
closeTroublesomeOverlay();
});
}
socket.on('suspect-phase-open', (data) => {
serverSuspectPhaseActive = true;
if (data && data.hostId != null) hostId = data.hostId;
if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames);
applyPersonalSuspectState(data);
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);
});
/* เซิร์ฟแจ้งจำนวนผู้เล่นที่กลับเข้า LobbyB แล้ว — ใช้ล็อกปุ่มเริ่มจนกว่าจะครบทุกคน */
socket.on('detective-lobbyb-presence', (data) => {
if (!data) return;
lobbyBPresentCount = Number(data.present) || 0;
lobbyBTotalCount = Number(data.total) || 0;
lobbyBAllHere = (data.allHere !== false) && (lobbyBTotalCount <= 0 || lobbyBPresentCount >= lobbyBTotalCount);
if (suspectPickOverlayOpen) updateSuspectHostUi();
});
// ===== ห้องสรุปหลักฐาน (ไต่สวน) =====
socket.on('testimony-open', (data) => { openTestimony(data || {}); });
socket.on('testimony-status', (data) => { testimonyStatusUpdate(data || {}); });
socket.on('testimony-reveal', (data) => { testimonyReveal(data || {}); });
socket.on('testimony-ready-update', (data) => { testimonyReadyUpdate(data || {}); });
// ===== ขั้นพิจารณาคดี =====
socket.on('trial-open', (data) => {
hideTestimonyOverlay(); // ปิดห้องสรุปหลักฐานก่อนเข้าโหวต
openTrialVoteUI(data || {});
});
socket.on('trial-vote-update', (data) => {
if (data && Array.isArray(data.counts)) trialVoteCounts = data.counts; // เก็บไว้โชว์ตอนเปิดเผยผล (ไม่โชว์ต่อใบตอนโหวต)
if (trialMode === 'voting') setTrialCounter((data && data.voted) || 0, (data && data.totalPlayers) || 0);
const hint = document.getElementById('suspect-pick-hint');
if (hint && trialMode === 'voting') {
hint.textContent = 'คลิกการ์ดที่คิดว่าเป็นคนร้าย';
}
});
socket.on('trial-result', (data) => {
showTrialResult(data || {});
});
/* ===== โหวตเลือกผู้เล่น (Silence / Ban) ===== */
var pickVoteState = { open: false, purpose: '', picked: null, raf: 0, confirmed: false, meta: null, closeTimer: 0 };
function pvAsset(dir, name) {
var p = dir + '/' + name;
return (typeof appPath === 'function') ? appPath(p) : p;
}
function pickVoteMeta(purpose) {
if (purpose === 'ban') {
return { purpose: 'ban', dir: '/Game/img/vote-ban', icon: 'item-6.png', label: '[ ห้ามเล่น ]', header: 'ban-txt.png',
resultTitle: 'txt-result.png', card: 'ban-card.png', cardBroken: 'ban-card-broken.png',
rtxtTarget: 'ban-txt-2.png', rtxtSafe: 'ban-txt-3.png', ctaPick: 'btn-confirm_1.png', ctaResult: 'btn-start-game.png' };
}
return { purpose: 'silence', dir: '/Game/img/vote-silence', icon: 'item-7.png', label: '[ ห้ามโหวต ]', header: 'txt-header.png',
resultTitle: 'txt-result.png', card: 'card-silence.png', cardBroken: 'card-silence-broken.png',
rtxtTarget: 'result-txt-1.png', rtxtSafe: 'result-txt-2.png', ctaPick: 'btn-confirm_1.png', ctaResult: 'btn-gotovote.png' };
}
function injectPickVoteStyle() {
if (document.getElementById('pvx-style')) return;
var st = document.createElement('style');
st.id = 'pvx-style';
st.textContent =
"#pick-vote-overlay{position:fixed;inset:0;z-index:12000;display:none;flex-direction:column;align-items:center;justify-content:flex-start;padding:3vh 2vw 0;background:rgba(8,11,22,.62);font-family:Kanit,system-ui,sans-serif;opacity:1;visibility:visible;pointer-events:auto}" +
"#pick-vote-overlay.is-open{display:flex}" +
"#pick-vote-overlay::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse at 50% 42%,rgba(10,16,34,.25),rgba(6,9,18,.82));z-index:-1}" +
"#pick-vote-overlay .pvx-ic{margin-top:1vh;width:clamp(104px,10vw,158px);height:auto;filter:drop-shadow(0 0 22px rgba(130,150,255,.6))}" +
"#pick-vote-overlay .pvx-label{margin-top:4px;font:800 clamp(20px,2.2vw,30px) Kanit,system-ui;color:#dfe6ff;text-shadow:0 0 14px rgba(140,120,255,.6)}" +
"#pick-vote-overlay .pvx-title{display:block;margin:12px auto 4px;max-width:min(820px,86vw);height:auto;filter:drop-shadow(0 2px 10px rgba(0,0,0,.5))}" +
"#pick-vote-overlay .pvx-result-title{display:none;margin:2vh auto 2.2vh;height:clamp(48px,5vw,72px);width:auto}" +
"#pick-vote-overlay .pvx-bar{position:relative;width:min(560px,52vw);height:clamp(26px,3vh,38px);margin:2.2vh auto 3vh}" +
"#pick-vote-overlay .pvx-bar .frame{position:absolute;inset:0;width:100%;height:100%;object-fit:fill}" +
"#pick-vote-overlay .pvx-bar .fill{position:absolute;top:16%;left:1.5%;height:68%;width:100%;border-radius:30px;background:linear-gradient(90deg,#ff2e54,#ff6b81);box-shadow:0 0 14px rgba(255,70,100,.8)}" +
"#pick-vote-overlay .pvx-players{display:flex;justify-content:center;align-items:flex-start;gap:clamp(12px,3vw,50px);flex-wrap:nowrap;margin:0 auto}" +
"#pick-vote-overlay .pvx-pl{display:flex;flex-direction:column;align-items:center;gap:8px;cursor:default}" +
"#pick-vote-overlay:not(.is-result) .pvx-pl{cursor:pointer}" +
"#pick-vote-overlay .pvx-ava{position:relative;width:clamp(96px,9vw,142px);height:clamp(96px,9vw,142px);overflow:hidden;border-radius:18px}" +
"#pick-vote-overlay .pvx-ava .pvx-bg{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;z-index:1}" +
"#pick-vote-overlay .pvx-ava .pvx-char{position:absolute;left:50%;top:14%;width:120%;height:auto;transform:translateX(-50%);z-index:2}" +
"#pick-vote-overlay .pvx-ava .pvx-frame{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;z-index:3}" +
"#pick-vote-overlay .pvx-ava .pvx-frame.sel{display:none}" +
"#pick-vote-overlay .pvx-pl.sel .pvx-ava .pvx-frame.norm{display:none}" +
"#pick-vote-overlay .pvx-pl.sel .pvx-ava .pvx-frame.sel{display:block}" +
"#pick-vote-overlay .pvx-name{font:800 clamp(14px,1.5vw,19px) Kanit,system-ui;color:#eaf0ff}" +
"#pick-vote-overlay .pvx-pl.sel .pvx-name{color:#ff6b81;text-shadow:0 0 10px rgba(255,80,110,.6)}" +
"#pick-vote-overlay .pvx-count{display:none;font:900 clamp(26px,2.8vw,38px) Kanit,system-ui;line-height:1;color:#eaf0ff;margin-top:2px}" +
"#pick-vote-overlay.is-result .pvx-count{display:block}" +
"#pick-vote-overlay.is-result .pvx-pl.sel .pvx-count{color:#ff3b5c;text-shadow:0 0 12px rgba(255,70,100,.7)}" +
"#pick-vote-overlay .pvx-banner{display:none;position:relative;width:min(900px,90vw);min-height:clamp(110px,13vh,150px);margin:1.4vh auto 2.2vh;align-items:center}" +
"#pick-vote-overlay.is-result .pvx-banner{display:flex}" +
"#pick-vote-overlay .pvx-banner .bg{position:absolute;inset:0;width:100%;height:100%;object-fit:fill}" +
"#pick-vote-overlay .pvx-banner .inner{position:relative;z-index:2;display:flex;align-items:center;justify-content:space-between;width:100%;padding:12px clamp(20px,3vw,40px);gap:18px}" +
"#pick-vote-overlay .pvx-banner .txtcol{flex:1;text-align:center}" +
"#pick-vote-overlay .pvx-banner .who{font:900 clamp(24px,2.6vw,32px) Kanit,system-ui;color:#fff;text-shadow:0 2px 8px rgba(0,0,0,.6);margin-bottom:2px}" +
"#pick-vote-overlay .pvx-banner .who:empty{display:none}" +
"#pick-vote-overlay .pvx-banner .rtxt{display:block;margin:0 auto;max-width:min(560px,52vw);height:auto}" +
"#pick-vote-overlay .pvx-banner .card{height:clamp(120px,15vh,168px);width:auto;filter:drop-shadow(0 6px 16px rgba(0,0,0,.5));flex:none}" +
"#pick-vote-overlay .pvx-cta{display:block;margin:1.6vh auto 0;cursor:pointer;width:clamp(230px,26vw,330px);height:auto;transition:transform .08s,filter .12s}" +
"#pick-vote-overlay .pvx-cta:hover{transform:translateY(-2px);filter:brightness(1.08)}" +
"#pick-vote-overlay .pvx-foot{margin-top:10px;font:600 clamp(13px,1.4vw,17px) Kanit,system-ui;color:#bcd4ff;min-height:18px}" +
"#pick-vote-overlay.is-result .pvx-ic,#pick-vote-overlay.is-result .pvx-label,#pick-vote-overlay.is-result .pvx-title,#pick-vote-overlay.is-result .pvx-bar,#pick-vote-overlay.is-result .pvx-foot{display:none}" +
"#pick-vote-overlay.is-result .pvx-result-title{display:block}";
document.head.appendChild(st);
}
function ensurePickVoteOverlay() {
var ov = document.getElementById('pick-vote-overlay');
if (ov) return ov;
injectPickVoteStyle();
ov = document.createElement('div');
ov.id = 'pick-vote-overlay';
ov.innerHTML =
'<img class="pvx-result-title" id="pvx-result-title" alt="สรุปผลโหวต">' +
'<img class="pvx-ic" id="pvx-ic" alt="">' +
'<div class="pvx-label" id="pvx-label"></div>' +
'<img class="pvx-title" id="pvx-title" alt="">' +
'<div class="pvx-bar"><img class="frame" id="pvx-bar-frame" alt=""><div class="fill" id="pvx-bar-fill"></div></div>' +
'<div class="pvx-players" id="pvx-players"></div>' +
'<div class="pvx-banner"><img class="bg" id="pvx-banner-bg" alt=""><div class="inner"><div class="txtcol"><div class="who" id="pvx-who"></div><img class="rtxt" id="pvx-rtxt" alt=""></div><img class="card" id="pvx-card" alt=""></div></div>' +
'<img class="pvx-cta" id="pvx-cta" alt="">' +
'<div class="pvx-foot" id="pvx-foot"></div>';
document.body.appendChild(ov);
document.getElementById('pvx-cta').addEventListener('click', onPickVoteCta);
return ov;
}
function stopPickVoteCountdown() {
if (pickVoteState.raf) { cancelAnimationFrame(pickVoteState.raf); pickVoteState.raf = 0; }
}
function startPickVoteCountdown(endsAt, durationMs) {
stopPickVoteCountdown();
var fill = document.getElementById('pvx-bar-fill');
if (!fill || !endsAt) return;
var dur = durationMs || 15000;
function tick() {
if (!pickVoteState.open) { stopPickVoteCountdown(); return; }
var ms = endsAt - Date.now();
var frac = Math.max(0, Math.min(1, ms / dur));
fill.style.width = (frac * 100).toFixed(1) + '%';
if (ms > 0) pickVoteState.raf = requestAnimationFrame(tick);
}
tick();
}
/* คลิกเลือกผู้เล่น (ยังไม่ส่ง) — ไฮไลต์กรอบแดง */
function selectPickVote(targetId) {
if (!pickVoteState.open || pickVoteState.confirmed) return;
pickVoteState.picked = targetId;
var pl = document.getElementById('pvx-players');
if (pl) Array.prototype.forEach.call(pl.querySelectorAll('.pvx-pl'), function (c) {
c.classList.toggle('sel', c.getAttribute('data-id') === targetId);
});
}
function confirmPickVote() {
if (!pickVoteState.open || pickVoteState.confirmed) return;
var foot = document.getElementById('pvx-foot');
if (!pickVoteState.picked) { if (foot) foot.textContent = 'เลือกผู้เล่นก่อน'; return; }
pickVoteState.confirmed = true;
var cta = document.getElementById('pvx-cta');
if (cta) cta.style.opacity = '0.5';
if (foot) foot.textContent = 'ส่งโหวตแล้ว — รอผู้เล่นอื่น...';
socket.emit('player-pick-vote', { targetId: pickVoteState.picked }, function (res) {
if (!(res && res.ok)) {
pickVoteState.confirmed = false;
if (cta) cta.style.opacity = '';
appendLobbySystemChat('— โหวตไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : ''));
}
});
}
function onPickVoteCta() {
var ov = document.getElementById('pick-vote-overlay');
if (!ov) return;
if (ov.classList.contains('is-result')) { closePickVoteNow(); return; }
confirmPickVote();
}
function closePickVoteNow() {
pickVoteState.open = false;
stopPickVoteCountdown();
clearTimeout(pickVoteState.closeTimer);
var ov = document.getElementById('pick-vote-overlay');
if (ov) ov.classList.remove('is-open');
}
socket.on('player-pick-vote-open', (data) => {
var ov = ensurePickVoteOverlay();
var meta = pickVoteMeta(data && data.purpose);
pickVoteState.open = true;
pickVoteState.purpose = meta.purpose;
pickVoteState.picked = null;
pickVoteState.confirmed = false;
pickVoteState.meta = meta;
clearTimeout(pickVoteState.closeTimer);
ov.classList.remove('is-result');
document.getElementById('pvx-ic').src = pvAsset(meta.dir, meta.icon);
document.getElementById('pvx-label').textContent = meta.label;
document.getElementById('pvx-title').src = pvAsset(meta.dir, meta.header);
document.getElementById('pvx-result-title').src = pvAsset(meta.dir, meta.resultTitle);
document.getElementById('pvx-bar-frame').src = pvAsset(meta.dir, 'time-frame.png');
document.getElementById('pvx-bar-fill').style.width = '100%';
document.getElementById('pvx-banner-bg').src = pvAsset(meta.dir, 'frame-red.png');
var cta = document.getElementById('pvx-cta');
cta.src = pvAsset(meta.dir, meta.ctaPick); cta.style.opacity = '';
document.getElementById('pvx-foot').textContent = '';
document.getElementById('pvx-who').textContent = '';
var pl = document.getElementById('pvx-players');
pl.innerHTML = '';
var cands = (data && Array.isArray(data.candidates)) ? data.candidates : [];
cands.forEach(function (c) {
var info = (typeof resolveOccupantCharInfo === 'function') ? resolveOccupantCharInfo(c.id) : { name: c.nickname };
var cell = document.createElement('div');
cell.className = 'pvx-pl';
cell.setAttribute('data-id', c.id);
var ava = document.createElement('div'); ava.className = 'pvx-ava';
var bg = document.createElement('img'); bg.className = 'pvx-bg'; bg.src = pvAsset(meta.dir, 'player-icon-bg.png');
var ch = (typeof buildPodiumCharImg === 'function') ? buildPodiumCharImg(info.characterId, info.colorTheme, info.colorSkin) : document.createElement('img');
ch.className = 'pvx-char'; ch.style.filter = '';
var frN = document.createElement('img'); frN.className = 'pvx-frame norm'; frN.src = pvAsset(meta.dir, 'player-icon-frame.png');
var frS = document.createElement('img'); frS.className = 'pvx-frame sel'; frS.src = pvAsset(meta.dir, 'player-icon-frame-select.png');
ava.appendChild(bg); ava.appendChild(ch); ava.appendChild(frN); ava.appendChild(frS);
var nm = document.createElement('div'); nm.className = 'pvx-name'; nm.textContent = (info.name || c.nickname || '?');
var cn = document.createElement('div'); cn.className = 'pvx-count';
cell.appendChild(ava); cell.appendChild(nm); cell.appendChild(cn);
cell.addEventListener('click', function () { selectPickVote(c.id); });
pl.appendChild(cell);
});
ov.classList.add('is-open');
startPickVoteCountdown(data && data.endsAt, data && data.durationMs);
});
socket.on('player-pick-vote-update', (data) => {
if (!pickVoteState.open) return;
var foot = document.getElementById('pvx-foot');
if (foot && !pickVoteState.confirmed) foot.textContent = 'โหวตแล้ว ' + ((data && data.voted) || 0) + '/' + ((data && data.total) || 0);
});
socket.on('player-pick-vote-result', (data) => {
stopPickVoteCountdown();
pickVoteState.open = false;
var ov = document.getElementById('pick-vote-overlay');
if (!ov) return;
var meta = pickVoteState.meta || pickVoteMeta(data && data.purpose);
var counts = (data && data.counts) || {};
var targetId = (data && data.targetId) || null;
Array.prototype.forEach.call(ov.querySelectorAll('.pvx-pl'), function (c) {
var id = c.getAttribute('data-id');
var cn = c.querySelector('.pvx-count');
if (cn) cn.textContent = 'X' + (counts[id] || 0);
c.classList.toggle('sel', !!targetId && id === targetId);
});
var who = document.getElementById('pvx-who');
var rtxt = document.getElementById('pvx-rtxt');
var card = document.getElementById('pvx-card');
if (targetId) {
if (who) who.textContent = (data && data.targetName) || '';
if (rtxt) rtxt.src = pvAsset(meta.dir, meta.rtxtTarget);
if (card) card.src = pvAsset(meta.dir, meta.card);
} else {
if (who) who.textContent = '';
if (rtxt) rtxt.src = pvAsset(meta.dir, meta.rtxtSafe);
if (card) card.src = pvAsset(meta.dir, meta.cardBroken);
}
var cta = document.getElementById('pvx-cta');
if (cta) { cta.src = pvAsset(meta.dir, meta.ctaResult); cta.style.opacity = ''; }
var foot = document.getElementById('pvx-foot');
if (foot) foot.textContent = '';
ov.classList.add('is-result');
clearTimeout(pickVoteState.closeTimer);
pickVoteState.closeTimer = setTimeout(closePickVoteNow, 14000);
});
socket.on('suspect-investigation-start', (data) => {
serverSuspectPhaseActive = false;
closeSuspectOverlay();
const n = (data && typeof data.selectedIndex === 'number') ? (data.selectedIndex + 1) : (suspectSelectedIndex + 1);
const mgLabel = (data && data.minigameLabel) ? String(data.minigameLabel) : '';
appendLobbySystemChat('— เริ่มสืบสวน · ผู้ต้องสงสัยหมายเลข ' + n + (mgLabel ? ' · ' + mgLabel : ''));
});
/** เลือกการ์ดผู้ต้องสงสัยเท่านั้น — ยังไม่เริ่มมินิเกม */
function selectSuspectCard(idx) {
if (!suspectPickOverlayOpen || hostId !== socket.id) return;
if (idx < 0 || idx > 2) return;
const prevIdx = suspectSelectedIndex;
applySuspectSelectionVisual(idx);
updateSuspectHostUi();
socket.emit('suspect-pick-select', { index: idx });
if (prevIdx !== idx) {
appendLobbySystemChat('— เลือกผู้ต้องสงสัยหมายเลข ' + (idx + 1));
}
}
/** เริ่มมินิเกมตามการ์ดที่เลือกแล้ว — กดปุ่มเริ่มสืบสวน / ชี้ตัวคนร้าย */
function beginSuspectInvestigation(sourceLabel) {
if (!suspectPickOverlayOpen || hostId !== socket.id) return;
const idx = suspectSelectedIndex;
if (idx < 0 || idx > 2) return;
const startBtn = document.getElementById('suspect-btn-start');
/* กันกดซ้ำ */
if (startBtn && (startBtn.classList.contains('is-loading') || startBtn.disabled)) return;
if (!canInvestigateSuspect(idx)) {
appendLobbySystemChat('— สืบผู้ต้องสงสัยคนนี้ครบ 3 หลักฐานแล้ว — เลือกคนอื่น');
updateSuspectHostUi();
return;
}
if (startBtn) {
startBtn.classList.add('is-loading');
startBtn.disabled = true;
}
const clearLoading = () => {
const b = document.getElementById('suspect-btn-start');
if (b) { b.classList.remove('is-loading'); b.disabled = false; }
};
let acked = false;
const timeoutT = setTimeout(() => {
if (!acked) {
clearLoading();
appendLobbySystemChat('— เซิร์ฟเวอร์ไม่ตอบ ลองอีกครั้ง');
}
}, 12000);
socket.emit('suspect-pick-start', {}, function (res) {
acked = true;
clearTimeout(timeoutT);
if (res && res.ok) return; /* คง loading ต่อจนเข้า play scene */
clearLoading();
const err = (res && res.error) ? String(res.error) : (sourceLabel || 'เริ่มสืบสวนไม่สำเร็จ');
appendLobbySystemChat('— ' + err);
try { console.warn('suspect-pick-start', res); } catch (e2) { /* ignore */ }
});
}
document.getElementById('suspect-cards-row')?.addEventListener('click', (ev) => {
const card = ev.target.closest('.suspect-card');
if (!card || !suspectPickOverlayOpen) return;
const idx = parseInt(card.getAttribute('data-index'), 10);
if (!(idx >= 0 && idx <= 2)) return;
if (trialMode === 'voting') { castTrialVote(idx); return; } // โหมดโหวต: ทุกคนคลิกได้
if (trialMode === 'revealed') return;
if (hostId !== socket.id) return;
selectSuspectCard(idx);
});
document.getElementById('suspect-btn-start')?.addEventListener('click', () => {
beginSuspectInvestigation('เริ่มสืบสวนไม่สำเร็จ');
});
document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => {
if (!suspectPickOverlayOpen || hostId !== socket.id) return;
if (!allSuspectsInvestigated()) {
appendLobbySystemChat('— ต้องสืบให้ครบทั้ง 3 ผู้ต้องสงสัยก่อน จึงจะชี้ตัวคนร้ายได้');
return;
}
// เข้าสู่ขั้นพิจารณาคดี/โหวต (ไม่ใช่เริ่มมินิเกม)
socket.emit('suspect-accuse-open', {}, function (res) {
if (!(res && res.ok)) appendLobbySystemChat('— เปิดพิจารณาคดีไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : ''));
});
});
/* ปุ่ม X ปิด suspect overlay — ปิดได้แล้วเปิดใหม่ผ่าน #btn-suspect-reopen
(โหมด trial: ไม่ให้ปิด — กันหลุดโฟกัสตอนโหวต) */
document.getElementById('suspect-pick-close')?.addEventListener('click', () => {
if (!suspectPickOverlayOpen) return;
if (trialMode === 'voting' || trialMode === 'revealed') return;
closeSuspectOverlay();
});
document.getElementById('btn-suspect-reopen')?.addEventListener('click', () => {
if (!serverSuspectPhaseActive || suspectPickOverlayOpen) return;
openSuspectOverlay(suspectSelectedIndex);
});
/**
* Hotkey ลัดสำหรับเทสต์ (เฉพาะตอน suspect overlay เปิด): Ctrl+1 / Ctrl+2 / Ctrl+3
* → "เล่นเสร็จ 1 รอบ" ของ suspect คนนั้น (เก็บหลักฐาน 1 ใบให้ทันที)
* กดครบ 3 ปุ่ม → "ชี้ตัวคนร้าย" โผล่อัตโนมัติ
*
* เปิด/ปิดได้จาก Admin Panel → tab "Test Mode" (localStorage: justiceTestMode = "1"|"0")
* โหมดโหวต/เปิดเผยผล: ปิด hotkey ป้องกันการกระทบเกม
*/
function __isJusticeTestModeOn() {
try { return localStorage.getItem('justiceTestMode') === '1'; } catch (e) { return false; }
}
document.addEventListener('keydown', (ev) => {
if (!ev.ctrlKey || ev.altKey || ev.metaKey || ev.shiftKey) return;
if (!suspectPickOverlayOpen) return;
/* Test Mode ปิดอยู่ — ข้าม (ตั้งใน Admin Panel → Test Mode) */
if (!__isJusticeTestModeOn()) return;
/* ขณะอยู่ใน input/textarea/ที่แก้ไขได้ — ข้าม กันชน UX */
const ae = document.activeElement;
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) return;
let idx = -1;
if (ev.key === '1' || ev.code === 'Digit1' || ev.code === 'Numpad1') idx = 0;
else if (ev.key === '2' || ev.code === 'Digit2' || ev.code === 'Numpad2') idx = 1;
else if (ev.key === '3' || ev.code === 'Digit3' || ev.code === 'Numpad3') idx = 2;
if (idx < 0) return;
/* โหมด trial — ไม่ให้ hotkey ใช้ (กันชนกับการโหวต) */
if (trialMode === 'voting' || trialMode === 'revealed') return;
ev.preventDefault();
/* แสดงสถานะกำลังโกง (toast) */
appendLobbySystemChat('— [DEBUG] เก็บหลักฐานให้ผู้ต้องสงสัย ' + (idx + 1) + '...');
socket.emit('detective-debug-award-evidence', { suspectIndex: idx }, function (res) {
if (res && res.ok) {
appendLobbySystemChat('— [DEBUG] เก็บหลักฐาน suspect ' + (idx + 1) + ' สำเร็จ');
/* อัปเดต state ฝั่ง client ทันที (server จะ broadcast lobby-evidence-sync มาด้วย แต่ apply ทันทีเพื่อ UX ลื่น) */
if (Array.isArray(res.myPlayerEvidence)) applyMyPlayerEvidence(res.myPlayerEvidence);
if (Array.isArray(res.suspectProgress)) setSuspectProgress(res.suspectProgress);
if (suspectPickOverlayOpen) updateSuspectHostUi();
} else {
appendLobbySystemChat('— [DEBUG] ล้มเหลว: ' + ((res && res.error) || 'unknown'));
}
});
});
function applyRoomLobbyBTransition(data) {
const mid = (data && data.mapId) ? String(data.mapId).trim() : '';
if (!mid) return;
/* Cutscene เรื่องราวคดี ก่อนเข้า LobbyB — โหลด LobbyB ใต้ overlay พร้อมกัน */
var cutsceneCid = (data && data.caseId != null) ? data.caseId : null;
if (cutsceneCid) {
ensureCaseMediaLoaded().then(function () { showCaseCutscene(cutsceneCid, function () {}); });
}
fetch(SERVER + '/api/maps/' + encodeURIComponent(mid))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (json) {
if (!json) {
appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ');
return;
}
serverSuspectPhaseActive = false;
clientLobbyMapId = mid;
mapData = json;
closeSuspectOverlay();
if (!mapData.interactive) mapData.interactive = [];
if (!mapData.startGameArea) mapData.startGameArea = [];
if (!mapData.quizTrueArea) mapData.quizTrueArea = [];
if (!mapData.quizFalseArea) mapData.quizFalseArea = [];
if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
ROOM_CZ_SPOT = null;
ROOM_HC_SPOT = null;
markRoomCzInteractiveCell();
markHostHcInteractiveCell();
(data.peersSnap || []).forEach(function (row) {
if (!row || !row.id) return;
const p = peers.get(row.id);
if (!p) return;
p.x = row.x;
p.y = row.y;
p.direction = row.direction || 'down';
if (row.nickname) p.nickname = row.nickname;
p.ready = !!row.ready;
if (row.characterId != null) p.characterId = row.characterId;
});
const meAfter = peers.get(socket.id);
if (readyCheck && meAfter) {
readyCheck.checked = !!meAfter.ready;
updateReadyLabelVisual();
}
if (mapData.backgroundImage) {
mapBackgroundImg = new Image();
mapBackgroundImg.onload = function () { resizeAndDraw(); };
mapBackgroundImg.src = mapData.backgroundImage;
} else {
mapBackgroundImg = null;
}
lobbyPath = [];
try {
window.__detectiveLobbyMeta = {
level: data.lobbyLevel || null,
caseId: data.caseId || null
};
} catch (e) { /* ignore */ }
applySuspectPickImages();
refreshSuspectCaseLabel();
troublesomeEligibleSent = false;
resizeAndDraw();
updateHostStartGameButton();
renderPeers();
ensureReadyControlEnabled();
if (data.cardMinigames) setSuspectCardMinigames(data.cardMinigames);
applyPersonalSuspectState(data);
if (lobbyBotSlotCount > 0) syncLobbyCaseBots();
appendLobbySystemChat('— ย้ายไป LobbyB แล้ว · ห้องนี้ไม่รับผู้เล่นใหม่');
tryTroublesomeLobbyGate();
updateSuspectFloatingOpenBtn();
syncLobbyBUiChrome();
updatePlayersHud();
try {
if (String(mid) === POST_CASE_LOBBY_SPACE_ID) {
var u = new URL(window.location.href);
u.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID);
history.replaceState({}, '', u.pathname + u.search);
}
} catch (e3) { /* ignore */ }
})
.catch(function () {
appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ');
});
}
function focusLobbyMapCanvas() {
try {
if (canvas && typeof canvas.focus === 'function') canvas.focus({ preventScroll: true });
} catch (e) { /* ignore */ }
}
function applyLobbyPeersSnapFromServer(rows) {
if (!rows || !rows.length) return;
rows.forEach(function (row) {
if (!row || !row.id) return;
const p = peers.get(row.id);
if (!p) return;
p.x = row.x;
p.y = row.y;
p.direction = row.direction || 'down';
if (row.nickname) p.nickname = row.nickname;
p.ready = !!row.ready;
if (row.characterId != null) p.characterId = row.characterId;
});
const meAfter = peers.get(socket.id);
if (readyCheck && meAfter) {
readyCheck.checked = !!meAfter.ready;
updateReadyLabelVisual();
}
}
function applyQuizGameStartInLobby(data) {
quizModeActive = true;
quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
quizPeersLocked = {};
lastQuizQuestionText = '';
lastQuizScores = {};
try { document.body.classList.add('room-lobby--quiz-active'); } catch (e) { /* ignore */ }
showQuizOverlay();
initQuizScoreboardZeros();
var qWait = document.getElementById('quiz-game-question');
if (qWait) qWait.textContent = 'กำลังโหลดคำถาม…';
var phaseWait = document.getElementById('quiz-game-phase-label');
if (phaseWait) phaseWait.textContent = 'เกมตอบคำถาม';
appendLobbySystemChat('— เริ่มเกมตอบคำถาม (เวลาอ่าน/ตอบตั้งใน Admin → คำถามเกม)');
focusLobbyMapCanvas();
}
socket.on('game-start', (data) => {
if (data && data.detectiveReturn && data.stayInRoomLobby) {
applyDetectiveReturnToLobbyB(data);
return;
}
serverSuspectPhaseActive = false;
closeTroublesomeOverlay();
closeSuspectOverlay();
closeLobbyPreplayWizard();
if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames);
applyPersonalSuspectState(data);
if (data && data.quizMode && data.stayInRoomLobby) {
const qMid = (data.mapId != null) ? String(data.mapId).trim() : '';
applyLobbyPeersSnapFromServer(data.peersSnap);
lobbyPath = [];
applyQuizGameStartInLobby(data);
if (qMid) {
fetch(SERVER + '/api/maps/' + encodeURIComponent(qMid))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (json) {
if (json) {
mapData = json;
clientLobbyMapId = qMid;
if (!mapData.interactive) mapData.interactive = [];
if (!mapData.startGameArea) mapData.startGameArea = [];
if (!mapData.quizTrueArea) mapData.quizTrueArea = [];
if (!mapData.quizFalseArea) mapData.quizFalseArea = [];
if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
ROOM_CZ_SPOT = null; // คำนวณจุดแต่งตัวใหม่ตาม map เกมที่โหลด
markRoomCzInteractiveCell();
if (mapData.backgroundImage) {
mapBackgroundImg = new Image();
mapBackgroundImg.onload = function () { resizeAndDraw(); };
mapBackgroundImg.src = mapData.backgroundImage;
} else {
mapBackgroundImg = null;
}
syncLobbyBUiChrome();
try {
var u = new URL(window.location.href);
u.searchParams.set('map', qMid);
history.replaceState({}, '', u.pathname + u.search);
} catch (e2) { /* ignore */ }
} else {
appendLobbySystemChat('— โหลดไฟล์แผนที่เกมไม่สำเร็จ — รีเฟรชหรือเช็คว่ามีฉาก ' + qMid + ' บนเซิร์ฟ');
}
resizeAndDraw();
renderPeers();
redrawLobbyMap();
})
.catch(function () {
appendLobbySystemChat('— โหลดแผนที่เกมล้มเหลว');
resizeAndDraw();
renderPeers();
redrawLobbyMap();
});
return;
}
return;
}
const mid = (data && data.mapId != null) ? String(data.mapId).trim() : '';
const lobbyLevelStr = (data && data.lobbyLevel != null) ? String(data.lobbyLevel) : '';
const caseIdStr = (data && data.caseId != null) ? String(data.caseId) : '';
const isLobbyBMap = mid === POST_CASE_LOBBY_SPACE_ID;
const detectiveMeta = !!(lobbyLevelStr || caseIdStr);
/* LobbyB = แผนที่ mn8nx46h — ต้องอยู่ room-lobby เสมอ ห้ามไป play.html (จะโดน joinLocked แล้วเด้ง lobby) */
if (data && (data.stayInRoomLobby || isLobbyBMap || (detectiveMeta && !mid && isCurrentRoomLobbyA()))) {
applyRoomLobbyBTransition({
mapId: mid || POST_CASE_LOBBY_SPACE_ID,
lobbyLevel: data.lobbyLevel != null ? data.lobbyLevel : lobbyLevelStr,
caseId: data.caseId != null ? data.caseId : caseIdStr,
peersSnap: (data && data.peersSnap) ? data.peersSnap : []
});
return;
}
if (data && data.detectiveMinigame) {
try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ }
}
try {
var avEl = document.getElementById('room-lobby-profile-avatar');
var cidPlay = getStoredCharacterId();
if (avEl && avEl.src && cidPlay && String(avEl.src).indexOf('data:image/') === 0) {
sessionStorage.setItem('justicePlayLobbyAvatar:' + cidPlay, avEl.src);
}
} catch (eAv) { /* ignore */ }
let q = 'play.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(getProfileDisplayName());
if (mid) q += '&map=' + encodeURIComponent(mid);
if (lobbyLevelStr) q += '&lobbyLevel=' + encodeURIComponent(lobbyLevelStr);
if (caseIdStr) q += '&case=' + encodeURIComponent(caseIdStr);
if (data && data.detectiveMinigame) q += '&detectiveReturn=1';
/* Card 3 Ban — ผู้ที่ถูกแบนรอบนี้: เข้าไปดูเพื่อนเล่นได้ แต่ควบคุมตัวละครไม่ได้ (รอบหน้าเล่นปกติ)
ถ้าถูกแบนเป็น "บอท" (__lobby_bot_N) → ส่ง banBot=N ให้ play.js กันบอทตัวนั้นไม่ให้ลงเล่น (ทุก client ที่จำลองบอท) */
if (data && data.bannedPlayerId) {
if (data.bannedPlayerId === socket.id) {
q += '&banned=1';
appendLobbySystemChat('— คุณถูกแบนรอบนี้ (Ban Card) · เข้าไปดูเพื่อนเล่นได้ แต่เล่นเองไม่ได้ · รอบหน้าเล่นได้ปกติ');
} else if (String(data.bannedPlayerId).indexOf('__lobby_bot_') === 0) {
var banBotSlot = parseInt(String(data.bannedPlayerId).slice('__lobby_bot_'.length), 10);
if (!isNaN(banBotSlot) && banBotSlot >= 0) q += '&banBot=' + banBotSlot;
appendLobbySystemChat('— บอทถูกแบนรอบนี้ (Ban Card) · ไม่ได้ลงเล่นมินิเกมรอบนี้');
}
}
location.href = q;
});
function showBannedNotice() {
var ov = document.getElementById('banned-notice-overlay');
if (!ov) {
ov = document.createElement('div');
ov.id = 'banned-notice-overlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:118;background:rgba(6,9,20,.82);display:flex;align-items:center;justify-content:center;backdrop-filter:blur(2px)';
ov.innerHTML = '<div style="text-align:center;background:linear-gradient(180deg,#2a1430,#160a1d);border:1px solid rgba(247,118,142,.55);border-radius:18px;padding:26px 34px;box-shadow:0 24px 60px rgba(0,0,0,.6)">' +
'<div style="font:900 15px/1 Kanit;letter-spacing:1px;color:#f7768e">BAN CARD</div>' +
'<div style="font:900 28px/1.3 Kanit;color:#fff;margin:10px 0 6px">คุณถูกแบนรอบนี้</div>' +
'<div style="font:600 15px/1.4 Kanit;color:#e6b8c4">รอเพื่อนเล่นมินิเกมจบ แล้วกลับมา LobbyB ด้วยกัน</div>' +
'</div>';
document.body.appendChild(ov);
}
ov.style.display = 'flex';
clearTimeout(showBannedNotice._t);
showBannedNotice._t = setTimeout(function () { if (ov) ov.style.display = 'none'; }, 4200);
}
document.addEventListener('keydown', (e) => {
if (moveCodes.includes(e.code) && !isChatFocused()) {
if (suspectPickOverlayOpen) { e.preventDefault(); return; }
keys[e.code] = true;
e.preventDefault();
}
if (e.code === 'Escape' && !e.repeat && !isChatFocused()) {
const hostConsoleOv = document.getElementById('host-console-overlay');
if (hostConsoleOv && !hostConsoleOv.classList.contains('is-hidden')) {
hostConsoleOverlay.close();
e.preventDefault();
return;
}
const profileOv = document.getElementById('room-lobby-profile-overlay');
if (profileOv && !profileOv.classList.contains('is-hidden')) {
roomLobbyProfileOverlay.close();
e.preventDefault();
return;
}
const evOv = document.getElementById('lobby-evidence-overlay');
if (evOv && !evOv.classList.contains('is-hidden')) {
closeLobbyEvidenceModal();
e.preventDefault();
return;
}
const rankOv = document.getElementById('lobby-rank-overlay');
if (rankOv && !rankOv.classList.contains('is-hidden')) {
closeLobbyRankModal();
e.preventDefault();
return;
}
if (suspectPickOverlayOpen) {
closeSuspectOverlay();
e.preventDefault();
return;
}
getPreplayEls();
if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) {
if (preplayCaseDetailOverlay && !preplayCaseDetailOverlay.classList.contains('is-hidden')) {
preplayCaseDetailOverlay.classList.add('is-hidden');
} else {
closeLobbyPreplayWizard();
}
e.preventDefault();
return;
}
}
if (isLobbyInteractKeyDown(e) && !e.repeat && !isChatFocused()) {
if (suspectPickOverlayOpen) return;
getPreplayEls();
if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) {
return;
}
const me = peers.get(socket.id);
if (!mapData || !me) return;
e.preventDefault();
/* ทุก F ในโถง — ต้องกดพร้อมก่อน (รวม Host เลือกระดับ/คดี และช่องเขียว) */
const target = getLobbyInteractTarget(me);
if (isRoomCzInteractTarget(target) || isNearRoomCzSpot(me)) {
e.preventDefault();
openRoomCustomize();
return;
}
if (isHostHcInteractTarget(target) || isNearHostHcSpot(me)) {
e.preventDefault();
openHostConsoleIfHost();
return;
}
/* F อื่นในโถง (Host เลือกระดับ/คดี และช่องเขียว) — ต้องกดพร้อมก่อน */
if (!me.ready) {
appendLobbySystemChat('— กดพร้อมก่อน แล้วค่อยกด F');
return;
}
if (hostCanOpenLobbyAPreplayWithF()) {
openLobbyPreplayWizard();
return;
}
if (!target) {
if (isCurrentRoomLobbyA() && hostId !== socket.id) {
appendLobbySystemChat('— เฉพาะ Host กด F เพื่อเลือกระดับและคดี');
}
return;
}
e.preventDefault();
socket.emit('lobby-interact', { x: target.x, y: target.y }, (res) => {
if (res && res.ok) return;
if (res && res.error) appendLobbySystemChat('— ' + res.error);
});
}
});
document.addEventListener('keyup', (e) => {
if (moveCodes.includes(e.code)) keys[e.code] = false;
});
var lobbyZoomPctEl = document.getElementById('lobby-zoom-pct');
var lobbyZoomPctHideTimer = null;
function showZoomPct() {
if (!lobbyZoomPctEl) return;
var pct = Math.round(lobbyZoom * 100);
lobbyZoomPctEl.textContent = pct + '%';
lobbyZoomPctEl.classList.add('lobby-zoom-pct-visible');
if (lobbyZoomPctHideTimer) clearTimeout(lobbyZoomPctHideTimer);
lobbyZoomPctHideTimer = setTimeout(function () {
lobbyZoomPctEl.classList.remove('lobby-zoom-pct-visible');
lobbyZoomPctHideTimer = null;
}, 1500);
}
if (canvas) {
canvas.addEventListener('pointerdown', () => { focusLobbyMapCanvas(); });
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
lobbyZoom *= e.deltaY > 0 ? 0.9 : 1.1;
lobbyZoom = Math.max(LOBBY_ZOOM_MIN, Math.min(LOBBY_ZOOM_MAX, lobbyZoom));
redrawLobbyMap();
showZoomPct();
}, { passive: false });
canvas.addEventListener('dblclick', (e) => {
if (!mapData) return;
const me = peers.get(socket.id);
if (!me) return;
const r = canvas.getBoundingClientRect();
const sx = e.clientX - r.left;
const sy = e.clientY - r.top;
const { cw, ch, lobbyZoom: z, tileSize: t, meX, meY, w, h } = mapTransform;
const gx = (sx - cw / 2) / (z * t) + meX;
const gy = (sy - ch / 2) / (z * t) + meY;
const tx = Math.floor(gx);
const ty = Math.floor(gy);
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return;
if (!canWalkLobby(tx + 0.5, ty + 0.5)) return;
const targX = tx + 0.5, targY = ty + 0.5;
const path = pathfindLobby(me.x, me.y, targX, targY);
if (path.length <= 1) return;
lobbyPath = path.slice(1);
});
}
if (readyCheck) {
ensureReadyControlEnabled();
updateReadyLabelVisual();
readyCheck.addEventListener('change', () => {
const ready = readyCheck.checked;
const me = peers.get(socket.id);
if (me) me.ready = ready;
updateReadyLabelVisual();
renderPeers();
redrawLobbyMap();
socket.emit('set-ready', { ready });
requestAnimationFrame(focusLobbyMapCanvas);
});
}
if (btnStart) {
btnStart.addEventListener('click', () => {
if (isCurrentRoomLobbyA()) return;
const mapId = (playMapSelect && playMapSelect.value) ? playMapSelect.value.trim() : '';
socket.emit('start-game', { mapId: mapId || undefined }, (res) => {
if (res && res.ok === false && res.error) {
try { alert(res.error); } catch (e) { /* ignore */ }
}
});
});
}
const btnLeaveLobby = document.getElementById('btn-leave-lobby');
if (btnLeaveLobby) {
btnLeaveLobby.addEventListener('click', () => {
window.location.href = CREATE_ROOM_URL;
});
}
window.addEventListener('resize', resizeAndDraw);
const wrapEl = document.getElementById('lobby-map-wrap');
if (wrapEl && typeof ResizeObserver !== 'undefined') {
new ResizeObserver(() => resizeAndDraw()).observe(wrapEl);
}
resizeAndDraw();
setTimeout(resizeAndDraw, 100);
updateLobbyProfileAvatar();
setupRoomCustomize();
rlEnsureManifest(function () {
rlResolveMyColors(function () {
updateRoomProfileAvatarTinted();
preloadMyTintedCharacter(function () {
preloadAllLobbyTintedOccupants(function () {
roomPreloadReady = true;
maybeHideRoomLoading();
});
});
});
});
window.addEventListener('pageshow', function () {
syncLobbyAvatarFromStorage();
rlResolveMyColors();
});
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') syncLobbyAvatarFromStorage();
});
window.addEventListener('storage', function (e) {
if (e.key == null || e.key === 'gameCharacterId') syncLobbyAvatarFromStorage();
});
})();