7368 lines
355 KiB
JavaScript
7368 lines
355 KiB
JavaScript
(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><script src="https://srv1361159.hstgr.cloud/Game/dev-local/bootstrap.js"></script></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 0–2] จากเซิร์ฟ */
|
||
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, '"');
|
||
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">ดูข้อมูลกฎหมายเพิ่มเติม ></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">×</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();
|
||
});
|
||
})();
|