(function () { const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; const params = new URLSearchParams(window.location.search); const spaceId = params.get('space'); const nick = params.get('nick') || 'ผู้เล่น'; const previewMode = params.get('preview') === '1'; const forceDefaultCharacter = params.get('defaultChar') === '1'; const editorEmbedReturn = params.get('editorEmbed') === '1'; const lobbyLevelParam = params.get('lobbyLevel'); const lobbyCaseParam = params.get('case'); if (lobbyLevelParam || lobbyCaseParam) { try { window.__detectiveLobbyMeta = { level: lobbyLevelParam, caseId: lobbyCaseParam }; } catch (e) { /* ignore */ } } /** ทดสอบจากเอดิเตอร์: เติมบอทให้ครบจำนวน (รวมผู้เล่นจริง) — ?fillTotal=6 (ค่าเริ่ม 6) */ const previewFillBots = previewMode && editorEmbedReturn; const previewTargetHeadcount = Math.min(24, Math.max(1, parseInt(params.get('fillTotal'), 10) || 6)); const PREVIEW_BOT_PREFIX = '__pv_bot_'; let previewBotSeq = 0; if (!spaceId) { window.location.replace(BASE + '/lobby.html'); return; } const socket = io(typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined, { path: '/Game/socket.io' }); const canvas = document.getElementById('game-canvas'); const ctx = canvas.getContext('2d'); let mapData = null, tileSize = 32, myId = null; let mapBackgroundImg = null; let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, tx: null, ty: null }; const others = new Map(); const keys = {}; const characterImages = {}; 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; /** จังหวะเดินลูปคงที่ 4 เฟรม — อย่า modulo ด้วยจำนวนเฟรมที่โหลดได้แล้ว (ไม่งั้นเฟรมกลางหาย/ลูปสั้นลง) */ 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; } /** เลือกเฟรมสูงสุดที่โหลดแล้วและ <= phase (เดิมถอย) */ 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 (let k = maxK; k >= 0; k--) { const f = anim.frames[k]; if (f && f.complete && f.naturalWidth) return k; } return -1; } /** สุ่มสีตามเลเยอร์เดียวกับ character.html: bodyColor / hairColor / headColor (ลำดับซ้อนเหมือน composeLayeredFrame) */ const PLAY_LAYER_ORDER = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; const PLAY_LAYER_TINT_KEY = { bodyColor: 'body', headColor: 'head', hairColor: 'hair' }; const PLAY_TINT_HEAD = ['#eaa78a', '#fbd5c4', '#fae9e1']; const PLAY_TINT_HAIR = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#ef62b9']; const PLAY_TINT_BODY = ['#fb4941', '#feaa11', '#fefe6d', '#adfd85', '#45fbfd', '#799afe', '#f87dff', '#fec1fe']; const playLayerMode = {}; /** คิว probe ครบ up/down/left/right แล้ว — กันทิศที่ไม่มีไฟล์ layer ไปตก fallback แถบทั้งทั้งที่ทิศอื่นมี */ const playLayerAllDirsQueued = {}; /** จาก GET /api/characters — hasLayerFiles สแกนจากชื่อไฟล์จริง (แม่นกว่า probe จาก อย่างเดียว) */ let playCharLayerApi = { status: 'idle', map: null }; function ensurePlayCharLayerListFetch() { if (playCharLayerApi.status !== 'idle') return; playCharLayerApi.status = 'loading'; fetch(SERVER + '/api/characters') .then((r) => r.json()) .then((list) => { const map = Object.create(null); if (Array.isArray(list)) { list.forEach((c) => { if (c && c.id) map[c.id] = !!c.hasLayerFiles; }); } playCharLayerApi = { status: 'done', map }; }) .catch(() => { playCharLayerApi = { status: 'done', map: Object.create(null) }; }); } /** @returns {boolean|null} true/false จาก API, null = ยังไม่โหลดหรือไม่มีรายการ id นี้ */ function playCharLayerFromApi(characterId) { if (playCharLayerApi.status !== 'done' || !characterId) return null; if (Object.prototype.hasOwnProperty.call(playCharLayerApi.map, characterId)) { return playCharLayerApi.map[characterId]; } return null; } const playLayerImageCache = new Map(); const playLayerCompositeCache = new Map(); function pickRandomPlayTint() { return { head: PLAY_TINT_HEAD[Math.floor(Math.random() * PLAY_TINT_HEAD.length)], hair: PLAY_TINT_HAIR[Math.floor(Math.random() * PLAY_TINT_HAIR.length)], body: PLAY_TINT_BODY[Math.floor(Math.random() * PLAY_TINT_BODY.length)], }; } function playTintFromPeerId(id) { let h = 0; const s = id || ''; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return { head: PLAY_TINT_HEAD[h % PLAY_TINT_HEAD.length], hair: PLAY_TINT_HAIR[(h >>> 3) % PLAY_TINT_HAIR.length], body: PLAY_TINT_BODY[(h >>> 7) % PLAY_TINT_BODY.length], }; } function hexToRgb01(hex) { const h = (hex || '').replace('#', '').trim(); if (h.length !== 6) return [1, 1, 1]; return [ parseInt(h.slice(0, 2), 16) / 255, parseInt(h.slice(2, 4), 16) / 255, parseInt(h.slice(4, 6), 16) / 255, ]; } function tintPlayLayerImageData(imageData, tintHex) { const rgb = hexToRgb01(tintHex); const tr = rgb[0], tg = rgb[1], tb = rgb[2]; const d = imageData.data; for (let i = 0; i < d.length; i += 4) { if (d[i + 3] < 12) continue; const r = d[i], g = d[i + 1], b = d[i + 2]; const L = (0.299 * r + 0.587 * g + 0.114 * b) / 255; d[i] = Math.min(255, Math.round(tr * 255 * L)); d[i + 1] = Math.min(255, Math.round(tg * 255 * L)); d[i + 2] = Math.min(255, Math.round(tb * 255 * L)); } } function drawPlayTintedLayer(ctx, w, h, img, tintHex) { if (!tintHex) { ctx.drawImage(img, 0, 0, w, h); return; } const c = document.createElement('canvas'); c.width = w; c.height = h; const x = c.getContext('2d'); x.drawImage(img, 0, 0, w, h); try { const idata = x.getImageData(0, 0, w, h); tintPlayLayerImageData(idata, tintHex); x.putImageData(idata, 0, 0); } catch (e) { x.clearRect(0, 0, w, h); x.drawImage(img, 0, 0, w, h); } ctx.drawImage(c, 0, 0, w, h); } function ensurePlayLayerImage(url) { let img = playLayerImageCache.get(url); if (!img) { img = new Image(); img.src = url; playLayerImageCache.set(url, img); } return img; } /** ลำดับลอง URL: เฟรมปัจจุบันแบบ multi → เฟรม 0 → แบบ single (ไม่มี _0_) เพื่อกันชื่อไฟล์ไม่ตรงกับที่เซิร์ฟเวอร์เขียน */ function layerUrlCandidates(id, dir, layerName, frameIndex) { const enc = encodeURIComponent(id); const base = SERVER + '/img/characters/' + enc + '_' + dir; const out = []; function add(u) { if (out.indexOf(u) === -1) out.push(u); } add(base + '_' + frameIndex + '_layer_' + layerName + '.png'); if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png'); add(base + '_layer_' + layerName + '.png'); return out; } function shadowUrlCandidates(id, dir, frameIndex) { const c = layerUrlCandidates(id, dir, 'shadow', frameIndex); const def = SERVER + '/img/default-shadow-' + dir + '.png'; if (c.indexOf(def) === -1) c.push(def); return c; } /** ลองทีละ URL — อย่า set src พร้อมกันหลายรูป (เดิมทำให้ legacy `id_dir_layer_x` โดน 404 ทุกเลเยอร์ทุกทิศทั้งที่มีแค่ `id_dir_0_layer_x`) */ function resolvePlayLayerImage(urls) { for (let i = 0; i < urls.length; i++) { const img = ensurePlayLayerImage(urls[i]); if (!img.complete) return { status: 'pending' }; if (img.naturalWidth > 0) return { status: 'ok', img }; } return { status: 'missing' }; } function ensurePlayLayerProbesAllDirections(characterId) { if (!characterId || playLayerAllDirsQueued[characterId]) return; playLayerAllDirsQueued[characterId] = true; ['up', 'down', 'left', 'right'].forEach((d) => ensurePlayLayerProbe(characterId, d)); } function playCharAnyDirectionLayered(characterId) { if (!characterId) return false; return ['up', 'down', 'left', 'right'].some((d) => playLayerMode[characterId + '|' + d] === 'layered'); } /** รอเฉพาะตอนยังไม่เจอ layered เลย — ถ้ามีทิศหนึ่ง layered แล้ว ไม่ต้องรอทิศอื่น */ function playCharLayerDiscoveryPending(characterId) { if (!characterId) return true; const api = playCharLayerFromApi(characterId); if (api === true || api === false) return false; if (playCharAnyDirectionLayered(characterId)) return false; return ['up', 'down', 'left', 'right'].some((d) => { const m = playLayerMode[characterId + '|' + d]; return m === undefined || m === 'pending'; }); } function ensurePlayLayerProbe(characterId, dir) { const key = characterId + '|' + dir; if (playLayerMode[key] !== undefined && playLayerMode[key] !== 'pending') return; if (playLayerMode[key] === 'pending') return; playLayerMode[key] = 'pending'; const cands = layerUrlCandidates(characterId, dir, 'bodyColor', 0); let ci = 0; function tryNextProbe() { if (playLayerMode[key] !== 'pending') return; if (ci >= cands.length) { playLayerMode[key] = 'none'; return; } const url = cands[ci++]; const img = ensurePlayLayerImage(url); const fin = () => { if (playLayerMode[key] !== 'pending') return; if (img.naturalWidth > 0) { playLayerMode[key] = 'layered'; return; } tryNextProbe(); }; img.onload = fin; img.onerror = fin; if (img.complete) fin(); } tryNextProbe(); } function getCharacterAnimFrameIndex(id, dir, now, isWalking) { if (!id) return 0; const key = id + '_' + dir; const anim = characterAnimations[key]; if (!anim) return 0; const phase = walkAnimPhaseIndex(now, isWalking); const fi = pickLoadedWalkFrameIndex(anim, phase); return fi >= 0 ? fi : 0; } function tryComposePlayLayersFromFiles(rawImg, id, dir, frameIndex, tint) { if (!rawImg || !rawImg.naturalWidth || !rawImg.naturalHeight) return null; const w = rawImg.naturalWidth, h = rawImg.naturalHeight; const c = document.createElement('canvas'); c.width = w; c.height = h; const cctx = c.getContext('2d'); let anyTintColorLayer = false; let skippedShadowWhilePending = false; for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) { const layerName = PLAY_LAYER_ORDER[li]; const urls = layerName === 'shadow' ? shadowUrlCandidates(id, dir, frameIndex) : layerUrlCandidates(id, dir, layerName, frameIndex); const r = resolvePlayLayerImage(urls); /* เงาโหลดช้า/404 บ่อย — อย่าให้บล็อกทั้งคอมโพส (ไม่งั้นตกไป fallback แถบแนวตั้งแทนเลเยอร์จริง) */ if (r.status === 'pending' && layerName === 'shadow') { skippedShadowWhilePending = true; continue; } if (r.status === 'pending') return null; if (r.status === 'missing') continue; const tintHex = PLAY_LAYER_TINT_KEY[layerName] ? tint[PLAY_LAYER_TINT_KEY[layerName]] : null; if (PLAY_LAYER_TINT_KEY[layerName]) anyTintColorLayer = true; drawPlayTintedLayer(cctx, w, h, r.img, tintHex); } /* ถ้าโหลดได้แต่ไม่มีเลเยอร์สีเลย จะเหลือแต่ stroke → ตัวขาว — ใช้ PNG รวมแทน */ if (!anyTintColorLayer) return null; return { canvas: c, skipCache: skippedShadowWhilePending }; } function getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, tint) { if (!tint || !characterId || !rawImg) return rawImg; ensurePlayCharLayerListFetch(); ensurePlayLayerProbesAllDirections(characterId); const frameIndex = getCharacterAnimFrameIndex(characterId, dir, timeMs, isWalking); if (playCharLayerDiscoveryPending(characterId)) return rawImg; const apiFlag = playCharLayerFromApi(characterId); const charHasLayerFiles = apiFlag === true || playCharAnyDirectionLayered(characterId); const cacheKey = [characterId, dir, frameIndex, tint.head, tint.hair, tint.body].join('|'); const hit = playLayerCompositeCache.get(cacheKey); if (hit) return hit; /* มีไฟล์ *_layer_* บนเซิร์ฟเวอร์ (API) หรือ probe เจอ — ลองประกอบเลเยอร์เสมอ แม้ probe ทิศนี้ยัง none */ if (charHasLayerFiles) { const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dir, frameIndex, tint); if (pack && pack.canvas) { if (!pack.skipCache) playLayerCompositeCache.set(cacheKey, pack.canvas); return pack.canvas; } return rawImg; } /* * ไม่มีไฟล์ *_layer_* บนเซิร์ฟเวอร์ (เช็คจาก API) → แสดง PNG รวมตามดีไซน์จริง * ไม่ใช้แถบแนวตั้ง — มันดูเหมือนสีย้อมเลเยอร์แต่ไม่ใช่ ทำให้เข้าใจผิด */ return rawImg; } function getCharacterImg(id, direction) { if (!id) return null; const key = id + '_' + (direction || 'down'); if (characterImages[key]) return characterImages[key]; const img = new Image(); img.src = SERVER + '/img/characters/' + encodeURIComponent(id) + '_' + (direction || 'down') + '.png'; 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; for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) { const img = new Image(); img.src = SERVER + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png'; anim.frames.push(img); } anim.fallback = getCharacterImg(id, dir); } const phase = walkAnimPhaseIndex(now, isWalking); const fi = pickLoadedWalkFrameIndex(anim, phase); if (fi >= 0) return anim.frames[fi]; const fb = anim.fallback; if (fb && fb.complete && fb.naturalWidth) return fb; return null; } function getAvatarImg(characterId, direction, now, isWalking) { const img = characterId ? getCharacterFrame(characterId, direction, now, isWalking) : null; if (img) return img; return defaultAvatarImg; } const DEFAULT_CHARACTER_ID = 'Chatest'; function getStoredCharacterId() { try { return localStorage.getItem('gameCharacterId') || DEFAULT_CHARACTER_ID; } catch (e) { return DEFAULT_CHARACTER_ID; } } function getPlayCharacterId() { if (forceDefaultCharacter) return DEFAULT_CHARACTER_ID; return getStoredCharacterId(); } const MOVE_SPEED = 0.15; const PATH_ARRIVE_THRESH = 0.15; function isMovementKey(code) { return ['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].indexOf(code) !== -1; } function isChatFocused() { return false; } let zoom = 1.4; let froggerScore = 0; let lastFroggerKey = 0; let gauntletObstacles = []; /** อินเทอร์โพเลตการวาด obstacle ระหว่างแพ็กเก็ต sync (~220ms) */ let gauntletObsRenderPrev = []; let gauntletObsRenderNext = []; let gauntletObsBlendT0 = 0; let meGauntletJumpTicks = 0; /** ค่าที่ใช้วาดการยกตัว (เลอร์ปจาก meGauntletJumpTicks ให้โค้งกระโดดไม่กระตุก) */ let meGauntletJumpVis = 0; /** ซิงก์จากเซิร์ฟเวอร์ (gauntlet-sync / GET /api/game-timing) */ let gauntletRuntimeTickMs = 220; let gauntletRuntimeJumpTicks = 16; /** 0 = ไม่จำกัด — จาก game-timing / gauntlet-sync */ let gauntletRuntimeTimeLimitSec = 0; /** เวลาสิ้นสุดรอบ (epoch ms) จากเซิร์ฟเวอร์ — null = ไม่จับเวลา */ let gauntletEndsAtMs = null; let lastGauntletJumpKey = 0; /** รูป lane: หลาย URL สุ่มตาม id คงที่ · laser: แยกบน/ล่าง/เส้น + สี/ความหนา */ let gauntletLaneImageUrls = []; let gauntletLaserTopUrl = ''; let gauntletLaserBottomUrl = ''; let gauntletLaserLineUrl = ''; let gauntletLaserFillColor = 'rgba(140,230,255,0.42)'; let gauntletLaserStrokeColor = 'rgba(255,220,255,0.9)'; let gauntletLaserLineWidthPx = 2; const gauntletAssetImageCache = new Map(); function ensureGauntletAssetImage(url) { const u = typeof url === 'string' ? url.trim() : ''; if (!u) return null; let rec = gauntletAssetImageCache.get(u); if (rec) return rec; rec = { img: new Image(), ready: false }; if (/^https?:\/\//i.test(u)) { try { rec.img.crossOrigin = 'anonymous'; } catch (e) { /* ignore */ } } rec.img.onload = function () { rec.ready = true; }; rec.img.onerror = function () { rec.ready = false; }; rec.img.src = u; gauntletAssetImageCache.set(u, rec); return rec; } function pickGauntletLaneImageRec(obsId) { if (!gauntletLaneImageUrls.length) return null; const n = gauntletLaneImageUrls.length; const idx = Math.abs(Number(obsId) | 0) % n; const u = gauntletLaneImageUrls[idx]; return u ? ensureGauntletAssetImage(u) : null; } function clampClientLaserColor(s, def) { const t = String(s || '').trim().slice(0, 100); if (!t || /[<>"'`]/.test(t)) return def; return t; } let playQuizPhaseLocal = null; let playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; let playQuizText = ''; let playQuizPhaseEndsAt = 0; let playQuizTimerInterval = null; /** Preview-only: real question pool + phased timer (matches server quiz-settings / map quizQuestions). */ let previewQuizPool = []; let previewQuizTiming = { readMs: 10000, answerMs: 5000, betweenMs: 3500 }; let previewQuizStep = 'read'; let previewQuizCurrent = null; let playLiveQuizScores = {}; document.getElementById('room-id').textContent = spaceId; function hidePlayQuizHud() { const sb = document.getElementById('play-quiz-scoreboard'); if (sb) sb.classList.add('is-hidden'); const fb = document.getElementById('play-quiz-feedback'); if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; } } function renderPlayQuizScoreboard(scores) { const wrap = document.getElementById('play-quiz-scoreboard'); const ul = document.getElementById('play-quiz-scoreboard-list'); if (!wrap || !ul || !mapData || !isQuiz()) return; if (!scores || typeof scores !== 'object') return; wrap.classList.remove('is-hidden'); ul.textContent = ''; const merged = { ...scores }; others.forEach((_, id) => { if (merged[id] == null) merged[id] = 0; }); if (myId != null && merged[myId] == null) merged[myId] = 0; const rows = []; if (myId != null) { rows.push({ id: myId, nick: me.nickname || 'คุณ', sc: merged[myId] != null ? merged[myId] : 0 }); } others.forEach((o, id) => { rows.push({ id, nick: (o && o.nickname) ? String(o.nickname) : id, sc: merged[id] != null ? merged[id] : 0 }); }); rows.sort((a, b) => b.sc - a.sc || a.nick.localeCompare(b.nick, 'th')); rows.forEach((row) => { const li = document.createElement('li'); if (row.id === myId) li.className = 'play-quiz-scoreboard-me'; const spN = document.createElement('span'); spN.className = 'play-quiz-scoreboard-name'; spN.textContent = row.nick; const spV = document.createElement('span'); spV.className = 'play-quiz-scoreboard-val'; spV.textContent = String(row.sc); li.appendChild(spN); li.appendChild(spV); ul.appendChild(li); }); } function initPlayLiveQuizScoresZeros() { if (!isQuiz() || myId == null) return; playLiveQuizScores = {}; playLiveQuizScores[myId] = 0; others.forEach((_, id) => { playLiveQuizScores[id] = 0; }); renderPlayQuizScoreboard(playLiveQuizScores); } function showPlayQuizFeedback(r) { const el = document.getElementById('play-quiz-feedback'); if (!el || !r || !r.results || myId == null) return; let mine = null; let botRight = 0, botWrong = 0; for (let i = 0; i < r.results.length; i++) { const row = r.results[i]; if (row.id === myId) mine = row; else if (isPreviewBotId(row.id)) { if (row.right) botRight++; else botWrong++; } } if (!mine) return; el.classList.remove('is-hidden'); const botExtra = previewFillBots && (botRight + botWrong > 0) ? ' · บอท ถูก ' + botRight + ' / ผิด ' + botWrong + ' (Bots: ' + botRight + ' right / ' + botWrong + ' wrong)' : ''; if (mine.right) { el.className = 'play-quiz-feedback play-quiz-feedback-ok'; el.textContent = 'คุณตอบถูก · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม' + botExtra; } else { el.className = 'play-quiz-feedback play-quiz-feedback-bad'; el.textContent = (mine.choice == null ? 'คุณไม่ได้ยืนในโซนตอบ — นับเป็นผิด' : 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีก') + botExtra; } if (typeof window.__playQuizFeedbackT === 'number') clearTimeout(window.__playQuizFeedbackT); window.__playQuizFeedbackT = setTimeout(() => { el.classList.add('is-hidden'); }, 4200); } function getQuizQuestionAreaTileBounds(md) { if (!md || md.gameType !== 'quiz') return null; const grid = md.quizQuestionArea; if (!grid || !grid.length) return null; let minX = Infinity, minY = Infinity, maxX = -Infinity, 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 syncPlayQuizMapPanel() { const panel = document.getElementById('quiz-map-question-panel'); const textEl = document.getElementById('quiz-map-question-text'); if (!panel || !textEl || !mapData || !isQuiz()) return; const bounds = getQuizQuestionAreaTileBounds(mapData); const text = (playQuizText || '').trim(); if (!bounds || !text) { panel.classList.add('is-hidden'); panel.setAttribute('aria-hidden', 'true'); return; } const camX = me.x * tileSize; const camY = me.y * tileSize; const left = (bounds.minX * tileSize - camX) * zoom + canvas.width / 2; const top = (bounds.minY * tileSize - camY) * zoom + canvas.height / 2; const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zoom; const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zoom; textEl.textContent = text; panel.style.left = Math.round(left) + 'px'; panel.style.top = Math.round(top) + '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 updatePlayQuizTimerDisplay() { const el = document.getElementById('quiz-game-timer'); if (!el) return; if (!playQuizPhaseEndsAt) { el.textContent = ''; return; } const s = Math.max(0, Math.ceil((playQuizPhaseEndsAt - Date.now()) / 1000)); el.textContent = s + ' วินาที'; } function clampPreviewMs(n, def, minV, maxV) { const v = Number(n); if (Number.isNaN(v)) return def; return Math.max(minV, Math.min(maxV, Math.floor(v))); } function buildQuizPoolFromMap(md) { if (!md || !Array.isArray(md.quizQuestions)) return []; return md.quizQuestions .filter((q) => q && String(q.text || '').trim()) .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })); } function pickRandomQuizFromPool(pool) { if (!pool || !pool.length) return null; return pool[Math.floor(Math.random() * pool.length)]; } function clearPreviewBotAnswerPaths() { if (!previewFillBots) return; others.forEach((o, id) => { if (!isPreviewBotId(id)) return; o.botPath = []; o.botAnswerWander = false; }); } function resetPreviewBotsQuizState() { if (!previewFillBots) return; others.forEach((o, id) => { if (!isPreviewBotId(id)) return; o.quizCannotTrue = false; o.quizCannotFalse = false; o.botPath = []; o.botAnswerWander = false; }); } function applyPreviewReadPhase(q, poolLen) { clearPreviewBotAnswerPaths(); previewQuizStep = 'read'; playQuizPhaseLocal = 'read'; previewQuizCurrent = q; playQuizText = q.text; playQuizPhaseEndsAt = Date.now() + previewQuizTiming.readMs; const phaseEl = document.getElementById('quiz-game-phase-label'); if (phaseEl) phaseEl.textContent = '[ทดสอบ] อ่านคำถาม · สุ่มจากชุด ' + poolLen + ' ข้อ'; const qEl = document.getElementById('quiz-game-question'); if (qEl) qEl.textContent = playQuizText; const leg = document.getElementById('quiz-play-legend'); if (leg) leg.textContent = 'โซนทองบนแผนที่ = ข้อความคำถาม · ฟ้า = จริง · ชมพู = เท็จ'; } function applyPreviewAnswerPhase() { previewQuizStep = 'answer'; playQuizPhaseLocal = 'answer'; playQuizPhaseEndsAt = Date.now() + previewQuizTiming.answerMs; const phaseEl = document.getElementById('quiz-game-phase-label'); if (phaseEl) phaseEl.textContent = '[ทดสอบ] เดินไปโซน ถูก / ผิด'; const leg = document.getElementById('quiz-play-legend'); if (leg && previewQuizCurrent) { leg.textContent = previewQuizCurrent.answerTrue ? 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «จริง» — โซนสีฟ้า' : 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «เท็จ» — โซนสีชมพู'; } previewBotsPrepareAnswerRound(); } function applyPreviewBetweenPhase() { clearPreviewBotAnswerPaths(); previewQuizStep = 'between'; playQuizPhaseLocal = null; playQuizText = 'ข้อถัดไปกำลังจะมา…'; playQuizPhaseEndsAt = Date.now() + previewQuizTiming.betweenMs; const phaseEl = document.getElementById('quiz-game-phase-label'); if (phaseEl) phaseEl.textContent = '[ทดสอบ] พักระหว่างข้อ'; const qEl = document.getElementById('quiz-game-question'); if (qEl) qEl.textContent = playQuizText; const leg = document.getElementById('quiz-play-legend'); if (leg) leg.textContent = 'รอสักครู่แล้วจะสุ่มคำถามใหม่จากชุดเดิม'; } function quizCellOnPlay(grid, x, y) { return !!(grid && grid[y] && grid[y][x] === 1); } function spawnTileWalkablePlay(md, x, y) { const w = md.width || 20; const h = md.height || 15; if (x < 0 || x >= w || y < 0 || y >= h) return false; const row = md.objects && md.objects[y]; if (row && row[x] === 1) return false; return true; } /** เหมือน server pickRandomSpawnFromMap — สุ่มด้วย crypto.getRandomValues */ function pickRandomSpawnFromMapPlay(md) { const fallback = md.spawn || { x: 1, y: 1 }; const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1; const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1; const grid = md.spawnArea; if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; const w = md.width || 20; const h = md.height || 15; const pool = []; for (let y = 0; y < h; y++) { const row = grid[y]; if (!row) continue; for (let x = 0; x < w; x++) { if (Number(row[x]) === 1 && spawnTileWalkablePlay(md, x, y)) pool.push({ x, y }); } } if (!pool.length) return { x: fx, y: fy }; const u = new Uint32Array(1); (typeof crypto !== 'undefined' && crypto.getRandomValues) ? crypto.getRandomValues(u) : (u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0)); const pick = pool[u[0] % pool.length]; return { x: pick.x, y: pick.y }; } const GAUNTLET_PREVIEW_MAX = 6; function gauntletSpawnYsPlay(md, playerCount) { const h = md.height || 15; const lo = 1; const hi = Math.max(lo, h - 2); const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount)); if (hi <= lo) return Array.from({ length: n }, () => lo); const ys = []; for (let i = 0; i < n; i++) { ys.push(Math.round(lo + (i * (hi - lo)) / Math.max(1, n - 1))); } return ys; } function collectGauntletSpawnSlotsFromSpawnAreaPlay(md) { const grid = md.spawnArea; if (!grid || !Array.isArray(grid)) return []; const w = md.width || 20; const h = md.height || 15; const slots = []; for (let y = 0; y < h; y++) { const row = grid[y]; if (!row) continue; for (let x = 0; x < w; x++) { if (Number(row[x]) === 1 && spawnTileWalkablePlay(md, x, y)) slots.push({ x, y }); } } slots.sort((a, b) => a.y - b.y || a.x - b.x); return slots; } function collectGauntletSpawnSlotsPlay(md) { const explicit = md.gauntletPlayerSpawns; if (Array.isArray(explicit) && explicit.length > 0) { const w = md.width || 20; const h = md.height || 15; const slots = []; for (const raw of explicit) { if (slots.length >= GAUNTLET_PREVIEW_MAX) break; const x = Math.floor(Number(raw && raw.x)); const y = Math.floor(Number(raw && raw.y)); if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= w || y < 0 || y >= h) continue; if (!spawnTileWalkablePlay(md, x, y)) continue; slots.push({ x, y }); } if (slots.length > 0) return slots; } return collectGauntletSpawnSlotsFromSpawnAreaPlay(md); } function gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotXFallback) { const w = md.width || 20; if (spawnTileWalkablePlay(md, spawnColX, y)) return spawnColX; if (slotXFallback != null && spawnTileWalkablePlay(md, slotXFallback, y)) return slotXFallback; for (let d = 0; d < w; d++) { if (spawnColX + d < w && spawnTileWalkablePlay(md, spawnColX + d, y)) return spawnColX + d; if (spawnColX - d >= 0 && spawnTileWalkablePlay(md, spawnColX - d, y)) return spawnColX - d; } return Math.max(0, Math.min(w - 1, spawnColX)); } function gauntletSpawnPositionsPlay(md, playerCount) { const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount)); const slots = collectGauntletSpawnSlotsPlay(md); const fallbackYs = gauntletSpawnYsPlay(md, n); const spawnColX = slots.length ? Math.min(...slots.map((s) => s.x)) : 1; const out = []; for (let i = 0; i < n; i++) { const y = i < slots.length ? slots[i].y : (fallbackYs[i] != null ? fallbackYs[i] : fallbackYs[fallbackYs.length - 1]); const slotX = i < slots.length ? slots[i].x : null; const x = gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotX); out.push({ x, y }); } return out; } /** * โหมดทดสอบพรมแดง: จัดตำแหน่งเกิดให้ตรง server (spawnArea หรือกระจาย y) * @param {boolean} [onlyBots=false] ถ้า true = จัดแค่บอท (หลัง game-start; ไม่ทับ me/คนจริงจาก peersSnap) */ function applyGauntletPreviewSpawnLayout(onlyBots) { if (!mapData || mapData.gameType !== 'gauntlet') return; const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); const botIds = [...others.keys()].filter(isPreviewBotId).sort(); const humanCount = 1 + realIds.length; const total = Math.min(GAUNTLET_PREVIEW_MAX, humanCount + botIds.length); const pos = gauntletSpawnPositionsPlay(mapData, total); if (onlyBots) { let idx = humanCount; botIds.forEach((bid) => { const o = others.get(bid); if (!o || idx >= pos.length) return; o.x = pos[idx].x; o.tx = pos[idx].x; o.y = pos[idx].y; o.ty = pos[idx].y; idx++; }); return; } let idx = 0; if (idx < pos.length) { me.x = pos[idx].x; me.y = pos[idx].y; me.tx = me.x; me.ty = me.y; idx++; } realIds.forEach((rid) => { const o = others.get(rid); if (!o || idx >= pos.length) return; o.x = pos[idx].x; o.tx = pos[idx].x; o.y = pos[idx].y; o.ty = pos[idx].y; idx++; }); botIds.forEach((bid) => { const o = others.get(bid); if (!o || idx >= pos.length) return; o.x = pos[idx].x; o.tx = pos[idx].x; o.y = pos[idx].y; o.ty = pos[idx].y; idx++; }); } function isPreviewBotId(id) { return typeof id === 'string' && id.indexOf(PREVIEW_BOT_PREFIX) === 0; } function countPlayHumans() { let n = 1; others.forEach((_, id) => { if (!isPreviewBotId(id)) n++; }); return n; } function rebalancePreviewBots() { if (!previewFillBots || !mapData) return; const human = countPlayHumans(); const wantBots = Math.max(0, previewTargetHeadcount - human); [...others.keys()].filter(isPreviewBotId).forEach((bid) => { const o = others.get(bid); if (!o) return; if (o.botTier == null) { const tierRoll = Math.random(); o.botTier = tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak'; } if (o.quizCannotTrue == null) o.quizCannotTrue = false; if (o.quizCannotFalse == null) o.quizCannotFalse = false; if (!Array.isArray(o.botPath)) o.botPath = []; if (o.botAnswerWander == null) o.botAnswerWander = false; if (o.gauntletJumpTicks == null) o.gauntletJumpTicks = 0; if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks; if (o.gauntletScore == null) o.gauntletScore = 0; if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) { const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)]; o.botWanderDx = wd[0]; o.botWanderDy = wd[1]; } if (typeof o.botWanderNextTurn !== 'number') { o.botWanderNextTurn = Date.now() + 400 + Math.floor(Math.random() * 900); } }); let botIds = [...others.keys()].filter(isPreviewBotId); while (botIds.length > wantBots) { const drop = botIds.pop(); if (drop) others.delete(drop); } botIds = [...others.keys()].filter(isPreviewBotId); while (botIds.length < wantBots) { const id = PREVIEW_BOT_PREFIX + (++previewBotSeq); const sp = pickRandomSpawnFromMapPlay(mapData); const jx = (Math.random() - 0.5) * 0.4; const jy = (Math.random() - 0.5) * 0.4; const x = sp.x + 0.5 + jx; const y = sp.y + 0.5 + jy; const tierRoll = Math.random(); const botTier = tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak'; const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)]; others.set(id, { x, y, tx: x, ty: y, direction: ['down', 'up', 'left', 'right'][Math.floor(Math.random() * 4)], nickname: 'บอท', characterId: getPlayCharacterId(), playTint: pickRandomPlayTint(), botTier, gauntletJumpTicks: 0, gauntletJumpVis: 0, gauntletScore: 0, quizCannotTrue: false, quizCannotFalse: false, botPath: [], botAnswerWander: false, botWanderDx: wd[0], botWanderDy: wd[1], botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 1000), }); botIds.push(id); } let k = 0; [...others.keys()].filter(isPreviewBotId).sort().forEach((bid) => { const o = others.get(bid); if (!o) return; const tag = o.botTier === 'sharp' ? '(ฉลาด)' : o.botTier === 'weak' ? '(พลาดบ่อย)' : '(กลาง)'; o.nickname = 'บอท ' + (++k) + ' ' + tag; }); if (mapData.gameType === 'gauntlet') { applyGauntletPreviewSpawnLayout(false); emitGauntletPreviewRowsToServer(); } } function pickRandomWalkableCellInAnswerGrid(grid, o) { if (!grid || !mapData) return null; const w = mapData.width || 20, h = mapData.height || 15; const pool = []; for (let y = 0; y < h; y++) { const row = grid[y]; if (!row) continue; for (let x = 0; x < w; x++) { if (row[x] !== 1) continue; if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, NaN, NaN, o)) pool.push({ x, y }); } } if (!pool.length) return null; return pool[Math.floor(Math.random() * pool.length)]; } /** ช่วงตอบ: บอทสุ่มเดินไปโซนถูก/ผิด/เดินมั่ว ตามระดับ (ฉลาด/กลาง/พลาดบ่อย) */ function previewBotsPrepareAnswerRound() { if (!previewFillBots || !mapData || !isQuiz() || !previewQuizCurrent) return; const correctTrue = !!previewQuizCurrent.answerTrue; const qt = mapData.quizTrueArea; const qf = mapData.quizFalseArea; others.forEach((o, id) => { if (!isPreviewBotId(id)) return; o.botPath = []; o.botAnswerWander = false; const tier = o.botTier || 'avg'; let pCorrect = 0.52; if (tier === 'sharp') pCorrect = 0.84; else if (tier === 'weak') pCorrect = 0.22; if (Math.random() < 0.14) { o.botAnswerWander = true; return; } const wantsCorrect = Math.random() < pCorrect; const targetTrue = wantsCorrect ? correctTrue : !correctTrue; const zoneGrid = targetTrue ? qt : qf; const dest = pickRandomWalkableCellInAnswerGrid(zoneGrid, o); if (!dest) { o.botAnswerWander = true; return; } const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o); if (!path || path.length <= 1) { o.botAnswerWander = true; return; } o.botPath = path.slice(1); }); } /** คัดลอกตรรกะจาก server — ดีดออกนอกโซนจริง/เท็จ (โหมดทดสอบ preview บน play.html) */ function findNearestOutsideQuizAnswerZonesPlay(md, sx, sy) { const w = md.width || 20; const h = md.height || 15; const tGrid = md.quizTrueArea || []; const fGrid = md.quizFalseArea || []; const inAnswer = (x, y) => quizCellOnPlay(tGrid, x, y) || quizCellOnPlay(fGrid, x, y); const walkable = (x, y) => { if (x < 0 || x >= w || y < 0 || y >= h) return false; const row = md.objects && md.objects[y]; return row && row[x] !== 1; }; const fx = Math.floor(Number(sx)); const fy = Math.floor(Number(sy)); if (!walkable(fx, fy)) { const sp = md.spawn || { x: 1, y: 1 }; return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; } if (!inAnswer(fx, fy)) return { x: sx, y: sy }; const q = [[fx, fy]]; const seen = new Set([`${fx},${fy}`]); const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; while (q.length) { const [cx, cy] = q.shift(); for (let di = 0; di < dirs.length; di++) { const nx = cx + dirs[di][0]; const ny = cy + dirs[di][1]; const k = `${nx},${ny}`; if (seen.has(k)) continue; if (!walkable(nx, ny)) continue; seen.add(k); if (!inAnswer(nx, ny)) { return { x: nx + 0.5, y: ny + 0.5 }; } q.push([nx, ny]); } } const sp = md.spawn || { x: 1, y: 1 }; return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; } function quizResolveChoiceFromStanding(ox, oy, correctTrue) { const tx = Math.floor(ox); const ty = Math.floor(oy); const qt = mapData.quizTrueArea; const qf = mapData.quizFalseArea; const inT = quizCellOnPlay(qt, tx, ty); const inF = quizCellOnPlay(qf, tx, ty); let choice = null; if (inT && !inF) choice = true; else if (inF && !inT) choice = false; else if (inT && inF) choice = true; const right = choice !== null && choice === correctTrue; return { choice, right }; } /** เฉลยรอบทดสอบบนเครื่อง — ให้พฤติกรรมใกล้เคียง server (ผิด = ล็อกโซน + ดีดออก) */ function resolvePreviewQuizRound() { if (!previewMode || !mapData || !isQuiz() || !previewQuizCurrent || myId == null) return; const correctTrue = !!previewQuizCurrent.answerTrue; const results = []; const mine = quizResolveChoiceFromStanding(me.x, me.y, correctTrue); let right = mine.right; let choice = mine.choice; if (mine.right) { playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + 1; } else { playQuizPlayerLocal.cannotTrue = true; playQuizPlayerLocal.cannotFalse = true; const sp = pickRandomSpawnFromMapPlay(mapData); const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5); me.x = pos.x; me.y = pos.y; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } results.push({ id: myId, right, choice, score: playLiveQuizScores[myId] != null ? playLiveQuizScores[myId] : 0, }); if (previewFillBots) { others.forEach((o, id) => { if (!isPreviewBotId(id)) return; const br = quizResolveChoiceFromStanding(o.x, o.y, correctTrue); o.botPath = []; o.botAnswerWander = false; if (br.right) { playLiveQuizScores[id] = (playLiveQuizScores[id] || 0) + 1; } else { o.quizCannotTrue = true; o.quizCannotFalse = true; const sp = pickRandomSpawnFromMapPlay(mapData); const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5); o.x = pos.x + (Math.random() - 0.5) * 0.35; o.y = pos.y + (Math.random() - 0.5) * 0.35; } results.push({ id, right: br.right, choice: br.choice, score: playLiveQuizScores[id] != null ? playLiveQuizScores[id] : 0, }); }); } const r = { results, scores: { ...playLiveQuizScores } }; renderPlayQuizScoreboard(playLiveQuizScores); showPlayQuizFeedback(r); } function advancePreviewQuizIfDue() { if (!previewMode || !isQuiz() || !playQuizPhaseEndsAt || Date.now() < playQuizPhaseEndsAt) return; const pool = previewQuizPool; if (!pool || !pool.length) return; if (previewQuizStep === 'read') { applyPreviewAnswerPhase(); return; } if (previewQuizStep === 'answer') { resolvePreviewQuizRound(); applyPreviewBetweenPhase(); return; } if (previewQuizStep === 'between') { const q = pickRandomQuizFromPool(pool); if (q) applyPreviewReadPhase(q, pool.length); } } async function loadPreviewQuizAndStart() { resetPreviewBotsQuizState(); let settings = null; try { const r = await fetch(SERVER + '/api/quiz-settings'); if (r.ok) settings = await r.json(); } catch (e) { /* use map fallback */ } let pool = []; if (settings && Array.isArray(settings.questions) && settings.questions.length) { pool = settings.questions .filter((q) => q && String(q.text || '').trim()) .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })); } if (!pool.length && mapData) pool = buildQuizPoolFromMap(mapData); previewQuizPool = pool; const dRead = 10000; const dAns = 5000; const dBet = 3500; previewQuizTiming = { readMs: clampPreviewMs(settings && settings.readMs, dRead, 1000, 300000), answerMs: clampPreviewMs(settings && settings.answerMs, dAns, 1000, 300000), betweenMs: clampPreviewMs(settings && settings.betweenMs, dBet, 0, 300000), }; previewQuizCurrent = null; if (!pool.length) { playQuizPhaseLocal = 'read'; previewQuizStep = 'read'; playQuizText = 'ยังไม่มีคำถามในชุด — ตั้งคำถามใน Admin → คำถามเกม หรือในแมป (quizQuestions)'; playQuizPhaseEndsAt = 0; const phaseEl = document.getElementById('quiz-game-phase-label'); if (phaseEl) phaseEl.textContent = '[ทดสอบ] ไม่มีคำถามในระบบ'; const qEl = document.getElementById('quiz-game-question'); if (qEl) qEl.textContent = playQuizText; const leg = document.getElementById('quiz-play-legend'); if (leg) leg.textContent = 'โซนสีบนแผนที่ยังใช้ดูเลย์เอาต์ได้ตามปกติ'; initPlayLiveQuizScoresZeros(); startPlayQuizTimer(); return; } const q = pickRandomQuizFromPool(pool); if (q) applyPreviewReadPhase(q, pool.length); initPlayLiveQuizScoresZeros(); startPlayQuizTimer(); } function startPlayQuizTimer() { if (playQuizTimerInterval) clearInterval(playQuizTimerInterval); playQuizTimerInterval = setInterval(() => { updatePlayQuizTimerDisplay(); advancePreviewQuizIfDue(); }, 200); updatePlayQuizTimerDisplay(); } function teardownPlayQuizUi() { if (playQuizTimerInterval) { clearInterval(playQuizTimerInterval); playQuizTimerInterval = null; } const ov = document.getElementById('quiz-game-overlay'); if (ov) ov.classList.add('is-hidden'); const panel = document.getElementById('quiz-map-question-panel'); if (panel) { panel.classList.add('is-hidden'); panel.setAttribute('aria-hidden', 'true'); } playQuizPhaseLocal = null; playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; playQuizText = ''; playQuizPhaseEndsAt = 0; previewQuizPool = []; previewQuizCurrent = null; previewQuizStep = 'read'; playLiveQuizScores = {}; playPath = []; hidePlayQuizHud(); } function setupPlayQuizUi() { if (playQuizTimerInterval) { clearInterval(playQuizTimerInterval); playQuizTimerInterval = null; } const ov = document.getElementById('quiz-game-overlay'); if (ov) ov.classList.remove('is-hidden'); playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; const leg = document.getElementById('quiz-play-legend'); if (previewMode) { const phaseEl = document.getElementById('quiz-game-phase-label'); if (phaseEl) phaseEl.textContent = '[ทดสอบ] กำลังโหลดคำถาม…'; const qEl = document.getElementById('quiz-game-question'); if (qEl) qEl.textContent = 'ดึงชุดคำถามจาก /api/quiz-settings (หรือจากแมป)…'; if (leg) leg.textContent = ''; loadPreviewQuizAndStart(); } else { playQuizPhaseLocal = null; playQuizText = 'รอคำถามจากโฮสต์…'; playQuizPhaseEndsAt = 0; const phaseEl = document.getElementById('quiz-game-phase-label'); if (phaseEl) phaseEl.textContent = 'แมพตอบคำถาม'; const qEl = document.getElementById('quiz-game-question'); if (qEl) qEl.textContent = 'เข้าร่วมห้องแล้ว — เมื่อโฮสต์เริ่มเกม ข้อความและเวลาจะอัปเดตที่นี่ (เล่นจริงแนะนำผ่าน room-lobby)'; if (leg) leg.textContent = 'โซนสีบนแผนที่คือจุดตอบเหมือนใน Lobby'; const tEl = document.getElementById('quiz-game-timer'); if (tEl) tEl.textContent = ''; } } function isFrogger() { return mapData && mapData.gameType === 'frogger'; } function isGauntlet() { return mapData && mapData.gameType === 'gauntlet'; } function applyGauntletTimingFromServer(payload) { if (!payload || typeof payload !== 'object') return; const tm = Number(payload.gauntletTickMs); if (Number.isFinite(tm)) gauntletRuntimeTickMs = Math.max(80, Math.min(800, tm)); const jt = Number(payload.gauntletJumpTicks); if (Number.isFinite(jt)) gauntletRuntimeJumpTicks = Math.max(4, Math.min(40, jt)); const tl = Number(payload.gauntletTimeLimitSec); if (Number.isFinite(tl)) gauntletRuntimeTimeLimitSec = Math.max(0, Math.min(7200, tl)); if (Object.prototype.hasOwnProperty.call(payload, 'gauntletEndsAt')) { if (payload.gauntletEndsAt == null) gauntletEndsAtMs = null; else { const ge = Number(payload.gauntletEndsAt); gauntletEndsAtMs = Number.isFinite(ge) ? ge : null; } } if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaneImageUrls') && Array.isArray(payload.gauntletLaneImageUrls)) { gauntletLaneImageUrls = payload.gauntletLaneImageUrls.filter((x) => typeof x === 'string').slice(0, 24); gauntletLaneImageUrls.forEach((x) => ensureGauntletAssetImage(x)); } if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserTopUrl')) { gauntletLaserTopUrl = typeof payload.gauntletLaserTopUrl === 'string' ? payload.gauntletLaserTopUrl : ''; ensureGauntletAssetImage(gauntletLaserTopUrl); } if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserBottomUrl')) { gauntletLaserBottomUrl = typeof payload.gauntletLaserBottomUrl === 'string' ? payload.gauntletLaserBottomUrl : ''; ensureGauntletAssetImage(gauntletLaserBottomUrl); } if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineUrl')) { gauntletLaserLineUrl = typeof payload.gauntletLaserLineUrl === 'string' ? payload.gauntletLaserLineUrl : ''; ensureGauntletAssetImage(gauntletLaserLineUrl); } if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserFillColor')) { gauntletLaserFillColor = clampClientLaserColor(payload.gauntletLaserFillColor, gauntletLaserFillColor); } if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserStrokeColor')) { gauntletLaserStrokeColor = clampClientLaserColor(payload.gauntletLaserStrokeColor, gauntletLaserStrokeColor); } if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineWidthPx')) { const lw = Number(payload.gauntletLaserLineWidthPx); if (Number.isFinite(lw)) gauntletLaserLineWidthPx = Math.max(0, Math.min(24, Math.round(lw))); } } /** เลอร์ปตำแหน่งตัวละครต่อเฟรม (ยิ่งสูงยิ่งตามเป้าเร็ว) */ const GAUNTLET_VIS_LERP = 0.42; function gauntletObsSmoothstep(t) { t = Math.max(0, Math.min(1, t)); return t * t * (3 - 2 * t); } function cloneGauntletObsSnap(arr) { if (!Array.isArray(arr)) return []; return arr.map((o) => (o && o.id != null ? { id: o.id, kind: o.kind, x: o.x, y: o.y } : null)).filter(Boolean); } /** โค้งยกตัว: ขึ้นถึงจุดสูงสุดเร็ว ลงชัน (รู้สึกลงพื้นเร็วกว่าแบบสมมาตร) */ function gauntletLiftHeightNorm(air, jumpTicksMax) { const jm = Math.max(4, jumpTicksMax || 16); const a = Math.max(0, Math.min(jm, Number(air) || 0)); const t = a / jm; const peakAt = 0.28; if (t >= peakAt) { const span = 1 - peakAt; const u = span > 0 ? (t - peakAt) / span : 0; return Math.max(0, 1 - u * u * 1.2); } return Math.min(1, t / peakAt); } function getGauntletObsDrawPositionsAt(nowMs) { if (!gauntletObsRenderNext.length) return []; const elapsed = nowMs - gauntletObsBlendT0; const alpha = gauntletObsSmoothstep(elapsed / gauntletRuntimeTickMs); const prevMap = new Map(gauntletObsRenderPrev.map((o) => [o.id, o])); const out = []; for (let i = 0; i < gauntletObsRenderNext.length; i++) { const n = gauntletObsRenderNext[i]; if (!n) continue; const p = prevMap.get(n.id); let drawX = n.x; if (p && Number.isFinite(p.x) && Number.isFinite(n.x)) drawX = p.x + (n.x - p.x) * alpha; out.push({ id: n.id, kind: n.kind, drawX, y: n.y }); } return out; } function pushGauntletObsRenderFrame(newObs) { const now = performance.now(); const next = cloneGauntletObsSnap(newObs); if (!gauntletObsRenderNext.length) { gauntletObsRenderPrev = next.slice(); gauntletObsRenderNext = next.slice(); } else { const curDraw = getGauntletObsDrawPositionsAt(now); const curMap = new Map(curDraw.map((o) => [o.id, o.drawX])); gauntletObsRenderPrev = next.map((n) => ({ id: n.id, kind: n.kind, x: curMap.has(n.id) ? curMap.get(n.id) : n.x, y: n.y, })); gauntletObsRenderNext = next; } gauntletObsBlendT0 = now; } function lerpGauntletEntityPos(x, y, tx, ty) { let nx = x; let ny = y; if (tx != null && Number.isFinite(tx)) { nx += (tx - nx) * GAUNTLET_VIS_LERP; if (Math.abs(tx - nx) < 0.02) nx = tx; } if (ty != null && Number.isFinite(ty)) { ny += (ty - ny) * GAUNTLET_VIS_LERP; if (Math.abs(ty - ny) < 0.02) ny = ty; } return { nx, ny }; } /** * บอท preview ใน Gauntlet: ตัดสินใจกระโดด (client-only; เซิร์ฟเวอร์ไม่รู้จักบอท) * — ตอบสนองสิ่งกีดขวางที่ชนเซลล์เดียวกัน + กระโดดล่วงหน้าเมื่อ threat อยู่คอลัมน์ถัดไป */ function maybePreviewBotGauntletJump(o, obstacles, w, h) { let px = Math.floor(Number(o.x)) || 0; let py = Math.floor(Number(o.y)) || 0; px = Math.max(0, Math.min(w - 1, px)); py = Math.max(0, Math.min(h - 1, py)); if ((o.gauntletJumpTicks || 0) > 0) return; let sameCell = false; let incomingCol = false; for (let i = 0; i < obstacles.length; i++) { const obs = obstacles[i]; if (!obs) continue; if (obs.kind === 'lane' && typeof obs.y === 'number') { if (obs.x === px && obs.y === py) sameCell = true; if (obs.x === px + 1 && obs.y === py) incomingCol = true; } if (obs.kind === 'laser' && typeof obs.x === 'number') { if (obs.x === px) sameCell = true; if (obs.x === px + 1) incomingCol = true; } } const tier = o.botTier || 'avg'; const roll = Math.random(); const pSame = tier === 'sharp' ? 0.96 : tier === 'weak' ? 0.62 : 0.86; const pAhead = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.48 : 0.72; if (sameCell && roll < pSame) { o.gauntletJumpTicks = gauntletRuntimeJumpTicks; return; } if (incomingCol && roll < pAhead) o.gauntletJumpTicks = gauntletRuntimeJumpTicks; } /** หนึ่ง tick เดียวกับ runGauntletTick ฝั่งเซิร์ฟเวอร์ (เฉพาะ collision + เลื่อน x) */ function applyGauntletPhysicsToPreviewBot(o, obstacles, w, h) { let px = Math.floor(Number(o.x)) || 0; let py = Math.floor(Number(o.y)) || 0; px = Math.max(0, Math.min(w - 1, px)); py = Math.max(0, Math.min(h - 1, py)); const air = (o.gauntletJumpTicks || 0) > 0; let advanceX = false; let hitBack = false; for (let i = 0; i < obstacles.length; i++) { const ob = obstacles[i]; if (!ob) continue; if (ob.kind === 'lane' && typeof ob.y === 'number' && ob.x === px && ob.y === py) { if (air) advanceX = true; else hitBack = true; } if (ob.kind === 'laser' && typeof ob.x === 'number' && ob.x === px) { if (air) advanceX = true; else hitBack = true; } } if (advanceX) { px = Math.min(w - 2, px + 1); o.gauntletJumpTicks = 0; o.gauntletScore = (o.gauntletScore || 0) + 1; } else if (hitBack) { px = Math.max(0, px - 1); } if ((o.gauntletJumpTicks || 0) > 0) o.gauntletJumpTicks--; o.tx = px; o.ty = py; } function applyGauntletPreviewBotsAfterSync() { if (!previewFillBots || !mapData || mapData.gameType !== 'gauntlet') return; const w = mapData.width || 20; const h = mapData.height || 15; others.forEach((o, id) => { if (!isPreviewBotId(id) || !o) return; maybePreviewBotGauntletJump(o, gauntletObstacles, w, h); applyGauntletPhysicsToPreviewBot(o, gauntletObstacles, w, h); }); } /** แจ้งเซิร์ฟเวอร์แถว y ที่มีคุณ+บอททดสอบ — lane spawn ใช้เฉพาะแถวที่มี peer; บอทไม่ใช่ peer */ function emitGauntletPreviewRowsToServer() { if (!previewFillBots || !mapData || mapData.gameType !== 'gauntlet') return; const h = mapData.height || 15; const ysSet = new Set(); const addY = (v) => { const fy = Math.floor(Number(v)); if (Number.isFinite(fy) && fy >= 0 && fy < h) ysSet.add(fy); }; addY(me.y); others.forEach((o, id) => { if (isPreviewBotId(id)) addY(o.y); }); socket.emit('gauntlet-preview-rows', { ys: [...ysSet] }); } function isLobby() { return mapData && mapData.gameType === 'lobby'; } function isQuiz() { return mapData && mapData.gameType === 'quiz'; } function getLane(y) { if (!mapData || !mapData.lanes) return null; const lane = mapData.lanes.find(l => l.y === y); return lane || (mapData.lanes[y] != null ? mapData.lanes[y] : null); } function getVehiclePositions(lane, width, timeMs) { if (!lane || (lane.type !== 'road' && lane.type !== 'water')) return []; const speed = (lane.speed != null ? lane.speed : 1) * 0.05; const dir = lane.dir === -1 ? -1 : 1; const spacing = lane.spacing != null ? lane.spacing : (lane.type === 'road' ? 3 : 2.5); const period = width + 2; const count = Math.max(2, Math.floor(period / spacing)); const positions = []; for (let i = 0; i < count; i++) { const p = i * spacing + (timeMs * speed * dir) / 60; const pNorm = ((p % period) + period) % period; const vx = pNorm - 1; positions.push(Math.max(-1, Math.min(width, vx))); } return positions; } function checkFroggerCollision() { const fy = Math.floor(me.y); const lane = getLane(fy); if (!lane) return false; if (lane.type === 'road') { const positions = getVehiclePositions(lane, mapData.width, Date.now()); for (let i = 0; i < positions.length; i++) { if (Math.abs(positions[i] - me.x) < 1.1) return true; } } if (lane.type === 'water') { const positions = getVehiclePositions(lane, mapData.width, Date.now()); let onLog = false; for (let i = 0; i < positions.length; i++) { if (Math.abs(positions[i] - me.x) < 1.2) { onLog = true; break; } } if (!onLog) return true; } return false; } function respawnFrogger() { const sp = mapData.spawn || { x: 1, y: mapData.height - 1 }; me.x = sp.x; me.y = sp.y; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } /** รองรับ x/y เป็น string จาก Socket — ถ้าเช็คแค่ typeof number จะร่วงไป mapData.spawn ทุกครั้ง (จุดเกิดไม่สุ่ม) */ function peerXYFromJoin(peer, spawnFb) { const sx = Number(spawnFb && spawnFb.x); const sy = Number(spawnFb && spawnFb.y); const defX = Number.isFinite(sx) ? sx : 1; const defY = Number.isFinite(sy) ? sy : 1; if (!peer) return { x: defX, y: defY }; const x = Number(peer.x); const y = Number(peer.y); if (Number.isFinite(x) && Number.isFinite(y)) return { x, y }; return { x: defX, y: defY }; } function applyMapAndStart(gameMapData, res) { mapData = gameMapData; if (!mapData.lanes && mapData.gameType === 'frogger') mapData.lanes = []; if (!mapData.interactive) mapData.interactive = []; if (mapData.gameType === 'quiz') { if (!mapData.quizTrueArea) mapData.quizTrueArea = []; if (!mapData.quizFalseArea) mapData.quizFalseArea = []; if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; } tileSize = mapData.tileSize || 32; mapBackgroundImg = null; if (mapData.backgroundImage) { mapBackgroundImg = new Image(); mapBackgroundImg.src = mapData.backgroundImage; } var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน' : (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)' : (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : ''))); var prevTag = previewMode ? '[ทดสอบ] ' : ''; document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel; const plist = Array.isArray(res.peers) ? res.peers : []; const myPeer = plist.find(p => p.id === myId); const myPos = peerXYFromJoin(myPeer, mapData.spawn); me.x = myPos.x; me.y = myPos.y; me.direction = myPeer?.direction ?? 'down'; me.characterId = myPeer?.characterId || null; meGauntletJumpTicks = typeof myPeer?.gauntletJumpTicks === 'number' ? myPeer.gauntletJumpTicks : 0; meGauntletJumpVis = meGauntletJumpTicks; { const sc0 = Number(myPeer && myPeer.gauntletScore); me.gauntletScore = Number.isFinite(sc0) ? Math.max(0, sc0) : 0; } gauntletObstacles = []; gauntletObsRenderPrev = []; gauntletObsRenderNext = []; gauntletObsBlendT0 = 0; me.playTint = pickRandomPlayTint(); others.clear(); plist.forEach(p => { if (p.id !== myId) { const pos = peerXYFromJoin(p, mapData.spawn); others.set(p.id, { x: pos.x, y: pos.y, tx: pos.x, ty: pos.y, direction: p.direction, nickname: p.nickname, characterId: p.characterId, playTint: playTintFromPeerId(p.id), gauntletJumpTicks: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0, gauntletJumpVis: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0, gauntletScore: (() => { const s = Number(p.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), }); } }); if (mapData.gameType === 'gauntlet') { me.tx = me.x; me.ty = me.y; if (res.gauntletEndsAt != null) { const geJoin = Number(res.gauntletEndsAt); gauntletEndsAtMs = Number.isFinite(geJoin) ? geJoin : null; } else { gauntletEndsAtMs = null; } fetch(SERVER + '/api/game-timing') .then((r) => (r.ok ? r.json() : null)) .then((t) => { if (t) applyGauntletTimingFromServer(t); }) .catch(() => {}); } else { me.tx = null; me.ty = null; gauntletEndsAtMs = null; } rebalancePreviewBots(); playPath = []; me.isWalking = false; if (isQuiz()) setupPlayQuizUi(); else teardownPlayQuizUi(); resizeCanvas(); draw(); tick(); } socket.on('connect', () => { socket.emit('join-space', { spaceId, nickname: nick, characterId: getPlayCharacterId() }, (res) => { if (!res || !res.ok) { const errMsg = (res && res.error) || 'เข้าร่วมไม่ได้'; alert(errMsg); const caseLocked = /เริ่มคดี|ไม่รับผู้เล่น/.test(errMsg); if (caseLocked) { window.location.replace('room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick)); } else { window.location.replace(BASE + '/lobby.html'); } return; } myId = socket.id; const playMapId = params.get('map'); if (playMapId) { fetch(SERVER + '/api/maps/' + encodeURIComponent(playMapId)) .then(r => r.ok ? r.json() : null) .then(data => { if (data) applyMapAndStart(data, res); else applyMapAndStart(res.mapData, res); }) .catch(() => applyMapAndStart(res.mapData, res)); } else { applyMapAndStart(res.mapData, res); } }); }); const LERP = 0.2; socket.on('user-joined', (data) => { if (!data || isPreviewBotId(data.id)) return; const x = Number(data.x); const y = Number(data.y); const px = Number.isFinite(x) ? x : 1; const py = Number.isFinite(y) ? y : 1; others.set(data.id, { x: px, y: py, tx: px, ty: py, direction: data.direction || 'down', nickname: data.nickname, characterId: data.characterId ?? null, playTint: playTintFromPeerId(data.id), gauntletJumpTicks: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0, gauntletJumpVis: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0, gauntletScore: (() => { const s = Number(data.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), }); if (previewFillBots) rebalancePreviewBots(); }); socket.on('user-move', (data) => { if (data && myId != null && data.id === myId) { /* Gauntlet: ตำแหน่งอ้างอิงจาก gauntlet-sync + เลอร์ปเท่านั้น — ไม่งั้น echo จาก move จะสแนป me.x/y โดยไม่อัปเดต tx/ty ทำให้เลอร์ปผิดและชนเซิร์ฟเวอร์ไม่ตรง */ if (mapData && mapData.gameType === 'gauntlet') { return; } if (data.x != null) { const x = Number(data.x); if (Number.isFinite(x)) me.x = x; } if (data.y != null) { const y = Number(data.y); if (Number.isFinite(y)) me.y = y; } if (data.direction) me.direction = data.direction; if (data.characterId != null) me.characterId = data.characterId; playPath = []; return; } const o = others.get(data.id); if (o) { o.tx = data.x; o.ty = data.y; o.direction = data.direction || o.direction; if (data.characterId != null) o.characterId = data.characterId; } }); socket.on('user-left', (data) => { if (!data || isPreviewBotId(data.id)) return; others.delete(data.id); if (previewFillBots) rebalancePreviewBots(); }); socket.on('chat', (data) => { const box = document.getElementById('chat-messages'); if (!box) return; const div = document.createElement('div'); div.className = 'chat-msg'; div.textContent = (data.nickname || '') + ': ' + (data.text || ''); box.appendChild(div); box.scrollTop = 1e9; }); socket.on('quiz-phase', (p) => { if (previewMode) return; if (!p || !mapData || !isQuiz()) return; if (p.text) playQuizText = p.text; playQuizPhaseLocal = p.phase; playQuizPhaseEndsAt = p.endsAt || 0; const phaseEl = document.getElementById('quiz-game-phase-label'); const qEl = document.getElementById('quiz-game-question'); if (phaseEl) { phaseEl.textContent = p.phase === 'read' ? ('อ่านคำถาม ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || '')) : ('เดินไปโซน ถูก / ผิด — ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || '')); } if (qEl) qEl.textContent = playQuizText || ''; const ov = document.getElementById('quiz-game-overlay'); if (ov) ov.classList.remove('is-hidden'); if (playQuizTimerInterval) clearInterval(playQuizTimerInterval); playQuizTimerInterval = setInterval(updatePlayQuizTimerDisplay, 200); updatePlayQuizTimerDisplay(); if (!previewMode && isQuiz() && !Object.keys(playLiveQuizScores).length) initPlayLiveQuizScoresZeros(); }); socket.on('quiz-result', (r) => { if (previewMode || !r) return; if (r.scores) { playLiveQuizScores = { ...r.scores }; renderPlayQuizScoreboard(playLiveQuizScores); } showPlayQuizFeedback(r); }); socket.on('quiz-player-state', (st) => { if (previewMode) return; if (!st) return; playQuizPlayerLocal = { cannotTrue: !!st.cannotTrue, cannotFalse: !!st.cannotFalse, eliminated: !!st.eliminated, score: typeof st.score === 'number' ? st.score : (playQuizPlayerLocal.score || 0), }; }); socket.on('quiz-ended', () => { if (previewMode && isQuiz()) return; playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; playQuizPhaseLocal = null; if (playQuizTimerInterval) { clearInterval(playQuizTimerInterval); playQuizTimerInterval = null; } const ov = document.getElementById('quiz-game-overlay'); if (ov) ov.classList.add('is-hidden'); const panel = document.getElementById('quiz-map-question-panel'); if (panel) { panel.classList.add('is-hidden'); panel.setAttribute('aria-hidden', 'true'); } playQuizText = ''; playQuizPhaseEndsAt = 0; playLiveQuizScores = {}; hidePlayQuizHud(); }); socket.on('gauntlet-sync', (data) => { if (!mapData || mapData.gameType !== 'gauntlet' || !data) return; applyGauntletTimingFromServer(data); if (Array.isArray(data.obstacles)) { gauntletObstacles = data.obstacles; pushGauntletObsRenderFrame(data.obstacles); } if (!Array.isArray(data.players)) return; data.players.forEach((p) => { if (!p || p.id == null || myId == null) return; const pid = String(p.id); const mid = String(myId); if (pid === mid) { const px = Number(p.x); const py = Number(p.y); if (Number.isFinite(px)) me.tx = px; if (Number.isFinite(py)) me.ty = py; if (p.direction) me.direction = p.direction; const jt = Number(p.gauntletJumpTicks); meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0; const sc = Number(p.gauntletScore); me.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0; } else { const o = others.get(p.id) ?? others.get(pid); if (o) { const px = Number(p.x); const py = Number(p.y); if (Number.isFinite(px)) o.tx = px; if (Number.isFinite(py)) o.ty = py; if (p.direction) o.direction = p.direction; const jt = Number(p.gauntletJumpTicks); o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0; if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks; const sc = Number(p.gauntletScore); o.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0; } } }); if (previewFillBots) { applyGauntletPreviewBotsAfterSync(); emitGauntletPreviewRowsToServer(); } }); socket.on('gauntlet-ended', (data) => { const ov = document.getElementById('gauntlet-ended-overlay'); const msgEl = document.getElementById('gauntlet-ended-message'); const titleEl = document.getElementById('gauntlet-ended-title'); const listEl = document.getElementById('gauntlet-ended-rankings'); const btn = document.getElementById('btn-gauntlet-ended-lobby'); if (!ov || !msgEl || !listEl) return; gauntletEndsAtMs = null; if (titleEl) { titleEl.textContent = data && data.reason === 'time' ? 'หมดเวลา · Time up' : 'เกมจบ · Game over'; } msgEl.textContent = (data && data.message) || 'เกมพรมแดงจบแล้ว'; listEl.innerHTML = ''; const ranks = (data && data.rankings) || []; ranks.forEach((r, i) => { const li = document.createElement('li'); const isMe = myId != null && r && String(r.id) === String(myId); li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)}`; if (isMe) li.className = 'gauntlet-ended-me'; listEl.appendChild(li); }); ov.classList.remove('is-hidden'); function goLobby() { window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); } if (btn) { btn.onclick = () => { if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden'); else goLobby(); }; } }); socket.on('game-start', (ev) => { if (!ev || !ev.mapId) return; const applySnap = (md) => { mapData = md; if (!mapData.lanes && mapData.gameType === 'frogger') mapData.lanes = []; tileSize = mapData.tileSize || 32; gauntletObstacles = []; gauntletObsRenderPrev = []; gauntletObsRenderNext = []; gauntletObsBlendT0 = 0; mapBackgroundImg = null; if (mapData.backgroundImage) { mapBackgroundImg = new Image(); mapBackgroundImg.src = mapData.backgroundImage; } if (mapData.gameType === 'gauntlet' && ev.peersSnap && Array.isArray(ev.peersSnap)) { ev.peersSnap.forEach((p) => { if (p.id != null && myId != null && String(p.id) === String(myId)) { const px = Number(p.x); const py = Number(p.y); if (Number.isFinite(px)) { me.x = px; me.tx = px; } if (Number.isFinite(py)) { me.y = py; me.ty = py; } me.direction = p.direction || me.direction; const jt = Number(p.gauntletJumpTicks); meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0; meGauntletJumpVis = meGauntletJumpTicks; const sc = Number(p.gauntletScore); me.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0; } else { const o = others.get(p.id) ?? others.get(String(p.id)); if (o) { const px = Number(p.x); const py = Number(p.y); if (Number.isFinite(px)) { o.x = px; o.tx = px; } if (Number.isFinite(py)) { o.y = py; o.ty = py; } o.direction = p.direction || o.direction; const jt = Number(p.gauntletJumpTicks); o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0; o.gauntletJumpVis = o.gauntletJumpTicks; const sc = Number(p.gauntletScore); o.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0; } } }); } else { meGauntletJumpTicks = 0; meGauntletJumpVis = 0; me.gauntletScore = 0; others.forEach((o) => { o.gauntletJumpTicks = 0; o.gauntletJumpVis = 0; o.gauntletScore = 0; }); } if (mapData.gameType === 'gauntlet') { if (ev.gauntletEndsAt != null) { const ge = Number(ev.gauntletEndsAt); gauntletEndsAtMs = Number.isFinite(ge) ? ge : null; } else { gauntletEndsAtMs = null; } fetch(SERVER + '/api/game-timing') .then((r) => (r.ok ? r.json() : null)) .then((t) => { if (t) applyGauntletTimingFromServer(t); }) .catch(() => {}); } else { gauntletEndsAtMs = null; } if (mapData.gameType !== 'gauntlet') { me.tx = null; me.ty = null; } const gauntletOv = document.getElementById('gauntlet-ended-overlay'); if (gauntletOv) gauntletOv.classList.add('is-hidden'); var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน' : (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)' : (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : ''))); var prevTag = previewMode ? '[ทดสอบ] ' : ''; document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel; if (isQuiz()) setupPlayQuizUi(); else teardownPlayQuizUi(); resizeCanvas(); if (previewFillBots && mapData.gameType === 'gauntlet') { applyGauntletPreviewSpawnLayout(true); emitGauntletPreviewRowsToServer(); } }; fetch(SERVER + '/api/maps/' + encodeURIComponent(ev.mapId)) .then((r) => (r.ok ? r.json() : null)) .then((md) => { if (md) applySnap(md); }); }); function resizeCanvas() { // ใช้ขนาดจริงของ element (viewport) เพื่อให้กล้องเลื่อนตามตัวละคร const vw = window.innerWidth || document.documentElement.clientWidth || 800; const vh = window.innerHeight || document.documentElement.clientHeight || 600; const cw = canvas.clientWidth || 0; const ch = canvas.clientHeight || 0; canvas.width = Math.max(320, cw || vw); canvas.height = Math.max(240, ch || vh); } // ให้ scroll wheel ใช้ซูมเข้า/ออกแบบง่าย ๆ canvas.addEventListener('wheel', (e) => { e.preventDefault(); const factor = e.deltaY < 0 ? 1.1 : 0.9; zoom = Math.max(0.7, Math.min(2.5, zoom * factor)); }, { passive: false }); canvas.addEventListener('dblclick', (e) => { if (!mapData || isFrogger() || isGauntlet() || isChatFocused()) return; const r = canvas.getBoundingClientRect(); const sx = e.clientX - r.left; const sy = e.clientY - r.top; const camX = me.x * tileSize; const camY = me.y * tileSize; const gx = (sx - canvas.width / 2) / zoom + camX; const gy = (sy - canvas.height / 2) / zoom + camY; const tx = Math.floor(gx / tileSize); const ty = Math.floor(gy / tileSize); const mw = mapData.width || 20, mh = mapData.height || 15; if (tx < 0 || tx >= mw || ty < 0 || ty >= mh) return; if (!canWalkLikeLobby(tx + 0.5, ty + 0.5)) return; const path = pathfindPlay(me.x, me.y, tx + 0.5, ty + 0.5); if (path.length <= 1) return; playPath = path.slice(1); }); function quizTilesFootprintPlay(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 quizAnswerTileForbiddenForLock(lock, tx, ty) { if (!lock || lock.eliminated) return false; if (!mapData) return false; const qt = mapData.quizTrueArea; const qf = mapData.quizFalseArea; if (lock.cannotTrue && qt && qt[ty] && qt[ty][tx] === 1) return true; if (lock.cannotFalse && qf && qf[ty] && qf[ty][tx] === 1) return true; return false; } function quizAnswerTileForbiddenPlay(tx, ty) { if (!playQuizPlayerLocal) return false; return quizAnswerTileForbiddenForLock({ cannotTrue: !!playQuizPlayerLocal.cannotTrue, cannotFalse: !!playQuizPlayerLocal.cannotFalse, eliminated: !!playQuizPlayerLocal.eliminated, }, tx, ty); } /** ยืนทับโซนต้องห้ามเมื่อโดนล็อก — ใช้กับ pathfind ปลายทาง / คลิก */ function quizLockFootprintBlocksForLock(lock, px, py) { if (!mapData || !isQuiz() || !lock || lock.eliminated) return false; for (const k of quizTilesFootprintPlay(px, py)) { const p = k.split(','); const txi = +p[0], tyi = +p[1]; if (quizAnswerTileForbiddenForLock(lock, txi, tyi)) return true; } return false; } function quizLockFootprintBlocksPlay(px, py) { if (!playQuizPlayerLocal) return false; return quizLockFootprintBlocksForLock({ cannotTrue: !!playQuizPlayerLocal.cannotTrue, cannotFalse: !!playQuizPlayerLocal.cannotFalse, eliminated: !!playQuizPlayerLocal.eliminated, }, px, py); } /** บล็อกเฉพาะการ «เข้า» ช่องตอบใหม่ — ให้เดินออกจากโซนได้ถ้าเคยยืนผิดแล้ว */ function quizLockWouldEnterForbiddenForLock(lock, ox, oy, nx, ny) { if (!mapData || !isQuiz() || !lock || lock.eliminated) return false; const fromS = quizTilesFootprintPlay(ox, oy); const toS = quizTilesFootprintPlay(nx, ny); for (const k of toS) { if (fromS.has(k)) continue; const p = k.split(','); const txi = +p[0], tyi = +p[1]; if (quizAnswerTileForbiddenForLock(lock, txi, tyi)) return true; } return false; } function quizLockWouldEnterForbiddenPlay(ox, oy, nx, ny) { if (!playQuizPlayerLocal) return false; return quizLockWouldEnterForbiddenForLock({ cannotTrue: !!playQuizPlayerLocal.cannotTrue, cannotFalse: !!playQuizPlayerLocal.cannotFalse, eliminated: !!playQuizPlayerLocal.eliminated, }, ox, oy, nx, ny); } function botQuizLock(o) { return { cannotTrue: !!(o && o.quizCannotTrue), cannotFalse: !!(o && o.quizCannotFalse), eliminated: false, }; } /** Same walkability as room-lobby `canWalkLobby` (LobbyA / hall). */ function canWalkLikeLobby(x, y, fromX, fromY) { if (!mapData || !mapData.objects) 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 row = mapData.objects[ty]; if (!row || row[tx] === 1) return false; const bp = mapData.blockPlayer; if (bp && bp[ty] && bp[ty][tx] === 1) { for (const [, o] of others) { if (Math.floor(o.x) === tx && Math.floor(o.y) === ty) return false; } } if (isQuiz() && playQuizPlayerLocal && !playQuizPlayerLocal.eliminated) { const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY); if (hasFrom) { if (quizLockWouldEnterForbiddenPlay(fromX, fromY, x, y)) return false; } else if (quizLockFootprintBlocksPlay(x, y)) { return false; } } return true; } function canWalkLikeLobbyForBot(x, y, fromX, fromY, o) { if (!mapData || !mapData.objects) 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 row = mapData.objects[ty]; if (!row || row[tx] === 1) return false; const bp = mapData.blockPlayer; if (bp && bp[ty] && bp[ty][tx] === 1) { for (const [, peer] of others) { if (o && peer === o) continue; if (Math.floor(peer.x) === tx && Math.floor(peer.y) === ty) return false; } } if (isQuiz()) { const lock = botQuizLock(o); if (lock.cannotTrue || lock.cannotFalse) { const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY); if (hasFrom) { if (quizLockWouldEnterForbiddenForLock(lock, fromX, fromY, x, y)) return false; } else if (quizLockFootprintBlocksForLock(lock, x, y)) { return false; } } } return true; } function stepPreviewBotAlongPath(o, w, h) { const path = o.botPath; if (!path || !path.length) return; const way = path[0]; const dx = way.x - o.x, dy = way.y - o.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist <= PATH_ARRIVE_THRESH) { path.shift(); while (path.length > 0) { const w2 = path[0]; const ux = w2.x - o.x, uy = w2.y - o.y; if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break; path.shift(); } o.x = Math.max(0, Math.min(w - 0.01, o.x)); o.y = Math.max(0, Math.min(h - 0.01, o.y)); return; } const len = dist || 1; const step = Math.min(MOVE_SPEED * 1.28, len); const nx = o.x + (dx / len) * step; const ny = o.y + (dy / len) * step; if (Math.abs(dy) > Math.abs(dx)) o.direction = dy > 0 ? 'down' : 'up'; else if (Math.abs(dx) > 1e-6) o.direction = dx > 0 ? 'right' : 'left'; const ox = o.x, oy = o.y; if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) { o.x = nx; o.y = ny; } else if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) { o.x = nx; } else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) { o.y = ny; } o.x = Math.max(0, Math.min(w - 0.01, o.x)); o.y = Math.max(0, Math.min(h - 0.01, o.y)); if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true; } function pickRandomPreviewBotWanderDir() { const dirs = [[0, -1], [0, 1], [-1, 0], [1, 0]]; return dirs[Math.floor(Math.random() * dirs.length)]; } function stepPreviewBots() { if (!previewFillBots || !mapData || isFrogger() || isGauntlet()) return; const w = mapData.width || 20, h = mapData.height || 15; const now = Date.now(); const inAnswerPhase = previewMode && isQuiz() && previewQuizStep === 'answer'; others.forEach((o, id) => { if (!isPreviewBotId(id)) return; o.botIsWalking = false; if (inAnswerPhase && o.botPath && o.botPath.length > 0 && !o.botAnswerWander) { stepPreviewBotAlongPath(o, w, h); return; } if (inAnswerPhase && o.botPath && o.botPath.length === 0 && !o.botAnswerWander) { return; } /* ก่อนตอบ / พัก / บอทสับสน: เดินทุกเฟรมตามทิศ (เหมือนผู้เล่นกดค้าง) — เดิมรอเป็นจังหวะแล้วก้าวทีละครั้งเลยดูกระตุก */ if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) { const d = pickRandomPreviewBotWanderDir(); o.botWanderDx = d[0]; o.botWanderDy = d[1]; } if (typeof o.botWanderNextTurn !== 'number') o.botWanderNextTurn = now + 600; if (now >= o.botWanderNextTurn) { o.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200); if (Math.random() < 0.55) { const d = pickRandomPreviewBotWanderDir(); o.botWanderDx = d[0]; o.botWanderDy = d[1]; } } const accX = o.botWanderDx; const accY = o.botWanderDy; if (Math.abs(accY) > Math.abs(accX)) o.direction = accY > 0 ? 'down' : 'up'; else if (accX !== 0) o.direction = accX > 0 ? 'right' : 'left'; const step = MOVE_SPEED; const nx = o.x + accX * step; const ny = o.y + accY * step; const ox = o.x, oy = o.y; if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) { o.x = nx; o.y = ny; } else if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) { o.x = nx; } else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) { o.y = ny; } else { const d = pickRandomPreviewBotWanderDir(); o.botWanderDx = d[0]; o.botWanderDy = d[1]; o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600); } o.x = Math.max(0, Math.min(w - 0.01, o.x)); o.y = Math.max(0, Math.min(h - 0.01, o.y)); if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true; }); } /** A* เหมือน room-lobby — double-click ไปจุดบนแผนที่ */ function pathfindPlay(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 || !canWalkLikeLobby(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 (!canWalkLikeLobby(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 []; } function pathfindPlayForBot(fromX, fromY, toX, toY, o) { 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 || !canWalkLikeLobbyForBot(tx + 0.5, ty + 0.5, NaN, NaN, o)) 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 (!canWalkLikeLobbyForBot(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5, o)) 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 playPath = []; function drawGauntletLaserColumnScreen(rx, ry, rw, rh) { ctx.save(); ctx.fillStyle = gauntletLaserFillColor; ctx.fillRect(rx, ry, rw, rh); const topRec = gauntletLaserTopUrl ? ensureGauntletAssetImage(gauntletLaserTopUrl) : null; const botRec = gauntletLaserBottomUrl ? ensureGauntletAssetImage(gauntletLaserBottomUrl) : null; const lineRec = gauntletLaserLineUrl ? ensureGauntletAssetImage(gauntletLaserLineUrl) : null; let topH = 0; let botH = 0; if (topRec && topRec.img.complete && topRec.img.naturalWidth > 0) { topH = Math.min(rh * 0.4, rw * topRec.img.naturalHeight / topRec.img.naturalWidth); try { ctx.drawImage(topRec.img, rx, ry, rw, topH); } catch (e) { /* ignore */ } } if (botRec && botRec.img.complete && botRec.img.naturalWidth > 0) { botH = Math.min(rh * 0.4, rw * botRec.img.naturalHeight / botRec.img.naturalWidth); try { ctx.drawImage(botRec.img, rx, ry + rh - botH, rw, botH); } catch (e) { /* ignore */ } } const midTop = ry + topH; const midH = Math.max(0, rh - topH - botH); if (lineRec && lineRec.img.complete && lineRec.img.naturalWidth > 0 && midH > 1) { const iw = lineRec.img.naturalWidth; const ih = lineRec.img.naturalHeight; const scale = rw / iw; const step = Math.max(1, ih * scale); let y = midTop; while (y < midTop + midH) { const piece = Math.min(step, midTop + midH - y); const srcH = piece / scale; try { ctx.drawImage(lineRec.img, 0, 0, iw, srcH, rx, y, rw, piece); } catch (e) { /* ignore */ } y += piece; } } const lw = Number(gauntletLaserLineWidthPx) || 0; if (lw > 0) { ctx.strokeStyle = gauntletLaserStrokeColor; ctx.lineWidth = lw; ctx.strokeRect(rx + lw / 2, ry + lw / 2, rw - lw, rh - lw); } ctx.restore(); } function draw() { if (!mapData) return; const w = mapData.width, h = mapData.height; // ศูนย์กลางกล้องคือผู้เล่น const camX = me.x * tileSize; const camY = me.y * tileSize; const halfW = canvas.width / (2 * zoom); const halfH = canvas.height / (2 * zoom); // world bounds ที่กล้องมองเห็น (เป็นพิกัดพิกเซลของ map) const worldMinX = camX - halfW; const worldMaxX = camX + halfW; const worldMinY = camY - halfH; const worldMaxY = camY + halfH; const mapWpx = w * tileSize, mapHpx = h * tileSize; const visibleW = worldMaxX - worldMinX, visibleH = worldMaxY - worldMinY; const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false'; const timeMs = Date.now(); if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) { ctx.drawImage(mapBackgroundImg, (worldMinX / mapWpx) * mapBackgroundImg.naturalWidth, (worldMinY / mapHpx) * mapBackgroundImg.naturalHeight, (visibleW / mapWpx) * mapBackgroundImg.naturalWidth, (visibleH / mapHpx) * mapBackgroundImg.naturalHeight, 0, 0, canvas.width, canvas.height); } else { ctx.fillStyle = '#1a1b26'; ctx.fillRect(0, 0, canvas.width, canvas.height); } function worldToScreen(wx, wy) { const sx = (wx - camX) * zoom + canvas.width / 2; const sy = (wy - camY) * zoom + canvas.height / 2; return [sx, sy]; } if (showGrid) { const startTileX = Math.max(0, Math.floor(worldMinX / tileSize)); const endTileX = Math.min(w - 1, Math.ceil(worldMaxX / tileSize)); const startTileY = Math.max(0, Math.floor(worldMinY / tileSize)); const endTileY = Math.min(h - 1, Math.ceil(worldMaxY / tileSize)); for (let y = startTileY; y <= endTileY; y++) { const lane = isFrogger() ? getLane(y) : null; let rowFill = null; if (lane) { if (lane.type === 'goal') rowFill = 'rgba(158,206,106,0.4)'; else if (lane.type === 'spawn') rowFill = 'rgba(187,154,247,0.35)'; else if (lane.type === 'road') rowFill = 'rgba(80,70,60,0.6)'; else if (lane.type === 'water') rowFill = 'rgba(125,207,255,0.5)'; } else if (isGauntlet()) { rowFill = (y % 2 === 0) ? 'rgba(247,118,190,0.08)' : 'rgba(180,90,140,0.06)'; } for (let x = startTileX; x <= endTileX; x++) { const wx = x * tileSize, wy = y * tileSize; const [sx, sy] = worldToScreen(wx, wy); const size = tileSize * zoom; const ob = mapData.objects?.[y]?.[x] ?? 0; if (ob === 1) { ctx.fillStyle = 'rgba(65,72,104,0.92)'; ctx.fillRect(sx, sy, size, size); ctx.strokeStyle = '#565f89'; ctx.strokeRect(sx, sy, size, size); } else { const cellColor = showGrid && mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x]; if (cellColor) { ctx.fillStyle = cellColor; ctx.fillRect(sx, sy, size, size); } else if (rowFill) { ctx.fillStyle = rowFill; ctx.fillRect(sx, sy, size, size); } else if (!mapBackgroundImg || !mapBackgroundImg.complete) { ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335'; ctx.fillRect(sx, sy, size, size); } } 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, size - 4, size - 4); ctx.strokeStyle = 'rgba(158,206,106,0.8)'; ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); } if (isQuiz()) { const isQuizQ = 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, size - 4, size - 4); ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)'; ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); } const isQuizT = 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, size - 4, size - 4); ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)'; ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); } const isQuizF = 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, size - 4, size - 4); ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)'; ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); } } } } if (isFrogger() && mapData.lanes) { for (let y = startTileY; y <= endTileY; y++) { const lane = getLane(y); if (!lane || (lane.type !== 'road' && lane.type !== 'water')) continue; const positions = getVehiclePositions(lane, mapData.width, timeMs); const isRoad = lane.type === 'road'; for (let i = 0; i < positions.length; i++) { const vx = positions[i]; const wx = vx * tileSize, wy = y * tileSize; const [sx, sy] = worldToScreen(wx, wy); const size = tileSize * zoom * (isRoad ? 1.2 : 1.5); ctx.fillStyle = isRoad ? '#e0a060' : '#8b7355'; ctx.fillRect(sx, sy, size, size * 0.7); ctx.strokeStyle = isRoad ? '#c0caf5' : '#9ece6a'; ctx.strokeRect(sx, sy, size, size * 0.7); } } } } const gauntletObsDraw = isGauntlet() ? getGauntletObsDrawPositionsAt(performance.now()) : []; if (isGauntlet() && gauntletObsDraw.length) { const stx = Math.max(0, Math.floor(worldMinX / tileSize)); const enx = Math.min(w - 1, Math.ceil(worldMaxX / tileSize)); const sty = Math.max(0, Math.floor(worldMinY / tileSize)); const eny = Math.min(h - 1, Math.ceil(worldMaxY / tileSize)); for (let i = 0; i < gauntletObsDraw.length; i++) { const o = gauntletObsDraw[i]; if (!o) continue; if (o.kind === 'lane' && typeof o.y === 'number') { if (o.drawX < stx - 2 || o.drawX > enx + 2 || o.y < sty || o.y > eny) continue; const wx = o.drawX * tileSize, wy = o.y * tileSize; const [sx, sy] = worldToScreen(wx, wy); const size = tileSize * zoom; const laneRec = pickGauntletLaneImageRec(o.id); if (laneRec && laneRec.img.complete && laneRec.img.naturalWidth > 0) { try { ctx.drawImage(laneRec.img, sx + 2, sy + 2, size - 4, size - 4); } catch (e) { ctx.fillStyle = '#f7768e'; ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4); } ctx.strokeStyle = '#ff9ebc'; ctx.lineWidth = 2; ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); ctx.lineWidth = 1; } else { ctx.fillStyle = '#f7768e'; ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4); ctx.strokeStyle = '#ff9ebc'; ctx.lineWidth = 2; ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); ctx.lineWidth = 1; } } else if (o.kind === 'laser' && typeof o.drawX === 'number') { if (o.drawX < stx - 2 || o.drawX > enx + 2) continue; const wx0 = o.drawX * tileSize; const wx1 = (o.drawX + 1) * tileSize; const wy0 = 0; const wy1 = h * tileSize; const [sx0, syTop] = worldToScreen(wx0, wy0); const [sx1, syBot] = worldToScreen(wx1, wy1); const rx = Math.min(sx0, sx1); const rw = Math.max(2, Math.abs(sx1 - sx0)); const ry = Math.min(syTop, syBot); const rh = Math.abs(syBot - syTop); drawGauntletLaserColumnScreen(rx, ry, rw, rh); } } } function drawAvatar(ax, ay, isMe, name, characterId, direction, isWalking, playTint, gauntletAirTicks, gauntletScoreLabel) { const cells = Math.max(1, Math.min(4, mapData.characterCells || 1)); const air = Number(gauntletAirTicks) || 0; let liftWorldY = 0; if (isGauntlet() && air > 0) { liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52; } const cxWorld = (ax + cells * 0.5) * tileSize; const cyBottomWorld = (ay + 1) * tileSize - liftWorldY; const [sx, sy] = worldToScreen(cxWorld, cyBottomWorld); const r = Math.max(14, (tileSize * zoom * cells) / 2 - 2); const size = r * 2.2; const dir = direction || 'down'; const rawImg = getAvatarImg(characterId, dir, timeMs, isWalking); const charImg = playTint ? getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, playTint) : rawImg; /* รองรับทั้ง และ canvas จากย้อมสี (canvas ไม่มี naturalWidth → เดิมตกวงกลม) */ let iw = 0, ih = 0; if (charImg && charImg.tagName === 'CANVAS' && charImg.width > 0 && charImg.height > 0) { iw = charImg.width; ih = charImg.height; } else if (charImg && charImg.complete && charImg.naturalWidth) { iw = charImg.naturalWidth; ih = charImg.naturalHeight; } if (iw > 0 && ih > 0) { const scale = Math.min(size / iw, size / ih, 1); const drawW = iw * scale; const drawH = ih * scale; ctx.drawImage(charImg, 0, 0, iw, ih, sx - drawW / 2, sy - drawH, drawW, drawH); } else { const cy = sy - r; ctx.fillStyle = isMe ? '#7aa2f7' : '#9ece6a'; ctx.beginPath(); ctx.arc(sx, cy, r, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#c0caf5'; ctx.lineWidth = 2; ctx.stroke(); } ctx.fillStyle = '#c0caf5'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; const nameBase = name || ''; const withScore = isGauntlet() && typeof gauntletScoreLabel === 'number' && gauntletScoreLabel >= 0 ? (nameBase + ' · ' + gauntletScoreLabel) : nameBase; ctx.fillText(withScore, sx, sy + 10); } const safeX = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1); const safeY = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1); function peerVisualOffset(id) { let h = 0; for (let i = 0; i < (id || '').length; i++) h = (h * 31 + (id || '').charCodeAt(i)) >>> 0; return { ax: ((h % 5) - 2) * 0.1, ay: ((Math.floor(h / 5) % 5) - 2) * 0.1 }; } const othersSorted = [...others.entries()].sort((a, b) => { const oa = a[1], ob = b[1]; const ya = safeY(oa.y), yb = safeY(ob.y); if (Math.abs(ya - yb) > 0.01) return ya - yb; return safeX(oa.x) - safeX(ob.x); }); othersSorted.forEach(([id, o]) => { const off = isGauntlet() ? { ax: 0, ay: 0 } : peerVisualOffset(id); const otherWalk = isGauntlet() ? ((o.gauntletJumpTicks || 0) > 0 || (o.gauntletJumpVis || 0) > 0.08) : (isPreviewBotId(id) ? !!o.botIsWalking : !!((o.tx != null && Math.abs((o.tx || o.x) - o.x) > 0.02) || (o.ty != null && Math.abs((o.ty || o.y) - o.y) > 0.02))); const ot = o.playTint || playTintFromPeerId(id); drawAvatar(safeX(o.x) + off.ax, safeY(o.y) + off.ay, false, o.nickname, o.characterId, o.direction, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0, o.gauntletScore || 0); }); const mt = me.playTint || pickRandomPlayTint(); if (!me.playTint) me.playTint = mt; const meTag = isFrogger() ? (' (กบ) ' + froggerScore) : (isGauntlet() && meGauntletJumpTicks > 0 ? ' (กระโดด)' : ' (คุณ)'); const meWalking = isFrogger() ? false : (isGauntlet() ? (meGauntletJumpTicks > 0 || meGauntletJumpVis > 0.08) : !!me.isWalking); drawAvatar(safeX(me.x), safeY(me.y), true, me.nickname + meTag, me.characterId, me.direction, meWalking, mt, meGauntletJumpVis, me.gauntletScore || 0); if (isFrogger()) { ctx.fillStyle = '#7aa2f7'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('โหมดกบ | คะแนน: ' + froggerScore, 10, 24); } if (isGauntlet()) { ctx.fillStyle = '#f7768e'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('พรมแดงสุดท้าย | คะแนน: ' + (me.gauntletScore || 0) + ' (ข้าม 1 ชิ้น +1) · สูงสุด 6 คน', 10, 24); ctx.font = '12px sans-serif'; ctx.fillStyle = '#c0caf5'; ctx.fillText('Space / W / ↑ = กระโดด · เลน + เลเซอร์ · ข้ามสำเร็จ → ขวา 1 + คะแนน · ชน → ซ้าย 1', 10, 42); ctx.fillStyle = '#e0af68'; if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) { const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000)); const mm = Math.floor(rem / 60); const ss = rem % 60; const clock = `${mm}:${String(ss).padStart(2, '0')}`; ctx.fillText(`เหลือเวลา ${clock} · Time left ${clock}`, 10, 60); } else if (gauntletRuntimeTimeLimitSec > 0) { ctx.fillText('เวลา: กำลังซิงก์จากเซิร์ฟเวอร์... · Syncing timer...', 10, 60); } else { ctx.fillText('เวลา: ไม่จำกัด · No time limit (ตั้งได้ที่ Admin → เวลาเกม)', 10, 60); } } if (isQuiz()) syncPlayQuizMapPanel(); if (previewFillBots && mapData) { const human = countPlayHumans(); const bots = [...others.keys()].filter(isPreviewBotId).length; ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = 'rgba(26,27,38,0.78)'; ctx.fillRect(6, canvas.height - 38, Math.min(canvas.width - 12, 320), 30); ctx.fillStyle = '#a9b1d6'; ctx.font = '12px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('ทดสอบ: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + previewTargetHeadcount + ' คน)', 12, canvas.height - 18); ctx.restore(); } } document.addEventListener('keydown', (e) => { if (isMovementKey(e.code) && isChatFocused()) return; if (isGauntlet() && mapData && !isChatFocused()) { if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') { e.preventDefault(); const now = Date.now(); if (now - lastGauntletJumpKey < 200) return; lastGauntletJumpKey = now; socket.emit('gauntlet-jump'); return; } if (['ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) { e.preventDefault(); return; } } keys[e.code] = true; keys[e.key] = true; if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault(); if (isFrogger() && mapData && !isChatFocused()) { const now = Date.now(); if (now - lastFroggerKey < 280) return; let dx = 0, dy = 0; if (e.code === 'ArrowUp' || e.code === 'KeyW') { dy = -1; me.direction = 'up'; } else if (e.code === 'ArrowDown' || e.code === 'KeyS') { dy = 1; me.direction = 'down'; } else if (e.code === 'ArrowLeft' || e.code === 'KeyA') { dx = -1; me.direction = 'left'; } else if (e.code === 'ArrowRight' || e.code === 'KeyD') { dx = 1; me.direction = 'right'; } if (dx !== 0 || dy !== 0) { const nx = Math.round(me.x) + dx, ny = Math.round(me.y) + dy; if (nx >= 0 && nx < mapData.width && ny >= 0 && ny < mapData.height) { const row = mapData.objects && mapData.objects[ny]; if (!row || row[nx] !== 1) { me.x = nx; me.y = ny; lastFroggerKey = now; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); const lane = getLane(Math.floor(me.y)); if (lane && lane.type === 'goal') { froggerScore++; respawnFrogger(); } else if (checkFroggerCollision()) respawnFrogger(); } } } } }); document.addEventListener('keyup', (e) => { keys[e.code] = false; keys[e.key] = false; }); let lastSend = 0; function tick() { if (!mapData) { requestAnimationFrame(tick); return; } if (isFrogger()) { if (checkFroggerCollision()) respawnFrogger(); me.isWalking = false; draw(); requestAnimationFrame(tick); return; } if (isGauntlet()) { me.isWalking = meGauntletJumpTicks > 0; const stepGauntletJumpVis = (vis, tgt) => { const t = Number(tgt) || 0; let v = Number(vis) || 0; const d = t - v; const k = d < 0 ? 0.68 : 0.4; return v + d * k; }; meGauntletJumpVis = stepGauntletJumpVis(meGauntletJumpVis, meGauntletJumpTicks); const mp = lerpGauntletEntityPos(me.x, me.y, me.tx, me.ty); me.x = mp.nx; me.y = mp.ny; others.forEach((o) => { const op = lerpGauntletEntityPos(o.x, o.y, o.tx, o.ty); o.x = op.nx; o.y = op.ny; if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks || 0; o.gauntletJumpVis = stepGauntletJumpVis(o.gauntletJumpVis, o.gauntletJumpTicks || 0); }); draw(); requestAnimationFrame(tick); return; } /* บอท preview ขยับที่ o.x/o.y โดยตรง — ห้าม LERP กับ tx/ty เพราะ stepPreviewBots เคยตั้ง tx=ก่อนเดิน ทำให้โดนดึงถอย ~20%/เฟรม กระตุกและแทบไม่ไป */ others.forEach((o, id) => { if (previewFillBots && isPreviewBotId(id)) return; if (o.tx != null) o.x += (o.tx - o.x) * LERP; if (o.ty != null) o.y += (o.ty - o.y) * LERP; }); stepPreviewBots(); if (isChatFocused()) { me.isWalking = false; draw(); requestAnimationFrame(tick); return; } const w = mapData.width || 20, h = mapData.height || 15; let accX = 0, accY = 0; let usePath = false; const keyPressed = keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD']; if (playPath.length > 0 && keyPressed) playPath = []; if (playPath.length > 0) { const way = playPath[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) { playPath.shift(); /* ข้าม waypoint ที่ซ้ำตำแหน่ง — กัน acc=0 ทั้งที่ยังมี path (เดินขึ้น/ลงแล้วแอนิเมชันค้าง) */ while (playPath.length > 0) { const w2 = playPath[0]; const ux = w2.x - me.x, uy = w2.y - me.y; if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break; playPath.shift(); } if (playPath.length === 0) { me.isWalking = false; 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 t = Date.now(); if (t - lastSend > 80) { lastSend = t; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } draw(); requestAnimationFrame(tick); return; } usePath = true; const next = playPath[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) { 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'; } } const preWalkX = me.x, preWalkY = me.y; 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 (canWalkLikeLobby(nx, ny, me.x, me.y)) { me.x = nx; me.y = ny; } else if (canWalkLikeLobby(nx, me.y, me.x, me.y)) { me.x = nx; } else if (canWalkLikeLobby(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 movedThisTick = Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5; me.isWalking = !!(accX !== 0 || accY !== 0) || playPath.length > 0 || movedThisTick; const now = Date.now(); if (now - lastSend > 80) { lastSend = now; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } draw(); requestAnimationFrame(tick); } const chatForm = document.getElementById('chat-form'); if (chatForm) { chatForm.addEventListener('submit', (e) => { e.preventDefault(); const input = document.getElementById('chat-input'); const text = (input && input.value || '').trim(); if (text) { socket.emit('chat', text); if (input) input.value = ''; } }); } document.getElementById('btn-leave').addEventListener('click', () => { socket.emit('leave-space'); const mid = params.get('map'); if (previewMode && mid) { var edQ = '?id=' + encodeURIComponent(mid) + (editorEmbedReturn ? '&embed=1' : ''); window.location.replace(BASE + '/editor.html' + edQ); } else { window.location.replace(BASE + '/lobby.html'); } }); window.addEventListener('beforeunload', () => socket.emit('leave-space')); window.addEventListener('resize', () => { if (!mapData || !canvas) return; resizeCanvas(); draw(); }); })();