(function () { const BASE = '/Game'; /** แผงคำถามบนแมป quiz_carry — ไฟล์อยู่ public/img/quiz-carry */ const QUIZ_CARRY_MAP_FEEDBACK_IMG = { correct: BASE + '/img/quiz-carry/icon-Correct.png', incorrect: BASE + '/img/quiz-carry/icon-Incorrect.png', scorePlus: BASE + '/img/quiz-carry/score+10.png', }; const QUIZ_MAP_CARRY_FEEDBACK_MS = 1400; 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 playMapIdFromQuery = (params.get('map') || '').trim(); /** mapId จาก join / game-start — ใช้จับฉาก crown เมื่อ URL ไม่มี ?map= */ let playSessionMapId = playMapIdFromQuery; /** สรุปภารกิจแบบ mock (popup-result ฯลฯ) — ใช้เฉพาะฉากนี้จาก editor (id ใน URL เช่น editor.html?id=mnorwqx1) */ const quizCarryMissionSummaryMapId = 'mnorwqx1'; const quizCarryUseMissionSummaryOverlay = playMapIdFromQuery === quizCarryMissionSummaryMapId; /** Gauntlet พรมแดง — ฉากนี้จาก editor (?map=mno9kb07): วาดตัวหันขวา + ใช้รูป lane/laser จาก game-timing */ const GAUNTLET_FACE_RIGHT_MAP_ID = 'mno9kb07'; /** Jump Survival ฉาก Jumper — UI สรุปภารกิจแบบ crown + รูปใน /img/Jumper/ (editor ?map=mnptfts2) */ const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2'; /** จนกว่าโหลด /api/characters — placeholder ชั่วคราวก่อน join */ const LEGACY_PLACEHOLDER_CHARACTER_ID = ''; let firstCharacterDefaultResolved = null; const firstCharacterDefaultPromise = fetch(BASE + '/api/characters') .then((r) => r.json()) .then((list) => { if (Array.isArray(list) && list.length > 0 && list[0] && list[0].id) { firstCharacterDefaultResolved = String(list[0].id); } else { firstCharacterDefaultResolved = ''; } }) .catch(() => { firstCharacterDefaultResolved = ''; }); const lobbyLevelParam = params.get('lobbyLevel'); const lobbyCaseParam = params.get('case'); if (lobbyLevelParam || lobbyCaseParam) { try { window.__detectiveLobbyMeta = { level: lobbyLevelParam, caseId: lobbyCaseParam }; } catch (e) { /* ignore */ } } const POST_CASE_LOBBY_MAP_ID = 'mn8nx46h'; function isDetectiveMinigamePlay() { if (params.get('detectiveReturn') === '1') return true; try { return sessionStorage.getItem('detectiveMinigameReturn') === '1'; } catch (e) { return false; } } function buildRoomLobbyReturnHref() { let h = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); h += '&map=' + encodeURIComponent(POST_CASE_LOBBY_MAP_ID); try { const dr = localStorage.getItem('lastCreatedSpaceName'); if (dr) h += '&displayRoom=' + encodeURIComponent(dr); } catch (e) { /* ignore */ } try { const meta = window.__detectiveLobbyMeta; if (meta && meta.level != null && String(meta.level).trim()) { h += '&lobbyLevel=' + encodeURIComponent(String(meta.level).trim()); } if (meta && meta.caseId != null && String(meta.caseId).trim()) { h += '&caseId=' + encodeURIComponent(String(meta.caseId).trim()); } } catch (e2) { /* ignore */ } try { if (sessionStorage.getItem('detectiveMinigameReturn') === '1') { h += '&detectiveReturn=1'; sessionStorage.removeItem('detectiveMinigameReturn'); } } catch (e3) { /* ignore */ } if (params.get('detectiveReturn') === '1' && h.indexOf('detectiveReturn') < 0) { h += '&detectiveReturn=1'; } return h; } function finishDetectiveMinigameAndReturnLobby() { quizCarrySessionEnded = true; try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ } const go = function () { window.location.href = buildRoomLobbyReturnHref(); }; if (socket && socket.connected) { socket.emit('detective-minigame-finished', {}, function (res) { if (res && res.ok) go(); else go(); }); } else { go(); } } /** ทดสอบจากเอดิเตอร์: เติมบอทให้ครบจำนวน (รวมผู้เล่นจริง) — ?fillTotal=6 (ค่าเริ่ม 6) */ const previewFillBots = previewMode && editorEmbedReturn; const previewTargetHeadcount = Math.min(24, Math.max(1, parseInt(params.get('fillTotal'), 10) || 6)); let detectiveCaseFillBots = false; let detectiveBotSlotCount = 0; function playBotsEnabled() { return previewFillBots || detectiveCaseFillBots; } function playBotTargetHeadcount() { if (previewFillBots) return previewTargetHeadcount; if (detectiveCaseFillBots) return countPlayHumans() + detectiveBotSlotCount; return countPlayHumans(); } const PREVIEW_BOT_PREFIX = '__pv_bot_'; let previewBotSeq = 0; /** Stack preview: บอทไม่วาบบนแผนที่ — ใช้ไฮไลต์แถบแข่งแทน */ let lastStackPreviewActorId = null; let lastStackPreviewActorUntil = 0; /** Stack preview: สลับตา Player 1 = มนุษย์, 2–6 = บอท (เฉพาะตาปัจจุบันเล่นได้) */ let stackTurnCount = 6; let stackPreviewTurnOrder = null; let stackPreviewTurnIndex = 0; let stackPreviewBotThinkUntil = 0; function rebuildStackPreviewTurnOrder() { if (!playBotsEnabled() || !mapData || mapData.gameType !== 'stack') { stackPreviewTurnOrder = null; stackTurnCount = 1; return; } const bots = [...others.keys()].filter(isPreviewBotId).sort(); stackTurnCount = Math.max(1, Math.min(12, playBotTargetHeadcount())); stackPreviewTurnOrder = [{ kind: 'human', seat: 1 }]; for (let i = 0; i < stackTurnCount - 1; i++) { stackPreviewTurnOrder.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null }); } stackPreviewTurnIndex = 0; stackPreviewBotThinkUntil = 0; } function advanceStackPreviewTurn() { if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== stackTurnCount) return; stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % stackTurnCount; stackPreviewBotThinkUntil = 0; } function isStackPreviewHumanTurn() { if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== stackTurnCount) return true; const cur = stackPreviewTurnOrder[stackPreviewTurnIndex]; return !!(cur && cur.kind === 'human'); } function getStackPreviewCurrentSeat() { if (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== stackTurnCount) return 1; const cur = stackPreviewTurnOrder[stackPreviewTurnIndex]; return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1; } if (!spaceId) { window.location.replace(BASE + '/lobby.html'); return; } const socket = io({ path: BASE + '/socket.io' }); const canvas = document.getElementById('game-canvas'); const ctx = canvas.getContext('2d'); let mapData = null, tileSize = 32, myId = null; let stackMini = null; let stackFall = null; let lastStackTickMs = performance.now(); let mapBackgroundImg = null; /** โหลดจาก mapData.gridImageLibrary — ดัชนีตรงกับ gridImageCells[y][x] */ let mapGridImageImgs = []; let mapGridImageHeldImgs = []; let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, gauntletEliminated: false, tx: null, ty: null, quizCarryHeld: null }; const others = new Map(); /** Stack preview HUD: เทอร์มินัลล็อก (cyber UI) */ const STACK_PREVIEW_HUD_LOG_MAX = 8; let stackPreviewHudLog = []; function stackPreviewPushHudLog(line) { if (!previewFillBots) return; stackPreviewHudLog.push(String(line || '').slice(0, 96)); while (stackPreviewHudLog.length > STACK_PREVIEW_HUD_LOG_MAX) stackPreviewHudLog.shift(); } function stackPreviewLogStackDrop(hit, actor) { if (!previewFillBots || !hit) return; const bid = actor && actor.botId; let nm = 'NODE'; if (actor && actor.human) nm = (me.nickname || 'YOU').toUpperCase().replace(/\s+/g, '_').slice(0, 12); else if (bid && others.get(bid)) nm = String(others.get(bid).nickname || 'BOT').toUpperCase().replace(/\s+/g, '_').slice(0, 12); if (hit.miss) stackPreviewPushHudLog(`>>> ${nm}: SECTOR_MISS`); else if (hit.perfect) stackPreviewPushHudLog(`>>> ${nm}: PERFECT +${hit.pts} SYN`); else stackPreviewPushHudLog(`>>> ${nm}: LOCK +${hit.pts}`); } 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 characterIdleAnimations = {}; 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; } /** ตัวละครอัปโหลดจากระบบมักเป็น char- + ตัวเลข (อาจมี suffix) — ไม่มี idle strip / idle layer ครบทุกทิศ */ function isUploadedCharAssetId(id) { if (!id) return false; const s = String(id).trim(); return /^char-\d/i.test(s); } function useMultiFrameIdleSpriteSheets(id) { if (!id) return true; return !isUploadedCharAssetId(id); } function ensureCharacterIdleAnim(id, dir) { const d = dir || 'down'; /* char-* ไม่มี *_idle_0.png — ใช้ strip เดิน dir_0..3 เป็น idle ต่อทิศ (มีลูป) แทนรูปเดียวที่มักไม่มี */ if (!useMultiFrameIdleSpriteSheets(id)) { const key = String(id) + '_' + d + '_idle'; let anim = characterIdleAnimations[key]; if (!anim) { anim = { frames: [], fallback: null }; characterIdleAnimations[key] = anim; const enc = encodeURIComponent(id); for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) { const img = new Image(); img.src = BASE + '/img/characters/' + enc + '_' + d + '_' + i + '.png'; anim.frames.push(img); } const fb = new Image(); fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png'; anim.fallback = fb; } return anim; } const key = id + '_' + d + '_idle'; let anim = characterIdleAnimations[key]; if (!anim) { anim = { frames: [], fallback: null }; characterIdleAnimations[key] = anim; const enc = encodeURIComponent(id); for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) { const img = new Image(); img.src = BASE + '/img/characters/' + enc + '_' + d + '_idle_' + i + '.png'; anim.frames.push(img); } const fb = new Image(); fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png'; anim.fallback = fb; } return anim; } /** ลูป idle ขณะยืน — ใช้จังหวะเวลาเหมือนเดิน (CHARACTER_ANIM_FRAME_MS) */ function idleAnimPhaseIndex(now) { const t = typeof now === 'number' ? now : Date.now(); return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES; } function characterIdleSpritesVisible(id, dir) { if (!id) return false; const anim = ensureCharacterIdleAnim(id, dir || 'down'); if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return true; for (let i = 0; i < anim.frames.length; i++) { const f = anim.frames[i]; if (f && f.complete && f.naturalWidth) return true; } return false; } /** สุ่มสีตามเลเยอร์เดียวกับ 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 }; /** id → Set เลเยอร์ที่มีไฟล์จริง (จาก GET /api/characters) — กันยิง URL เลเยอร์ที่ไม่เคยอัปโหลด → 404 รกคอนโซล */ const playCharDiskLayersById = Object.create(null); function ensurePlayCharLayerListFetch() { if (playCharLayerApi.status !== 'idle') return; playCharLayerApi.status = 'loading'; fetch(BASE + '/api/characters') .then((r) => r.json()) .then((list) => { const map = Object.create(null); if (Array.isArray(list)) { list.forEach((c) => { if (!c || !c.id) return; map[c.id] = !!c.hasLayerFiles; if (Array.isArray(c.layers) && c.layers.length) { const st = new Set(); c.layers.forEach((n) => { if (typeof n === 'string' && n.length) st.add(n); }); if (st.size) playCharDiskLayersById[c.id] = st; else delete playCharDiskLayersById[c.id]; } else { delete playCharDiskLayersById[c.id]; } }); } 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; } /** ถ้าเซิร์ฟเวอร์ส่งรายชื่อเลเยอร์ที่มีบนดิสก์ — ไม่โหลดเลเยอร์อื่น (ลด 404) */ function playCharDiskLayersSet(characterId) { if (!characterId || playCharLayerApi.status !== 'done') return null; const s = playCharDiskLayersById[characterId]; return s && s.size ? s : 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)], }; } /** FNV-1a 32-bit — แยกดัชนี head/hair/body ได้กระจายกว่า hash*31 เดิม (กันบอท __pv_bot_N สีซ้ำ) */ function hashStringFnv1a32(str) { let h = 2166136261 >>> 0; const s = String(str || ''); for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } return h >>> 0; } function playTintFromPeerId(id) { const s = String(id || 'x'); const h0 = hashStringFnv1a32(s); const h1 = hashStringFnv1a32(s + '\n1hair'); const h2 = hashStringFnv1a32(s + '\n2body'); return { head: PLAY_TINT_HEAD[h0 % PLAY_TINT_HEAD.length], hair: PLAY_TINT_HAIR[h1 % PLAY_TINT_HAIR.length], body: PLAY_TINT_BODY[h2 % 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 = BASE + '/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; } /** เลเยอร์ idle: id__idle__layer_.png หรือ id__idle_layer_.png */ function layerUrlCandidatesIdle(id, dir, layerName, frameIndex) { const enc = encodeURIComponent(id); const base = BASE + '/img/characters/' + enc + '_' + dir + '_idle'; 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 = BASE + '/img/default-shadow-' + dir + '.png'; if (c.indexOf(def) === -1) c.push(def); return c; } function shadowUrlCandidatesIdle(id, dir, frameIndex) { const c = layerUrlCandidatesIdle(id, dir, 'shadow', frameIndex); const def = BASE + '/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, opts) { const useIdle = opts && opts.idle; 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; const diskLayers = playCharDiskLayersSet(id); /* ยืน (opts.idle): ลอง compose จาก *_idle_*_layer_* เหมือนหน้า character upload — รวม char-* ที่อัปโหลด idle เลเยอร์; ถ้าไม่มีไฟล์จะ missing → ไม่มีเลเยอร์ย้อม → getPlayTintedAvatarSource fallback ไป walk compose */ const idleLayerStripes = !!useIdle; const resDir = dir; const resFi = frameIndex; for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) { const layerName = PLAY_LAYER_ORDER[li]; if (diskLayers && layerName !== 'shadow' && !diskLayers.has(layerName)) continue; /* char-* หันหลัง (up): ไม่มี face ทิศนี้ — ข้ามกันตาหน้าซ้อนหลังหัว; ซ้าย/ขวา/หน้าใช้ face ตามทิศได้ */ if (layerName === 'face' && isUploadedCharAssetId(id) && dir === 'up') { continue; } let urls; if (layerName === 'shadow') { urls = idleLayerStripes ? shadowUrlCandidatesIdle(id, dir, frameIndex) : shadowUrlCandidates(id, resDir, resFi); } else { urls = idleLayerStripes ? layerUrlCandidatesIdle(id, dir, layerName, frameIndex) : layerUrlCandidates(id, resDir, layerName, resFi); } 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 dirn = dir || 'down'; const frameIndexWalk = getCharacterAnimFrameIndex(characterId, dirn, timeMs, isWalking); const idlePhase = idleAnimPhaseIndex(typeof timeMs === 'number' ? timeMs : Date.now()); if (playCharLayerDiscoveryPending(characterId)) return rawImg; const apiFlag = playCharLayerFromApi(characterId); /* เหมือนเดิม: มีเลเยอร์แยกไฟล์เมื่อ API บอก hasLayerFiles หรือ probe เจอ — gen สีทำบน canvas จาก *_layer_* เท่านั้น */ const charHasLayerFiles = apiFlag === true || (apiFlag == null && playCharAnyDirectionLayered(characterId)); /* มี PNG idle รวม — ใช้เมื่อไม่มีระบบเลเยอร์สี (ถ้ามีเลเยอร์ต้อง compose ไม่ return raw ขาว) */ if (!charHasLayerFiles && !isWalking && characterIdleSpritesVisible(characterId, dirn)) { return rawImg; } const cacheKeyWalk = [characterId, dirn, frameIndexWalk, tint.head, tint.hair, tint.body, 'ct3'].join('|'); if (charHasLayerFiles) { if (!isWalking) { const composeFrame = isUploadedCharAssetId(characterId) ? 0 : idlePhase; const cacheKeyIdle = [characterId, dirn, composeFrame, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|'); const hitI = playLayerCompositeCache.get(cacheKeyIdle); if (hitI) return hitI; const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, composeFrame, tint, { idle: true }); if (packI && packI.canvas) { if (!packI.skipCache) playLayerCompositeCache.set(cacheKeyIdle, packI.canvas); return packI.canvas; } } const hit = playLayerCompositeCache.get(cacheKeyWalk); if (hit) return hit; const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, frameIndexWalk, tint, { idle: false }); if (pack && pack.canvas) { if (!pack.skipCache) playLayerCompositeCache.set(cacheKeyWalk, pack.canvas); return pack.canvas; } return rawImg; } /* * ไม่มีไฟล์เลเยอร์ตาม API/probe — แสดง PNG รวม (ไม่ย้อมทั้งตัว: ไม่เหมือนระบบ gen สีแบบเลเยอร์เดิม) */ 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 = BASE + '/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 t = typeof now === 'number' ? now : Date.now(); if (!isWalking) { const idleAnim = ensureCharacterIdleAnim(id, dir); let idlePhase = idleAnimPhaseIndex(t); if (isUploadedCharAssetId(id)) idlePhase = 0; const idleFi = pickLoadedWalkFrameIndex(idleAnim, idlePhase); if (idleFi >= 0) return idleAnim.frames[idleFi]; const idfb = idleAnim.fallback; if (idfb && idfb.complete && idfb.naturalWidth) return idfb; } 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 = BASE + '/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; } function getStoredCharacterId() { try { let v = localStorage.getItem('gameCharacterId'); if (v === 'Chatest') v = ''; // legacy placeholder — ไม่มี sprite จริง if (v) return v; } catch (e) { /* ignore */ } if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID; return firstCharacterDefaultResolved || ''; } function getPlayCharacterId() { if (forceDefaultCharacter) { if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID; return firstCharacterDefaultResolved || ''; } 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; /** Stack: รอบสวิงต่อวินาที — จาก GET /api/game-timing · ค่าน้อย = สวิงช้า */ let playStackSwingHz = 0.55; /** Stack: ความกว้างบล็อก (tile) — null = คำนวณจากโซนลงบนแผนที่ */ let playStackBlockWidthTiles = null; /** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัว (ch×tile) — จาก GET /api/game-timing */ let playJumpSurviveJumpHeightMult = 1.5; /** จำกัดเวลารอบภารกิจกระโดดขึ้นแท่น (วินาที) — จาก GET /api/game-timing; 0 = ใช้ค่าเริ่ม 60 ในเกม */ let playJumpSurviveMissionTimeSec = 0; /** ยิงยานอวกาศ (space_shooter): เวลารอบจาก game-timing; 0 = ใช้ค่าเริ่ม 90 ในเกมถ้าแมปไม่ทับ */ let playSpaceShooterMissionTimeSec = 0; /** 0 = ไม่จำกัด — จาก game-timing / gauntlet-sync (พรมแดง Gauntlet เท่านั้น) */ let gauntletRuntimeTimeLimitSec = 0; /** เวลาสิ้นสุดรอบ (epoch ms) จากเซิร์ฟเวอร์ — null = ไม่จับเวลา */ let gauntletEndsAtMs = null; /** Crown (mno9kb07): เซิร์ฟยังไม่ปล่อยรัน — หยุด tick / เวลา */ let gauntletCrownRunHeldRemote = false; /** null | 'howto' | 'countdown' | 'live' — พรีรันก่อน GO */ let gauntletCrownPregamePhase = null; let gauntletCrownLobbyReadyMap = {}; let gauntletCrownCountdownTimer = 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(); /** แปลง URL จาก Admin/game-timing ให้โหลดได้ (nginx เสิร์ฟจาก /Game/...) */ function normalizeGauntletAssetUrlForPlay(u) { if (typeof u !== 'string') return ''; const t = u.trim(); if (!t) return ''; if (/^https?:\/\//i.test(t)) return t; const qIdx = t.indexOf('?'); const base = (qIdx >= 0 ? t.slice(0, qIdx) : t).trim(); const qs = qIdx >= 0 ? t.slice(qIdx) : ''; if (!base) return ''; if (base.startsWith('/')) return base + qs; if (/^Game\//i.test(base)) return '/' + base.replace(/^\/+/, '') + qs; return base + qs; } function ensureGauntletAssetImage(url) { const u = normalizeGauntletAssetUrlForPlay(typeof url === 'string' ? url : ''); 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 = {}; /** คะแนนต่อคำตอบที่ถูกเมื่อส่งป้ายที่ฮับ/โซนส่ง — quiz_carry เท่านั้น */ const QUIZ_CARRY_POINTS_PER_CORRECT = 10; /** quiz_carry: หยิบตัวเลือกมาวางโซนกลาง — เล่นพร้อมกัน (ไม่สลับตา) */ let quizCarryPool = []; let quizCarryCurrent = null; /** editor embed + preview: นับ 3–2–1 ก่อนโชว์คำถามและให้เดิน/หยิบได้ */ let quizCarryEmbedPendingQuestion = null; let quizCarryEmbedCountdownStartAt = 0; let quizCarryEmbedCountdownEndAt = 0; /** quiz_carry: epoch ให้หยิบตัวเลือกได้ · epoch ปิดรอบตอบ (ตั้งที่ Admin แท็บหยิบมาวาง) */ let quizCarryOptionRevealAt = 0; let quizCarryAnswerCloseAt = 0; let quizCarryCarryTimingMs = { carryReadMs: 3000, carryAnswerMs: 5000 }; /** พรีวิว (รวม editor embed): จำนวนข้อที่เล่นก่อนโอเวอร์เลย์จบ — 0 = ไม่จบอัตโนมัติ */ let quizCarrySessionLength = 0; let quizCarryRoundsCompleted = 0; let quizCarrySessionEnded = false; /** หลัง timeup บนโต๊ะ (embed): รอ 5s แล้วโชว์ result-complete / result-gameover */ let quizCarryResultEndTimer = null; /** หลังโชว์ result-end (embed preview): รอ 5s แล้วกลับ room-lobby — ใช้ร่วม quiz_carry + crown */ let embedPreviewLobbyReturnTimer = null; /** embed พรีวิว: รอ Ready / START ของโฮสต์ก่อนนับ 3–2–1 */ let quizCarryPregameActive = false; let playHostId = null; /** ซิงก์จากเซิร์ฟเวอร์: socketId → กด Ready แล้ว */ let quizCarryLobbyReadyMap = {}; /** จาก /api/quiz-settings carryMapPanelTheme — ใช้กับ #quiz-map-question-panel เฉพาะ quiz_carry */ let quizCarryMapPanelTheme = null; /** จาก /api/quiz-settings carryEmbedCountdownTheme — สี/ขนาด overlay นับ 3-2-1 (embed) */ let quizCarryEmbedCountdownTheme = null; /** จาก /api/quiz-settings carryChoicePlaqueThemes — ป้าย canvas ต่อช่องตัวเลือก 0..15 (null = ใช้ค่าเริ่มต้นทุกช่อง) */ let quizCarryChoicePlaqueThemes = null; /** จาก carryChoicePlaqueMapScale — ขยายป้ายบนแมป + ฟอนต์ (0.85–2.5) */ let quizCarryPlaqueMapScale = 1.25; const quizCarryChoiceImageCache = new Map(); /** สแนปจาก join-space (Node) — ใช้เมื่อ fetch HTTP quiz-settings ไม่ได้หรือไม่มีธีม */ let quizCarryJoinSettingsSnap = null; /** quiz_carry — โซนตัวเลือกบนแมป / ธีมป้ายต่อช่องสูงสุด 16 */ const QUIZ_CARRY_MAX_OPTION_SLOTS = 16; /** jump_survive — กล้องตามตัวในกรอบ + แพลตฟอร์มเลื่อนขึ้น (scroll) ไม่ลากทั้งฉาก */ let jumpSurviveCamCenterX = 0; let jumpSurviveCamCenterY = 0; let jumpSurviveEliminated = false; /** จับเวลาโหมดกระโดดให้รอด (วินาทีบน HUD) */ let jumpSurviveSessionStartMs = 0; let jumpSurvivePlatformScrollPx = 0; let jumpSurviveVy = 0; let jumpSurviveLastTickMs = 0; let jumpSurviveJumpQueued = false; let jumpSurviveOnGround = false; let jumpSurviveLastEmitT = 0; /** mnptfts2: howto → countdown → live → ended (คะแนน 100 ผู้รอดสุดท้าย) */ let jumpSurviveMissionPhase = null; let jumpSurviveGameEnded = false; let jumperMissionCountdownTimer = null; /** space_shooter — ยิงหิน (จุดเกิดจากกริด P1–P6) */ let spaceShooterBullets = []; let spaceShooterAsteroids = []; let spaceShooterPopups = []; let spaceShooterLastTickMs = 0; let spaceShooterSpawnAccMs = 0; let spaceShooterFireCd = 0; let spaceShooterSessionStartMs = 0; let spaceShooterLastMoveEmit = 0; let spaceShooterGameEnded = false; /** balloon_boss — ลูกโป้งยิงบอส (Mega Virus) */ let balloonBossPendingShots = []; let balloonBossPlayerBullets = []; let balloonBossBossBullets = []; let balloonBossSessionStartMs = 0; let balloonBossLastTickMs = 0; let balloonBossLastMoveEmit = 0; let balloonBossPlayerFireCd = 0; let balloonBossGameEnded = false; let balloonBossHitFx = []; let balloonBossBossFireAcc = 0; /** quiz_battle — โดม MCQ กด E (ข้อจาก battleQuizMcq) */ let quizBattleMcqPool = []; let quizBattleAnsweredComps = new Set(); let quizBattleModalCompId = null; const SPACE_SHOOTER_SHIP_COLORS = ['#50fa7b', '#ff79c6', '#ff5555', '#f1fa8c', '#bd93f9', '#8be9fd']; 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() && !isQuizCarry() && !isQuizBattle())) 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); }); if (isQuizCarry() && useCyberPlayHud()) { wrap.classList.add('is-hidden'); } } function initPlayLiveQuizScoresZeros() { if ((!isQuiz() && !isQuizCarry() && !isQuizBattle()) || 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 getTileBoundsForOnesGrid(grid) { 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 (Number(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 getQuizQuestionAreaTileBounds(md) { if (!md || md.gameType !== 'quiz') return null; return getTileBoundsForOnesGrid(md.quizQuestionArea); } /** quiz_carry: โซนทองพิเศษสำหรับข้อความคำถาม (ถ้าไม่วาด ใช้โซนกลางม่วงแทนใน syncPlayQuizMapPanel) */ function getQuizCarryQuestionAreaTileBounds(md) { if (!md || md.gameType !== 'quiz_carry') return null; return getTileBoundsForOnesGrid(md.quizQuestionArea); } function clearQuizMapPanelThemeInline(panel, textEl) { if (!panel || !textEl) return; panel.style.removeProperty('--qmap-bg'); panel.style.removeProperty('--qmap-border'); panel.style.removeProperty('--qmap-border-w'); panel.style.removeProperty('--qmap-shadow'); panel.style.removeProperty('background'); panel.style.removeProperty('border'); panel.style.removeProperty('border-color'); panel.style.removeProperty('border-width'); panel.style.removeProperty('border-style'); panel.style.removeProperty('box-shadow'); textEl.style.removeProperty('--qmap-text'); textEl.style.removeProperty('--qmap-text-shadow'); textEl.style.removeProperty('color'); textEl.style.removeProperty('text-shadow'); textEl.style.removeProperty('font-size'); textEl.style.removeProperty('line-height'); textEl.style.removeProperty('width'); textEl.style.removeProperty('box-sizing'); textEl.style.removeProperty('max-height'); textEl.style.removeProperty('overflow'); } function clearQuizMapQuestionCarryFeedback() { if (typeof window.__quizMapCarryFeedbackT === 'number') { clearTimeout(window.__quizMapCarryFeedbackT); window.__quizMapCarryFeedbackT = null; } const icon = document.getElementById('quiz-map-q-feedback-icon'); const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap'); const scoreEl = document.getElementById('quiz-map-q-feedback-score'); if (icon) { icon.classList.add('is-hidden'); icon.removeAttribute('src'); } if (scoreEl) scoreEl.classList.remove('quiz-map-q-feedback-score--pop'); if (scoreWrap) { scoreWrap.classList.add('is-hidden'); scoreWrap.setAttribute('aria-hidden', 'true'); } } /** ไอคอนถูก/ผิดที่ขอบบนแผง + score+10 ตรงกลางใต้ข้อความ (เฉพาะถูก) — ตาม mock quiz_carry */ function showQuizMapQuestionCarryFeedback(isCorrect) { if (!isQuizCarry()) return; const panel = document.getElementById('quiz-map-question-panel'); const icon = document.getElementById('quiz-map-q-feedback-icon'); const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap'); const scoreEl = document.getElementById('quiz-map-q-feedback-score'); if (!panel || !icon || panel.classList.contains('is-hidden')) return; clearQuizMapQuestionCarryFeedback(); icon.src = isCorrect ? QUIZ_CARRY_MAP_FEEDBACK_IMG.correct : QUIZ_CARRY_MAP_FEEDBACK_IMG.incorrect; icon.classList.remove('is-hidden'); if (isCorrect && scoreWrap && scoreEl) { scoreEl.src = QUIZ_CARRY_MAP_FEEDBACK_IMG.scorePlus; scoreWrap.classList.remove('is-hidden'); scoreWrap.setAttribute('aria-hidden', 'false'); scoreEl.classList.remove('quiz-map-q-feedback-score--pop'); void scoreEl.offsetWidth; scoreEl.classList.add('quiz-map-q-feedback-score--pop'); } else if (scoreWrap) { scoreWrap.classList.add('is-hidden'); scoreWrap.setAttribute('aria-hidden', 'true'); } window.__quizMapCarryFeedbackT = setTimeout(() => { window.__quizMapCarryFeedbackT = null; clearQuizMapQuestionCarryFeedback(); }, QUIZ_MAP_CARRY_FEEDBACK_MS); } /** ค่าเริ่มต้นแผงคำถามบนแผนที่ (ตรงกับ server defaultCarryMapPanelTheme) — ใช้เมื่อยังไม่โหลดจาก API */ function defaultCarryMapPanelThemePlay() { return { panelBg: 'rgba(12, 14, 28, 0.88)', panelBorder: 'rgba(255, 214, 102, 0.7)', borderWidthPx: 2, textColor: '#f1f5f9', questionFontMinPx: 10, questionFontMaxPx: 24, }; } /** ตรงกับ drawQuizCarryChoiceLabels — carryChoicePlaqueMapScale */ function quizCarryPlaqueMapScaleClampedPlay() { const sc = Number(quizCarryPlaqueMapScale); if (!Number.isFinite(sc)) return 1.25; return Math.max(0.85, Math.min(2.5, sc)); } /** * ขนาดตัวอักษรแผงคำถาม DOM ให้เท่าป้ายคำตอบบนพื้น: Math.max(10, Math.min(24, tileSize*zoom*0.24*ps)) * แล้ว clamp ด้วย questionFontMinPx / questionFontMaxPx จากธีม (ถ้าต้องการใหญ่กว่าป้ายได้โดยยกเพดาน max) */ function quizMapQuestionFontPxLikeCarryPlaques(th, zoomVal) { const ts = tileSize * zoomVal; const ps = quizCarryPlaqueMapScaleClampedPlay(); const base = Math.max(10, Math.min(24, ts * 0.24 * ps)); const fb = defaultCarryMapPanelThemePlay(); let mn = Number(th && th.questionFontMinPx); let mx = Number(th && th.questionFontMaxPx); if (!Number.isFinite(mn)) mn = fb.questionFontMinPx; if (!Number.isFinite(mx)) mx = fb.questionFontMaxPx; mn = Math.round(Math.max(8, Math.min(40, mn))); mx = Math.round(Math.max(10, Math.min(72, mx))); if (mx < mn) { const s = mn; mn = mx; mx = s; } return Math.max(mn, Math.min(mx, base)); } /** * ลด px ทีละ 1 จนเนื้อหาไม่ล้นกล่อง (ไม่ใช้ scrollbar) * zoom out กล่องเตี้ยมาก: อนุญาตต่ำกว่า questionFontMinPx ของธีมได้ถึง hardMin (≥6) ไม่ให้บรรทัดถูกตัด * แผงใช้ justify-content:flex-start ใน CSS — ไม่จัดกึ่งกลางแนวตั้งแล้วโดน overflow ตัดบน-ล่าง */ function fitQuizMapQuestionFontToPanel(panel, textEl, startPx, minPx) { const cs = window.getComputedStyle(panel); const padT = parseFloat(cs.paddingTop) || 0; const padB = parseFloat(cs.paddingBottom) || 0; const padL = parseFloat(cs.paddingLeft) || 0; const padR = parseFloat(cs.paddingRight) || 0; const availH = Math.max(12, panel.clientHeight - padT - padB); const availW = Math.max(24, panel.clientWidth - padL - padR); const themeMin = Math.max(8, Math.floor(Number(minPx) || 8)); const hardMin = Math.min(themeMin, Math.max(6, Math.floor(availH / 7))); let px = Math.round(Math.min(80, Math.max(hardMin, Number(startPx) || hardMin))); textEl.style.setProperty('line-height', '1.28', 'important'); textEl.style.setProperty('width', '100%', 'important'); textEl.style.setProperty('box-sizing', 'border-box', 'important'); textEl.style.removeProperty('max-height'); textEl.style.setProperty('overflow', 'hidden', 'important'); for (let i = 0; i < 96 && px > hardMin; i++) { textEl.style.setProperty('font-size', String(px) + 'px', 'important'); const sh = textEl.scrollHeight; const sw = textEl.scrollWidth; if (sh <= availH + 2 && sw <= availW + 2) break; px -= 1; } textEl.style.setProperty('font-size', String(px) + 'px', 'important'); textEl.style.setProperty('max-height', availH + 'px', 'important'); } /** ธีมจาก quiz-settings / join API — ถ้า JSON ไม่มีฟอนต์ให้ใช้ค่าเริ่มต้น */ function parseCarryMapPanelThemeObject(raw) { let t = raw; if (typeof t === 'string') { try { t = JSON.parse(t); } catch (e) { t = null; } } if (!t || typeof t !== 'object') return null; const bw = Number(t.borderWidthPx); const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2; const fb = defaultCarryMapPanelThemePlay(); let qMin = Number(t.questionFontMinPx); let qMax = Number(t.questionFontMaxPx); if (!Number.isFinite(qMin)) qMin = fb.questionFontMinPx; if (!Number.isFinite(qMax)) qMax = fb.questionFontMaxPx; qMin = Math.round(Math.max(10, Math.min(40, qMin))); qMax = Math.round(Math.max(14, Math.min(56, qMax))); if (qMax < qMin) { const s = qMin; qMin = qMax; qMax = s; } return { panelBg: String(t.panelBg || '').trim().slice(0, 120), panelBorder: String(t.panelBorder || '').trim().slice(0, 120), borderWidthPx, textColor: String(t.textColor || '').trim().slice(0, 120), questionFontMinPx: qMin, questionFontMaxPx: qMax, }; } /** * ชั้นทับจาก carryMapPanelTheme ในไฟล์แมป — ฟอนต์ใส่เฉพาะเมื่อ JSON มี key จริง * (กันธีมแมปมีแค่สีแล้วไปทับขนาดฟอนต์จาก Admin เป็นค่าเริ่ม 16/24) */ function parseCarryMapPanelThemeMapOverlay(raw) { let t = raw; if (typeof t === 'string') { try { t = JSON.parse(t); } catch (e) { t = null; } } if (!t || typeof t !== 'object') return null; const bw = Number(t.borderWidthPx); const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2; const out = { panelBg: String(t.panelBg || '').trim().slice(0, 120), panelBorder: String(t.panelBorder || '').trim().slice(0, 120), borderWidthPx, textColor: String(t.textColor || '').trim().slice(0, 120), }; const hasMin = Object.prototype.hasOwnProperty.call(t, 'questionFontMinPx'); const hasMax = Object.prototype.hasOwnProperty.call(t, 'questionFontMaxPx'); if (hasMin || hasMax) { const fb = defaultCarryMapPanelThemePlay(); let qMin = hasMin ? Number(t.questionFontMinPx) : fb.questionFontMinPx; let qMax = hasMax ? Number(t.questionFontMaxPx) : fb.questionFontMaxPx; if (!Number.isFinite(qMin)) qMin = fb.questionFontMinPx; if (!Number.isFinite(qMax)) qMax = fb.questionFontMaxPx; qMin = Math.round(Math.max(10, Math.min(40, qMin))); qMax = Math.round(Math.max(14, Math.min(56, qMax))); if (qMax < qMin) { const s = qMin; qMin = qMax; qMax = s; } out.questionFontMinPx = qMin; out.questionFontMaxPx = qMax; } return out; } function setQuizCarryMapPanelThemeFromApi(s) { quizCarryMapPanelTheme = null; if (!s || typeof s !== 'object') return; const parsed = parseCarryMapPanelThemeObject(s.carryMapPanelTheme); if (!parsed) return; quizCarryMapPanelTheme = parsed; } function defaultCarryChoicePlaqueThemePlay() { return { borderMode: 'neon', fixedBorder: 'rgba(122, 200, 255, 0.9)', fillBg: 'rgba(12, 10, 20, 0.88)', textColor: 'rgba(248, 249, 255, 1)', borderWidthPx: 2.5, plaqueImageUrl: '', }; } function parseCarryChoicePlaqueThemeObject(raw) { const d = defaultCarryChoicePlaqueThemePlay(); if (!raw || typeof raw !== 'object') return d; const safeColor = (x, fb) => { const t = String(x == null ? '' : x).trim().slice(0, 120); if (!t || /url\s*\(|expression|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fb; if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t; if (/^rgba?\(\s*[^)]+\)$/i.test(t)) return t.replace(/\s+/g, ' ').trim(); return fb; }; const mode = String(raw.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon'; let bw = Number(raw.borderWidthPx); if (!Number.isFinite(bw)) bw = d.borderWidthPx; bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10; return { borderMode: mode, fixedBorder: safeColor(raw.fixedBorder, d.fixedBorder), fillBg: safeColor(raw.fillBg, d.fillBg), textColor: safeColor(raw.textColor, d.textColor), borderWidthPx: bw, plaqueImageUrl: sanitizeQuizCarryImageUrlClient(raw.plaqueImageUrl), }; } function buildCarryChoicePlaqueThemesArrayFromApi(s) { if (!s || typeof s !== 'object') return null; if (Array.isArray(s.carryChoicePlaqueThemes) && s.carryChoicePlaqueThemes.length) { const out = []; for (let i = 0; i < QUIZ_CARRY_MAX_OPTION_SLOTS; i++) { out.push(parseCarryChoicePlaqueThemeObject(s.carryChoicePlaqueThemes[i])); } return out; } if (s.carryChoicePlaqueTheme && typeof s.carryChoicePlaqueTheme === 'object') { const one = parseCarryChoicePlaqueThemeObject(s.carryChoicePlaqueTheme); return Array.from({ length: QUIZ_CARRY_MAX_OPTION_SLOTS }, () => ({ ...one })); } return null; } function getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex) { const i = Math.max(0, Math.min(QUIZ_CARRY_MAX_OPTION_SLOTS - 1, choiceIndex | 0)); if (quizCarryChoicePlaqueThemes && quizCarryChoicePlaqueThemes[i]) { return quizCarryChoicePlaqueThemes[i]; } return defaultCarryChoicePlaqueThemePlay(); } function setQuizCarryChoicePlaqueThemeFromApi(s) { quizCarryChoicePlaqueThemes = buildCarryChoicePlaqueThemesArrayFromApi(s); } function sanitizeQuizCarryImageUrlClient(u) { const s = String(u == null ? '' : u).trim().slice(0, 512); if (!s || /[\s<>'"`]/.test(s)) return ''; if (s.startsWith('/')) { if (!/^\/[\w\-./?#=&%+]+$/i.test(s)) return ''; return s; } const low = s.toLowerCase(); if (!low.startsWith('https://') && !low.startsWith('http://')) return ''; try { const parsed = new URL(s); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; return s; } catch (e) { return ''; } } /** crossOrigin=anonymous กับ same-origin บางเซิร์ฟ/nginx ทำให้โหลดรูปล้ม — ใช้เฉพาะข้ามโดเมน */ function quizCarryApplyImageCrossOrigin(img, rawUrl) { if (!img) return; try { img.removeAttribute('crossorigin'); const u = sanitizeQuizCarryImageUrlClient(rawUrl); if (!u || u.startsWith('data:')) return; let imgOrigin = ''; if (u.startsWith('http://') || u.startsWith('https://')) { imgOrigin = new URL(u).origin; } else if (u.startsWith('/')) { imgOrigin = window.location.origin; } if (imgOrigin && imgOrigin !== window.location.origin) { img.crossOrigin = 'anonymous'; } } catch (e) { /* ignore */ } } function preloadQuizCarryChoiceImages(q) { if (!q) return; if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) { for (let i = 0; i < q.choiceImageUrls.length; i++) { const raw = sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[i]); if (!raw) continue; if (quizCarryChoiceImageCache.has(raw)) continue; const im = new Image(); quizCarryApplyImageCrossOrigin(im, raw); im.onload = () => { try { draw(); } catch (e) { /* ignore */ } }; quizCarryChoiceImageCache.set(raw, im); try { im.src = raw; } catch (e) { /* ignore */ } } } if (Array.isArray(q.choices)) { for (let j = 0; j < q.choices.length; j++) { const raw2 = getQuizCarryPlaqueImageUrlForIndex(q, j); if (!raw2 || quizCarryChoiceImageCache.has(raw2)) continue; const im2 = new Image(); quizCarryApplyImageCrossOrigin(im2, raw2); im2.onload = () => { try { draw(); } catch (e) { /* ignore */ } }; quizCarryChoiceImageCache.set(raw2, im2); try { im2.src = raw2; } catch (e) { /* ignore */ } } } } function getQuizCarryChoiceImageCached(url) { const u = sanitizeQuizCarryImageUrlClient(url); if (!u) return null; const im = quizCarryChoiceImageCache.get(u); if (im && im.complete && im.naturalWidth > 0) return im; return null; } function getQuizCarryChoiceImageUrlForIndex(q, idx) { if (!q || !Array.isArray(q.choiceImageUrls)) return ''; const u = q.choiceImageUrls[idx]; return sanitizeQuizCarryImageUrlClient(u); } /** รูปป้าย: คำถามต่อช่องก่อน แล้วค่อยรูปจากธีมช่อง (carryChoicePlaqueThemes) */ function getQuizCarryPlaqueImageUrlForIndex(q, idx) { const perQ = getQuizCarryChoiceImageUrlForIndex(q, idx); if (perQ) return perQ; const th = getEffectiveCarryChoicePlaqueThemeForChoice(idx); const u = th && th.plaqueImageUrl ? sanitizeQuizCarryImageUrlClient(th.plaqueImageUrl) : ''; return u || ''; } function defaultCarryEmbedCountdownThemePlay() { return { overlayBackdrop: 'rgba(8, 10, 20, 0.42)', innerBg: 'rgba(12, 14, 28, 0.82)', innerBorder: 'rgba(122, 162, 247, 0.45)', innerBorderWpx: 1, innerRadiusPx: 12, digitColor: '#ffe066', mapDigitCqmin: 78, mapDigitCqh: 82, mapDigitMaxPx: 200, screenDigitVw: 28, screenDigitMaxPx: 132, }; } function parseCarryEmbedCountdownThemeObject(raw) { const d = defaultCarryEmbedCountdownThemePlay(); if (!raw || typeof raw !== 'object') return d; const clampN = (v, lo, hi, def) => { const n = Number(v); if (!Number.isFinite(n)) return def; return Math.max(lo, Math.min(hi, Math.round(n))); }; const clipStr = (x, maxLen) => { const t = String(x == null ? '' : x).trim().slice(0, maxLen); return t || null; }; const safeColor = (x, fb) => { const t = clipStr(x, 120); if (!t || /url\s*\(|expression|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fb; if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t; if (/^rgba?\(\s*[^)]+\)$/i.test(t)) return t.replace(/\s+/g, ' ').trim(); return fb; }; return { overlayBackdrop: safeColor(raw.overlayBackdrop, d.overlayBackdrop), innerBg: safeColor(raw.innerBg, d.innerBg), innerBorder: safeColor(raw.innerBorder, d.innerBorder), innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx), innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx), digitColor: safeColor(raw.digitColor, d.digitColor), mapDigitCqmin: clampN(raw.mapDigitCqmin, 35, 100, d.mapDigitCqmin), mapDigitCqh: clampN(raw.mapDigitCqh, 35, 100, d.mapDigitCqh), mapDigitMaxPx: clampN(raw.mapDigitMaxPx, 48, 400, d.mapDigitMaxPx), screenDigitVw: clampN(raw.screenDigitVw, 6, 44, d.screenDigitVw), screenDigitMaxPx: clampN(raw.screenDigitMaxPx, 48, 220, d.screenDigitMaxPx), }; } function setQuizCarryEmbedCountdownThemeFromApi(s) { quizCarryEmbedCountdownTheme = parseCarryEmbedCountdownThemeObject(s && s.carryEmbedCountdownTheme); applyQuizCarryEmbedCountdownThemeToDom(); } const CARRY_ECD_VAR_KEYS = [ '--carry-ecd-overlay', '--carry-ecd-inner-bg', '--carry-ecd-inner-border', '--carry-ecd-inner-border-w', '--carry-ecd-inner-radius', '--carry-ecd-digit-color', '--carry-ecd-map-digit-fs', '--carry-ecd-screen-vw', '--carry-ecd-screen-max', ]; function clearQuizCarryEmbedCountdownThemeDomVars() { const root = document.getElementById('quiz-carry-embed-countdown'); if (!root) return; CARRY_ECD_VAR_KEYS.forEach((k) => { try { root.style.removeProperty(k); } catch (e) { /* ignore */ } }); } /** ใส่ CSS variables บน #quiz-carry-embed-countdown — อ่านจาก quizCarryEmbedCountdownTheme */ function applyQuizCarryEmbedCountdownThemeToDom() { const root = document.getElementById('quiz-carry-embed-countdown'); const numEl = document.getElementById('quiz-carry-embed-countdown-num'); if (!root) return; clearQuizCarryEmbedCountdownThemeDomVars(); const th = quizCarryEmbedCountdownTheme || defaultCarryEmbedCountdownThemePlay(); root.style.setProperty('--carry-ecd-overlay', th.overlayBackdrop); root.style.setProperty('--carry-ecd-inner-bg', th.innerBg); root.style.setProperty('--carry-ecd-inner-border', th.innerBorder); root.style.setProperty('--carry-ecd-inner-border-w', `${Math.max(0, Math.min(12, Math.round(Number(th.innerBorderWpx) || 1)))}px`); root.style.setProperty('--carry-ecd-inner-radius', `${Math.max(0, Math.min(32, Math.round(Number(th.innerRadiusPx) || 12)))}px`); root.style.setProperty('--carry-ecd-digit-color', th.digitColor); const cqmin = Math.max(35, Math.min(100, Math.round(Number(th.mapDigitCqmin) || 78))); const cqh = Math.max(35, Math.min(100, Math.round(Number(th.mapDigitCqh) || 82))); const mpx = Math.max(48, Math.min(400, Math.round(Number(th.mapDigitMaxPx) || 200))); root.style.setProperty('--carry-ecd-map-digit-fs', `clamp(14px, min(${cqmin}cqmin, ${cqh}cqh), ${mpx}px)`); const vw = Math.max(6, Math.min(44, Math.round(Number(th.screenDigitVw) || 28))); const smx = Math.max(48, Math.min(220, Math.round(Number(th.screenDigitMaxPx) || 132))); root.style.setProperty('--carry-ecd-screen-vw', `${vw}vw`); root.style.setProperty('--carry-ecd-screen-max', `${smx}px`); if (numEl) { numEl.style.textShadow = '0 0 0.45em currentColor, 0 4px 14px rgba(0,0,0,0.55)'; } } /** default ← quiz-settings/API ← แมป (สีจากแมปทับได้; ฟอนต์จากแมปเฉพาะเมื่อแมประบุ key) */ function getEffectiveCarryMapPanelThemeForApply() { const d = defaultCarryMapPanelThemePlay(); const api = quizCarryMapPanelTheme != null && typeof quizCarryMapPanelTheme === 'object' ? quizCarryMapPanelTheme : null; let merged = api ? { ...d, ...api } : { ...d }; if (mapData && mapData.carryMapPanelTheme != null) { const mp = parseCarryMapPanelThemeMapOverlay(mapData.carryMapPanelTheme); if (mp) merged = { ...merged, ...mp }; } return merged; } function applyQuizCarryMapPanelThemeIfNeeded(panel, textEl) { if (!panel || !textEl) return; if (!isQuizCarry()) { clearQuizMapPanelThemeInline(panel, textEl); return; } const th = getEffectiveCarryMapPanelThemeForApply(); clearQuizMapPanelThemeInline(panel, textEl); const bgStr = th.panelBg != null ? String(th.panelBg).trim() : ''; const brStr = th.panelBorder != null ? String(th.panelBorder).trim() : ''; const txStr = th.textColor != null ? String(th.textColor).trim() : ''; const wRaw = Number(th.borderWidthPx); const w = Number.isFinite(wRaw) ? Math.max(0, Math.min(12, Math.round(wRaw))) : 0; const bgUse = bgStr || 'transparent'; const brUse = brStr || 'transparent'; const txUse = txStr || '#f1f5f9'; panel.style.setProperty('--qmap-bg', bgUse); panel.style.setProperty('--qmap-border', brUse); panel.style.setProperty('--qmap-border-w', String(w) + 'px'); panel.style.setProperty('--qmap-shadow', 'none'); textEl.style.setProperty('--qmap-text', txUse); textEl.style.setProperty('--qmap-text-shadow', 'none'); /* โหลด play.html แบบแคชเก่า (ไม่มี var) ยังต้องทับได้ — ใช้ !important คู่กับตัวแปร */ panel.style.setProperty('background', bgUse, 'important'); panel.style.setProperty('border', String(w) + 'px solid ' + brUse, 'important'); panel.style.setProperty('box-shadow', 'none', 'important'); textEl.style.setProperty('color', txUse, 'important'); textEl.style.setProperty('text-shadow', 'none', 'important'); } function syncPlayQuizMapPanel() { const panel = document.getElementById('quiz-map-question-panel'); const textEl = document.getElementById('quiz-map-question-text'); if (!panel || !textEl || !mapData) return; /* เอดิเตอร์ embed: ซ่อนแผงทองเฉพาะโหมด quiz (ทับกลาง) — quiz_carry ต้องโชว์ตามโซนที่วาดบนแผนที่ */ if (previewMode && editorEmbedReturn && !isQuizCarry()) { clearQuizMapQuestionCarryFeedback(); panel.classList.add('is-hidden'); panel.setAttribute('aria-hidden', 'true'); clearQuizMapPanelThemeInline(panel, textEl); return; } if (isQuizBattle()) { clearQuizMapQuestionCarryFeedback(); panel.classList.add('is-hidden'); panel.setAttribute('aria-hidden', 'true'); clearQuizMapPanelThemeInline(panel, textEl); return; } if (!isQuiz() && !isQuizCarry()) { clearQuizMapQuestionCarryFeedback(); return; } const bounds = isQuiz() ? getQuizQuestionAreaTileBounds(mapData) : (getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData)); const text = (playQuizText || '').trim(); if (!bounds || !text) { clearQuizMapQuestionCarryFeedback(); panel.classList.add('is-hidden'); panel.setAttribute('aria-hidden', 'true'); clearQuizMapPanelThemeInline(panel, textEl); 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'); applyQuizCarryMapPanelThemeIfNeeded(panel, textEl); const thFont = getEffectiveCarryMapPanelThemeForApply(); const startFontPx = quizMapQuestionFontPxLikeCarryPlaques(thFont, zoom); const mnFont = Math.max(8, Math.round(Number(thFont.questionFontMinPx) || 10)); fitQuizMapQuestionFontToPanel(panel, textEl, startFontPx, mnFont); } /** โซนเดียวกับแผงคำถามบนแผนที่ — ยึด timeup ให้ตรงกล่อง #quiz-map-question-panel (ห้ามคำนวณซ้ำแล้วคลาด) */ function syncQuizCarryTimeupDeskLayerPosition() { const layer = document.getElementById('quiz-carry-timeup-desk-layer'); if (!layer || layer.classList.contains('is-hidden')) return; const anchor = layer.querySelector('.qc-timeup-desk-anchor') || layer.querySelector('.qc-timeup-desk-inner') || (() => { const img = document.getElementById('quiz-carry-timeup-txt-img'); return img && img.parentElement && img.parentElement !== layer ? img.parentElement : null; })(); if (!anchor || !canvas) return; const clearToFullStack = () => { anchor.style.left = '0'; anchor.style.top = '0'; anchor.style.width = '100%'; anchor.style.height = '100%'; }; const stack = document.getElementById('play-canvas-stack'); const panel = document.getElementById('quiz-map-question-panel'); if (stack && panel && !panel.classList.contains('is-hidden')) { try { const sr = stack.getBoundingClientRect(); const pr = panel.getBoundingClientRect(); const rw = pr.width; const rh = pr.height; if (rw >= 8 && rh >= 8 && sr.width > 0 && sr.height > 0) { anchor.style.left = Math.round(pr.left - sr.left) + 'px'; anchor.style.top = Math.round(pr.top - sr.top) + 'px'; anchor.style.width = Math.round(rw) + 'px'; anchor.style.height = Math.round(rh) + 'px'; return; } } catch (e) { /* fallback ด้านล่าง */ } } const pl = panel && panel.style && String(panel.style.left || '').trim(); const pt = panel && panel.style && String(panel.style.top || '').trim(); const pw = panel && panel.style && String(panel.style.width || '').trim(); const ph = panel && panel.style && String(panel.style.height || '').trim(); if (pl && pt && pw && ph) { anchor.style.left = pl; anchor.style.top = pt; anchor.style.width = pw; anchor.style.height = ph; return; } if (!mapData || mapData.gameType !== 'quiz_carry') { clearToFullStack(); return; } const bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData); if (!bounds) { clearToFullStack(); 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; anchor.style.left = Math.round(left) + 'px'; anchor.style.top = Math.round(top) + 'px'; anchor.style.width = Math.round(Math.max(48, wPx)) + 'px'; anchor.style.height = Math.round(Math.max(40, hPx)) + 'px'; } function carryEmbedCountdownAnchorResolved() { if (!mapData || !isQuizCarry()) return 'screen'; const v = mapData.carryEmbedCountdownAnchor; if (v === 'grid' || v === 'map') return v; return 'screen'; } function resetQuizCarryEmbedCountdownLayoutDom() { const ov = document.getElementById('quiz-carry-embed-countdown'); const inner = document.getElementById('quiz-carry-embed-countdown-inner'); if (ov) ov.classList.remove('quiz-carry-embed-countdown--map-anchor'); if (inner) { inner.classList.remove('quiz-carry-embed-countdown-inner--map-rect'); inner.style.position = ''; inner.style.left = ''; inner.style.top = ''; inner.style.width = ''; inner.style.height = ''; inner.style.transform = ''; inner.style.maxWidth = ''; inner.style.boxSizing = ''; inner.style.display = ''; inner.style.margin = ''; } } /** พรีวิว embed: วางกล่อง 3–2–1 กลางจอ / ชิดโซนทองหรือโซนกลาง / ชิดกริด carryEmbedCountdownArea — ตาม carryEmbedCountdownAnchor */ function syncQuizCarryEmbedCountdownLayout() { if (!previewMode || !editorEmbedReturn || !mapData || !isQuizCarry()) { resetQuizCarryEmbedCountdownLayoutDom(); return; } const ov = document.getElementById('quiz-carry-embed-countdown'); const inner = document.getElementById('quiz-carry-embed-countdown-inner'); const canvasEl = document.getElementById('game-canvas'); if (!ov || !inner || !canvasEl) return; if (ov.classList.contains('is-hidden')) { resetQuizCarryEmbedCountdownLayoutDom(); return; } const anchor = carryEmbedCountdownAnchorResolved(); if (anchor === 'screen') { resetQuizCarryEmbedCountdownLayoutDom(); return; } let bounds = null; if (anchor === 'grid') { bounds = getTileBoundsForOnesGrid(mapData.carryEmbedCountdownArea); if (!bounds) { resetQuizCarryEmbedCountdownLayoutDom(); return; } } else { bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData); if (!bounds) { resetQuizCarryEmbedCountdownLayoutDom(); return; } } const camX = me.x * tileSize; const camY = me.y * tileSize; const l = (bounds.minX * tileSize - camX) * zoom + canvasEl.width / 2; const t = (bounds.minY * tileSize - camY) * zoom + canvasEl.height / 2; const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zoom; const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zoom; const r = canvasEl.getBoundingClientRect(); const sl = r.left + l; const st = r.top + t; ov.classList.add('quiz-carry-embed-countdown--map-anchor'); inner.classList.add('quiz-carry-embed-countdown-inner--map-rect'); inner.style.position = 'fixed'; inner.style.left = Math.round(sl) + 'px'; inner.style.top = Math.round(st) + 'px'; inner.style.width = Math.round(Math.max(100, wPx)) + 'px'; inner.style.height = Math.round(Math.max(72, hPx)) + 'px'; inner.style.transform = 'none'; inner.style.boxSizing = 'border-box'; inner.style.display = 'flex'; inner.style.flexDirection = 'column'; inner.style.alignItems = 'center'; inner.style.justifyContent = 'center'; inner.style.margin = '0'; inner.style.maxWidth = 'none'; } /** quiz_carry + embed: คำถามไปที่แผงทองบนแผนที่ / ใน overlay นับถอยหลัง — ไม่ใช้แถบบน */ function syncQuizCarryEmbedQuestionStrip() { const wrap = document.getElementById('quiz-carry-embed-q-strip'); const p = document.getElementById('quiz-carry-embed-q-strip-text'); if (!wrap || !p) return; wrap.classList.remove('quiz-carry-embed-q-strip--countdown'); wrap.classList.add('is-hidden'); wrap.setAttribute('aria-hidden', 'true'); } 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 clampCarrySessionLen(n, def) { const v = Number(n); if (Number.isNaN(v)) return def; return Math.max(0, Math.min(500, 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 }; } function parseLobbyPlayerSpawnsFromMapPlay(md) { const w = md.width || 20; const h = md.height || 15; const out = [null, null, null, null, null, null]; const raw = md && md.lobbyPlayerSpawns; if (!Array.isArray(raw)) return out; for (let i = 0; i < 6 && i < raw.length; i++) { const cell = raw[i]; if (!cell || typeof cell !== 'object') continue; const x = Math.floor(Number(cell.x)); const y = Math.floor(Number(cell.y)); if (!Number.isFinite(x) || !Number.isFinite(y)) continue; if (x < 0 || x >= w || y < 0 || y >= h) continue; out[i] = { x, y }; } return out; } /** สอดคล้องกับ server pickSpawnForJoin — ใช้พรีวิวบอท / ทดสอบ */ function pickSpawnForJoinPlay(md, joinOrderIndex) { if (!md) return { x: 1, y: 1 }; const mode = md.lobbySpawnMode; const ord = joinOrderIndex | 0; if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMapPlay(md); const j = Math.min(Math.max(0, ord), 5); if (mode === 'fixed' && md.spawn) { const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1; const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1; const w = md.width || 20; const h = md.height || 15; const x = Math.max(0, Math.min(w - 1, fx)); const y = Math.max(0, Math.min(h - 1, fy)); if (spawnTileWalkablePlay(md, x, y)) return { x, y }; return pickRandomSpawnFromMapPlay(md); } if (mode === 'slots6') { const slots = parseLobbyPlayerSpawnsFromMapPlay(md); const pick = slots[j]; if (pick && spawnTileWalkablePlay(md, pick.x, pick.y)) return { x: pick.x, y: pick.y }; return pickRandomSpawnFromMapPlay(md); } return pickRandomSpawnFromMapPlay(md); } 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 (!playBotsEnabled() || !mapData) return; if (isDetectiveMinigamePlay() && isQuizCarry()) { /* บอทคดีสืบสวน — ใช้ stepQuizCarryPreviewBots */ } const human = countPlayHumans(); const wantBots = Math.max(0, playBotTargetHeadcount() - 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 = (mapData && mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay()) ? 100 : 0; } if (o.gauntletEliminated == null) o.gauntletEliminated = false; 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 joinIdx = countPlayHumans() + botIds.length; const sp = pickSpawnForJoinPlay(mapData, joinIdx); const jx = (Math.random() - 0.5) * 0.4; const jy = (Math.random() - 0.5) * 0.4; let x = sp.x + 0.5 + jx; let y = sp.y + 0.5 + jy; if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) { const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(x, y); x = pathSnap.x; y = pathSnap.y; } 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)]; const crownStart = mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay(); others.set(id, { x, y, tx: x, ty: y, direction: ['down', 'up', 'left', 'right'][Math.floor(Math.random() * 4)], nickname: 'บอท', characterId: getPlayCharacterId(), playTint: playTintFromPeerId(id), botTier, gauntletJumpTicks: 0, gauntletJumpVis: 0, gauntletScore: crownStart ? 100 : 0, gauntletEliminated: false, quizCannotTrue: false, quizCannotFalse: false, quizCarryHeld: null, botPath: [], botAnswerWander: false, botWanderDx: wd[0], botWanderDy: wd[1], botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 1000), jumpSurviveEliminated: false, spaceShooterScore: 0, balloonBossScore: 0, balloonBossBalloons: 5, balloonBossEliminated: false, botQuizCarryPathfindAfter: performance.now() + previewBotSeq * 75 + Math.floor(Math.random() * 160), }); 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 === 'quiz_battle' && quizBattlePathModeActive(mapData)) { [...others.keys()].filter(isPreviewBotId).forEach((bid) => { const o = others.get(bid); if (!o) return; const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y); o.x = s.x; o.y = s.y; o.tx = s.x; o.ty = s.y; }); } if (mapData.gameType === 'quiz_carry') { const t0 = performance.now(); [...others.keys()].filter(isPreviewBotId).forEach((bid, idx) => { const o = others.get(bid); if (!o) return; o.botQuizCarryPathfindAfter = t0 + idx * 90 + Math.floor(Math.random() * 140); }); } if (mapData.gameType === 'gauntlet') { applyGauntletPreviewSpawnLayout(false); emitGauntletPreviewRowsToServer(); } if (mapData.gameType === 'stack') { applyStackPreviewSpawnLayout(); rebuildStackPreviewTurnOrder(); } if (mapData.gameType === 'jump_survive') { applyJumpSurvivePreviewSpawnLayout(false); } if (mapData.gameType === 'space_shooter') { applySpaceShooterSpawnLayoutPlay(); } if (mapData.gameType === 'balloon_boss') { applyBalloonBossSpawnLayoutPlay(); } if (quizCarryPregameActive && mapData && mapData.gameType === 'quiz_carry') updateQuizCarryPregameHud(); } 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(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' }); 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'); const mapQText = document.getElementById('quiz-map-question-text'); if (panel) { panel.classList.add('is-hidden'); panel.setAttribute('aria-hidden', 'true'); clearQuizMapPanelThemeInline(panel, mapQText); } playQuizPhaseLocal = null; playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; playQuizText = ''; playQuizPhaseEndsAt = 0; previewQuizPool = []; previewQuizCurrent = null; previewQuizStep = 'read'; playLiveQuizScores = {}; playPath = []; hidePlayQuizHud(); quizCarryJoinSettingsSnap = null; quizCarryMapPanelTheme = null; quizCarryChoicePlaqueThemes = null; resetQuizCarryPlayState(); resetQuizBattlePlayState(); const grabBtnTeardown = document.getElementById('quiz-carry-grab-btn'); if (grabBtnTeardown) { grabBtnTeardown.classList.add('is-hidden'); grabBtnTeardown.classList.remove('quiz-carry-grab-btn--active'); grabBtnTeardown.classList.remove('quiz-carry-grab-btn--place'); grabBtnTeardown.style.pointerEvents = ''; const im = grabBtnTeardown.querySelector('img'); if (im) im.src = '/Game/img/quiz-carry/btn-grab.png'; } } function setupPlayQuizUi() { if (playQuizTimerInterval) { clearInterval(playQuizTimerInterval); playQuizTimerInterval = null; } const ov = document.getElementById('quiz-game-overlay'); if (ov) { if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden'); else 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 isGauntletFaceRightMapMno9kb07() { return !!(isGauntlet() && playSessionMapId === GAUNTLET_FACE_RIGHT_MAP_ID); } /** Crown heist rules + UI (เดียวกับหันขวา — ฉาก mno9kb07) */ function isGauntletCrownHeistMapPlay() { return isGauntletFaceRightMapMno9kb07(); } let gauntletCrownHowtoVisible = false; function isGauntletCrownPregameBlockingPlay() { if (!isGauntletCrownHeistMapPlay()) return false; if (gauntletCrownRunHeldRemote) return true; if (gauntletCrownPregamePhase != null && gauntletCrownPregamePhase !== 'live') return true; return false; } function isStack() { return mapData && mapData.gameType === 'stack'; } function isJumpSurvive() { return mapData && mapData.gameType === 'jump_survive'; } function currentPlayMapId() { const q = (playSessionMapId || '').trim(); if (q) return q; if (mapData && mapData.id != null && String(mapData.id).trim() !== '') return String(mapData.id).trim(); return (playMapIdFromQuery || '').trim(); } function isJumpSurviveMissionUiMapPlay() { return isJumpSurvive() && currentPlayMapId() === JUMP_SURVIVE_MISSION_MAP_ID; } function isJumpSurviveMissionPregameBlockingPlay() { if (!isJumpSurviveMissionUiMapPlay()) return false; return jumpSurviveMissionPhase != null && jumpSurviveMissionPhase !== 'live' && jumpSurviveMissionPhase !== 'ended'; } function jumperAssetUrl(file) { return BASE + '/img/Jumper/' + file; } function hideConflictingOverlaysForJumpSurviveMission() { hideConflictingOverlaysForGauntletCrown(); } function jumpSurviveTimeLimitSecForMission() { const m = Number(mapData && mapData.jumpSurviveTimeSec); if (Number.isFinite(m) && m > 0 && m < 7200) return Math.floor(m); const j = Number(playJumpSurviveMissionTimeSec); if (Number.isFinite(j) && j > 0 && j < 7200) return Math.floor(j); return 60; } function jumpSurviveRemainingSecMission() { if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveSessionStartMs <= 0) return null; const lim = jumpSurviveTimeLimitSecForMission(); if (!(lim > 0)) return null; const elapsed = (performance.now() - jumpSurviveSessionStartMs) / 1000; return Math.max(0, Math.ceil(lim - elapsed)); } function applyJumpSurviveJumperPanelImages() { const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); if (howtoBg) { howtoBg.src = jumperAssetUrl('popup-Howto.png'); howtoBg.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; }; } const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); if (resBg) { resBg.src = jumperAssetUrl('popup-result.png'); resBg.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/popup-result.png'; }; } } function showJumpSurviveMissionHowtoOverlay() { if (!isJumpSurviveMissionUiMapPlay()) return; hideConflictingOverlaysForJumpSurviveMission(); applyJumpSurviveJumperPanelImages(); if (jumperMissionCountdownTimer) { clearTimeout(jumperMissionCountdownTimer); jumperMissionCountdownTimer = null; } const cd = document.getElementById('gauntlet-crown-countdown'); if (cd) cd.classList.add('is-hidden'); jumpSurviveMissionPhase = 'howto'; const ov = document.getElementById('gauntlet-crown-howto-overlay'); if (!ov) return; gauntletCrownHowtoVisible = true; ov.classList.remove('is-hidden'); const st = document.getElementById('gauntlet-crown-howto-status'); if (st) { st.textContent = ''; st.classList.remove('gch-status--jumper'); st.classList.add('is-hidden'); } const btn = document.getElementById('btn-gch-ready'); if (btn) { const humans = quizCarryPregameHumanIds(); const soleHuman = humans.length === 1; const canGo = soleHuman || isMePlayHost(); btn.classList.remove('is-start-phase'); btn.classList.toggle('is-read-only', !canGo); btn.disabled = !canGo; btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host'; btn.setAttribute('aria-pressed', 'false'); } } function beginJumpSurviveMissionCountdownThenRun() { if (!isJumpSurviveMissionUiMapPlay()) return; if (jumperMissionCountdownTimer) { clearTimeout(jumperMissionCountdownTimer); jumperMissionCountdownTimer = null; } jumpSurviveMissionPhase = 'countdown'; const howto = document.getElementById('gauntlet-crown-howto-overlay'); if (howto) howto.classList.add('is-hidden'); gauntletCrownHowtoVisible = false; const st = document.getElementById('gauntlet-crown-howto-status'); if (st) { st.classList.add('is-hidden'); st.classList.remove('gch-status--jumper'); } const cd = document.getElementById('gauntlet-crown-countdown'); const numEl = document.getElementById('gauntlet-crown-countdown-num'); const runFinish = function () { if (cd) cd.classList.add('is-hidden'); if (jumperMissionCountdownTimer) { clearTimeout(jumperMissionCountdownTimer); jumperMissionCountdownTimer = null; } jumpSurviveMissionPhase = 'live'; jumpSurviveGameEnded = false; jumpSurviveEliminated = false; jumpSurviveInitRuntime(); }; if (!cd || !numEl) { runFinish(); return; } cd.classList.remove('is-hidden'); let n = 3; numEl.textContent = String(n); const step = function () { n--; if (n > 0) { numEl.textContent = String(n); jumperMissionCountdownTimer = setTimeout(step, 1000); } else { jumperMissionCountdownTimer = null; runFinish(); } }; jumperMissionCountdownTimer = setTimeout(step, 1000); } /** Jumper ภารกิจ mnptfts2: เกรดจากจำนวนผู้รอด — 6=A … 3=D; น้อยกว่า 2 = F; พอดี 2 คน = E */ function jumpSurviveGradeFromSurvivorCount(survivors) { const s = Math.max(0, Math.floor(Number(survivors) || 0)); if (s >= 6) return 'A'; if (s === 5) return 'B'; if (s === 4) return 'C'; if (s === 3) return 'D'; if (s === 2) return 'E'; return 'F'; } function jumpSurviveRollRewardCardForGrade(grade) { if (grade === 'F') return null; const r = Math.random(); if (grade === 'A' || grade === 'B') { if (r < 0.35) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; if (r < 0.7) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; } if (grade === 'C' || grade === 'D') { if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; } if (grade === 'E') { if (r < 0.12) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; } if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; } function jumpSurviveMissionTotalParticipants() { let n = myId != null ? 1 : 0; if (others && typeof others.size === 'number') n += others.size; return n; } function jumpSurviveMissionCountAlive() { let n = 0; if (myId != null && !jumpSurviveEliminated) n++; if (others && typeof others.forEach === 'function') { others.forEach(function (o) { if (o && !o.jumpSurviveEliminated) n++; }); } return n; } /** จบทันทีเมื่อไม่มีผู้รอด หรือเหลือคนเดียว (หลายคนในรอบ) — โหมด solo 1 คนยังเล่นจนหมดเวลา */ function jumpSurviveMissionMaybeEarlyFinish() { if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return; const total = jumpSurviveMissionTotalParticipants(); const alive = jumpSurviveMissionCountAlive(); if (alive === 0) { endJumpSurviveMissionRound(); return; } if (total >= 2 && alive === 1) { endJumpSurviveMissionRound(); } } function jumpSurviveBuildMissionPayload() { const rows = []; const pushEnt = function (id, nickname, characterId, eliminated) { const alive = !eliminated; const baseScore = alive ? 100 : 0; rows.push({ id: id, nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id), characterId: characterId ?? null, baseScore: baseScore, eliminated: !!eliminated, rankBonus: 0, finalScore: baseScore, }); }; if (myId != null) { pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), jumpSurviveEliminated); } others.forEach(function (o, id) { if (!o) return; pushEnt(id, o.nickname, o.characterId, !!o.jumpSurviveEliminated); }); rows.sort(function (a, b) { if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); }); let survivorCount = 0; for (let si = 0; si < rows.length; si++) { if (rows[si] && !rows[si].eliminated) survivorCount++; } const participantCount = rows.length; const grade = jumpSurviveGradeFromSurvivorCount(survivorCount); const top = rows.slice(0, 5); const ranked = top.map(function (row, idx) { const pos = idx + 1; return { id: row.id, nickname: row.nickname, characterId: row.characterId, baseScore: row.baseScore, eliminated: row.eliminated, rank: pos, rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), rankBonus: 0, finalScore: row.baseScore, }; }); const totalParts = ranked.map(function (r) { return r.baseScore; }); const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); const n = ranked.length || 1; const averageScore = Math.floor(totalSum / n); const rewardCard = jumpSurviveRollRewardCardForGrade(grade); return { ranked: ranked, totalSum: totalSum, averageScore: averageScore, grade: grade, rewardCard: rewardCard, totalParts: totalParts, uiSkin: 'jumper', survivorCount: survivorCount, participantCount: participantCount, }; } function jumpSurviveMergePreviewBotsMission(mission) { if (!mission || mission.uiSkin !== 'jumper' || !previewFillBots || !others || typeof others.forEach !== 'function') return mission; const seen = new Set(); (mission.ranked || []).forEach(function (r) { if (r && r.id != null) seen.add(String(r.id)); }); const baseRows = (mission.ranked || []).map(function (r) { return { id: r.id, nickname: r.nickname, characterId: r.characterId, baseScore: Math.max(0, Number(r.baseScore) || 0), eliminated: !!r.eliminated, rankBonus: 0, }; }); others.forEach(function (o, id) { if (!o || !isPreviewBotId(id)) return; const sid = String(id); if (seen.has(sid)) return; seen.add(sid); baseRows.push({ id: id, nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid, characterId: o.characterId ?? null, baseScore: o.jumpSurviveEliminated ? 0 : 100, eliminated: !!o.jumpSurviveEliminated, rankBonus: 0, }); }); baseRows.sort(function (a, b) { if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); }); let survivorCount = 0; for (let si = 0; si < baseRows.length; si++) { if (baseRows[si] && !baseRows[si].eliminated) survivorCount++; } const participantCount = baseRows.length; const grade = jumpSurviveGradeFromSurvivorCount(survivorCount); const top = baseRows.slice(0, 5); const ranked = top.map(function (row, idx) { const pos = idx + 1; const bs = Math.max(0, Number(row.baseScore) || 0); return { id: row.id, nickname: row.nickname, characterId: row.characterId, baseScore: bs, eliminated: !!row.eliminated, rank: pos, rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), rankBonus: 0, finalScore: bs, }; }); const totalParts = ranked.map(function (r) { return r.baseScore; }); const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); const n = ranked.length || 1; const averageScore = Math.floor(totalSum / n); return { ranked: ranked, totalSum: totalSum, averageScore: averageScore, grade: grade, rewardCard: jumpSurviveRollRewardCardForGrade(grade), totalParts: totalParts, uiSkin: 'jumper', survivorCount: survivorCount, participantCount: participantCount, }; } function endJumpSurviveMissionRound() { if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveGameEnded) return; jumpSurviveGameEnded = true; jumpSurviveMissionPhase = 'ended'; applyJumpSurviveJumperPanelImages(); const mission = jumpSurviveBuildMissionPayload(); showGauntletCrownMissionOverlay(mission); } function isSpaceShooter() { return mapData && mapData.gameType === 'space_shooter'; } function isBalloonBoss() { return mapData && mapData.gameType === 'balloon_boss'; } function isQuizCarry() { return mapData && mapData.gameType === 'quiz_carry'; } function isQuizBattle() { return mapData && mapData.gameType === 'quiz_battle'; } function quizCarryNeonColorForChoice(choiceIndex) { const i = Math.max(0, choiceIndex | 0); const h = (i * 47 + 100) % 360; return 'hsl(' + h + ', 72%, 62%)'; } function quizCarryMinimapOptionFillCss(ov) { const th = getEffectiveCarryChoicePlaqueThemeForChoice((Number(ov) | 0) - 1); if (th.borderMode === 'fixed' && th.fixedBorder) { const m = /^rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*(?:,\s*([0-9.]+)\s*)?\)$/i.exec(String(th.fixedBorder).trim()); if (m) { const a0 = m[4] != null && m[4] !== '' ? Number(m[4]) : 1; const aa = (Number.isFinite(a0) ? Math.max(0, Math.min(1, a0)) : 1) * 0.32; const t = Math.round(aa * 1000) / 1000; return 'rgba(' + m[1] + ',' + m[2] + ',' + m[3] + ',' + t + ')'; } } const i = Math.max(0, ov - 1); const h = (i * 47 + 100) % 360; return 'hsla(' + h + ', 58%, 52%, 0.32)'; } function normalizeQuizCarryQuestionFromAny(q) { if (!q) return null; let text = String(q.text || q.question || q.prompt || q.title || '').trim(); if (Array.isArray(q.choices) && q.choices.length >= 2) { const choices = q.choices.map((c) => String(c)); let ci = Number(q.correctIndex); if (!Number.isFinite(ci) || ci < 0 || ci >= choices.length) ci = 0; if (!text) text = 'เลือกคำตอบที่ถูกต้อง — หยิบตัวเลือกไปวางโซนส่งคำตอบ'; let chSlot = Number(q.countdownHighlightSlot); if (!Number.isFinite(chSlot) || chSlot < 1 || chSlot > 16) chSlot = null; else chSlot = Math.floor(chSlot); let choiceImageUrls = null; if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) { const urls = choices.map((_, idx) => sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[idx])); if (urls.some((u) => u)) choiceImageUrls = urls; } return { text, choices, correctIndex: ci, countdownHighlightSlot: chSlot, choiceImageUrls }; } if (!text) return null; const t = !!q.answerTrue; return { text, choices: ['จริง', 'เท็จ'], correctIndex: t ? 0 : 1, countdownHighlightSlot: null }; } function buildQuizCarryPoolFromMap(md) { if (!md || !Array.isArray(md.quizQuestions)) return []; const out = []; for (let i = 0; i < md.quizQuestions.length; i++) { const n = normalizeQuizCarryQuestionFromAny(md.quizQuestions[i]); if (n) out.push(n); } return out; } function getQuizCarryHubTileBounds(md) { if (!md || md.gameType !== 'quiz_carry') return null; const grid = md.quizCarryHubArea; 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 }; } /** จุดกลางโซนตัวเลือกหยิบมาวาง (ค่า grid 1..N = ลำดับ choices) เพื่อวาดข้อความบนแผนที่ */ function getQuizCarryOptionClusterBounds(md, slot1toN) { const g = md && md.quizCarryOptionArea; if (!g || !g.length) return null; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let n = 0, sumX = 0, sumY = 0; for (let yy = 0; yy < g.length; yy++) { const row = g[yy]; if (!row) continue; for (let xx = 0; xx < row.length; xx++) { if (Number(row[xx]) === slot1toN) { if (xx < minX) minX = xx; if (yy < minY) minY = yy; if (xx > maxX) maxX = xx; if (yy > maxY) maxY = yy; sumX += xx + 0.5; sumY += yy + 0.5; n++; } } } if (!n || minX === Infinity) return null; return { minX, minY, maxX, maxY, cx: sumX / n, cy: sumY / n, }; } function canvasWordWrapLines(ctx, text, maxWidth) { const raw = String(text || '').trim(); if (!raw) return []; const words = raw.split(/\s+/); const lines = []; let cur = words[0]; for (let i = 1; i < words.length; i++) { const w = words[i]; const test = cur + ' ' + w; if (ctx.measureText(test).width <= maxWidth) cur = test; else { lines.push(cur); cur = w; } } lines.push(cur); return lines; } function quizCarryOptionHeldByAnyone(optionIdx) { if (optionIdx == null || optionIdx < 0) return false; if (me.quizCarryHeld === optionIdx) return true; for (const o of others.values()) { if (o && o.quizCarryHeld === optionIdx) return true; } return false; } /** สไปรต์ทับช่องโซนกลาง (ม่วง) อย่างน้อย 1 ช่อง */ function spriteOverlapsQuizCarryHubArea(md, sp) { if (!md || md.gameType !== 'quiz_carry' || !sp) return false; const hub = md.quizCarryHubArea; if (!hub || !hub.length) return false; const mw = md.width || 20, mh = md.height || 15; const x0 = sp.x | 0, y0 = sp.y | 0, ww = sp.w | 0, hh = sp.h | 0; for (let yy = y0; yy < y0 + hh; yy++) { if (yy < 0 || yy >= mh) continue; const row = hub[yy]; if (!row) continue; for (let xx = x0; xx < x0 + ww; xx++) { if (xx < 0 || xx >= mw) continue; if (row[xx] === 1) return true; } } return false; } /** ตัวเลือกหนึ่งข้อ — มีคนถือป้ายอยู่ได้แค่คนเดียว (หยิบซ้ำไม่ได้) */ function pickQuizCarryTargetOptionIndexAvoidingTaken(preferredIdx) { const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0; if (n < 1) return null; let p = preferredIdx | 0; if (p < 0 || p >= n) p = 0; if (!quizCarryOptionHeldByAnyone(p)) return p; const avail = []; for (let i = 0; i < n; i++) { if (!quizCarryOptionHeldByAnyone(i)) avail.push(i); } if (!avail.length) return null; return avail[Math.floor(Math.random() * avail.length)]; } /** * วาดป้ายมุมมน + ขอบเรือง + ข้อความ (รูปแบบเดียวกันทั้งแมปและติดตัว) — สีจาก carryChoicePlaqueThemes[choiceIndex] · รูปจาก plaqueExtra.imageUrl * @param tether ถ้ามี วาดเส้นขาวจาก (x0,y0) ถึง (x1,y1) ก่อนป้าย * @param plaqueExtra optional { imageUrl } */ function drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, tether, plaqueExtra) { const th = getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex); const neon = th.borderMode === 'fixed' ? th.fixedBorder : quizCarryNeonColorForChoice(choiceIndex); const fillCol = th.fillBg || 'rgba(12, 10, 20, 0.88)'; const textCol = th.textColor || '#f8f9ff'; const baseLw = Math.max(0.5, Math.min(8, Number(th.borderWidthPx))); const imgUrl = plaqueExtra && plaqueExtra.imageUrl ? String(plaqueExtra.imageUrl) : ''; const elImg = plaqueExtra && plaqueExtra.imageElement; const imgFromEl = (elImg && elImg.complete && elImg.naturalWidth > 0) ? elImg : null; const img = !imgFromEl && imgUrl ? getQuizCarryChoiceImageCached(imgUrl) : null; const drawPlaqueImg = imgFromEl || ((img && img.complete && img.naturalWidth > 0) ? img : null); const rr = Math.max(6, Math.min(12, Math.min(w, h) * 0.2)); const simplePreviewPlaque = previewMode && editorEmbedReturn; function pathBoard() { ctx.beginPath(); if (typeof ctx.roundRect === 'function') ctx.roundRect(signX, signY, w, h, rr); else ctx.rect(signX, signY, w, h); } if (!simplePreviewPlaque) { for (let g = 4; g >= 1; g--) { ctx.strokeStyle = neon; ctx.globalAlpha = 0.07 + g * 0.06; ctx.lineWidth = baseLw + g * 3.2; pathBoard(); ctx.stroke(); } } ctx.globalAlpha = 1; ctx.fillStyle = fillCol; pathBoard(); ctx.fill(); if (drawPlaqueImg) { ctx.save(); pathBoard(); ctx.clip(); const padImg = Math.max(2, padY * 0.65); const ix = signX + padImg; const iy = signY + padImg; const iw = Math.max(0, w - padImg * 2); const ih = Math.max(0, h - padImg * 2); if (iw > 0 && ih > 0) { const nw = drawPlaqueImg.naturalWidth; const nh = drawPlaqueImg.naturalHeight; const ir = nw / nh; let dw; let dh; if (iw / ih > ir) { dh = ih; dw = dh * ir; } else { dw = iw; dh = dw / ir; } const dx = ix + (iw - dw) / 2; const dy = iy + (ih - dh) / 2; ctx.drawImage(drawPlaqueImg, 0, 0, nw, nh, dx, dy, dw, dh); } ctx.restore(); } ctx.strokeStyle = neon; if (simplePreviewPlaque) { ctx.lineWidth = Math.max(1, baseLw * 0.85); ctx.shadowBlur = 0; pathBoard(); ctx.stroke(); } else { ctx.lineWidth = baseLw; ctx.shadowColor = neon; ctx.shadowBlur = 14; pathBoard(); ctx.stroke(); ctx.shadowBlur = 8; pathBoard(); ctx.stroke(); ctx.shadowBlur = 0; } const attachX = signX + w / 2; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = textCol; const lh = lineH || 12; if (!simplePreviewPlaque && lines && lines.length) { ctx.shadowColor = 'rgba(0,0,0,0.55)'; ctx.shadowBlur = 3; } if (lines && lines.length) { const n = lines.length; const textBlockH = n * lh; const y0 = signY + Math.max(padY, (h - textBlockH) / 2); const firstLineCenterY = y0 + lh / 2; for (let li = 0; li < n; li++) { ctx.fillText(lines[li], attachX, firstLineCenterY + li * lh); } } ctx.shadowBlur = 0; if (tether && Number.isFinite(tether.x0) && Number.isFinite(tether.y0)) { ctx.strokeStyle = 'rgba(255,255,255,0.95)'; ctx.lineWidth = 1.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.shadowBlur = 0; ctx.beginPath(); ctx.moveTo(tether.x0, tether.y0); ctx.lineTo(tether.x1, tether.y1); ctx.stroke(); } } /** * กล่องป้ายบนจอ (signX, signY, w, h) ให้ตรงกับ drawQuizCarryChoiceLabels สำหรับช่อง idx 0-based */ function computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, idx0Based) { if (!isQuizCarry() || !mapData || !quizCarryCurrent) return null; const q = quizCarryCurrent; const choices = q.choices; if (!choices || !choices.length || idx0Based < 0 || idx0Based >= choices.length) return null; const i = idx0Based; const b = getQuizCarryOptionClusterBounds(mapData, i + 1); if (!b) return null; const ts = tileSize * zoom; const ps = quizCarryPlaqueMapScaleClampedPlay(); const choiceText = String(choices[i] || '').trim(); const imgUrl = getQuizCarryPlaqueImageUrlForIndex(q, i); const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i); const plaqueExtra = imgUrl ? { imageUrl: imgUrl } : (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {}); if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) return null; const tileSpanX = (b.maxX - b.minX + 1); const tileSpanY = (b.maxY - b.minY + 1); const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps); const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps); const wx = b.cx * tileSize; const wy = b.cy * tileSize; const [sx, sy] = worldToScreen(wx, wy); ctx.save(); let signX; let signY; let w; let h; if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) { const minW = minPlaqueW; const minH = minPlaqueH; signX = sx - minW / 2; signY = sy - minH / 2; w = minW; h = minH; ctx.restore(); return { signX, signY, w, h }; } const line = choiceText; const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps)); ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; let maxW = Math.max(56, tileSpanX * ts - 14); const lineH = fontPx * 1.2; let textLines = canvasWordWrapLines(ctx, line, maxW); if (!textLines.length) textLines = [line]; if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) { const fp2 = Math.max(9, fontPx * 0.85); ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; const lh2 = fp2 * 1.2; maxW = Math.max(48, maxW - 4); textLines = canvasWordWrapLines(ctx, line, maxW); if (textLines.length * lh2 > tileSpanY * ts - 8) { textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2))); } for (let ti = 0; ti < textLines.length; ti++) { let s = textLines[ti]; if (ctx.measureText(s).width <= maxW) continue; while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); textLines[ti] = s + '…'; } const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40); const padX = 10; const padY = 8; w = Math.ceil(maxLineW + padX * 2); h = Math.ceil(textLines.length * lh2 + padY * 2); if (imgUrl || plaqueExtra.imageElement) { w = Math.max(w, minPlaqueW); h = Math.max(h, minPlaqueH); } signX = sx - w / 2; signY = sy - h / 2; ctx.restore(); return { signX, signY, w, h }; } for (let ti = 0; ti < textLines.length; ti++) { let s = textLines[ti]; if (ctx.measureText(s).width <= maxW) continue; while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); textLines[ti] = s + '…'; } let maxLineW = 0; for (let ti = 0; ti < textLines.length; ti++) { const lw = ctx.measureText(textLines[ti]).width; if (lw > maxLineW) maxLineW = lw; } const padX = 10; const padY = 8; w = Math.ceil(Math.max(maxLineW, 52) + padX * 2); h = Math.ceil(textLines.length * lineH + padY * 2); if (imgUrl || plaqueExtra.imageElement) { w = Math.max(w, minPlaqueW); h = Math.max(h, minPlaqueH); } signX = sx - w / 2; signY = sy - h / 2; ctx.restore(); return { signX, signY, w, h }; } /** * ขอบเรืองรอบช่องตัวเลือก (carryEmbedCountdownHighlight + carryEmbedCountdownHighlightColor) * ขนาดกรอบเท่าป้ายบนแมป (เดียวกับ drawQuizCarryChoiceLabels) ไม่ใช่ทั้ง cluster กริด */ function drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zoom, timeMs) { if (!isQuizCarry() || !mapData) return; if (mapData.carryEmbedCountdownHighlight === false) return; const optIdx = getQuizCarryLocalGrabbableOptionIndex(); if (optIdx == null) return; const pr = computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, optIdx); if (!pr || pr.w < 4 || pr.h < 4) return; const rx = pr.signX; const ry = pr.signY; const rw = pr.w; const rh = pr.h; const rr = Math.max(6, Math.min(16, Math.min(rw, rh) * 0.14)); const colRaw = mapData.carryEmbedCountdownHighlightColor; const strokeBase = typeof colRaw === 'string' && /^#[0-9a-fA-F]{6}$/.test(colRaw.trim()) ? colRaw.trim() : '#ffb44a'; const pulse = 0.55 + 0.45 * Math.sin((timeMs || 0) / 180); ctx.save(); function pathRr() { ctx.beginPath(); if (typeof ctx.roundRect === 'function') ctx.roundRect(rx, ry, rw, rh, rr); else ctx.rect(rx, ry, rw, rh); } pathRr(); ctx.fillStyle = strokeBase; ctx.globalAlpha = 0.12 * pulse; ctx.fill(); for (let g = 6; g >= 1; g--) { ctx.strokeStyle = strokeBase; ctx.globalAlpha = (0.07 + g * 0.055) * pulse; ctx.lineWidth = 2.5 + g * 4; pathRr(); ctx.stroke(); } ctx.globalAlpha = 0.98; ctx.strokeStyle = strokeBase; ctx.lineWidth = 4; ctx.shadowColor = strokeBase; ctx.shadowBlur = 22 * pulse; pathRr(); ctx.stroke(); ctx.shadowBlur = 12 * pulse; pathRr(); ctx.stroke(); ctx.shadowBlur = 0; ctx.globalAlpha = 1; ctx.restore(); } function drawQuizCarryChoiceLabels(ctx, worldToScreen, zoom) { if (!isQuizCarry() || !mapData || !quizCarryCurrent) return; if (!quizCarryOptionsPickableNow()) return; const choices = quizCarryCurrent.choices; if (!choices || !choices.length) return; const maxSlots = choices.length; const ts = tileSize * zoom; const ps = quizCarryPlaqueMapScaleClampedPlay(); ctx.save(); for (let i = 0; i < Math.min(choices.length, maxSlots); i++) { if (quizCarryOptionHeldByAnyone(i)) continue; const b = getQuizCarryOptionClusterBounds(mapData, i + 1); if (!b) continue; const choiceText = String(choices[i] || '').trim(); const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, i); const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i); const plaqueExtra = imgUrl ? { imageUrl: imgUrl } : (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {}); if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) continue; const tileSpanX = (b.maxX - b.minX + 1); const tileSpanY = (b.maxY - b.minY + 1); const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps); const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps); if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) { const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps)); const lineH = fontPx * 1.2; const padY = 8; const minW = minPlaqueW; const minH = minPlaqueH; const wx = b.cx * tileSize; const wy = b.cy * tileSize; const [sx, sy] = worldToScreen(wx, wy); drawQuizCarryNeonPlaque(ctx, sx - minW / 2, sy - minH / 2, minW, minH, [], lineH, padY, i, null, plaqueExtra); continue; } const line = choiceText; const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps)); ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; let maxW = Math.max(56, tileSpanX * ts - 14); const lineH = fontPx * 1.2; let textLines = canvasWordWrapLines(ctx, line, maxW); if (!textLines.length) textLines = [line]; if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) { const fp2 = Math.max(9, fontPx * 0.85); ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; const lh2 = fp2 * 1.2; maxW = Math.max(48, maxW - 4); textLines = canvasWordWrapLines(ctx, line, maxW); if (textLines.length * lh2 > tileSpanY * ts - 8) { textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2))); } for (let ti = 0; ti < textLines.length; ti++) { let s = textLines[ti]; if (ctx.measureText(s).width <= maxW) continue; while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); textLines[ti] = s + '…'; } const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40); const padX = 10; const padY = 8; let w = Math.ceil(maxLineW + padX * 2); let h = Math.ceil(textLines.length * lh2 + padY * 2); if (imgUrl || plaqueExtra.imageElement) { w = Math.max(w, minPlaqueW); h = Math.max(h, minPlaqueH); } const wx = b.cx * tileSize; const wy = b.cy * tileSize; const [sx, sy] = worldToScreen(wx, wy); const signX = sx - w / 2; const signY = sy - h / 2; drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lh2, padY, i, null, plaqueExtra); continue; } for (let ti = 0; ti < textLines.length; ti++) { let s = textLines[ti]; if (ctx.measureText(s).width <= maxW) continue; while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); textLines[ti] = s + '…'; } let maxLineW = 0; for (let ti = 0; ti < textLines.length; ti++) { const lw = ctx.measureText(textLines[ti]).width; if (lw > maxLineW) maxLineW = lw; } const padX = 10; const padY = 8; let w = Math.ceil(Math.max(maxLineW, 52) + padX * 2); let h = Math.ceil(textLines.length * lineH + padY * 2); if (imgUrl || plaqueExtra.imageElement) { w = Math.max(w, minPlaqueW); h = Math.max(h, minPlaqueH); } const wx = b.cx * tileSize; const wy = b.cy * tileSize; const [sx, sy] = worldToScreen(wx, wy); const signX = sx - w / 2; const signY = sy - h / 2; drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lineH, padY, i, null, plaqueExtra); } ctx.restore(); } function quizCarryHubCellPlay(md, tx, ty) { const g = md && md.quizCarryHubArea; return !!(g && g[ty] && g[ty][tx] === 1); } function quizCarryInteractiveCellPlay(md, tx, ty) { const g = md && md.interactive; return !!(g && g[ty] && g[ty][tx] === 1); } function quizCarryMapHasAnswerInteractive(md) { if (!md || !md.interactive || !md.interactive.length) return false; const h = md.height || 15, w = md.width || 20; for (let y = 0; y < h; y++) { const row = md.interactive[y]; if (!row) continue; for (let x = 0; x < w; x++) { if (row[x] === 1) return true; } } return false; } /** @returns {number|null} index ใน choices จากช่องหมายเลข 1..N บนแมป */ function quizCarryOptionIndexAtPlay(md, tx, ty) { const g = md && md.quizCarryOptionArea; if (!g || !g[ty]) return null; const v = g[ty][tx]; if (v < 1 || v > QUIZ_CARRY_MAX_OPTION_SLOTS) return null; const idx = v - 1; const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0; if (n < 1 || idx >= n) return null; return idx; } /** ร่างตัวละครทับช่องโซนกลาง (ม่วง) — ใช้บล็อกการเดิน */ function quizCarryFootprintOverlapsHub(px, py) { if (!mapData || !isQuizCarry()) return false; const md = mapData; for (const k of quizTilesFootprintPlay(px, py)) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (quizCarryHubCellPlay(md, tx, ty)) return true; } return false; } /** ยืนข้างนอกแต่ชิดโซนกลาง — ใช้ให้กด F ส่งคำตอบได้โดยไม่ต้องย่ำบนม่วง */ function quizCarryFootprintAdjacentToHub(md, px, py) { if (!md) return false; const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; for (const k of quizTilesFootprintPlay(px, py)) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (quizCarryHubCellPlay(md, tx, ty)) continue; for (let di = 0; di < dirs.length; di++) { const nx = tx + dirs[di][0], ny = ty + dirs[di][1]; if (quizCarryHubCellPlay(md, nx, ny)) return true; } } return false; } function quizCarryCanSubmitAtHub(md, px, py) { if (!md) return false; return quizCarryFootprintOverlapsHub(px, py) || quizCarryFootprintAdjacentToHub(md, px, py); } function quizCarryFootprintOverlapsInteractive(md, px, py) { if (!md) return false; for (const k of quizTilesFootprintPlay(px, py)) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (quizCarryInteractiveCellPlay(md, tx, ty)) return true; } return false; } function quizCarryFootprintAdjacentToInteractive(md, px, py) { if (!md) return false; const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; for (const k of quizTilesFootprintPlay(px, py)) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (quizCarryInteractiveCellPlay(md, tx, ty)) continue; for (let di = 0; di < dirs.length; di++) { const nx = tx + dirs[di][0], ny = ty + dirs[di][1]; if (quizCarryInteractiveCellPlay(md, nx, ny)) return true; } } return false; } function quizCarryCanSubmitAtInteractiveZone(md, px, py) { if (!md) return false; return quizCarryFootprintOverlapsInteractive(md, px, py) || quizCarryFootprintAdjacentToInteractive(md, px, py); } /** ส่งคำตอบ: ถ้ามีโซน interactive (เขียว) ในแมป ใช้โซนนั้น — ไม่มี fallback ชิดโซนกลาง */ function quizCarryCanSubmitAnswerAt(md, px, py) { if (!md) return false; if (quizCarryMapHasAnswerInteractive(md)) { return quizCarryCanSubmitAtInteractiveZone(md, px, py); } return quizCarryCanSubmitAtHub(md, px, py); } /** ข้อความใน HUD / โซนคำถาม — ไม่รวมตัวเลือก (แสดงบนแผนที่ตามจุดสี) */ function formatQuizCarryQuestionHud(q) { if (!q) return ''; return String(q.text || q.question || q.prompt || q.title || '').trim(); } function quizCarryRestoreEmbedPhaseLabel() { const phaseEl = document.getElementById('quiz-game-phase-label'); if (!phaseEl || !mapData) return; phaseEl.textContent = quizCarryMapHasAnswerInteractive(mapData) ? 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งที่โซน interactive (เขียว) — เล่นพร้อมกันได้' : 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งเมื่อชิดโซนกลาง (ม่วงเป็นกำแพง) — เล่นพร้อมกันได้'; } function isQuizCarryEmbedCountdownBlockingMovement() { return !!(previewMode && editorEmbedReturn && isQuizCarry() && quizCarryEmbedCountdownEndAt > Date.now()); } function isQuizCarryEmbedLobbyBlockingMovement() { return !!(previewMode && editorEmbedReturn && isQuizCarry() && quizCarryPregameActive); } function quizCarryPregameHumanIds() { const ids = []; if (myId != null && !isPreviewBotId(myId)) ids.push(String(myId)); others.forEach((_, id) => { if (!isPreviewBotId(id)) ids.push(String(id)); }); return ids; } function quizCarryPregameBotCount() { let n = 0; others.forEach((_, id) => { if (isPreviewBotId(id)) n++; }); return n; } function quizCarryPregameReadyNumerator() { let n = quizCarryPregameBotCount(); quizCarryPregameHumanIds().forEach((id) => { if (quizCarryLobbyReadyMap[id]) n++; }); return n; } function quizCarryPregameTotalPlayers() { return quizCarryPregameHumanIds().length + quizCarryPregameBotCount(); } function isMePlayHost() { if (myId == null) return false; if (playHostId == null) return true; return String(playHostId) === String(myId); } function hideQuizCarryPregameOverlay() { const ov = document.getElementById('quiz-carry-pregame-overlay'); if (ov) { ov.classList.add('is-hidden'); ov.setAttribute('aria-hidden', 'true'); } } function showQuizCarryPregameOverlay() { const ov = document.getElementById('quiz-carry-pregame-overlay'); if (ov) { ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); } updateQuizCarryPregameHud(); } function quizCarrySyncGuestReadyIfNeeded() { if (!quizCarryPregameActive || myId == null || isMePlayHost()) return; const sid = String(myId); if (quizCarryLobbyReadyMap[sid]) return; quizCarryLobbyReadyMap[sid] = true; if (socket && socket.connected) socket.emit('quiz-carry-lobby-ready', { ready: true }); } function updateQuizCarryPregameHud() { if (!quizCarryPregameActive) return; const st = document.getElementById('quiz-carry-pregame-status'); const primary = document.getElementById('quiz-carry-pregame-primary'); const primaryImg = document.getElementById('quiz-carry-pregame-primary-img'); const num = quizCarryPregameReadyNumerator(); const tot = Math.max(1, quizCarryPregameTotalPlayers()); if (st) { st.textContent = 'Ready Status : ' + num + '/' + tot; } const humans = quizCarryPregameHumanIds(); const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]); const meReady = !!(myId && quizCarryLobbyReadyMap[String(myId)]); if (primaryImg) { primaryImg.src = humansReady ? '/Game/img/quiz-carry/btn-start.png' : '/Game/img/quiz-carry/btn-ready.png'; primaryImg.alt = humansReady ? 'START' : 'READY'; } if (primary) { primary.classList.toggle('is-start-phase', humansReady); primary.classList.toggle('is-read-only', !isMePlayHost()); primary.classList.toggle('is-pressed', isMePlayHost() && meReady && !humansReady); primary.disabled = !isMePlayHost(); primary.setAttribute('aria-pressed', humansReady ? 'false' : (meReady ? 'true' : 'false')); primary.title = isMePlayHost() ? (humansReady ? 'START' : (meReady ? 'ยกเลิก READY' : 'READY')) : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); } } function beginQuizCarryEmbedPregame() { if (!(previewMode && editorEmbedReturn && isQuizCarry())) return; quizCarryPregameActive = true; quizCarryLobbyReadyMap = {}; quizCarryPregameHumanIds().forEach((id) => { quizCarryLobbyReadyMap[id] = false; }); showQuizCarryPregameOverlay(); if (socket && socket.connected) socket.emit('quiz-carry-lobby-sync-request'); quizCarrySyncGuestReadyIfNeeded(); updateQuizCarryPregameHud(); } function endQuizCarryEmbedPregameAndStart() { if (!quizCarryPregameActive) return; quizCarryPregameActive = false; hideQuizCarryPregameOverlay(); quizCarryPickNextQuestion(); if (!(previewMode && editorEmbedReturn && quizCarryEmbedCountdownEndAt > Date.now())) { quizCarryRestoreEmbedPhaseLabel(); } } function quizCarryOptionsPickableNow() { if (!isQuizCarry() || !quizCarryCurrent) return true; if (!quizCarryOptionRevealAt || quizCarryOptionRevealAt <= 0) return true; return Date.now() >= quizCarryOptionRevealAt; } function quizCarryApplyPhaseTimersForCurrentQuestion() { if (!quizCarryCurrent) { quizCarryOptionRevealAt = 0; quizCarryAnswerCloseAt = 0; return; } const readMs = Math.max(0, Math.floor(Number(quizCarryCarryTimingMs.carryReadMs)) || 0); const ansMs = Math.max(1000, Math.floor(Number(quizCarryCarryTimingMs.carryAnswerMs)) || 5000); const t0 = Date.now(); if (readMs <= 0) { quizCarryOptionRevealAt = 0; quizCarryAnswerCloseAt = t0 + ansMs; } else { quizCarryOptionRevealAt = t0 + readMs; quizCarryAnswerCloseAt = quizCarryOptionRevealAt + ansMs; } } function updateQuizCarryCarryPhaseHud() { if (quizCarrySessionEnded) return; if (quizCarryPregameActive) return; if (!isQuizCarry() || !quizCarryCurrent) return; const phaseEl = document.getElementById('quiz-game-phase-label'); if (!phaseEl) return; const now = Date.now(); if (quizCarryOptionRevealAt > 0 && now < quizCarryOptionRevealAt) { const remain = Math.max(0, Math.ceil((quizCarryOptionRevealAt - now) / 1000)); phaseEl.textContent = 'อ่านคำถาม — ตัวเลือกขึ้นใน ' + remain + ' วิ'; return; } if (quizCarryAnswerCloseAt > now) { const remain = Math.max(0, Math.ceil((quizCarryAnswerCloseAt - now) / 1000)); phaseEl.textContent = 'หยิบ/ส่งคำตอบ — เหลือ ' + remain + ' วิ'; return; } quizCarryRestoreEmbedPhaseLabel(); } function tickQuizCarryRoundTimers() { if (quizCarrySessionEnded) return; if (quizCarryPregameActive) return; if (!isQuizCarry() || !mapData || !quizCarryCurrent) return; if (quizCarryEmbedCountdownEndAt > Date.now()) return; if (quizCarryEmbedPendingQuestion) return; if (!quizCarryAnswerCloseAt || quizCarryAnswerCloseAt <= 0) return; if (Date.now() < quizCarryAnswerCloseAt) return; quizCarryAnswerCloseAt = 0; quizCarryOptionRevealAt = 0; me.quizCarryHeld = null; others.forEach((o) => { if (!o) return; o.quizCarryHeld = null; o.botPath = []; }); renderPlayQuizScoreboard(playLiveQuizScores); quizCarryAfterRoundResolved(); } /** นับ 3–2–1: ไม่โชว์ข้อความคำถามใน overlay — มีแค่เลข */ function hideQuizCarryEmbedCountdownQuestionChrome() { const cq = document.getElementById('quiz-carry-embed-countdown-q'); const ck = document.getElementById('quiz-carry-embed-countdown-kicker'); if (cq) { cq.textContent = ''; cq.style.display = 'none'; } if (ck) ck.style.display = 'none'; } function hideQuizCarryEmbedCountdownOverlay() { resetQuizCarryEmbedCountdownLayoutDom(); const ov = document.getElementById('quiz-carry-embed-countdown'); if (ov) { ov.classList.add('is-hidden'); ov.setAttribute('aria-hidden', 'true'); } hideQuizCarryEmbedCountdownQuestionChrome(); } function tickQuizCarryEmbedCountdown() { if (!previewMode || !editorEmbedReturn || !isQuizCarry()) return; if (!quizCarryEmbedPendingQuestion || quizCarryEmbedCountdownEndAt <= 0) return; const now = Date.now(); const ov = document.getElementById('quiz-carry-embed-countdown'); const numEl = document.getElementById('quiz-carry-embed-countdown-num'); if (now >= quizCarryEmbedCountdownEndAt) { const pq = quizCarryEmbedPendingQuestion; quizCarryEmbedPendingQuestion = null; quizCarryEmbedCountdownEndAt = 0; quizCarryEmbedCountdownStartAt = 0; hideQuizCarryEmbedCountdownOverlay(); if (!pq) return; quizCarryCurrent = pq; preloadQuizCarryChoiceImages(pq); playQuizText = formatQuizCarryQuestionHud(pq); const qEl = document.getElementById('quiz-game-question'); if (qEl) qEl.textContent = playQuizText; quizCarryRestoreEmbedPhaseLabel(); others.forEach((o) => { if (!o) return; o.botQuizCarryPathfindAfter = 0; }); syncQuizCarryEmbedQuestionStrip(); quizCarryApplyPhaseTimersForCurrentQuestion(); return; } const elapsed = now - quizCarryEmbedCountdownStartAt; const n = 3 - Math.min(2, Math.floor(elapsed / 1000)); if (numEl) numEl.textContent = String(Math.max(1, Math.min(3, n))); if (ov) { ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); } syncQuizCarryEmbedCountdownLayout(); hideQuizCarryEmbedCountdownQuestionChrome(); syncQuizCarryEmbedQuestionStrip(); } function quizCarryPickNextQuestion() { if (quizCarrySessionEnded) return; if (quizCarryPregameActive) return; if (!quizCarryPool.length) { quizCarryCurrent = null; quizCarryEmbedPendingQuestion = null; quizCarryEmbedCountdownEndAt = 0; quizCarryEmbedCountdownStartAt = 0; quizCarryOptionRevealAt = 0; quizCarryAnswerCloseAt = 0; hideQuizCarryEmbedCountdownOverlay(); playQuizText = 'ไม่มีคำถาม — ใส่ quizQuestions ในแมป (choices + correctIndex) หรือตั้งใน Admin'; return; } let q; let guard = 0; do { q = quizCarryPool[Math.floor(Math.random() * quizCarryPool.length)]; guard++; } while (guard < 12 && quizCarryPool.length > 1 && quizCarryCurrent && q && q.text === quizCarryCurrent.text); if (previewMode && editorEmbedReturn) { quizCarryEmbedPendingQuestion = q; preloadQuizCarryChoiceImages(q); quizCarryEmbedCountdownStartAt = Date.now(); quizCarryEmbedCountdownEndAt = quizCarryEmbedCountdownStartAt + 3000; quizCarryCurrent = null; playQuizText = ''; clearQuizMapQuestionCarryFeedback(); quizCarryOptionRevealAt = 0; quizCarryAnswerCloseAt = 0; me.quizCarryHeld = null; others.forEach((o) => { if (!o) return; o.quizCarryHeld = null; o.botPath = []; o.botPathStuckTicks = 0; /* ต้องใช้ performance.now() ช่วงกับ stepQuizCarryPreviewBots — ห้ามใช้ Date.now() epoch ไม่งั้นบอทค้าง pathfind ตลอด */ o.botQuizCarryPathfindAfter = 0; }); const qEl = document.getElementById('quiz-game-question'); if (qEl) qEl.textContent = '…'; const phaseEl = document.getElementById('quiz-game-phase-label'); if (phaseEl) phaseEl.textContent = 'รอเริ่มคำถาม — นับถอยหลัง 3 · 2 · 1'; tickQuizCarryEmbedCountdown(); syncQuizCarryEmbedQuestionStrip(); return; } quizCarryCurrent = q; preloadQuizCarryChoiceImages(q); playQuizText = formatQuizCarryQuestionHud(q); clearQuizMapQuestionCarryFeedback(); const qEl = document.getElementById('quiz-game-question'); if (qEl) qEl.textContent = playQuizText; me.quizCarryHeld = null; others.forEach((o) => { if (!o) return; o.quizCarryHeld = null; }); quizCarryApplyPhaseTimersForCurrentQuestion(); } /** หลังจบข้อหนึ่งข้อ (ถูกหรือหมดเวลา): พรีวิวนับรอบจนครบ carrySessionLength แล้วจบเซสชัน */ function quizCarryAfterRoundResolved() { if (!isQuizCarry()) return; if (isDetectiveMinigamePlay()) { finishDetectiveMinigameAndReturnLobby(); return; } if (!previewMode) { quizCarryPickNextQuestion(); return; } quizCarryRoundsCompleted++; const lim = quizCarrySessionLength; if (lim > 0 && quizCarryRoundsCompleted >= lim) { showQuizCarrySessionCompleteOverlay(); return; } quizCarryPickNextQuestion(); } function playQuizCarryAvatarUrlForMission(characterId) { if (!characterId) return ''; return BASE + '/img/characters/' + encodeURIComponent(String(characterId)) + '_down.png'; } /** อวาตาร์สรุปภารกิจ / DOM — ใช้สี gen เดียวกับในเกม (เลเยอร์ + tint) */ function applyQuizCarryRankAvatarTint(imgEl, characterId, peerId) { if (!imgEl || !characterId) return; let attempts = 0; const fallbackUrl = playQuizCarryAvatarUrlForMission(characterId); const tick = () => { attempts++; const nowT = Date.now(); const rawImg = getCharacterFrame(characterId, 'down', nowT, false) || getCharacterImg(characterId, 'down'); if (!rawImg) { if (fallbackUrl) imgEl.src = fallbackUrl; return; } if ((!rawImg.complete || !rawImg.naturalWidth) && attempts < 28) { if (fallbackUrl) imgEl.src = fallbackUrl + '?p=' + String(attempts); setTimeout(tick, 90); return; } if (!rawImg.naturalWidth) { if (fallbackUrl) imgEl.src = fallbackUrl; return; } const tint = playTintFromPeerId(peerId != null ? String(peerId) : String(characterId)); const out = getPlayTintedAvatarSource(rawImg, characterId, 'down', nowT, false, tint); if (out && out.tagName === 'CANVAS' && out.width > 0) { try { imgEl.src = out.toDataURL('image/png'); return; } catch (e) { /* ต่อไปลอง walk หรือ URL */ } } if (out && out.src && out !== rawImg) { imgEl.src = out.src; return; } if (attempts < 28 && playCharLayerDiscoveryPending(characterId)) { setTimeout(tick, 120); return; } imgEl.src = rawImg.src || fallbackUrl; }; tick(); } /** เกรดจากค่าเฉลี่ยคะแนนทีม (0–100 หลังปัด) — A 80–100, B 60–79, C 0–59 */ function quizCarryGradeFromTeamAverage(avgRounded) { if (avgRounded >= 80) return 'A'; if (avgRounded >= 60) return 'B'; return 'C'; } function quizCarryRollCardRewardForGrade(grade) { const u = Math.random() * 100; if (grade === 'A') { if (u < 30) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' }; if (u < 80) return { th: 'การ์ด Rare', en: 'Rare card' }; return { th: 'การ์ดธรรมดา', en: 'Common card' }; } if (grade === 'B') { if (u < 20) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' }; if (u < 50) return { th: 'การ์ด Rare', en: 'Rare card' }; return { th: 'การ์ดธรรมดา', en: 'Common card' }; } if (grade === 'C') { if (u < 20) return { th: 'การ์ด Rare', en: 'Rare card' }; return { th: 'การ์ดธรรมดา', en: 'Common card' }; } return null; } function quizCarryBuildMissionRankList() { const ranks = []; if (myId != null) { ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, Number(playLiveQuizScores[myId]) || 0), characterId: me.characterId || getPlayCharacterId(), }); } others.forEach((o, id) => { if (!o) return; ranks.push({ id, nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id), score: Math.max(0, Number(playLiveQuizScores[id]) || 0), characterId: o.characterId || null, }); }); ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th')); return ranks.slice(0, 5); } function cancelEmbedPreviewLobbyReturnTimer() { if (embedPreviewLobbyReturnTimer != null) { clearTimeout(embedPreviewLobbyReturnTimer); embedPreviewLobbyReturnTimer = null; } } function cancelQuizCarryResultEndAfterTimeup() { if (quizCarryResultEndTimer != null) { clearTimeout(quizCarryResultEndTimer); quizCarryResultEndTimer = null; } cancelEmbedPreviewLobbyReturnTimer(); } /** หลังภาพ result-complete / gameover (editor embed + preview): รอ 5s แล้วไปล็อบบี้ห้องพรีวิว */ function scheduleEmbedPreviewReturnToLobbyAfterResultEnd() { cancelEmbedPreviewLobbyReturnTimer(); if (!(previewMode && editorEmbedReturn)) return; embedPreviewLobbyReturnTimer = setTimeout(function () { embedPreviewLobbyReturnTimer = null; window.location.href = buildRoomLobbyReturnHref(); }, 5000); } /** มีผู้เล่นอย่างน้อยหนึ่งคนได้คะแนน > 0 (= ส่งคำตอบถูกที่ฮับอย่างน้อย 1 ครั้ง; คะแนนต่อข้อคงที่ QUIZ_CARRY_POINTS_PER_CORRECT) */ function quizCarryAnyPlayerHasCorrectAnswer() { if (!playLiveQuizScores || typeof playLiveQuizScores !== 'object') return false; const keys = Object.keys(playLiveQuizScores); for (let i = 0; i < keys.length; i++) { if (Math.max(0, Number(playLiveQuizScores[keys[i]]) || 0) > 0) return true; } return false; } function hideQuizCarryResultEndLayer() { cancelEmbedPreviewLobbyReturnTimer(); const el = document.getElementById('quiz-carry-result-end-layer'); if (!el) return; el.classList.add('is-hidden'); el.setAttribute('aria-hidden', 'true'); } function showQuizCarryResultEndLayer(useComplete) { const el = document.getElementById('quiz-carry-result-end-layer'); const img = document.getElementById('quiz-carry-result-end-img'); if (!el || !img) return; const fname = useComplete ? 'result-complete.png' : 'result-gameover.png'; img.src = BASE + '/img/quiz-carry/' + fname + '?v=' + String(Date.now()); el.classList.remove('is-hidden'); el.setAttribute('aria-hidden', 'false'); } /** Embed editor: หลัง timeup บนโต๊ะ — รอ 5 วิ แล้วโชว์ result-complete / result-gameover (ใช้ร่วม quiz_carry + crown) */ function scheduleEmbedDeskResultAfterDelay(okResolver) { cancelQuizCarryResultEndAfterTimeup(); hideQuizCarryResultEndLayer(); const resolve = typeof okResolver === 'function' ? okResolver : function () { return !!okResolver; }; quizCarryResultEndTimer = setTimeout(function () { quizCarryResultEndTimer = null; let anyOk = false; try { anyOk = !!resolve(); } catch (e) { anyOk = false; } hideQuizCarryTimeupOnDeskLayer(); showQuizCarryResultEndLayer(anyOk); scheduleEmbedPreviewReturnToLobbyAfterResultEnd(); }, 5000); } function hideQuizCarryTimeupOnDeskLayer() { cancelQuizCarryResultEndAfterTimeup(); const el = document.getElementById('quiz-carry-timeup-desk-layer'); if (!el) return; const anchor = el.querySelector('.qc-timeup-desk-anchor') || el.querySelector('.qc-timeup-desk-inner'); if (anchor) { anchor.style.left = ''; anchor.style.top = ''; anchor.style.width = ''; anchor.style.height = ''; } el.classList.add('is-hidden'); el.setAttribute('aria-hidden', 'true'); } /** หลังกดรับหลักฐาน (embed preview) — แสดง timeup กลางแคนวาส (Jumper mnptfts2 = img/Jumper/result-timeup.png) แยกจากป๊อปสรุปคะแนน * @param {function():boolean} [embedResultOkFn] — ถ้าไม่ส่ง = ใช้คะแนน quiz_carry (คนตอบถูก); ส่งเมื่อ crown เพื่อเลือก complete vs gameover */ function showQuizCarryTimeupOnDeskLayer(embedResultOkFn) { const el = document.getElementById('quiz-carry-timeup-desk-layer'); if (!el) return; const img = document.getElementById('quiz-carry-timeup-txt-img'); if (img) { if (isJumpSurviveMissionUiMapPlay()) { img.onerror = function () { this.onerror = null; this.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now()); }; img.src = jumperAssetUrl('result-timeup.png') + '?v=' + String(Date.now()); } else { img.onerror = null; img.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now()); } } el.classList.remove('is-hidden'); el.setAttribute('aria-hidden', 'false'); try { syncQuizCarryTimeupDeskLayerPosition(); } catch (e) { /* ignore */ } if (previewMode && editorEmbedReturn) { scheduleEmbedDeskResultAfterDelay(embedResultOkFn || quizCarryAnyPlayerHasCorrectAnswer); } } function showQuizCarryMissionSummaryOverlay(opts) { opts = opts || {}; const forceF = !!opts.forceGradeF; const overlay = document.getElementById('quiz-carry-mission-overlay'); if (!overlay) return; hideQuizCarryTimeupOnDeskLayer(); const ranks = quizCarryBuildMissionRankList(); const parts = ranks.map((r) => Math.max(0, Number(r.score) || 0)); const total = parts.reduce((a, b) => a + b, 0); const n = ranks.length; const avgForGrade = n ? Math.min(100, Math.max(0, Math.round(total / n))) : 0; const grade = forceF ? 'F' : quizCarryGradeFromTeamAverage(avgForGrade); const reward = grade === 'F' ? null : quizCarryRollCardRewardForGrade(grade); const rowEl = document.getElementById('qc-mission-rank-row'); const totalEl = document.getElementById('qc-mission-total-line'); const gradeEl = document.getElementById('qc-mission-grade'); const cardTextEl = document.getElementById('qc-mission-card-text'); const checkEl = document.getElementById('qc-mission-check'); const successTxt = document.getElementById('qc-mission-success-txt'); const rankTagLabels = ['[1st]', '[2nd]', '[3rd]', '[4th]', '[5th]']; if (rowEl) { rowEl.innerHTML = ''; ranks.forEach((r, idx) => { const rank = idx + 1; const cell = document.createElement('div'); cell.className = 'qc-mission-rank-cell'; const tag = document.createElement('div'); tag.className = 'qc-mission-rank-tag'; tag.textContent = rankTagLabels[rank - 1] || ('[' + rank + ']'); const frame = document.createElement('div'); frame.className = 'qc-mission-rank-frame'; const img = document.createElement('img'); img.className = 'qc-mission-rank-avatar'; img.alt = ''; applyQuizCarryRankAvatarTint(img, r.characterId, r.id); img.onerror = function () { this.onerror = null; this.src = 'data:image/svg+xml,' + encodeURIComponent('?'); }; frame.appendChild(img); const nick = document.createElement('div'); nick.className = 'qc-mission-rank-nick'; nick.textContent = r.nickname; nick.title = r.nickname; const sc = document.createElement('div'); sc.className = 'qc-mission-rank-score'; sc.textContent = String(r.score); cell.appendChild(tag); cell.appendChild(frame); cell.appendChild(nick); cell.appendChild(sc); rowEl.appendChild(cell); }); } if (totalEl) { totalEl.textContent = ''; if (!n) { totalEl.textContent = '—'; } else { const lab = document.createElement('span'); lab.textContent = 'คะแนนรวม '; const nums = document.createElement('span'); nums.className = 'qc-mission-total-nums'; nums.textContent = '(' + parts.join('+') + ') = ' + total; const tail = document.createElement('span'); tail.textContent = ' · เฉลี่ยทีม ' + avgForGrade + '/100'; totalEl.appendChild(lab); totalEl.appendChild(nums); totalEl.appendChild(tail); } } if (gradeEl) { gradeEl.textContent = grade; gradeEl.className = 'qc-mission-grade qc-mission-grade--' + String(grade).toLowerCase(); } const okMission = grade !== 'F'; if (checkEl) checkEl.style.display = okMission ? '' : 'none'; if (successTxt) { successTxt.style.display = okMission ? '' : 'none'; if (okMission) successTxt.textContent = 'ภารกิจสำเร็จ'; } if (cardTextEl) { cardTextEl.textContent = ''; if (grade === 'F') { const pl = document.createElement('div'); pl.className = 'qc-mission-reward-plaque qc-mission-reward-plaque--muted'; const t = document.createElement('div'); t.className = 'qc-mission-reward-plaque-title'; t.textContent = 'ไม่ได้รับการ์ด'; const s = document.createElement('div'); s.className = 'qc-mission-reward-plaque-sub'; s.textContent = 'เกรด F'; pl.appendChild(t); pl.appendChild(s); cardTextEl.appendChild(pl); } else if (reward) { const pl = document.createElement('div'); pl.className = 'qc-mission-reward-plaque'; const t = document.createElement('div'); t.className = 'qc-mission-reward-plaque-title'; t.textContent = reward.th; const s = document.createElement('div'); s.className = 'qc-mission-reward-plaque-sub'; s.textContent = reward.en; pl.appendChild(t); pl.appendChild(s); cardTextEl.appendChild(pl); } } overlay.classList.remove('is-hidden'); const btn = document.getElementById('btn-quiz-carry-mission-done'); if (btn) { btn.onclick = function () { overlay.classList.add('is-hidden'); if (previewMode && editorEmbedReturn) { showQuizCarryTimeupOnDeskLayer(); } else { window.location.href = buildRoomLobbyReturnHref(); } }; } } function showQuizCarrySessionCompleteOverlay() { if (quizCarrySessionEnded) return; quizCarrySessionEnded = true; quizCarryPregameActive = false; hideQuizCarryPregameOverlay(); quizCarryCurrent = null; quizCarryEmbedPendingQuestion = null; quizCarryEmbedCountdownEndAt = 0; quizCarryEmbedCountdownStartAt = 0; quizCarryOptionRevealAt = 0; quizCarryAnswerCloseAt = 0; hideQuizCarryEmbedCountdownOverlay(); me.quizCarryHeld = null; others.forEach((o) => { if (!o) return; o.quizCarryHeld = null; o.botPath = []; }); playQuizText = 'ครบชุดคำถามแล้ว'; const qEl = document.getElementById('quiz-game-question'); if (qEl) qEl.textContent = playQuizText; const phaseEl = document.getElementById('quiz-game-phase-label'); if (phaseEl) phaseEl.textContent = 'ครบ ' + String(quizCarryRoundsCompleted) + ' ข้อ'; const missionOv = document.getElementById('quiz-carry-mission-overlay'); if (missionOv && quizCarryUseMissionSummaryOverlay) { showQuizCarryMissionSummaryOverlay({ forceGradeF: !!(playQuizPlayerLocal && playQuizPlayerLocal.eliminated), }); renderPlayQuizScoreboard(playLiveQuizScores); return; } 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; if (titleEl) titleEl.textContent = 'เกมจบ · Game over'; msgEl.textContent = 'ครบจำนวนคำถามที่ตั้งไว้ (' + String(quizCarryRoundsCompleted) + ' ข้อ) · Quiz carry session finished'; listEl.innerHTML = ''; const ranks = []; if (myId != null) { ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, Number(playLiveQuizScores[myId]) || 0), }); } others.forEach((o, id) => { if (!o) return; ranks.push({ id, nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id), score: Math.max(0, Number(playLiveQuizScores[id]) || 0), }); }); ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th')); ranks.forEach((r, i) => { const li = document.createElement('li'); const isMe = myId != null && 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 = buildRoomLobbyReturnHref(); } if (btn) { btn.onclick = () => { if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden'); else goLobby(); }; } renderPlayQuizScoreboard(playLiveQuizScores); } /** Toast กลางบน (หยิบ / ถูก / ผิด / คำใบ้) — ปิดการแสดงตามคำขอ UX */ function showQuizCarryToast(msg, ok) { if (typeof window.__quizCarryToastT === 'number') clearTimeout(window.__quizCarryToastT); window.__quizCarryToastT = null; const el = document.getElementById('play-quiz-feedback'); if (!el) return; el.classList.add('is-hidden'); el.className = 'play-quiz-feedback'; el.textContent = ''; } /** * หยิบ/ส่งคำตอบ — ต้องกด F (fromKey) สำหรับผู้เล่น · บอท preview เรียกแบบ silent เมื่อหยุดเดินแล้ว * @returns {boolean} ทำ action สำเร็จหรือไม่ */ function tryQuizCarryInteractionForPlayer(actorId, ox, oy, opts) { opts = opts || {}; if (quizCarryPregameActive) return false; if (quizCarrySessionEnded) return false; if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false; const md = mapData; const footprint = quizTilesFootprintPlay(ox, oy); const ent = actorId === myId ? me : others.get(actorId); if (!ent) return false; let pickupIdx = null; for (const k of footprint) { const p = k.split(','); const tx = +p[0], ty = +p[1]; const opt = quizCarryOptionIndexAtPlay(md, tx, ty); if (opt != null) pickupIdx = pickupIdx === null ? opt : pickupIdx; } const canSubmitAnswer = quizCarryCanSubmitAnswerAt(md, ox, oy); const correct = quizCarryCurrent.correctIndex; let held = ent.quizCarryHeld; if (held != null && canSubmitAnswer) { if (!quizCarryOptionsPickableNow()) return false; ent.quizCarryHeld = null; const ok = held === correct; if (ok) { playLiveQuizScores[actorId] = (playLiveQuizScores[actorId] || 0) + QUIZ_CARRY_POINTS_PER_CORRECT; const lim = previewMode && isQuizCarry() ? quizCarrySessionLength : 0; const willEnd = lim > 0 && (quizCarryRoundsCompleted + 1) >= lim; const showMapFb = !opts.silent && actorId === myId; if (showMapFb) showQuizMapQuestionCarryFeedback(true); if (!opts.silent) { const pts = QUIZ_CARRY_POINTS_PER_CORRECT; showQuizCarryToast(willEnd ? ('ถูกต้อง +' + pts + ' แต้ม — ครบชุดคำถาม') : ('ถูกต้อง +' + pts + ' แต้ม — สุ่มคำถามใหม่'), true); } me.quizCarryHeld = null; others.forEach((o) => { if (o) o.quizCarryHeld = null; }); const afterOk = () => { quizCarryAfterRoundResolved(); }; if (showMapFb) { window.setTimeout(afterOk, 560); } else { afterOk(); } } else { if (!opts.silent && actorId === myId) { showQuizMapQuestionCarryFeedback(false); } if (!opts.silent) { showQuizCarryToast('ผิด — คืนป้ายที่โซนตัวเลือกแล้ว กด F หยิบใหม่ได้', false); } } renderPlayQuizScoreboard(playLiveQuizScores); return true; } if (held == null && pickupIdx != null) { if (!quizCarryOptionsPickableNow()) return false; if (quizCarryOptionHeldByAnyone(pickupIdx)) { if (opts.fromKey && actorId === myId && !opts.silent) { showQuizCarryToast('ตัวเลือกนี้มีคนถืออยู่แล้ว — เลือกข้ออื่น', false); } return false; } ent.quizCarryHeld = pickupIdx; const label = (quizCarryCurrent.choices && quizCarryCurrent.choices[pickupIdx]) || String(pickupIdx); if (!opts.silent) { showQuizCarryToast('หยิบ: ' + label, true); } return true; } if (opts.fromKey && actorId === myId && !opts.silent) { const t = Date.now(); if (t - (me.quizCarryHintT || 0) > 2200) { me.quizCarryHintT = t; if (held != null && !canSubmitAnswer) { showQuizCarryToast(quizCarryMapHasAnswerInteractive(md) ? 'ถือป้ายอยู่ — ยืนที่โซน interactive (เขียว) หรือชิดขอบแล้วกด F เพื่อส่ง' : 'ถือป้ายอยู่ — ยืนชิดโซนกลาง (ม่วง) แล้วกด F เพื่อส่ง', false); } else { showQuizCarryToast(quizCarryMapHasAnswerInteractive(md) ? 'ยืนโซนตัวเลือกแล้วกด F หยิบ · ยืนโซนเขียว (interactive) แล้วกด F ส่งเมื่อถือป้าย' : 'ยืนโซนตัวเลือกแล้วกด F หยิบ · ยืนชิดโซนกลางแล้วกด F ส่งเมื่อถือป้าย', false); } } } return false; } function quizCarryGrabCoreGatesOk() { if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false; if (quizCarryPregameActive || quizCarrySessionEnded) return false; if (myId == null) return false; if (isQuizCarryEmbedCountdownBlockingMovement()) return false; if (!quizCarryOptionsPickableNow()) return false; return true; } /** ดัชนีตัวเลือก 0-based ที่ผู้เล่นท้องถิ่นยืนอยู่และหยิบได้ — ไม่มีคืน null */ function getQuizCarryLocalGrabbableOptionIndex() { if (!quizCarryGrabCoreGatesOk()) return null; if (me.quizCarryHeld != null) return null; const md = mapData; const footprint = quizTilesFootprintPlay(me.x, me.y); let pickupIdx = null; for (const k of footprint) { const p = k.split(','); const tx = +p[0]; const ty = +p[1]; const opt = quizCarryOptionIndexAtPlay(md, tx, ty); if (opt != null) pickupIdx = pickupIdx === null ? opt : pickupIdx; } if (pickupIdx == null || quizCarryOptionHeldByAnyone(pickupIdx)) return null; return pickupIdx; } /** ยืนบนช่องตัวเลือกและหยิบได้ */ function quizCarryGrabPickupAvailable() { return getQuizCarryLocalGrabbableOptionIndex() != null; } /** ถือป้ายแล้วยืนโซนส่งได้ */ function quizCarryGrabSubmitAvailable() { if (!quizCarryGrabCoreGatesOk()) return false; if (me.quizCarryHeld == null) return false; return quizCarryCanSubmitAnswerAt(mapData, me.x, me.y); } /** true = กด F / ปุ่ม จะหยิบหรือส่งได้ทันที */ function quizCarryGrabInteractionAvailable() { return quizCarryGrabPickupAvailable() || quizCarryGrabSubmitAvailable(); } function syncQuizCarryGrabButton() { const btn = document.getElementById('quiz-carry-grab-btn'); if (!btn) return; if (!isQuizCarry() || !mapData) { btn.classList.add('is-hidden'); btn.classList.remove('quiz-carry-grab-btn--active'); btn.classList.remove('quiz-carry-grab-btn--place'); btn.setAttribute('aria-disabled', 'true'); return; } const grabSrc = BASE + '/img/quiz-carry/btn-grab.png'; const placeSrc = BASE + '/img/quiz-carry/btn-drop.png'; const img = btn.querySelector('img'); btn.classList.remove('is-hidden'); const active = quizCarryGrabInteractionAvailable(); btn.classList.toggle('quiz-carry-grab-btn--active', active); btn.setAttribute('aria-disabled', active ? 'false' : 'true'); btn.style.pointerEvents = active ? '' : 'none'; if (img) { if (active && quizCarryGrabSubmitAvailable()) { img.src = placeSrc; btn.classList.add('quiz-carry-grab-btn--place'); btn.title = 'ส่ง / วางคำตอบ — เหมือนกด F'; btn.setAttribute('aria-label', 'ส่งหรือวางคำตอบ'); } else { img.src = grabSrc; btn.classList.remove('quiz-carry-grab-btn--place'); btn.title = 'หยิบตัวเลือก — เหมือนกด F'; btn.setAttribute('aria-label', 'หยิบตัวเลือก (Grab)'); } } } function resetQuizCarryPlayState() { quizCarryPregameActive = false; hideQuizCarryPregameOverlay(); quizCarryPool = []; quizCarryCurrent = null; quizCarryEmbedPendingQuestion = null; quizCarryEmbedCountdownStartAt = 0; quizCarryEmbedCountdownEndAt = 0; quizCarryOptionRevealAt = 0; quizCarryAnswerCloseAt = 0; quizCarryRoundsCompleted = 0; quizCarrySessionEnded = false; hideQuizCarryEmbedCountdownOverlay(); hideQuizCarryTimeupOnDeskLayer(); hideQuizCarryResultEndLayer(); me.quizCarryHeld = null; others.forEach((o) => { if (!o) return; o.quizCarryHeld = null; o.botPath = []; }); } /** รวม carry จากดิสก์ — ใช้เฉพาะ GET ที่ Node รองรับ (path เดียวกับ server.js) ไม่เรียก .php เพื่อกัน 404 บน nginx/php-fpm */ async function mergeQuizCarrySettingsFromDisk(s) { const out = s && typeof s === 'object' ? s : {}; try { const rd = await fetch(BASE + '/api/quiz-carry-from-disk?_=' + Date.now(), { cache: 'no-store' }); if (rd.ok) { const disk = await rd.json(); if (disk && typeof disk === 'object') { if (disk.carryMapPanelTheme && typeof disk.carryMapPanelTheme === 'object') { out.carryMapPanelTheme = disk.carryMapPanelTheme; } if (disk.carryEmbedCountdownTheme && typeof disk.carryEmbedCountdownTheme === 'object') { out.carryEmbedCountdownTheme = disk.carryEmbedCountdownTheme; } if (Array.isArray(disk.carryChoicePlaqueThemes) && disk.carryChoicePlaqueThemes.length) { out.carryChoicePlaqueThemes = disk.carryChoicePlaqueThemes; } else if (disk.carryChoicePlaqueTheme && typeof disk.carryChoicePlaqueTheme === 'object') { out.carryChoicePlaqueTheme = disk.carryChoicePlaqueTheme; } if (disk.carryReadMs != null) out.carryReadMs = disk.carryReadMs; if (disk.carryAnswerMs != null) out.carryAnswerMs = disk.carryAnswerMs; if (disk.carrySessionLength != null) out.carrySessionLength = disk.carrySessionLength; const diskPlaqueScale = Number(disk.carryChoicePlaqueMapScale); if (Number.isFinite(diskPlaqueScale)) { out.carryChoicePlaqueMapScale = Math.max(0.85, Math.min(2.5, diskPlaqueScale)); } if (Array.isArray(disk.carryQuestions) && disk.carryQuestions.length > 0) { out.carryQuestions = disk.carryQuestions; } } } } catch (e) { /* ignore */ } return out; } async function loadQuizCarryPoolAndStart() { quizCarryRoundsCompleted = 0; quizCarrySessionEnded = false; hideQuizCarryTimeupOnDeskLayer(); hideQuizCarryResultEndLayer(); quizCarrySessionLength = 0; let pool = []; let s = {}; const snap = quizCarryJoinSettingsSnap && typeof quizCarryJoinSettingsSnap === 'object' ? quizCarryJoinSettingsSnap : null; try { const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' }); if (r.ok) { const raw = await r.text(); const trimmed = (raw || '').trim(); if (trimmed.startsWith('{') && trimmed.endsWith('}')) { const parsed = JSON.parse(trimmed); if (parsed && typeof parsed === 'object') s = parsed; } } } catch (e) { /* Node/HTML error — ต่อจาก merge ดิสก์ */ } try { s = await mergeQuizCarrySettingsFromDisk(s); } catch (e) { /* ignore */ } if (snap) { if (s.carryReadMs == null && snap.carryReadMs != null) s.carryReadMs = snap.carryReadMs; if (s.carryAnswerMs == null && snap.carryAnswerMs != null) s.carryAnswerMs = snap.carryAnswerMs; if (s.carrySessionLength == null && snap.carrySessionLength != null) s.carrySessionLength = snap.carrySessionLength; } if (snap && snap.carryMapPanelTheme && typeof snap.carryMapPanelTheme === 'object') { s.carryMapPanelTheme = snap.carryMapPanelTheme; } if (snap && snap.carryEmbedCountdownTheme && typeof snap.carryEmbedCountdownTheme === 'object') { s.carryEmbedCountdownTheme = snap.carryEmbedCountdownTheme; } /* ไม่ทับ carryChoicePlaqueThemes จาก snap ถ้ามีแล้ว — snapshot ตอน join อาจเก่ากว่าไฟล์ที่เพิ่งบันทึกใน Admin (รูปป้ายหาย) */ if (snap && (!Array.isArray(s.carryChoicePlaqueThemes) || !s.carryChoicePlaqueThemes.length)) { if (Array.isArray(snap.carryChoicePlaqueThemes) && snap.carryChoicePlaqueThemes.length) { s.carryChoicePlaqueThemes = snap.carryChoicePlaqueThemes; } else if (snap.carryChoicePlaqueTheme && typeof snap.carryChoicePlaqueTheme === 'object') { s.carryChoicePlaqueTheme = snap.carryChoicePlaqueTheme; } } if (snap && (s.carryChoicePlaqueMapScale == null || !Number.isFinite(Number(s.carryChoicePlaqueMapScale)))) { const sm = Number(snap.carryChoicePlaqueMapScale); if (Number.isFinite(sm)) s.carryChoicePlaqueMapScale = sm; } { const sc = Number(s.carryChoicePlaqueMapScale); quizCarryPlaqueMapScale = Number.isFinite(sc) ? Math.max(0.85, Math.min(2.5, sc)) : 1.25; } try { quizCarryCarryTimingMs = { carryReadMs: clampPreviewMs(s.carryReadMs, 3000, 0, 120000), carryAnswerMs: clampPreviewMs(s.carryAnswerMs, 5000, 1000, 300000), }; quizCarrySessionLength = clampCarrySessionLen(s.carrySessionLength, 0); setQuizCarryMapPanelThemeFromApi(s); setQuizCarryEmbedCountdownThemeFromApi(s); setQuizCarryChoicePlaqueThemeFromApi(s); for (let ti = 0; ti < QUIZ_CARRY_MAX_OPTION_SLOTS; ti++) { const tUrl = sanitizeQuizCarryImageUrlClient(getEffectiveCarryChoicePlaqueThemeForChoice(ti).plaqueImageUrl); if (!tUrl || quizCarryChoiceImageCache.has(tUrl)) continue; const pim = new Image(); quizCarryApplyImageCrossOrigin(pim, tUrl); pim.onload = () => { try { draw(); } catch (e) { /* ignore */ } }; quizCarryChoiceImageCache.set(tUrl, pim); try { pim.src = tUrl; } catch (e) { /* ignore */ } } const carryOnly = s && Array.isArray(s.carryQuestions) && s.carryQuestions.length > 0; const source = carryOnly ? s.carryQuestions : (s && Array.isArray(s.questions) ? s.questions : []); for (let i = 0; i < source.length; i++) { const n = normalizeQuizCarryQuestionFromAny(source[i]); if (n) pool.push(n); } } catch (e) { /* map only */ } if (!pool.length && mapData) pool = buildQuizCarryPoolFromMap(mapData); quizCarryPool = pool; for (let pi = 0; pi < pool.length; pi++) preloadQuizCarryChoiceImages(pool[pi]); playLiveQuizScores = {}; if (myId != null) playLiveQuizScores[myId] = 0; others.forEach((_, id) => { playLiveQuizScores[id] = 0; }); if (!pool.length) { playQuizText = 'ไม่มีคำถาม — ตั้งใน Admin → คำถามหลายตัวเลือก หรือใส่ในแมป (quizQuestions)'; const qEl = document.getElementById('quiz-game-question'); if (qEl) qEl.textContent = playQuizText; try { syncPlayQuizMapPanel(); } catch (e) { /* ignore */ } quizCarryJoinSettingsSnap = null; return; } if (previewMode && editorEmbedReturn) { beginQuizCarryEmbedPregame(); } else if (isDetectiveMinigamePlay()) { quizCarryPickNextQuestion(); } else { quizCarryPickNextQuestion(); } if (!(previewMode && editorEmbedReturn && quizCarryEmbedCountdownEndAt > Date.now())) { quizCarryRestoreEmbedPhaseLabel(); } try { syncPlayQuizMapPanel(); } catch (e) { /* ignore */ } quizCarryJoinSettingsSnap = null; } function setupPlayQuizCarryUi() { if (playQuizTimerInterval) { clearInterval(playQuizTimerInterval); playQuizTimerInterval = null; } const ov = document.getElementById('quiz-game-overlay'); if (ov) { if (isDetectiveMinigamePlay() || (previewMode && editorEmbedReturn)) { ov.classList.add('is-hidden'); ov.setAttribute('aria-hidden', 'true'); } else { ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); } } const tEl = document.getElementById('quiz-game-timer'); if (tEl) tEl.textContent = ''; const leg = document.getElementById('quiz-play-legend'); if (leg) { leg.textContent = isDetectiveMinigamePlay() ? '' : (quizCarryMapHasAnswerInteractive(mapData) ? 'แต่ละตัวเลือกมีคนถือได้คนเดียว — ส่งคำตอบที่โซน interactive (เขียว) · โซนกลางม่วงเป็นกำแพง · กด F หรือปุ่ม GRAB มุมขวาล่าง' : 'แต่ละตัวเลือกมีคนถือได้คนเดียว — โซนกลางเป็นกำแพง · ยืนชิดขอบแล้วกด F หรือปุ่ม GRAB ส่งเมื่อถือป้าย'); } resetQuizCarryPlayState(); if (previewMode) { loadQuizCarryPoolAndStart(); } else { playQuizText = 'เข้าห้องแล้ว — คำถามจากแมป (เล่นจริงแนะนำซิงก์ผ่านโฮสต์ในอนาคต)'; loadQuizCarryPoolAndStart(); } renderPlayQuizScoreboard(playLiveQuizScores); syncQuizCarryGrabButton(); } function pickRandomTileForQuizCarryOption(md, optionIndex, o) { const w = md.width || 20, h = md.height || 15; const want = optionIndex + 1; const pool = []; for (let y = 0; y < h; y++) { const row = md.quizCarryOptionArea && md.quizCarryOptionArea[y]; if (!row) continue; for (let x = 0; x < w; x++) { if (Number(row[x]) === want && spawnTileWalkablePlay(md, x, y)) { if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y }); } } } if (!pool.length) return null; return pool[Math.floor(Math.random() * pool.length)]; } function pickRandomTileInQuizCarryHub(md, o) { const w = md.width || 20, h = md.height || 15; const pool = []; for (let y = 0; y < h; y++) { const row = md.quizCarryHubArea && md.quizCarryHubArea[y]; if (!row) continue; for (let x = 0; x < w; x++) { if (row[x] === 1 && spawnTileWalkablePlay(md, x, y)) { if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y }); } } } if (!pool.length) return null; return pool[Math.floor(Math.random() * pool.length)]; } function pickRandomTileInQuizCarryInteractive(md, o) { const w = md.width || 20, h = md.height || 15; const pool = []; for (let y = 0; y < h; y++) { const row = md.interactive && md.interactive[y]; if (!row) continue; for (let x = 0; x < w; x++) { if (row[x] === 1 && spawnTileWalkablePlay(md, x, y)) { if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y }); } } } if (!pool.length) return null; return pool[Math.floor(Math.random() * pool.length)]; } function pickRandomTileForQuizCarrySubmit(md, o) { if (quizCarryMapHasAnswerInteractive(md)) { const p = pickRandomTileInQuizCarryInteractive(md, o); if (p) return p; } return pickRandomTileInQuizCarryHub(md, o); } /** ช่วงไม่มีข้อ / รอตัวเลือก — ให้บอทเดินเล่นในแมปเหมือน preview ทั่วไป (กันยืนนิ่งทั้งเกม) */ function quizCarryPreviewBotLobbyWanderStep(o, w, h) { const now = Date.now(); o.botIsWalking = false; 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; const 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); } } if (!Number.isFinite(o.x)) o.x = 0.5; if (!Number.isFinite(o.y)) o.y = 0.5; clampPlayEntityFootprintToMap(o, mapData); if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true; } function stepQuizCarryPreviewBots() { if (quizCarrySessionEnded) return; if (quizCarryPregameActive) return; if (!playBotsEnabled() || !isQuizCarry() || !mapData) return; const w = mapData.width || 20, h = mapData.height || 15; const nowPf = performance.now(); const pathfindMinGap = editorEmbedReturn ? 280 : 140; others.forEach((o, id) => { if (!isPreviewBotId(id)) return; if (quizCarryFootprintOverlapsHub(o.x, o.y)) { const sn = snapPositionOutOfQuizCarryHubIfNeeded(o.x, o.y); if (Math.abs(sn.x - o.x) > 0.02 || Math.abs(sn.y - o.y) > 0.02) { o.x = sn.x; o.y = sn.y; o.botPath = []; o.botPathStuckTicks = 0; o.botQuizCarryPathfindAfter = 0; } } if (!o.botPath || o.botPath.length === 0) { tryQuizCarryInteractionForPlayer(id, o.x, o.y, { silent: true }); } /* ช่วงนับถอยหลัง / เปลี่ยนข้อ — ยังให้เดินได้ */ if (!quizCarryCurrent) { quizCarryPreviewBotLobbyWanderStep(o, w, h); return; } const tier = o.botTier || 'avg'; const pCorrect = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.35 : 0.65; const rawWant = Math.random() < pCorrect ? quizCarryCurrent.correctIndex : Math.floor(Math.random() * Math.max(2, (quizCarryCurrent.choices || []).length)); const wantIdx = pickQuizCarryTargetOptionIndexAvoidingTaken(rawWant); if (!o.botPath || !o.botPath.length) { if (typeof o.botQuizCarryPathfindAfter !== 'number') o.botQuizCarryPathfindAfter = 0; if (nowPf < o.botQuizCarryPathfindAfter) { quizCarryPreviewBotLobbyWanderStep(o, w, h); return; } const jitter = (String(id).length * 31 + ((o.x | 0) ^ (o.y | 0)) * 7) % 160; if (quizCarryOptionsPickableNow()) { if (o.quizCarryHeld == null && wantIdx != null) { const dest = pickRandomTileForQuizCarryOption(mapData, wantIdx, o); if (dest) { const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o); if (path && path.length > 1) o.botPath = path.slice(1); } } else { const sub = pickRandomTileForQuizCarrySubmit(mapData, o); if (sub) { const path = pathfindPlayForBot(o.x, o.y, sub.x + 0.5, sub.y + 0.5, o); if (path && path.length > 1) o.botPath = path.slice(1); } } o.botQuizCarryPathfindAfter = nowPf + pathfindMinGap + jitter; } else { o.botQuizCarryPathfindAfter = nowPf + 120; quizCarryPreviewBotLobbyWanderStep(o, w, h); } } else { stepPreviewBotAlongPath(o, w, h); } clampPlayEntityFootprintToMap(o, mapData); }); separateClumpedPreviewBots(); [...others.keys()].filter(isPreviewBotId).forEach((bid) => { const ob = others.get(bid); if (ob) clampPlayEntityFootprintToMap(ob, mapData); }); } function getStackAreaBoundsPlay(area, w, h) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let any = false; for (let y = 0; y < h; y++) { const row = area && area[y]; if (!row) continue; for (let x = 0; x < w; x++) { if (row[x] === 1) { any = true; minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); } } } if (!any) return null; return { minX, maxX, minY, maxY, cx: (minX + maxX + 1) / 2, cy: (minY + maxY + 1) / 2, }; } function getStackCameraCentersPx() { const w = mapData.width || 20, h = mapData.height || 15; const ts = tileSize; const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h); const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h); let cx = (w / 2) * ts; let cy = (h / 2) * ts; if (land && rel) { cx = land.cx * ts; cy = ((land.cy + rel.cy) / 2) * ts; } else if (land) { cx = land.cx * ts; cy = land.cy * ts; } else if (rel) { cx = rel.cx * ts; cy = rel.cy * ts; } return { px: cx, py: cy }; } /** ใช้ playStackBlockWidthTiles + แผนที่ — เรียกหลังโหลด /api/game-timing เมื่อหอว่าง */ function reapplyStackMiniSizingFromGlobals() { if (!mapData || !isStack() || !stackMini) return; if (stackMini.layers && stackMini.layers.length > 0) return; if (stackFall || stackMini.settling) return; const wMap = mapData.width || 20; const hMap = mapData.height || 15; const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap); const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2)); const iw = playStackBlockWidthTiles != null && Number.isFinite(playStackBlockWidthTiles) ? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles)) : autoW; stackMini.initialWidthTiles = iw; stackMini.widthTiles = iw; } function resetStackMinigameState() { stackMini = null; if (!mapData || !isStack()) return; const w = mapData.width || 20, h = mapData.height || 15; const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h); const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h); let towerCX = w * 0.5; let swingAmp = 1.85; let floorWorldY = (h - 1) * tileSize; let swingWorldY = floorWorldY - tileSize * 1.35; if (land) { towerCX = land.cx; floorWorldY = (land.maxY + 1) * tileSize; const span = Math.max(1, land.maxX - land.minX + 1); swingAmp = Math.min(span * 0.44, Math.max(0.6, span * 0.36)); } if (rel) { swingWorldY = (rel.cy + 0.5) * tileSize; const spanR = Math.max(1, rel.maxX - rel.minX + 1); swingAmp = Math.max(swingAmp, Math.min(spanR * 0.42, 4.2)); } const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2)); const initW = playStackBlockWidthTiles != null && Number.isFinite(playStackBlockWidthTiles) ? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles)) : autoW; stackFall = null; stackMini = { topCenterX: towerCX, widthTiles: initW, initialWidthTiles: initW, craneWorldX: towerCX * tileSize, swingAmp, phase: Math.random() * Math.PI * 2, phaseSpeed: playStackSwingHz, floorWorldY, swingWorldY, layerWorldH: Math.max(14, tileSize * 0.3), blockStripH: Math.max(16, tileSize * 0.32), layers: [], lives: 3, score: 0, combo: 0, lastDropAt: 0, stackTiltRad: 0, stackTiltVel: 0, }; if (previewFillBots) { me.stackPreviewHumanPts = 0; stackPreviewHudLog = []; } } function stackEaseDrop(t) { t = Math.max(0, Math.min(1, t)); return t * t; } /** ตกช้า/นุ่มขึ้นเมื่อปล่อยเพี้ยนกลาง (คล้าย Tower of Babel — มีน้ำหนักก่อนนิ่ง) */ function stackEaseDropPhys(t, sloppyN) { t = Math.max(0, Math.min(1, t)); if (sloppyN < 0.14) return t * t; return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } /** * @param {object} hit * @param {{ human?: boolean, botId?: string } | undefined} previewAttribution — โหมด preview: แยกคะแนนแสดงในแถบ (ตึกยังเป็นของร่วม) */ function applyStackHitFromDrop(hit, previewAttribution) { if (!stackMini || !hit) return; if (hit.miss) { stackMini.lives = Math.max(0, stackMini.lives - 1); stackMini.combo = 0; if (stackMini.lives <= 0) { stackMini.score = Math.max(0, stackMini.score - 2); stackMini.lives = 3; stackMini.layers = []; stackMini.widthTiles = stackMini.initialWidthTiles; const lb = getStackAreaBoundsPlay(mapData.stackLandArea, mapData.width || 20, mapData.height || 15); stackMini.topCenterX = lb ? lb.cx : (mapData.width || 20) * 0.5; stackMini.combo = 0; stackMini.stackTiltRad = 0; stackMini.stackTiltVel = 0; } } else { const W = stackMini.initialWidthTiles; stackMini.layers.push({ w: W, cx: hit.placedCx }); stackMini.score += hit.pts; if (previewFillBots && previewAttribution) { if (previewAttribution.human) { me.stackPreviewHumanPts = (me.stackPreviewHumanPts || 0) + hit.pts; } else if (previewAttribution.botId) { const ob = others.get(previewAttribution.botId); if (ob) ob.stackBotScore = (ob.stackBotScore || 0) + hit.pts; } } stackMini.combo = hit.perfect ? stackMini.combo + 1 : 0; stackMini.widthTiles = W; stackMini.topCenterX = hit.placedCx; } } /** * บล็อกทุกชั้นหน้ากว้างเท่ากัน (initialWidthTiles) · พลาดเมื่อทับแท่นไม่พอ * วาดชั้นเป็นสี่เหลี่ยมแนวนอน (ไม่หมุนต่อชั้น) เพื่อกองตรง ไม่บิดเป็นกองไม้ */ function computeStackDropResult(m) { const raw = m.swingAmp * Math.sin(m.phase); const wMap = mapData.width || 20; const hMap = mapData.height || 15; const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap); const fallW = m.initialWidthTiles; let supportCx; let supportW; if (m.layers.length === 0) { supportCx = land ? land.cx : wMap * 0.5; supportW = land ? (land.maxX - land.minX + 1) : Math.max(8, fallW * 2.2); } else { const sup = m.layers[m.layers.length - 1]; supportCx = sup.cx; supportW = fallW; } const c1 = m.topCenterX + raw; const half1 = fallW * 0.5; const half0 = supportW * 0.5; const left = Math.max(supportCx - half0, c1 - half1); const right = Math.min(supportCx + half0, c1 + half1); const overlap = right - left; const missThreshold = m.layers.length === 0 ? Math.max(0.32, fallW * 0.11) : Math.max(0.26, fallW * 0.17); const miss = overlap < missThreshold; const placedCx = c1; const delta = c1 - supportCx; const span = supportW * 0.5 + fallW * 0.5; const offN = Math.abs(delta) / Math.max(0.01, span); const perfect = !miss && offN < 0.075; const bonus = perfect ? Math.min(4, m.combo || 0) : 0; const pts = perfect ? (2 + bonus) : (miss ? 0 : 1); const supportRatio = fallW > 0 ? overlap / fallW : 0; return { miss, perfect, pts, landOffsetTiles: raw, placedW: fallW, placedCx, overlap, supportRatio, delta, }; } /** * เริ่มแอนิเมชันบล็อกร่วง (ผู้เล่น/บอทใช้ path เดียวกัน) * @param {object} hit จาก computeStackDropResult * @param {{ human?: boolean, botId?: string } | null} previewActor โหมด preview: ใครเป็นคนปล่อย (สำหรับคะแนน + เลื่อนตา) * @returns {boolean} */ function startStackFallAnimation(hit, previewActor) { if (!stackMini || !hit || stackFall || stackMini.settling) return false; const m = stackMini; const tw = m.widthTiles * tileSize; const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize; const lh = m.layerWorldH || Math.max(14, tileSize * 0.3); let y1 = m.floorWorldY - (m.layers.length + 1) * lh; if (y1 <= m.swingWorldY) y1 = m.swingWorldY + lh * 0.55; const landOff = hit.landOffsetTiles || 0; const landCxWorld = (m.topCenterX + landOff) * tileSize; const xLeft0 = swingXW - tw / 2; const xLeft1 = landCxWorld - tw / 2; const offN = Math.min(1, Math.abs(landOff) / Math.max(0.01, m.swingAmp)); const dur = Math.max(400, Math.min(1280, 240 + (y1 - m.swingWorldY) * 0.92 + offN * 560)); const tiltMax = hit.miss ? 0.28 * (0.25 + offN) : 0.16 * offN * (hit.perfect ? 0.1 : 1); const isHumanPreview = !!(previewActor && previewActor.human); const botIdPreview = previewActor && previewActor.botId ? previewActor.botId : null; stackFall = { t0: performance.now(), dur, xLeft0, xLeft1, tw, y0: m.swingWorldY, y1, hit, tiltMax, sloppyN: offN, previewHumanDrop: isHumanPreview, previewBotId: botIdPreview, }; return true; } /** @returns {boolean} เริ่มแอนิเมชันร่วงแล้วหรือไม่ */ function tryHumanStackDrop() { if (!stackMini || !isStack() || stackFall || stackMini.settling) return false; if (playBotsEnabled() && mapData && mapData.gameType === 'stack' && (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== stackTurnCount)) { rebuildStackPreviewTurnOrder(); } if (playBotsEnabled() && !isStackPreviewHumanTurn()) return false; const now = Date.now(); if (now - stackMini.lastDropAt < 480) return false; const hit = computeStackDropResult(stackMini); if (!startStackFallAnimation(hit, playBotsEnabled() ? { human: true } : null)) return false; if (playBotsEnabled()) { lastStackPreviewActorId = '__human__'; lastStackPreviewActorUntil = Date.now() + 900; } return true; } /** บอท preview: ปล่อยแบบมีแอนิเมชันร่วงเหมือนผู้เล่น — apply หลังแอนิเมชันจบ */ function simulatePreviewBotStackDrop(botId, o) { if (!stackMini || stackFall || stackMini.settling) return; const hit = computeStackDropResult(stackMini); if (!startStackFallAnimation(hit, playBotsEnabled() ? { botId } : null)) return; o.stackBotGlowUntil = Date.now() + Math.max(900, 400 + (stackFall && stackFall.dur ? stackFall.dur : 800)); lastStackPreviewActorId = botId; lastStackPreviewActorUntil = Date.now() + Math.max(1000, 500 + (stackFall && stackFall.dur ? stackFall.dur : 800)); } /** Stack preview: สลับตา — เฉพาะบอทที่ถึงตาถึงจะจำลองปล่อยหนึ่งครั้งแล้วเลื่อนตา */ function stepPreviewStackTurnBased(nowMs) { if (!playBotsEnabled() || !isStack() || !stackMini || stackFall || stackMini.settling) return; if (mapData && mapData.gameType === 'stack' && (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== stackTurnCount)) { rebuildStackPreviewTurnOrder(); } const order = stackPreviewTurnOrder; if (!order || order.length !== stackTurnCount) return; const cur = order[stackPreviewTurnIndex]; if (!cur || cur.kind !== 'bot') return; if (!cur.botId) { advanceStackPreviewTurn(); return; } const o = others.get(cur.botId); if (!o) { advanceStackPreviewTurn(); return; } if (stackPreviewBotThinkUntil === 0) { stackPreviewBotThinkUntil = nowMs + 320 + Math.random() * 480; return; } if (nowMs < stackPreviewBotThinkUntil) return; stackPreviewBotThinkUntil = 0; simulatePreviewBotStackDrop(cur.botId, o); } function stackTickFrame() { const now = performance.now(); const dt = Math.min(0.06, (now - lastStackTickMs) / 1000); lastStackTickMs = now; if (stackMini && stackMini.settling) { const s = stackMini.settling; const u = (now - s.t0) / s.dur; if (u >= 1) { const wasHuman = !!s.previewHumanDrop; const settleBotId = s.previewBotId || null; applyStackHitFromDrop({ miss: true, perfect: false, pts: 0, landOffsetTiles: 0 }, undefined); if (playBotsEnabled() && (wasHuman || settleBotId)) { stackPreviewLogStackDrop({ miss: true, perfect: false, pts: 0 }, { human: wasHuman, botId: settleBotId }); } stackMini.settling = null; stackMini.lastDropAt = Date.now(); if (playBotsEnabled() && (wasHuman || settleBotId)) advanceStackPreviewTurn(); } } else if (stackFall) { const t = (now - stackFall.t0) / stackFall.dur; if (t >= 1) { const wasHuman = !!stackFall.previewHumanDrop; const fallBotId = stackFall.previewBotId || null; const hit = stackFall.hit; const stableMin = 0.52; if (hit.miss) { applyStackHitFromDrop(hit); if (playBotsEnabled() && (wasHuman || fallBotId)) { stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId }); } stackMini.lastDropAt = Date.now(); stackFall = null; if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn(); } else if (hit.supportRatio != null && hit.supportRatio < stableMin) { const stripH = stackMini.blockStripH || Math.max(16, tileSize * 0.32); stackMini.settling = { t0: performance.now(), dur: Math.max(820, Math.min(2600, 720 + (1 - hit.supportRatio) * 3400)), hit, xLeft0: stackFall.xLeft1, yTop0: stackFall.y1, twWorld: stackFall.tw, stripH, delta: hit.delta || 0, previewHumanDrop: wasHuman, previewBotId: fallBotId, }; stackFall = null; } else { let previewAttr; if (playBotsEnabled()) { if (wasHuman) previewAttr = { human: true }; else if (fallBotId) previewAttr = { botId: fallBotId }; } applyStackHitFromDrop(hit, previewAttr); if (playBotsEnabled() && (wasHuman || fallBotId)) { stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId }); } stackMini.lastDropAt = Date.now(); stackFall = null; if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn(); } } } else if (stackMini) { stackMini.phase += stackMini.phaseSpeed * dt * Math.PI * 2; } stepPreviewStackTurnBased(Date.now()); } function applyStackPreviewSpawnLayout() { if (!mapData || mapData.gameType !== 'stack') return; const w = mapData.width || 20, h = mapData.height || 15; const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h); const cx = land ? land.cx : w / 2 - 0.5; const cy = land ? land.cy : h / 2 - 0.5; me.x = cx; me.y = cy; me.tx = cx; me.ty = cy; others.forEach((o, id) => { if (!isPreviewBotId(id)) return; o.x = cx + (Math.random() - 0.5) * 0.15; o.y = cy + (Math.random() - 0.5) * 0.15; o.tx = o.x; o.ty = o.y; o.stackBotScore = 0; o.stackBotNextTry = Date.now() + 300 + Math.random() * 700; }); } function drawStackMinigame(ctx, worldToScreen, zoom) { if (!stackMini) return; const m = stackMini; const layerWorldH = m.layerWorldH || Math.max(14, tileSize * 0.3); const stripH = m.blockStripH || Math.max(16, tileSize * 0.32); const floorY = m.floorWorldY; const guideTopY = Math.max(0, m.swingWorldY - tileSize * 6.5); const [gtx, gty] = worldToScreen(m.topCenterX * tileSize, guideTopY); const [gbx, gby] = worldToScreen(m.topCenterX * tileSize, floorY); ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = Math.max(1, zoom * 0.85); ctx.setLineDash([5, 7]); ctx.beginPath(); ctx.moveTo(gtx, gty); ctx.lineTo(gbx, gby); ctx.stroke(); ctx.setLineDash([]); ctx.lineWidth = 1; const nLay = m.layers.length; for (let i = 0; i < nLay; i++) { const L = m.layers[i]; const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize; const cxW = L.cx * tileSize; const yb = floorY - (i + 1) * layerWorldH; const xl = cxW - lw / 2; const [sx, sy] = worldToScreen(xl, yb); const drawW = lw * zoom; const drawH = layerWorldH * zoom; const hue = (i * 47) % 360; ctx.fillStyle = 'hsla(' + hue + ', 68%, 56%, 0.9)'; ctx.fillRect(sx, sy, drawW, drawH); ctx.strokeStyle = 'rgba(255,255,255,0.38)'; ctx.lineWidth = 1; ctx.strokeRect(sx, sy, drawW, drawH); } if (m.settling) { const s = m.settling; const u = Math.min(1, (performance.now() - s.t0) / s.dur); const ue = u * u; const rotSign = s.delta >= 0 ? 1 : -1; const rotRad = ue * 1.22 * rotSign; const yFall = ue * ue * tileSize * 6.5; const xSlide = rotSign * ue * tileSize * 0.62; const cxW = s.xLeft0 + s.twWorld / 2 + xSlide; const cyW = s.yTop0 + s.stripH * 0.5 + yFall; const [cxs, cys] = worldToScreen(cxW, cyW); const drawW = s.twWorld * zoom; const drawH = s.stripH * zoom; const hue = (m.layers.length * 47) % 360; ctx.save(); ctx.translate(cxs, cys); ctx.rotate(rotRad); ctx.fillStyle = 'hsla(' + hue + ', 70%, 52%, 0.93)'; ctx.fillRect(-drawW / 2, -drawH / 2, drawW, drawH); ctx.strokeStyle = 'rgba(255, 200, 140, 0.9)'; ctx.lineWidth = 2; ctx.strokeRect(-drawW / 2, -drawH / 2, drawW, drawH); ctx.restore(); } else { const twWorld = m.widthTiles * tileSize; const drawStripPx = stripH * zoom; let blockLeftWorld; let blockTopWorld; let fallTiltRad = 0; if (stackFall) { const tRaw = (performance.now() - stackFall.t0) / stackFall.dur; const t = Math.min(1, tRaw); const u = stackEaseDropPhys(t, stackFall.sloppyN); blockLeftWorld = stackFall.xLeft0 + (stackFall.xLeft1 - stackFall.xLeft0) * u; blockTopWorld = stackFall.y0 + (stackFall.y1 - stackFall.y0) * u; fallTiltRad = stackFall.tiltMax * Math.sin(Math.PI * u); } else { const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize; blockLeftWorld = swingXW - twWorld / 2; blockTopWorld = m.swingWorldY; const cableTopY = Math.max(0, m.swingWorldY - tileSize * 6); const craneX = m.craneWorldX != null ? m.craneWorldX : m.topCenterX * tileSize; const cxMid = blockLeftWorld + twWorld / 2; const [tcx, tcy] = worldToScreen(craneX, cableTopY); const [bcx, bcy] = worldToScreen(cxMid, blockTopWorld); ctx.strokeStyle = 'rgba(90, 88, 86, 0.75)'; ctx.lineWidth = Math.max(2.2, zoom * 1.4); ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(tcx, tcy); ctx.lineTo(bcx, bcy); ctx.stroke(); ctx.strokeStyle = 'rgba(125, 207, 255, 0.35)'; ctx.lineWidth = Math.max(1, zoom * 0.65); ctx.setLineDash([3, 5]); ctx.beginPath(); ctx.moveTo(tcx, tcy); ctx.lineTo(bcx, bcy); ctx.stroke(); ctx.setLineDash([]); ctx.lineWidth = 1; } const hitMissTint = stackFall && stackFall.hit && stackFall.hit.miss; ctx.fillStyle = hitMissTint ? 'rgba(247, 118, 190, 0.88)' : 'rgba(125, 207, 255, 0.95)'; ctx.strokeStyle = hitMissTint ? 'rgba(255, 180, 210, 0.98)' : 'rgba(192, 202, 245, 0.98)'; const cxW = blockLeftWorld + twWorld / 2; const cyW = blockTopWorld + stripH * 0.5; const [cxs, cys] = worldToScreen(cxW, cyW); ctx.save(); ctx.translate(cxs, cys); ctx.rotate(fallTiltRad); ctx.fillRect(-twWorld * zoom / 2, -drawStripPx / 2, twWorld * zoom, drawStripPx); ctx.lineWidth = 2; ctx.strokeRect(-twWorld * zoom / 2, -drawStripPx / 2, twWorld * zoom, drawStripPx); ctx.lineWidth = 1; ctx.restore(); } const [fsx, fsy] = worldToScreen(0, floorY); const [fex, fey] = worldToScreen(mapData.width * tileSize, floorY); ctx.strokeStyle = 'rgba(247, 118, 190, 0.65)'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(fsx, fsy); ctx.lineTo(fex, fey); ctx.stroke(); ctx.lineWidth = 1; } 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, 'gauntletCrownRunHeld')) { gauntletCrownRunHeldRemote = !!payload.gauntletCrownRunHeld; if (!gauntletCrownRunHeldRemote && isGauntletCrownHeistMapPlay()) { gauntletCrownPregamePhase = 'live'; } } if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaneImageUrls') && Array.isArray(payload.gauntletLaneImageUrls)) { gauntletLaneImageUrls = payload.gauntletLaneImageUrls .filter((x) => typeof x === 'string') .map((x) => normalizeGauntletAssetUrlForPlay(x)) .filter((x) => x) .slice(0, 24); gauntletLaneImageUrls.forEach((x) => ensureGauntletAssetImage(x)); } if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserTopUrl')) { gauntletLaserTopUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserTopUrl === 'string' ? payload.gauntletLaserTopUrl : ''); ensureGauntletAssetImage(gauntletLaserTopUrl); } if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserBottomUrl')) { gauntletLaserBottomUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserBottomUrl === 'string' ? payload.gauntletLaserBottomUrl : ''); ensureGauntletAssetImage(gauntletLaserBottomUrl); } if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineUrl')) { gauntletLaserLineUrl = normalizeGauntletAssetUrlForPlay(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))); } if (Object.prototype.hasOwnProperty.call(payload, 'stackSwingHz')) { const sh = Number(payload.stackSwingHz); if (Number.isFinite(sh)) playStackSwingHz = Math.max(0.08, Math.min(2.8, sh)); if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz; } if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockWidthTiles')) { const raw = payload.stackBlockWidthTiles; if (raw == null || raw === '') playStackBlockWidthTiles = null; else { const nw = Number(raw); playStackBlockWidthTiles = Number.isFinite(nw) && nw >= 0.85 ? Math.max(0.85, Math.min(3.2, Math.round(nw * 100) / 100)) : null; } } if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurviveJumpHeightMult')) { const jm = Number(payload.jumpSurviveJumpHeightMult); if (Number.isFinite(jm)) { playJumpSurviveJumpHeightMult = Math.round(Math.max(0.5, Math.min(4, jm)) * 100) / 100; } } if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurviveMissionTimeSec')) { const jt = Number(payload.jumpSurviveMissionTimeSec); if (Number.isFinite(jt) && jt > 0) { playJumpSurviveMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(jt))); } else { playJumpSurviveMissionTimeSec = 0; } } if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterMissionTimeSec')) { const st = Number(payload.spaceShooterMissionTimeSec); if (Number.isFinite(st) && st > 0) { playSpaceShooterMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st))); } else { playSpaceShooterMissionTimeSec = 0; } } if (isStack() && stackMini) reapplyStackMiniSizingFromGlobals(); } /** เลอร์ปตำแหน่งตัวละครต่อเฟรม (ยิ่งสูงยิ่งตามเป้าเร็ว) */ 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) => { if (!o || o.id == null) return null; const row = { id: o.id, kind: o.kind, x: o.x, y: o.y }; if (o.kind === 'laser') { if (o.y0 != null) row.y0 = o.y0; if (o.y1 != null) row.y1 = o.y1; } return row; }).filter(Boolean); } function gauntletLaserRowHitRange(ob, mapH) { const h = Math.max(1, Math.floor(Number(mapH)) || 15); let y0 = ob && ob.y0 != null && Number.isFinite(Number(ob.y0)) ? Math.floor(Number(ob.y0)) : 0; let y1 = ob && ob.y1 != null && Number.isFinite(Number(ob.y1)) ? Math.floor(Number(ob.y1)) : h - 1; y0 = Math.max(0, Math.min(h - 1, y0)); y1 = Math.max(0, Math.min(h - 1, y1)); if (y1 < y0) { const t = y0; y0 = y1; y1 = t; } return { y0, y1 }; } function gauntletLaserOverlapsPlayerRow(ob, py, mapH) { const { y0, y1 } = gauntletLaserRowHitRange(ob, mapH); return py >= y0 && py <= y1; } /** โค้งยกตัว: ขึ้นถึงจุดสูงสุดเร็ว ลงชัน (รู้สึกลงพื้นเร็วกว่าแบบสมมาตร) */ 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; const row = { id: n.id, kind: n.kind, drawX, y: n.y }; if (n.kind === 'laser') { if (n.y0 != null) row.y0 = n.y0; if (n.y1 != null) row.y1 = n.y1; } out.push(row); } 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) => { const row = { id: n.id, kind: n.kind, x: curMap.has(n.id) ? curMap.get(n.id) : n.x, y: n.y, }; if (n.kind === 'laser') { if (n.y0 != null) row.y0 = n.y0; if (n.y1 != null) row.y1 = n.y1; } return row; }); 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) { if (isGauntletCrownHeistMapPlay() && o.gauntletEliminated) return; 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; const { ch: gauntletCh } = getCharacterFootprintWH(mapData); 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 && obs.y < py + gauntletCh) sameCell = true; if (obs.x === px + 1 && obs.y >= py && obs.y < py + gauntletCh) incomingCol = true; } if (obs.kind === 'laser' && typeof obs.x === 'number') { if (obs.x === px && gauntletLaserOverlapsPlayerRow(obs, py, h)) sameCell = true; if (obs.x === px + 1 && gauntletLaserOverlapsPlayerRow(obs, py, h)) 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) { if (isGauntletCrownHeistMapPlay() && o.gauntletEliminated) return; 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; const { ch: gauntletCh2 } = getCharacterFootprintWH(mapData); const crown = isGauntletCrownHeistMapPlay(); 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 && ob.y < py + gauntletCh2) { if (air) advanceX = true; else hitBack = true; } if (ob.kind === 'laser' && typeof ob.x === 'number' && ob.x === px) { if (!gauntletLaserOverlapsPlayerRow(ob, py, h)) { /* เลเซอร์ไม่ครอบแถวนี้ */ } else if (air) { advanceX = true; } else { hitBack = true; } } } if (advanceX) { px = Math.min(w - 2, px + 1); o.gauntletJumpTicks = 0; if (!crown) { o.gauntletScore = (o.gauntletScore || 0) + 1; } } else if (hitBack) { if (crown) { if (px <= 0) { o.gauntletEliminated = true; o.gauntletScore = 0; } else { o.gauntletScore = Math.max(0, (o.gauntletScore || 0) - 10); px = Math.max(0, px - 1); } } else { 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; if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) 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; if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) 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] }); } /** ซ่อน overlay โหมดอื่นที่ค้างจาก embed / session ก่อน — กัน UI ซ้อนไม่ตรง mock */ function hideConflictingOverlaysForGauntletCrown() { hideQuizCarryPregameOverlay(); quizCarryPregameActive = false; const hideIds = [ 'quiz-game-overlay', 'quiz-carry-mission-overlay', 'gauntlet-ended-overlay', 'quiz-battle-mcq-overlay', 'quiz-carry-embed-countdown', 'gauntlet-crown-countdown', 'quiz-carry-embed-q-strip', 'play-quiz-scoreboard', 'play-quiz-feedback', ]; for (let i = 0; i < hideIds.length; i++) { const el = document.getElementById(hideIds[i]); if (el) el.classList.add('is-hidden'); } const gcm = document.getElementById('gauntlet-crown-mission-overlay'); if (gcm) gcm.classList.add('is-hidden'); } function gcmRankBadgeUrl(rank) { const r = Math.floor(Number(rank)) || 99; if (r === 1) return BASE + '/img/gauntlet-assets/result-1st.png'; if (r === 2) return BASE + '/img/gauntlet-assets/result-2nd.png'; if (r === 3) return BASE + '/img/gauntlet-assets/result-3rd.png'; return BASE + '/img/gauntlet-assets/result-txt-2.png'; } function gauntletCrownPregameReadyNumerator() { let n = quizCarryPregameBotCount(); quizCarryPregameHumanIds().forEach((id) => { if (gauntletCrownLobbyReadyMap[id]) n++; }); return n; } function gauntletCrownSyncGuestReadyIfNeeded() { if (gauntletCrownPregamePhase !== 'howto') return; if (myId == null || isMePlayHost()) return; const sid = String(myId); if (gauntletCrownLobbyReadyMap[sid]) return; gauntletCrownLobbyReadyMap[sid] = true; if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: true }); } function updateGauntletCrownHowtoHud() { const st = document.getElementById('gauntlet-crown-howto-status'); const btn = document.getElementById('btn-gch-ready'); if (!btn || !isGauntletCrownHeistMapPlay() || gauntletCrownPregamePhase !== 'howto') return; const humans = quizCarryPregameHumanIds(); const tot = Math.max(1, quizCarryPregameTotalPlayers()); const num = gauntletCrownPregameReadyNumerator(); if (st) { st.classList.remove('is-hidden'); st.textContent = 'Ready Status : ' + num + '/' + tot; } const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); btn.classList.toggle('is-start-phase', humansReady); btn.classList.toggle('is-read-only', !isMePlayHost()); btn.disabled = !isMePlayHost(); btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false')); btn.title = isMePlayHost() ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY')) : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); } function beginGauntletCrownCountdownThenRun() { if (!isGauntletCrownHeistMapPlay()) return; if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } gauntletCrownPregamePhase = 'countdown'; const howto = document.getElementById('gauntlet-crown-howto-overlay'); if (howto) howto.classList.add('is-hidden'); gauntletCrownHowtoVisible = false; const cd = document.getElementById('gauntlet-crown-countdown'); const numEl = document.getElementById('gauntlet-crown-countdown-num'); const runFinish = () => { if (cd) cd.classList.add('is-hidden'); if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } if (socket && socket.connected) { socket.emit('gauntlet-crown-begin-run', (res) => { if (res && res.ok) gauntletCrownPregamePhase = 'live'; }); } else { gauntletCrownPregamePhase = 'live'; } }; if (!cd || !numEl) { runFinish(); return; } cd.classList.remove('is-hidden'); let n = 3; numEl.textContent = String(n); const step = () => { n--; if (n > 0) { numEl.textContent = String(n); gauntletCrownCountdownTimer = setTimeout(step, 1000); } else { gauntletCrownCountdownTimer = null; runFinish(); } }; gauntletCrownCountdownTimer = setTimeout(step, 1000); } function showGauntletCrownHowtoOverlay() { if (!isGauntletCrownHeistMapPlay()) return; hideConflictingOverlaysForGauntletCrown(); const cd = document.getElementById('gauntlet-crown-countdown'); if (cd) cd.classList.add('is-hidden'); if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } gauntletCrownPregamePhase = 'howto'; const ov = document.getElementById('gauntlet-crown-howto-overlay'); if (!ov) return; gauntletCrownHowtoVisible = true; ov.classList.remove('is-hidden'); if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); gauntletCrownSyncGuestReadyIfNeeded(); updateGauntletCrownHowtoHud(); } /** Editor embed: quiz_carry / crown ใช้เกรด F = gameover — Jumper: ภาพจบใช้เฉพาะเมื่อไม่มีผู้รอด (0 คน) */ function gauntletCrownEmbedMissionAnyOk(mission) { if (!mission) return false; if (mission.uiSkin === 'jumper') { const sc = Number(mission.survivorCount); return Number.isFinite(sc) ? sc > 0 : (String(mission.grade || '').trim().toUpperCase().charAt(0) !== 'F'); } const g = String(mission.grade || '').trim().toUpperCase().charAt(0); return g !== 'F'; } function gauntletCrownRankBonusLocal(rankOrdinal) { if (rankOrdinal === 1) return 100; if (rankOrdinal === 2) return 80; if (rankOrdinal === 3) return 60; return 0; } function gauntletCrownRollRewardCardLocal(grade) { const r = Math.random(); if (grade === 'A') { if (r < 0.3) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' }; if (r < 0.8) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; } if (grade === 'B') { if (r < 0.2) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' }; if (r < 0.5) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; } if (r < 0.2) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; } /** * Editor embed + preview bots: รวมแถวอันดับกับบอท (สูงสุด 5 ช่อง) และคำนวณรวม/เฉลี่ย/เกรดให้สอดคล้องกับแถวที่โชว์ * — อันดับในช่องใช้ลำดับ 1..5 หลังเรียงคะแนน (เหมือนแนว quiz_carry mission row) */ function gauntletCrownBuildDisplayMission(mission) { if (!mission || !Array.isArray(mission.ranked)) return mission; if (mission.uiSkin === 'jumper') return mission; if (!(previewFillBots && isGauntletCrownHeistMapPlay() && others && typeof others.forEach === 'function')) return mission; const seen = new Set(); const baseRows = []; mission.ranked.forEach((r) => { if (!r) return; const sid = String(r.id); if (seen.has(sid)) return; seen.add(sid); baseRows.push({ id: r.id, nickname: (r.nickname && String(r.nickname).trim()) ? String(r.nickname).trim() : 'ผู้เล่น', characterId: r.characterId ?? null, baseScore: r.eliminated ? 0 : Math.max(0, Number(r.baseScore) || 0), eliminated: !!r.eliminated, }); }); others.forEach((o, id) => { if (!o || !isPreviewBotId(id)) return; const sid = String(id); if (seen.has(sid)) return; seen.add(sid); baseRows.push({ id: id, nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid, characterId: o.characterId ?? null, baseScore: o.gauntletEliminated ? 0 : Math.max(0, Number(o.gauntletScore) | 0), eliminated: !!o.gauntletEliminated, }); }); baseRows.sort((a, b) => { if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; const nick = String(a.nickname).localeCompare(String(b.nickname), 'th'); if (nick !== 0) return nick; return String(a.id).localeCompare(String(b.id)); }); const top = baseRows.slice(0, 5); const ranked = top.map((row, idx) => { const pos = idx + 1; const rankBonus = gauntletCrownRankBonusLocal(pos); const rankLabel = pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos); const finalScore = row.baseScore + rankBonus; return { id: row.id, nickname: row.nickname, characterId: row.characterId, baseScore: row.baseScore, eliminated: row.eliminated, rank: pos, rankLabel: rankLabel, rankBonus: rankBonus, finalScore: finalScore, }; }); const totalSum = ranked.reduce(function (s, r) { return s + r.finalScore; }, 0); const n = ranked.length || 1; const averageScore = Math.floor(totalSum / n); const grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; const rewardCard = gauntletCrownRollRewardCardLocal(grade); return { ranked: ranked, totalSum: totalSum, averageScore: averageScore, grade: grade, rewardCard: rewardCard, totalParts: ranked.map(function (r) { return r.finalScore; }), }; } function showGauntletCrownMissionOverlay(mission) { const ov = document.getElementById('gauntlet-crown-mission-overlay'); const rowEl = document.getElementById('gcm-rank-row'); const totalEl = document.getElementById('gcm-total'); const gradeEl = document.getElementById('gcm-grade'); const bonusEl = document.getElementById('gcm-bonus'); const btn = document.getElementById('btn-gcm-done'); if (!ov || !rowEl || !mission || !Array.isArray(mission.ranked)) return; let disp = gauntletCrownBuildDisplayMission(mission); if (mission.uiSkin === 'jumper') { disp = jumpSurviveMergePreviewBotsMission(disp) || disp; } if (!disp || !Array.isArray(disp.ranked)) return; rowEl.innerHTML = ''; disp.ranked.forEach((r) => { const cell = document.createElement('div'); cell.className = 'gcm-cell'; const tag = document.createElement('div'); tag.className = 'gcm-rank-tag'; const rk = Math.floor(Number(r.rank)) || 0; if (rk >= 1 && rk <= 3) { const tagImg = document.createElement('img'); tagImg.src = gcmRankBadgeUrl(rk); tagImg.alt = '[' + String(r.rankLabel || rk) + ']'; tagImg.style.maxWidth = '100%'; tagImg.style.height = 'auto'; tag.appendChild(tagImg); } else { tag.textContent = '[' + String(r.rankLabel || rk) + ']'; } const frame = document.createElement('div'); frame.className = 'gcm-frame'; const img = document.createElement('img'); img.className = 'gcm-av'; img.alt = ''; const cid = r.characterId ? String(r.characterId) : ''; if (cid) { const im = getCharacterImg(cid, 'down'); if (im && im.src) img.src = im.src; } else { img.src = defaultAvatarImg.src; } img.onerror = function () { this.onerror = null; this.src = defaultAvatarImg.src; }; frame.appendChild(img); if (r.eliminated) { const st = document.createElement('img'); st.className = 'gcm-stamp'; const jSkin = mission && mission.uiSkin === 'jumper'; st.src = jSkin ? jumperAssetUrl('stamp-sacrifice.png') : (BASE + '/img/gauntlet-assets/result-Lose_stamp.png'); st.alt = jSkin ? 'ผู้เสียสละ' : 'เสียชีวิต'; st.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/result-Lose_stamp.png'; this.alt = 'เสียชีวิต'; }; frame.appendChild(st); } const nick = document.createElement('div'); nick.className = 'gcm-nick'; nick.textContent = r.nickname || '—'; const sc = document.createElement('div'); sc.className = 'gcm-sc'; const jumpPlain = mission && mission.uiSkin === 'jumper'; const botScorePlain = previewFillBots && isGauntletCrownHeistMapPlay() && isPreviewBotId(r.id); sc.textContent = (jumpPlain || botScorePlain) ? String(Math.max(0, Number(r.baseScore) || 0)) : String(r.baseScore || 0) + '(+' + String(r.rankBonus || 0) + ')'; cell.appendChild(tag); cell.appendChild(frame); cell.appendChild(nick); cell.appendChild(sc); rowEl.appendChild(cell); }); if (totalEl) { const parts = (disp.totalParts || []).map((n) => String(Math.max(0, Number(n) || 0))); const sum = Math.max(0, Number(disp.totalSum) || 0); const avg = Math.max(0, Math.floor(Number(disp.averageScore) || 0)); const jumperSkin = mission && mission.uiSkin === 'jumper'; if (jumperSkin) { const sc = Math.max(0, Math.floor(Number(disp.survivorCount) || 0)); const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0)); totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้รอด ' + sc + '/' + pc + ' → เกรด'; } else { totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · เฉลี่ย ' + avg + ' → เกรด'; } } if (gradeEl) { const g = String(disp.grade || 'C').toUpperCase().charAt(0); gradeEl.textContent = g; gradeEl.className = 'gcm-grade gcm-grade--' + g.toLowerCase(); } if (bonusEl) { bonusEl.innerHTML = ''; const gLetter = String(disp.grade || 'C').toUpperCase().charAt(0); const missionOk = gLetter !== 'F'; if (missionOk) { const row = document.createElement('div'); row.style.display = 'flex'; row.style.alignItems = 'center'; row.style.gap = '8px'; const chk = document.createElement('img'); chk.src = BASE + '/img/gauntlet-assets/result-check.png'; chk.alt = ''; chk.width = 26; chk.height = 26; const sp = document.createElement('span'); sp.style.color = '#9ece6a'; sp.style.fontWeight = '700'; sp.textContent = 'ภารกิจสำเร็จ'; row.appendChild(chk); row.appendChild(sp); bonusEl.appendChild(row); } const rc = disp.rewardCard; if (missionOk && rc && rc.th) { const pl = document.createElement('div'); pl.className = 'gcm-plaque'; pl.innerHTML = '' + String(rc.th) + '' + String(rc.en || '') + ''; bonusEl.appendChild(pl); } else if (!missionOk) { const pl = document.createElement('div'); pl.className = 'gcm-plaque'; pl.style.borderColor = 'rgba(148, 153, 168, 0.45)'; pl.style.opacity = '0.95'; pl.innerHTML = 'ไม่ได้รับการ์ดเกรด ' + gLetter + ' · ภารกิจไม่ผ่านเกณฑ์'; bonusEl.appendChild(pl); } } ov.classList.remove('is-hidden'); function goLobby() { ov.classList.add('is-hidden'); window.location.href = buildRoomLobbyReturnHref(); } if (btn) { btn.onclick = function () { if (previewMode && editorEmbedReturn) { ov.classList.add('is-hidden'); showQuizCarryTimeupOnDeskLayer(function () { return gauntletCrownEmbedMissionAnyOk(disp); }); } else { goLobby(); } }; } } 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 }; } /** Hub = 0/1 · กริดตัวเลือก = 0 หรือเลข 1..16 (ห้ามบังคับ === 1 เหมือน hub — เลข 2–16จะหาย) */ function normalizeQuizCarryLayersInPlay(md) { if (!md || md.gameType !== 'quiz_carry') return; const w = md.width || 20, h = md.height || 15; const maxOpt = QUIZ_CARRY_MAX_OPTION_SLOTS; const srcHub = md.quizCarryHubArea || []; const hubRows = []; for (let y = 0; y < h; y++) { const r = srcHub[y]; const row = []; for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0); hubRows.push(row); } md.quizCarryHubArea = hubRows; const srcOpt = md.quizCarryOptionArea || []; const optRows = []; for (let y = 0; y < h; y++) { const r = srcOpt[y]; const row = []; for (let x = 0; x < w; x++) { const v = r && r[x]; const n = typeof v === 'number' ? v : parseInt(String(v), 10); row.push(Number.isFinite(n) && n >= 1 && n <= maxOpt ? Math.floor(n) : 0); } optRows.push(row); } md.quizCarryOptionArea = optRows; const srcCd = md.carryEmbedCountdownArea || []; const cdRows = []; for (let y = 0; y < h; y++) { const r = srcCd[y]; const row = []; for (let x = 0; x < w; x++) { const v = r && r[x]; row.push(Number(v) === 1 ? 1 : 0); } cdRows.push(row); } md.carryEmbedCountdownArea = cdRows; } /** โดม = ช่องติดกัน (4 ทิศ) ต่อ 1 ข้อ · comp id เรียงตามการค้นพบบนแมป */ function normalizeQuizBattleDomeInPlay(md) { if (!md || md.gameType !== 'quiz_battle') return; const w = md.width || 20, h = md.height || 15; const src = md.quizBattleDomeArea || []; const grid = []; for (let y = 0; y < h; y++) { const r = src[y]; const row = []; for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0); grid.push(row); } md.quizBattleDomeArea = grid; const comp = Array(h).fill(0).map(() => Array(w).fill(0)); let nextId = 0; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { if (grid[y][x] !== 1 || comp[y][x]) continue; nextId++; const stack = [[x, y]]; comp[y][x] = nextId; while (stack.length) { const c = stack.pop(); const cx = c[0], cy = c[1]; for (let i = 0; i < 4; i++) { const dx = (i === 0 ? 1 : i === 1 ? -1 : 0); const dy = (i === 2 ? 1 : i === 3 ? -1 : 0); const nx = cx + dx, ny = cy + dy; if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; if (grid[ny][nx] !== 1 || comp[ny][nx]) continue; comp[ny][nx] = nextId; stack.push([nx, ny]); } } } } md.quizBattleDomeComp = comp; } /** เส้นทาง Quiz Battle — วาดอย่างน้อย 1 ช่องแล้วจะเดินได้เฉพาะบนเส้นทาง (เปลี่ยนแค่รูปพื้นหลังได้) */ function normalizeQuizBattlePathInPlay(md) { if (!md || md.gameType !== 'quiz_battle') return; const w = md.width || 20, h = md.height || 15; const src = md.quizBattlePathArea || []; const grid = []; for (let y = 0; y < h; y++) { const r = src[y]; const row = []; for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0); grid.push(row); } md.quizBattlePathArea = grid; } function quizBattlePathModeActive(md) { if (!md || md.gameType !== 'quiz_battle' || !md.quizBattlePathArea) return false; const g = md.quizBattlePathArea; for (let y = 0; y < g.length; y++) { const row = g[y]; if (!row) continue; for (let x = 0; x < row.length; x++) if (row[x] === 1) return true; } return false; } function quizBattleFootprintFullyOnPath(md, px, py) { if (!md || !quizBattlePathModeActive(md)) return true; if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false; const tiles = quizTilesFootprintPlay(px, py); if (tiles.size === 0) return false; const g = md.quizBattlePathArea; for (const k of tiles) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (!g[ty] || g[ty][tx] !== 1) return false; } return true; } /** สแน็ปบนเลน — เช็คแค่กำแพง + อยู่บน path (ให้ตรงกับ server) ไม่ใช้ blockPlayer/ผู้เล่นคนอื่น เพราะจะทำให้หาจุดสแน็ปไม่ได้แล้วค้างนอกเลน */ function quizBattleSnapTargetValidPlay(x, y) { if (!mapData || !mapData.objects) return false; if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false; const w = mapData.width || 20, h = mapData.height || 15; for (const k of quizTilesWallCollisionFootprintPlay(x, y)) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; const row = mapData.objects[ty]; if (!row || row[tx] === 1) return false; } if (quizBattlePathModeActive(mapData) && !quizBattleFootprintFullyOnPath(mapData, x, y)) return false; return true; } function snapPositionOntoQuizBattlePathIfNeeded(px, py) { if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return { x: px, y: py }; if (quizBattleSnapTargetValidPlay(px, py)) return { x: px, y: py }; const w = mapData.width || 20, h = mapData.height || 15; const g = mapData.quizBattlePathArea; let bestX = null, bestY = null, bestD = Infinity; for (let ty = 0; ty < h; ty++) { for (let tx = 0; tx < w; tx++) { if (!g[ty] || g[ty][tx] !== 1) continue; const nx = tx + 0.01; const ny = ty + 0.01; if (!quizBattleSnapTargetValidPlay(nx, ny)) continue; const d = Math.abs(nx - px) + Math.abs(ny - py); if (d < bestD) { bestD = d; bestX = nx; bestY = ny; } } } if (bestX != null) return { x: bestX, y: bestY }; /* footprint ใหญ่กว่าเลน (เช่น 2×2 บนทางแคบ 1 ช่อง): หาช่อง path ใกล้สุดที่เดินได้อย่างน้อยที่เซ็นเตอร์ — ดีกว่าปล่อยค้างนอกเลน */ for (let ty = 0; ty < h; ty++) { for (let tx = 0; tx < w; tx++) { if (!g[ty] || g[ty][tx] !== 1) continue; if (!spawnTileWalkablePlay(mapData, tx, ty)) continue; const nx = tx + 0.5; const ny = ty + 0.5; const d = Math.abs(nx - px) + Math.abs(ny - py); if (d < bestD) { bestD = d; bestX = nx; bestY = ny; } } } if (bestX != null) return { x: bestX, y: bestY }; return { x: px, y: py }; } /** บังคับให้ผู้เล่นอยู่บนเลน — เรียกก่อน return กลาง tick (แชท / path หมด) เพราะเดิมข้ามบล็อกสแน็ปท้าย tick */ function enforceQuizBattleLaneOnMePlay() { if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return; if (quizBattleSnapTargetValidPlay(me.x, me.y)) return; const snapped = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y); me.x = snapped.x; me.y = snapped.y; } function hideQuizBattleMcqModal() { const ov = document.getElementById('quiz-battle-mcq-overlay'); if (ov) { ov.classList.add('is-hidden'); ov.setAttribute('aria-hidden', 'true'); } } function flashQuizBattleFeedback(text, ok) { const el = document.getElementById('play-quiz-feedback'); if (!el) return; el.textContent = text || ''; el.classList.remove('is-hidden', 'play-quiz-feedback-ok', 'play-quiz-feedback-bad'); el.classList.add(ok ? 'play-quiz-feedback-ok' : 'play-quiz-feedback-bad'); if (typeof window.__quizBattleFbT === 'number') clearTimeout(window.__quizBattleFbT); window.__quizBattleFbT = setTimeout(() => { el.classList.add('is-hidden'); }, 1800); } function getQuizBattleDomeCompIdsUnderFootprint(px, py) { if (!mapData || !isQuizBattle()) return []; const comp = mapData.quizBattleDomeComp; const grid = mapData.quizBattleDomeArea; if (!comp || !grid) return []; const ids = []; const seen = Object.create(null); for (const k of quizTilesFootprintPlay(px, py)) { const p = k.split(','); const tx = +p[0], ty = +p[1]; const cid = comp[ty] && comp[ty][tx]; if (grid[ty] && grid[ty][tx] === 1 && cid > 0 && !seen[cid]) { seen[cid] = 1; ids.push(cid); } } return ids; } function getPrimaryQuizBattleDomeCompAt(px, py) { const arr = getQuizBattleDomeCompIdsUnderFootprint(px, py); if (!arr.length) return null; return Math.min.apply(null, arr); } function showQuizBattleMcqModal(q) { const ov = document.getElementById('quiz-battle-mcq-overlay'); const textEl = document.getElementById('quiz-battle-mcq-text'); if (!ov || !textEl || !q) return; textEl.textContent = q.text || ''; const labels = ['A', 'B', 'C']; for (let i = 0; i < 3; i++) { const btn = ov.querySelector('.quiz-battle-choice[data-idx="' + i + '"]'); if (btn) { const ch = (q.choices && q.choices[i]) ? String(q.choices[i]) : ''; btn.textContent = labels[i] + (ch ? (': ' + ch.slice(0, 96)) : ''); } } ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); } function trySubmitQuizBattleChoice(idx) { if (quizBattleModalCompId == null || !mapData || !isQuizBattle()) return; const compId = quizBattleModalCompId; const pool = quizBattleMcqPool; if (!pool.length) { flashQuizBattleFeedback('ยังไม่มีคำถาม — ตั้ง battleQuizMcq ใน Admin → Quiz Battle', false); quizBattleModalCompId = null; hideQuizBattleMcqModal(); return; } const q = pool[(compId - 1) % pool.length]; if (!q) return; const ok = Number(q.correctIndex) === Number(idx); if (ok) { quizBattleAnsweredComps.add(compId); if (myId != null) { if (!playLiveQuizScores) playLiveQuizScores = {}; playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + 1; renderPlayQuizScoreboard(playLiveQuizScores); } flashQuizBattleFeedback('ถูกต้อง · +1', true); } else { flashQuizBattleFeedback('ยังไม่ถูก — ลองใหม่ (กด E อีกครั้ง)', false); } quizBattleModalCompId = null; hideQuizBattleMcqModal(); } function tryOpenQuizBattleFromKey() { if (!isQuizBattle() || !mapData || myId == null) return; const compId = getPrimaryQuizBattleDomeCompAt(me.x, me.y); if (compId == null) { flashQuizBattleFeedback('ยังไม่ยืนบนโดมคำถาม', false); return; } if (quizBattleAnsweredComps.has(compId)) { flashQuizBattleFeedback('ตอบโดมนี้ถูกแล้ว', false); return; } const pool = quizBattleMcqPool; if (!pool.length) { flashQuizBattleFeedback('ยังไม่มีคำถามในระบบ (Admin → Quiz Battle)', false); return; } const q = pool[(compId - 1) % pool.length]; if (!q) return; quizBattleModalCompId = compId; showQuizBattleMcqModal(q); } function pushSanitizedBattleMcqFromPayload(s) { if (!s || !Array.isArray(s.battleQuizMcq)) return 0; let n = 0; for (let i = 0; i < s.battleQuizMcq.length; i++) { const raw = s.battleQuizMcq[i]; if (!raw || !String(raw.text || '').trim()) continue; const ch = Array.isArray(raw.choices) ? raw.choices : []; if (ch.length !== 3) continue; const a = String(ch[0] || '').trim(); const b = String(ch[1] || '').trim(); const c = String(ch[2] || '').trim(); if (!a || !b || !c) continue; let ci = Number(raw.correctIndex); if (!Number.isFinite(ci)) ci = 0; ci = Math.max(0, Math.min(2, Math.floor(ci))); quizBattleMcqPool.push({ text: String(raw.text).trim(), choices: [a, b, c], correctIndex: ci, }); n++; } return n; } /** * โหลดชุดข้อ A/B/C — ลอง Node ก่อน แล้ว fallback PHP อ่านจากดิสก์ (กรณี nginx ไม่ส่ง /api/quiz-settings ถึง Node) */ async function loadQuizBattleMcqPool() { quizBattleMcqPool = []; const bust = '_=' + Date.now(); const tryUrls = [ BASE + '/api/quiz-settings?' + bust, BASE + '/api-quiz-battle-mcq.php?' + bust, ]; for (let u = 0; u < tryUrls.length; u++) { try { const r = await fetch(tryUrls[u], { cache: 'no-store' }); if (!r.ok) continue; const s = await r.json(); if (pushSanitizedBattleMcqFromPayload(s) > 0) break; } catch (e) { /* next url */ } } } function resetQuizBattlePlayState() { quizBattleMcqPool = []; quizBattleAnsweredComps = new Set(); quizBattleModalCompId = null; hideQuizBattleMcqModal(); } function setupPlayQuizBattleUi() { if (playQuizTimerInterval) { clearInterval(playQuizTimerInterval); playQuizTimerInterval = null; } const ov = document.getElementById('quiz-game-overlay'); /* ทดสอบจากเอดิเตอร์: อย่าเปิดแผงคำถามเต็มจอล่าง — บังมองเห็นบอท/แผนที่ (overlay ยังอัปเดตข้อความไว้ถ้าเปิด preview แบบไม่ embed) */ if (ov) { if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden'); else ov.classList.remove('is-hidden'); } const phaseEl = document.getElementById('quiz-game-phase-label'); if (phaseEl) phaseEl.textContent = 'Quiz Battle'; const qEl = document.getElementById('quiz-game-question'); if (qEl) qEl.textContent = 'เดินทับโดม (สีฟ้า–ขอบแดง) แล้วกด E เพื่อตอบ A / B / C'; const tEl = document.getElementById('quiz-game-timer'); if (tEl) tEl.textContent = ''; const leg = document.getElementById('quiz-play-legend'); if (leg) { leg.textContent = quizBattlePathModeActive(mapData) ? 'โหมดเส้นทาง: เดินได้เฉพาะช่องเส้นทาง (ม่วงบนแผนที่) — วางโดมบนเส้นทาง · ไม่วาดเส้นทางใน Editor = เดินอิสระ · เปลี่ยนรูปพื้นหลังได้ทีหลัง' : 'คำถามจาก Admin → Quiz Battle (battleQuizMcq) · ช่องโดมติดกัน = 1 ข้อ · ถูกแล้วได้ +1 ต่อโดม'; } quizBattleAnsweredComps = new Set(); quizBattleModalCompId = null; quizBattleMcqPool = []; hideQuizBattleMcqModal(); playLiveQuizScores = {}; if (myId != null) playLiveQuizScores[myId] = 0; others.forEach((_, id) => { playLiveQuizScores[id] = 0; }); renderPlayQuizScoreboard(playLiveQuizScores); loadQuizBattleMcqPool().then(() => { const q2 = document.getElementById('quiz-game-question'); if (!q2) return; if (!quizBattleMcqPool.length) { q2.textContent = 'ยังไม่มีคำถาม — ไปที่ Admin แท็บ Quiz Battle แล้วบันทึกข้อ A/B/C (ครบคำถาม + A + B + C) · หรือเช็คว่าเซิร์ฟเวอร์เกม (Node) รันอยู่ · ลองรีเฟรชแบบ hard refresh (Ctrl+F5)'; } else { const q0 = quizBattleMcqPool[0]; const snippet = q0 && q0.text ? String(q0.text).trim().slice(0, 100) : ''; const more = q0 && q0.text && String(q0.text).trim().length > 100 ? '…' : ''; q2.textContent = snippet ? ('โหลดคำถามแล้ว ' + quizBattleMcqPool.length + ' ข้อ · ตัวอย่าง: 「' + snippet + more + '」\nเข้าโดมสีฟ้า (ขอบแดง) แล้วกด E เพื่อเลือก A / B / C') : ('โหลดคำถามแล้ว ' + quizBattleMcqPool.length + ' ข้อ — เดินเข้าโดม (ฟ้า–ขอบแดง) แล้วกด E เพื่อตอบ A / B / C'); } const ov2 = document.getElementById('quiz-game-overlay'); if (ov2 && previewMode && editorEmbedReturn) ov2.classList.add('is-hidden'); }); } function normalizeJumpSurvivePlatformAreaInPlay(md) { if (!md || md.gameType !== 'jump_survive') return; const w = md.width || 20, h = md.height || 15; const src = md.jumpSurvivePlatformArea || []; const rows = []; for (let y = 0; y < h; y++) { const r = src[y]; const row = []; for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0); rows.push(row); } md.jumpSurvivePlatformArea = rows; } function normalizeShooterSpawnSlotsInPlay(md) { if (!md || md.gameType !== 'space_shooter') return; const w = md.width || 20, h = md.height || 15; const src = md.shooterSpawnSlots || []; const rows = []; for (let y = 0; y < h; y++) { const r = src[y]; const row = []; for (let x = 0; x < w; x++) { const v = r && r[x]; const n = typeof v === 'number' ? v : parseInt(String(v), 10); row.push(Number.isFinite(n) && n >= 1 && n <= 6 ? Math.floor(n) : 0); } rows.push(row); } md.shooterSpawnSlots = rows; } function getShooterSpawnWorldCenterFromMap(md, slot1to6, ts) { if (!md || !md.shooterSpawnSlots) return null; const w = md.width || 20, h = md.height || 15; const g = md.shooterSpawnSlots; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { if (g[y] && g[y][x] === slot1to6) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts }; } } return null; } function buildSpaceShooterParticipantRefsPlay() { const refs = []; if (me) refs.push({ id: myId, ref: me }); const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); humanIds.forEach((id) => { const o = others.get(id); if (o) refs.push({ id, ref: o }); }); const botIds = [...others.keys()].filter(isPreviewBotId).sort(); botIds.forEach((id) => { const o = others.get(id); if (o) refs.push({ id, ref: o }); }); return refs.filter((r) => r.ref); } function applySpaceShooterSpawnLayoutPlay() { if (!mapData || mapData.gameType !== 'space_shooter') return; normalizeShooterSpawnSlotsInPlay(mapData); const ts = tileSize; const w = mapData.width || 20, h = mapData.height || 15; const { cw, ch } = getCharacterFootprintWH(mapData); const mw = w * ts, mh = h * ts; const refs = buildSpaceShooterParticipantRefsPlay(); const n = refs.length || 1; refs.forEach((entry, idx) => { const ent = entry.ref; const slot = (idx % 6) + 1; const cen = getShooterSpawnWorldCenterFromMap(mapData, slot, ts); let cx, cy; if (cen) { cx = cen.cx; cy = cen.cy; } else { cx = ((idx + 1) / (n + 1)) * mw; cy = Math.min(mh - ts * 1.2, (h - 1.5) * ts); } cx = Math.max(ts * 0.5, Math.min(mw - ts * 0.5, cx)); cy = clampSpaceShooterWorldCy(cy, mh); ent.spaceShooterCx = cx; ent.spaceShooterCy = cy; if (typeof ent.spaceShooterScore !== 'number' || !Number.isFinite(ent.spaceShooterScore)) ent.spaceShooterScore = 0; ent.x = cx / ts - cw * 0.5; ent.y = cy / ts - ch * 0.92; ent.tx = ent.x; ent.ty = ent.y; ent.direction = 'up'; }); } function spaceShooterTimeLimitSecPlay() { const t = Number(mapData && mapData.spaceShooterTimeSec); if (Number.isFinite(t) && t === 0) return 0; if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t); const g = Number(playSpaceShooterMissionTimeSec); if (Number.isFinite(g) && g > 0 && g < 7200) return Math.floor(g); return 90; } function spaceShooterRemainingSecPlay() { const lim = spaceShooterTimeLimitSecPlay(); if (lim <= 0) return null; const elapsed = (performance.now() - spaceShooterSessionStartMs) / 1000; return Math.max(0, Math.ceil(lim - elapsed)); } function endSpaceShooterTimeUp() { if (spaceShooterGameEnded || !mapData || mapData.gameType !== 'space_shooter') return; spaceShooterGameEnded = true; spaceShooterBullets = []; spaceShooterAsteroids = []; spaceShooterSpawnAccMs = 0; 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; if (titleEl) titleEl.textContent = 'หมดเวลา · Time up'; msgEl.textContent = 'ยิงยานอวกาศจบแล้ว · SPACE SHOOTER finished — อันดับจากคะแนนยิงหิน'; listEl.innerHTML = ''; const ranks = []; if (myId != null) { ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, me.spaceShooterScore | 0) }); } others.forEach((o, id) => { ranks.push({ id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, score: Math.max(0, o.spaceShooterScore | 0) }); }); ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th')); 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 = buildRoomLobbyReturnHref(); } if (btn) { btn.onclick = () => { if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden'); else goLobby(); }; } } function syncSpaceShooterFootFromShipCenter(ent) { if (!ent || !mapData || ent.spaceShooterCx == null || ent.spaceShooterCy == null) return; const { cw, ch } = getCharacterFootprintWH(mapData); const ts = tileSize; ent.x = ent.spaceShooterCx / ts - cw * 0.5; ent.y = ent.spaceShooterCy / ts - ch * 0.92; } /** จำกัดกลางยานแนวตั้งให้อยู่แค่ครึ่งล่างของแมพ (ไม่ขึ้นเกินกึ่งกลางความสูง) */ function clampSpaceShooterWorldCy(cy, mhPx) { if (!Number.isFinite(cy) || !Number.isFinite(mhPx) || mhPx <= 0) return cy; const edge = 20; const lo = mhPx * 0.5; const hi = Math.max(lo + 4, mhPx - edge); return Math.max(lo, Math.min(hi, cy)); } function stepSpaceShooterPreviewBots(dt) { if (spaceShooterGameEnded || !previewFillBots || !mapData || mapData.gameType !== 'space_shooter') return; const ts = tileSize; const mw = (mapData.width || 20) * ts; const mh = (mapData.height || 15) * ts; [...others.keys()].filter(isPreviewBotId).forEach((bid) => { const o = others.get(bid); if (!o || o.spaceShooterCx == null) return; let nearest = null, nd = 1e9; for (let i = 0; i < spaceShooterAsteroids.length; i++) { const a = spaceShooterAsteroids[i]; if (!a) continue; const d = Math.abs(a.x - o.spaceShooterCx) + Math.abs(a.y - o.spaceShooterCy) * 0.15; if (d < nd) { nd = d; nearest = a; } } let vx = 0; if (nearest) vx = nearest.x > o.spaceShooterCx ? 1 : nearest.x < o.spaceShooterCx ? -1 : 0; else vx = Math.sin(performance.now() / 900 + bid.length) > 0 ? 1 : -1; let vy = 0; if (nearest && Math.abs(nearest.y - o.spaceShooterCy) > ts * 0.25) { vy = nearest.y > o.spaceShooterCy ? 1 : -1; } else if (!nearest) { vy = Math.sin(performance.now() / 700 + bid.charCodeAt(0)) > 0 ? 1 : -1; } const spd = (o.botTier === 'sharp' ? 195 : o.botTier === 'weak' ? 115 : 155); o.spaceShooterCx = Math.max(18, Math.min(mw - 18, o.spaceShooterCx + vx * spd * dt)); o.spaceShooterCy = clampSpaceShooterWorldCy(o.spaceShooterCy + vy * spd * 0.7 * dt, mh); syncSpaceShooterFootFromShipCenter(o); o.tx = o.x; o.ty = o.y; if (typeof o.spaceShooterBotFireCd !== 'number') o.spaceShooterBotFireCd = 0; o.spaceShooterBotFireCd -= dt; if (o.spaceShooterBotFireCd <= 0 && nearest && nd < ts * 5) { o.spaceShooterBotFireCd = o.botTier === 'sharp' ? 0.22 : o.botTier === 'weak' ? 0.48 : 0.34; spaceShooterBullets.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 16, vy: -500, ownerId: bid }); } }); } function spaceShooterTickFrame() { if (!mapData || mapData.gameType !== 'space_shooter') return; const w = mapData.width || 20, h = mapData.height || 15; const ts = tileSize; const mw = w * ts, mh = h * ts; const { cw, ch } = getCharacterFootprintWH(mapData); const now = performance.now(); const dt = Math.min(0.055, spaceShooterLastTickMs ? (now - spaceShooterLastTickMs) / 1000 : 0.016); spaceShooterLastTickMs = now; if (spaceShooterGameEnded) { const wallClock = Date.now(); for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) { if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1); } return; } const remSec = spaceShooterRemainingSecPlay(); if (remSec != null && remSec <= 0) { endSpaceShooterTimeUp(); return; } const interval = Math.max(320, Number(mapData.spaceShooterAsteroidIntervalMs) || 1040); spaceShooterSpawnAccMs += dt * 1000; while (spaceShooterSpawnAccMs >= interval) { spaceShooterSpawnAccMs -= interval; spaceShooterAsteroids.push({ x: 36 + Math.random() * (mw - 72), y: -45 - Math.random() * 90, r: 13 + Math.random() * 17, vy: 58 + Math.random() * 62, hp: 1, }); } others.forEach((o, id) => { if (isPreviewBotId(id)) return; if (o.tx != null) o.x += (o.tx - o.x) * 0.28; if (o.ty != null) o.y += (o.ty - o.y) * 0.28; o.spaceShooterCx = (o.x + cw * 0.5) * ts; o.spaceShooterCy = clampSpaceShooterWorldCy((o.y + ch * 0.92) * ts, mh); }); let vx = 0; let vy = 0; if (!isChatFocused()) { if (keys['ArrowLeft'] || keys['KeyA']) vx -= 1; if (keys['ArrowRight'] || keys['KeyD']) vx += 1; if (keys['ArrowUp'] || keys['KeyW']) vy -= 1; if (keys['ArrowDown'] || keys['KeyS']) vy += 1; } const moveSpd = 280; if (me.spaceShooterCx != null) { me.spaceShooterCx = Math.max(18, Math.min(mw - 18, me.spaceShooterCx + vx * moveSpd * dt)); } if (me.spaceShooterCy != null) { me.spaceShooterCy = clampSpaceShooterWorldCy(me.spaceShooterCy + vy * moveSpd * dt, mh); } syncSpaceShooterFootFromShipCenter(me); spaceShooterFireCd -= dt; const fireHeld = !isChatFocused() && !!keys['Space']; if (fireHeld && spaceShooterFireCd <= 0 && me.spaceShooterCy != null) { spaceShooterFireCd = 0.21; spaceShooterBullets.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 20, vy: -580, ownerId: myId }); } stepSpaceShooterPreviewBots(dt); for (let i = spaceShooterBullets.length - 1; i >= 0; i--) { const b = spaceShooterBullets[i]; b.y += b.vy * dt; if (b.y < -50 || b.x < -30 || b.x > mw + 30) spaceShooterBullets.splice(i, 1); } for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) { const a = spaceShooterAsteroids[ai]; a.y += a.vy * dt; if (a.y > mh + 100) spaceShooterAsteroids.splice(ai, 1); } for (let bi = spaceShooterBullets.length - 1; bi >= 0; bi--) { const b = spaceShooterBullets[bi]; let hit = -1; for (let ai = 0; ai < spaceShooterAsteroids.length; ai++) { const a = spaceShooterAsteroids[ai]; const dx = b.x - a.x, dy = b.y - a.y; const rr = (a.r + 7) * (a.r + 7); if (dx * dx + dy * dy <= rr) { hit = ai; break; } } if (hit >= 0) { const a = spaceShooterAsteroids[hit]; a.hp = (a.hp || 1) - 1; spaceShooterBullets.splice(bi, 1); if (a.hp <= 0) { spaceShooterAsteroids.splice(hit, 1); const add = 5; if (b.ownerId === myId) { me.spaceShooterScore = Math.max(0, (me.spaceShooterScore || 0) + add); spaceShooterPopups.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 }); } else { const o = others.get(b.ownerId); if (o) { o.spaceShooterScore = Math.max(0, (o.spaceShooterScore || 0) + add); spaceShooterPopups.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 }); } } } } } const wallClock = Date.now(); for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) { /* until ใช้ Date.now() — ห้ามเทียบกับ performance.now() (สเกลคนละระบบ → ลบไม่ออก) */ if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1); } me.direction = 'up'; me.isWalking = vx !== 0 || vy !== 0; const tEmit = Date.now(); if (tEmit - spaceShooterLastMoveEmit > 90 && socket && myId != null) { spaceShooterLastMoveEmit = tEmit; socket.emit('move', { x: me.x, y: me.y, direction: me.direction, spaceShooterScore: Math.max(0, me.spaceShooterScore | 0), }); } } function drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs) { if (!mapData || !isSpaceShooter()) return; const refs = buildSpaceShooterParticipantRefsPlay(); const wallNow = typeof timeMs === 'number' ? timeMs : Date.now(); for (let i = 0; i < spaceShooterAsteroids.length; i++) { const a = spaceShooterAsteroids[i]; const [sx, sy] = worldToScreen(a.x, a.y); const sr = Math.max(6, a.r * zDraw); const grd = ctx.createRadialGradient(sx - sr * 0.25, sy - sr * 0.25, sr * 0.1, sx, sy, sr); grd.addColorStop(0, 'rgba(90, 85, 95, 0.95)'); grd.addColorStop(0.55, 'rgba(35, 32, 42, 0.98)'); grd.addColorStop(1, 'rgba(18, 16, 24, 1)'); ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255, 90, 60, 0.75)'; ctx.lineWidth = Math.max(1.5, zDraw * 1.2); ctx.beginPath(); ctx.arc(sx, sy, sr * 0.88, 0.6, 1.9); ctx.stroke(); ctx.beginPath(); ctx.arc(sx + sr * 0.15, sy + sr * 0.1, sr * 0.35, 0, Math.PI * 2); ctx.stroke(); ctx.shadowColor = 'rgba(255, 60, 40, 0.55)'; ctx.shadowBlur = sr * 0.45; ctx.strokeStyle = 'rgba(255, 120, 80, 0.35)'; ctx.beginPath(); ctx.arc(sx, sy, sr + 3 * zDraw, 0, Math.PI * 2); ctx.stroke(); ctx.shadowBlur = 0; } for (let i = 0; i < spaceShooterBullets.length; i++) { const b = spaceShooterBullets[i]; for (let k = 0; k < 4; k++) { const t = k / 4; const yy = b.y + t * 22; const [bx, by] = worldToScreen(b.x, yy); ctx.fillStyle = `rgba(255, ${180 - k * 35}, ${80 - k * 15}, ${0.95 - k * 0.18})`; ctx.beginPath(); ctx.arc(bx, by, Math.max(2, 3.2 * zDraw - k * 0.4), 0, Math.PI * 2); ctx.fill(); } } refs.forEach((entry, idx) => { const ent = entry.ref; if (ent.spaceShooterCx == null || ent.spaceShooterCy == null) return; const [sx, sy] = worldToScreen(ent.spaceShooterCx, ent.spaceShooterCy); const col = SPACE_SHOOTER_SHIP_COLORS[idx % SPACE_SHOOTER_SHIP_COLORS.length]; const bodyW = 14 * zDraw; const bodyH = 22 * zDraw; ctx.save(); ctx.translate(sx, sy); ctx.fillStyle = col; ctx.strokeStyle = 'rgba(180, 255, 255, 0.85)'; ctx.lineWidth = Math.max(1.2, zDraw); ctx.beginPath(); ctx.moveTo(0, -bodyH * 0.55); ctx.lineTo(-bodyW * 0.55, bodyH * 0.42); ctx.lineTo(0, bodyH * 0.22); ctx.lineTo(bodyW * 0.55, bodyH * 0.42); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.fillStyle = 'rgba(255, 220, 120, 0.95)'; ctx.beginPath(); ctx.moveTo(-bodyW * 0.22, bodyH * 0.38); ctx.lineTo(0, bodyH * 0.72); ctx.lineTo(bodyW * 0.22, bodyH * 0.38); ctx.closePath(); ctx.fill(); ctx.restore(); const name = (ent.nickname || '').slice(0, 10); if (name) { ctx.font = `${Math.max(9, 10 * zDraw)}px system-ui, "Kanit", sans-serif`; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(224, 242, 255, 0.92)'; ctx.strokeStyle = 'rgba(0, 20, 40, 0.85)'; ctx.lineWidth = 3; ctx.strokeText(name, sx, sy - bodyH * 0.62); ctx.fillText(name, sx, sy - bodyH * 0.62); ctx.textAlign = 'left'; } }); for (let i = 0; i < spaceShooterPopups.length; i++) { const p = spaceShooterPopups[i]; if (wallNow >= p.until) continue; const [px, py] = worldToScreen(p.x, p.y); const dur = 700; const age = 1 - Math.max(0, Math.min(1, (p.until - wallNow) / dur)); ctx.save(); ctx.globalAlpha = Math.max(0, 1 - age * 0.92); ctx.font = `bold ${Math.max(12, 16 * zDraw)}px Orbitron, ui-sans-serif, sans-serif`; ctx.textAlign = 'center'; ctx.fillStyle = '#ffe066'; ctx.strokeStyle = 'rgba(120, 60, 0, 0.6)'; ctx.lineWidth = 3; ctx.strokeText(p.text || '+5', px, py - age * 18); ctx.fillText(p.text || '+5', px, py - age * 18); ctx.restore(); } } const BALLOON_BOSS_PLAYER_COLORS = ['#ff79c6', '#7aa2f7', '#f9e2af', '#f7768e', '#9ece6a', '#bb9af7']; function normalizeBalloonBossPlayerSlotsInPlay(md) { if (!md || md.gameType !== 'balloon_boss') return; const w = md.width || 20, h = md.height || 15; const src = md.balloonBossPlayerSlots || []; const rows = []; for (let y = 0; y < h; y++) { const r = src[y]; const row = []; for (let x = 0; x < w; x++) { const v = r && r[x]; const n = typeof v === 'number' ? v : parseInt(String(v), 10); row.push(Number.isFinite(n) && n >= 1 && n <= 6 ? Math.floor(n) : 0); } rows.push(row); } md.balloonBossPlayerSlots = rows; } function getBalloonBossPlayerSpawnWorldCenterFromMap(md, slot1to6, ts) { if (!md || !md.balloonBossPlayerSlots) return null; const w = md.width || 20, h = md.height || 15; const g = md.balloonBossPlayerSlots; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { if (g[y] && g[y][x] === slot1to6) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts }; } } return null; } function getBalloonBossBossWorldCenterPlay(md, ts) { const bx = md && md.balloonBossBossSpawn; if (bx && Number.isFinite(Number(bx.x)) && Number.isFinite(Number(bx.y))) { return { cx: (Number(bx.x) + 0.5) * ts, cy: (Number(bx.y) + 0.5) * ts }; } const w = md.width || 20, h = md.height || 15; return { cx: (w * 0.5) * ts, cy: (h * 0.38) * ts }; } function buildBalloonBossParticipantRefsPlay() { const refs = []; if (me) refs.push({ id: myId, ref: me }); const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); humanIds.forEach((id) => { const o = others.get(id); if (o) refs.push({ id, ref: o }); }); const botIds = [...others.keys()].filter(isPreviewBotId).sort(); botIds.forEach((id) => { const o = others.get(id); if (o) refs.push({ id, ref: o }); }); return refs.filter((r) => r.ref); } function balloonBossBalloonsStartPlay() { const n = Number(mapData && mapData.balloonBossBalloonsPerPlayer); return Math.max(1, Math.min(12, Number.isFinite(n) ? Math.floor(n) : 5)); } function balloonBossMaxHpPlay() { const n = Number(mapData && mapData.balloonBossMaxHp); return Math.max(20, Math.min(9999, Number.isFinite(n) ? Math.floor(n) : 100)); } function balloonBossFireDelayMsPlay() { const n = Number(mapData && mapData.balloonBossFireDelayMs); return Math.max(120, Math.min(2000, Number.isFinite(n) ? Math.floor(n) : 380)); } /** ความเร่งยาน (px/s²) — ต่ำ = คุมยาก ลื่น */ function balloonBossShipAccelPlay() { const n = Number(mapData && mapData.balloonBossShipAccel); return Math.max(80, Math.min(520, Number.isFinite(n) ? n : 210)); } /** ความเร็วสูงสุดยาน (px/s) */ function balloonBossShipMaxSpeedPlay() { const n = Number(mapData && mapData.balloonBossShipMaxSpeed); return Math.max(60, Math.min(320, Number.isFinite(n) ? n : 158)); } /** แรงหน่วงต่อวินาที (velocity *= 1 - min(1, drag*dt)) — สูง = หยุดเร็วขึ้น */ function balloonBossShipDragPlay() { const n = Number(mapData && mapData.balloonBossShipDrag); return Math.max(0.4, Math.min(6, Number.isFinite(n) ? n : 1.65)); } function balloonBossTimeLimitSecPlay() { const t = Number(mapData && mapData.balloonBossTimeSec); if (Number.isFinite(t) && t === 0) return 0; if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t); return 120; } function balloonBossRemainingSecPlay() { const lim = balloonBossTimeLimitSecPlay(); if (lim <= 0) return null; const elapsed = (performance.now() - balloonBossSessionStartMs) / 1000; return Math.max(0, Math.ceil(lim - elapsed)); } function balloonBossTeamDamagePlay() { let s = Math.max(0, me.balloonBossScore | 0); others.forEach((o) => { s += Math.max(0, o.balloonBossScore | 0); }); return s; } function syncBalloonBossFootFromCenter(ent) { if (!ent || !mapData || ent.balloonBossCx == null || ent.balloonBossCy == null) return; const { cw, ch } = getCharacterFootprintWH(mapData); const ts = tileSize; ent.x = ent.balloonBossCx / ts - cw * 0.5; ent.y = ent.balloonBossCy / ts - ch * 0.92; } function clampBalloonBossWorld(cx, cy, mw, mh) { const e = 22; return { cx: Math.max(e, Math.min(mw - e, cx)), cy: Math.max(e, Math.min(mh - e, cy)), }; } function applyBalloonBossSpawnLayoutPlay() { if (!mapData || mapData.gameType !== 'balloon_boss') return; normalizeBalloonBossPlayerSlotsInPlay(mapData); const ts = tileSize; const w = mapData.width || 20, h = mapData.height || 15; const { cw, ch } = getCharacterFootprintWH(mapData); const mw = w * ts, mh = h * ts; const refs = buildBalloonBossParticipantRefsPlay(); const n = refs.length || 1; const bStart = balloonBossBalloonsStartPlay(); refs.forEach((entry, idx) => { const ent = entry.ref; const slot = (idx % 6) + 1; const cen = getBalloonBossPlayerSpawnWorldCenterFromMap(mapData, slot, ts); let cx, cy; if (cen) { cx = cen.cx; cy = cen.cy; } else { const t = (idx + 1) / (n + 1); cx = t * mw; cy = Math.min(mh - ts * 1.2, (h - 1.2) * ts); } const cl = clampBalloonBossWorld(cx, cy, mw, mh); ent.balloonBossCx = cl.cx; ent.balloonBossCy = cl.cy; if (typeof ent.balloonBossScore !== 'number' || !Number.isFinite(ent.balloonBossScore)) ent.balloonBossScore = 0; if (typeof ent.balloonBossBalloons !== 'number' || !Number.isFinite(ent.balloonBossBalloons)) ent.balloonBossBalloons = bStart; if (typeof ent.balloonBossHitIframe !== 'number') ent.balloonBossHitIframe = 0; ent.balloonBossVelX = 0; ent.balloonBossVelY = 0; ent.x = ent.balloonBossCx / ts - cw * 0.5; ent.y = ent.balloonBossCy / ts - ch * 0.92; ent.tx = ent.x; ent.ty = ent.y; ent.direction = 'up'; }); } function endBalloonBossGame(reason) { if (balloonBossGameEnded || !mapData || mapData.gameType !== 'balloon_boss') return; balloonBossGameEnded = true; balloonBossPendingShots = []; balloonBossPlayerBullets = []; balloonBossBossBullets = []; 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; if (titleEl) { titleEl.textContent = reason === 'victory' ? 'ชนะบอส! · Boss defeated' : 'หมดเวลา · Time up'; } msgEl.textContent = reason === 'victory' ? 'MEGA VIRUS ถูกกำจัดแล้ว — อันดับจากความเสียหายที่ทำกับบอส' : 'หมดเวลา — อันดับจากความเสียหายที่ทำกับบอส'; listEl.innerHTML = ''; const ranks = []; if (myId != null) { ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, me.balloonBossScore | 0) }); } others.forEach((o, id) => { ranks.push({ id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, score: Math.max(0, o.balloonBossScore | 0) }); }); ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th')); 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) || '—'} — DMG ${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 = buildRoomLobbyReturnHref(); } if (btn) { btn.onclick = () => { if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden'); else goLobby(); }; } } function stepBalloonBossPreviewBots(dt, mw, mh, bossCx, bossCy, ts) { if (!previewFillBots || !mapData || mapData.gameType !== 'balloon_boss') return; [...others.keys()].filter(isPreviewBotId).forEach((bid) => { const o = others.get(bid); if (!o || o.balloonBossEliminated || o.balloonBossCx == null) return; const wob = Math.sin(performance.now() / 800 + bid.length) * 0.55; const wob2 = Math.cos(performance.now() / 1100 + bid.charCodeAt(0)) * 0.45; const spd = 48; let nx = o.balloonBossCx + wob * spd * dt; let ny = o.balloonBossCy + wob2 * spd * 0.72 * dt; const cl = clampBalloonBossWorld(nx, ny, mw, mh); o.balloonBossCx = cl.cx; o.balloonBossCy = cl.cy; syncBalloonBossFootFromCenter(o); o.tx = o.x; o.ty = o.y; if (typeof o.balloonBossBotNextFire === 'number') o.balloonBossBotNextFire -= dt; else o.balloonBossBotNextFire = 0.8 + Math.random() * 0.9; if (o.balloonBossBotNextFire <= 0) { o.balloonBossBotNextFire = 0.9 + Math.random() * 1.1; const delayMs = balloonBossFireDelayMsPlay(); const dx = bossCx - o.balloonBossCx, dy = bossCy - o.balloonBossCy; const d = Math.sqrt(dx * dx + dy * dy) || 1; balloonBossPendingShots.push({ releaseAt: performance.now() + delayMs, sx: o.balloonBossCx, sy: o.balloonBossCy, vx: (dx / d) * 520, vy: (dy / d) * 520, ownerId: bid, }); } }); } function balloonBossTickFrame() { if (!mapData || mapData.gameType !== 'balloon_boss') return; const w = mapData.width || 20, h = mapData.height || 15; const ts = tileSize; const mw = w * ts, mh = h * ts; const { cw, ch } = getCharacterFootprintWH(mapData); others.forEach((o, id) => { if (isPreviewBotId(id)) return; if (!o || o.balloonBossEliminated) return; if (o.tx != null) o.x += (o.tx - o.x) * 0.24; if (o.ty != null) o.y += (o.ty - o.y) * 0.24; const cx = (o.x + cw * 0.5) * ts; const cy = (o.y + ch * 0.92) * ts; const cl = clampBalloonBossWorld(cx, cy, mw, mh); o.balloonBossCx = cl.cx; o.balloonBossCy = cl.cy; }); const now = performance.now(); const dt = Math.min(0.055, balloonBossLastTickMs ? (now - balloonBossLastTickMs) / 1000 : 0.016); balloonBossLastTickMs = now; const bossC = getBalloonBossBossWorldCenterPlay(mapData, ts); const bossR = Math.max(36, ts * 1.15); const maxHp = balloonBossMaxHpPlay(); const teamDmg = balloonBossTeamDamagePlay(); if (me.balloonBossEliminated) { me.balloonBossVelX = 0; me.balloonBossVelY = 0; } if (balloonBossGameEnded) { for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) { balloonBossHitFx[hi].t -= dt; if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1); } return; } const remSec = balloonBossRemainingSecPlay(); if (remSec != null && remSec <= 0) { endBalloonBossGame('time'); return; } if (teamDmg >= maxHp) { endBalloonBossGame('victory'); return; } for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) { balloonBossHitFx[hi].t -= dt; if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1); } const aliveRefs = buildBalloonBossParticipantRefsPlay().filter((e) => e.ref && !e.ref.balloonBossEliminated); for (let pi = balloonBossPendingShots.length - 1; pi >= 0; pi--) { const pnd = balloonBossPendingShots[pi]; if (now >= pnd.releaseAt) { balloonBossPlayerBullets.push({ x: pnd.sx, y: pnd.sy, vx: pnd.vx, vy: pnd.vy, ownerId: pnd.ownerId }); balloonBossPendingShots.splice(pi, 1); } } const bossFireEvery = 0.92; balloonBossBossFireAcc += dt; while (balloonBossBossFireAcc >= bossFireEvery) { balloonBossBossFireAcc -= bossFireEvery; if (aliveRefs.length) { const wave = Math.floor(now / 1000); const tgt = aliveRefs[wave % aliveRefs.length].ref; if (tgt && tgt.balloonBossCx != null) { const dx = tgt.balloonBossCx - bossC.cx, dy = tgt.balloonBossCy - bossC.cy; const d = Math.sqrt(dx * dx + dy * dy) || 1; const spd = 290; balloonBossBossBullets.push({ x: bossC.cx, y: bossC.cy, vx: (dx / d) * spd, vy: (dy / d) * spd }); } } } function applyBalloonBossHitToEntity(ent) { if (!ent || ent.balloonBossEliminated) return; if (ent.balloonBossHitIframe > now) return; ent.balloonBossHitIframe = now + 520; ent.balloonBossBalloons = Math.max(0, (ent.balloonBossBalloons | 0) - 1); if (ent.balloonBossBalloons <= 0) { ent.balloonBossEliminated = true; ent.balloonBossBalloons = 0; } if (ent === me && socket) { socket.emit('move', { x: me.x, y: me.y, direction: me.direction, balloonBossBalloons: me.balloonBossBalloons, balloonBossEliminated: !!me.balloonBossEliminated, balloonBossScore: Math.max(0, me.balloonBossScore | 0), }); } } for (let bi = balloonBossBossBullets.length - 1; bi >= 0; bi--) { const b = balloonBossBossBullets[bi]; b.x += b.vx * dt; b.y += b.vy * dt; if (b.x < -80 || b.x > mw + 80 || b.y < -80 || b.y > mh + 80) { balloonBossBossBullets.splice(bi, 1); continue; } let hit = false; aliveRefs.forEach((entry) => { if (hit) return; const ent = entry.ref; if (!ent || ent.balloonBossCx == null) return; const dx = b.x - ent.balloonBossCx, dy = b.y - ent.balloonBossCy; if (dx * dx + dy * dy <= (26 * 26)) { hit = true; applyBalloonBossHitToEntity(ent); } }); if (hit) balloonBossBossBullets.splice(bi, 1); } for (let i = balloonBossPlayerBullets.length - 1; i >= 0; i--) { const b = balloonBossPlayerBullets[i]; b.x += b.vx * dt; b.y += b.vy * dt; if (b.x < -60 || b.x > mw + 60 || b.y < -60 || b.y > mh + 60) { balloonBossPlayerBullets.splice(i, 1); continue; } const dx = b.x - bossC.cx, dy = b.y - bossC.cy; if (dx * dx + dy * dy <= bossR * bossR) { balloonBossHitFx.push({ x: bossC.cx, y: bossC.cy, t: 0.22 }); const add = 1; if (b.ownerId === myId) { me.balloonBossScore = Math.max(0, (me.balloonBossScore | 0) + add); } else { const o = others.get(b.ownerId); if (o) o.balloonBossScore = Math.max(0, (o.balloonBossScore | 0) + add); } balloonBossPlayerBullets.splice(i, 1); } } let inX = 0, inY = 0; if (!me.balloonBossEliminated && !isChatFocused()) { if (keys['ArrowLeft'] || keys['KeyA']) inX -= 1; if (keys['ArrowRight'] || keys['KeyD']) inX += 1; if (keys['ArrowUp'] || keys['KeyW']) inY -= 1; if (keys['ArrowDown'] || keys['KeyS']) inY += 1; } const accel = balloonBossShipAccelPlay(); const maxSpd = balloonBossShipMaxSpeedPlay(); const dragPerSec = balloonBossShipDragPlay(); if (typeof me.balloonBossVelX !== 'number' || !Number.isFinite(me.balloonBossVelX)) me.balloonBossVelX = 0; if (typeof me.balloonBossVelY !== 'number' || !Number.isFinite(me.balloonBossVelY)) me.balloonBossVelY = 0; let nvx = me.balloonBossVelX; let nvy = me.balloonBossVelY; if (inX !== 0 || inY !== 0) { const il = Math.sqrt(inX * inX + inY * inY) || 1; nvx += (inX / il) * accel * dt; nvy += (inY / il) * accel * dt; } const drag = Math.min(1, dragPerSec * dt); nvx *= (1 - drag); nvy *= (1 - drag); const curSpd = Math.sqrt(nvx * nvx + nvy * nvy); if (curSpd > maxSpd) { const s = maxSpd / curSpd; nvx *= s; nvy *= s; } me.balloonBossVelX = nvx; me.balloonBossVelY = nvy; if (!me.balloonBossEliminated && me.balloonBossCx != null) { const ncx = me.balloonBossCx + nvx * dt; const ncy = me.balloonBossCy + nvy * dt; const cl = clampBalloonBossWorld(ncx, ncy, mw, mh); if (Math.abs(cl.cx - ncx) > 0.5) me.balloonBossVelX = 0; if (Math.abs(cl.cy - ncy) > 0.5) me.balloonBossVelY = 0; me.balloonBossCx = cl.cx; me.balloonBossCy = cl.cy; } syncBalloonBossFootFromCenter(me); me.direction = 'up'; me.isWalking = Math.abs(me.balloonBossVelX) > 8 || Math.abs(me.balloonBossVelY) > 8 || inX !== 0 || inY !== 0; balloonBossPlayerFireCd -= dt; const wantFire = !isChatFocused() && !!keys['Space'] && !me.balloonBossEliminated; if (wantFire && balloonBossPlayerFireCd <= 0 && me.balloonBossCy != null) { balloonBossPlayerFireCd = 0.35; const delayMs = balloonBossFireDelayMsPlay(); const dx = bossC.cx - me.balloonBossCx, dy = bossC.cy - me.balloonBossCy; const d = Math.sqrt(dx * dx + dy * dy) || 1; balloonBossPendingShots.push({ releaseAt: now + delayMs, sx: me.balloonBossCx, sy: me.balloonBossCy, vx: (dx / d) * 480, vy: (dy / d) * 480, ownerId: myId, }); } stepBalloonBossPreviewBots(dt, mw, mh, bossC.cx, bossC.cy, ts); const tEmit = Date.now(); if (tEmit - balloonBossLastMoveEmit > 95 && socket && myId != null) { balloonBossLastMoveEmit = tEmit; socket.emit('move', { x: me.x, y: me.y, direction: me.direction, balloonBossScore: Math.max(0, me.balloonBossScore | 0), balloonBossBalloons: Math.max(0, me.balloonBossBalloons | 0), balloonBossEliminated: !!me.balloonBossEliminated, }); } } function drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs) { if (!mapData || !isBalloonBoss()) return; const ts = tileSize; const w = mapData.width || 20, h = mapData.height || 15; const mw = w * ts, mh = h * ts; const bossC = getBalloonBossBossWorldCenterPlay(mapData, ts); const maxHp = balloonBossMaxHpPlay(); const dmg = balloonBossTeamDamagePlay(); const hpLeft = Math.max(0, maxHp - dmg); const bossR = Math.max(36, ts * 1.15); const refs = buildBalloonBossParticipantRefsPlay(); const leaderId = (() => { let best = null, bs = -1; refs.forEach((e) => { const sc = Math.max(0, e.ref.balloonBossScore | 0); if (sc > bs) { bs = sc; best = e.id; } }); return best; })(); for (let hi = 0; hi < balloonBossHitFx.length; hi++) { const fx = balloonBossHitFx[hi]; const [hx, hy] = worldToScreen(fx.x, fx.y); const pulse = 1 + (fx.t || 0) * 3; ctx.save(); ctx.globalAlpha = Math.min(1, (fx.t || 0) * 4); ctx.strokeStyle = 'rgba(255, 220, 120, 0.9)'; ctx.lineWidth = 3 * zDraw; ctx.beginPath(); ctx.arc(hx, hy, bossR * zDraw * 0.5 * pulse, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } const [bsx, bsy] = worldToScreen(bossC.cx, bossC.cy); ctx.save(); ctx.translate(bsx, bsy); ctx.strokeStyle = 'rgba(0, 255, 255, 0.75)'; ctx.lineWidth = Math.max(2, 2.5 * zDraw); ctx.beginPath(); for (let k = 0; k < 6; k++) { const ang = (k / 6) * Math.PI * 2 - Math.PI / 2; const px = Math.cos(ang) * bossR * zDraw * 1.05; const py = Math.sin(ang) * bossR * zDraw * 1.05; if (k === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); } ctx.closePath(); ctx.stroke(); ctx.fillStyle = 'rgba(0, 40, 60, 0.35)'; ctx.fill(); const skullR = bossR * zDraw * 0.62; const grd = ctx.createRadialGradient(-skullR * 0.2, -skullR * 0.25, skullR * 0.1, 0, 0, skullR); grd.addColorStop(0, 'rgba(55, 50, 62, 0.98)'); grd.addColorStop(1, 'rgba(22, 18, 30, 1)'); ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(0, 0, skullR, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(255, 40, 60, 0.95)'; ctx.shadowColor = 'rgba(255, 0, 0, 0.8)'; ctx.shadowBlur = 12 * zDraw; ctx.beginPath(); ctx.arc(-skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2); ctx.arc(skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; ctx.fillStyle = 'rgba(255, 100, 200, 0.9)'; ctx.font = `bold ${Math.max(10, 11 * zDraw)}px ui-monospace, monospace`; ctx.textAlign = 'center'; ctx.fillText('MEGA VIRUS : [ ' + hpLeft + ' / ' + maxHp + ' ]', 0, -bossR * zDraw - 14); const barW = bossR * zDraw * 2.2; const barH = Math.max(5, 6 * zDraw); ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(-barW / 2, -bossR * zDraw - 8, barW, barH); ctx.fillStyle = 'rgba(255, 80, 200, 0.85)'; ctx.fillRect(-barW / 2, -bossR * zDraw - 8, barW * (hpLeft / maxHp), barH); ctx.strokeStyle = 'rgba(180, 255, 255, 0.5)'; ctx.strokeRect(-barW / 2, -bossR * zDraw - 8, barW, barH); ctx.restore(); function drawBalloonsCluster(sx, sy, count, col, z) { const n = Math.max(0, Math.min(8, count | 0)); for (let b = 0; b < n; b++) { const ox = (b - (n - 1) / 2) * 7 * z; const oy = -18 * z - Math.abs(b - (n - 1) / 2) * 2 * z; ctx.fillStyle = col; ctx.beginPath(); ctx.ellipse(sx + ox, sy + oy, 6.5 * z, 8 * z, 0, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke(); } } refs.forEach((entry, idx) => { const ent = entry.ref; if (ent.balloonBossCx == null || ent.balloonBossCy == null) return; const [sx, sy] = worldToScreen(ent.balloonBossCx, ent.balloonBossCy); const col = BALLOON_BOSS_PLAYER_COLORS[idx % BALLOON_BOSS_PLAYER_COLORS.length]; const z = zDraw; ctx.save(); if (ent.balloonBossEliminated) ctx.globalAlpha = 0.38; ctx.strokeStyle = 'rgba(255, 255, 255, 0.65)'; ctx.lineWidth = Math.max(1.2, 1.5 * z); ctx.beginPath(); ctx.arc(sx, sy, 22 * z, 0, Math.PI * 2); ctx.stroke(); drawBalloonsCluster(sx, sy, ent.balloonBossBalloons | 0, col, z); const nm = String(ent.nickname || '').slice(0, 12); ctx.font = `${Math.max(9, 10 * z)}px system-ui, "Kanit", sans-serif`; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(248, 250, 252, 0.95)'; ctx.strokeStyle = 'rgba(0, 10, 30, 0.85)'; ctx.lineWidth = 3; let label = nm; if (entry.id === leaderId && leaderId != null) label = '♔ ' + nm; ctx.strokeText(label, sx, sy + 28 * z); ctx.fillText(label, sx, sy + 28 * z); const charImg = getPlayTintedAvatarSource( getAvatarImg(ent.characterId, 'up', timeMs, !!ent.isWalking), ent.characterId, 'up', timeMs, !!ent.isWalking, ent.playTint || playTintFromPeerId(entry.id) ); const size = 26 * z; if (charImg && ((charImg.tagName === 'CANVAS' && charImg.width > 0) || (charImg.complete && charImg.naturalWidth))) { const iw = charImg.tagName === 'CANVAS' ? charImg.width : charImg.naturalWidth; const ih = charImg.tagName === 'CANVAS' ? charImg.height : charImg.naturalHeight; const sc = Math.min(size / iw, size / ih, 1); const dw = iw * sc, dh = ih * sc; ctx.drawImage(charImg, 0, 0, iw, ih, sx - dw / 2, sy - dh + 4 * z, dw, dh); } else { ctx.fillStyle = col; ctx.fillRect(sx - 10 * z, sy - 18 * z, 20 * z, 22 * z); } ctx.restore(); }); for (let i = 0; i < balloonBossPlayerBullets.length; i++) { const b = balloonBossPlayerBullets[i]; const [bx, by] = worldToScreen(b.x, b.y); ctx.save(); ctx.translate(bx, by); ctx.rotate(Math.atan2(b.vy, b.vx) + Math.PI / 2); ctx.fillStyle = 'rgba(255, 255, 255, 0.95)'; ctx.beginPath(); ctx.moveTo(0, -8 * zDraw); ctx.lineTo(-5 * zDraw, 6 * zDraw); ctx.lineTo(5 * zDraw, 6 * zDraw); ctx.closePath(); ctx.fill(); ctx.fillStyle = 'rgba(0, 255, 200, 0.5)'; ctx.font = `${Math.max(7, 8 * zDraw)}px monospace`; ctx.textAlign = 'center'; ctx.fillText('01001', 0, 12 * zDraw); ctx.restore(); } for (let i = 0; i < balloonBossBossBullets.length; i++) { const b = balloonBossBossBullets[i]; const [bx, by] = worldToScreen(b.x, b.y); ctx.fillStyle = 'rgba(255, 60, 120, 0.92)'; ctx.beginPath(); ctx.arc(bx, by, Math.max(3, 4.5 * zDraw), 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255, 200, 255, 0.6)'; ctx.stroke(); } } function jumpSurviveCollectPlatformCells(md) { const w = md.width || 20, h = md.height || 15; const pa = md.jumpSurvivePlatformArea; const out = []; if (!pa) return out; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { if (pa[y] && pa[y][x] === 1) out.push({ x, y }); } } return out; } /** ความสูงแมปเป็นคาบวนของแพลตฟอร์ม — แถวที่เลื่อนออกบนจะวนมาโผล่ล่าง */ function jumpSurvivePlatformPeriodPx(md) { return (md.height || 15) * tileSize; } /** * ยืนบนหน้าแพลตฟอร์มช่อง (tx,ty) — เลือกชั้น k ที่ใกล้ hintY (กล้อง / กลางแมป) */ function jumpSurviveGridPosStandingOnPlatform(md, tx, ty, scrollPx) { const ts = tileSize; const h = md.height || 15; const period = jumpSurvivePlatformPeriodPx(md); const { cw, ch } = getCharacterFootprintWH(md); const rawTop = ty * ts - scrollPx; const hintY = (jumpSurviveCamCenterY > 1) ? jumpSurviveCamCenterY : h * ts * 0.72; const k = Math.round((hintY - rawTop - ts * 0.35) / period); const platTop = rawTop + k * period; const feetY = platTop; const pxc = (tx + 0.5) * ts; return { x: pxc / ts - cw * 0.5, y: feetY / ts - ch, }; } function jumpSurviveGridPosFromTileCenter(md, tx, ty) { return jumpSurviveGridPosStandingOnPlatform(md, tx, ty, 0); } function jumpSurviveRandomStandGridPos(md) { const cells = jumpSurviveCollectPlatformCells(md); const sc = jumpSurvivePlatformScrollPx; if (cells.length) { const c = cells[Math.floor(Math.random() * cells.length)]; return jumpSurviveGridPosStandingOnPlatform(md, c.x, c.y, sc); } const sp = md.spawn || { x: 1, y: 1 }; return jumpSurviveGridPosStandingOnPlatform(md, Number(sp.x) || 1, Number(sp.y) || 1, sc); } /** ทดสอบจากเอดิเตอร์: วางคน+บอทบนแพลตฟอร์ม (ไม่ให้เกิดบน spawnArea บนฟ้าแล้วลอย) */ function applyJumpSurvivePreviewSpawnLayout(onlyBots) { if (!mapData || mapData.gameType !== 'jump_survive') return; const cells = jumpSurviveCollectPlatformCells(mapData); cells.sort((a, b) => b.y - a.y || a.x - b.x); const sc = jumpSurvivePlatformScrollPx; const pool = cells.length ? cells.map((c) => jumpSurviveGridPosStandingOnPlatform(mapData, c.x, c.y, sc)) : [jumpSurviveGridPosStandingOnPlatform(mapData, Number(mapData.spawn && mapData.spawn.x) || 1, Number(mapData.spawn && mapData.spawn.y) || 1, sc)]; const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); const botIds = [...others.keys()].filter(isPreviewBotId).sort(); function stampJumpBotState(o) { if (!o) return; o.jumpSurviveVy = 0; o.jumpSurviveOnGround = true; o.jumpSurviveEliminated = false; o.tx = o.x; o.ty = o.y; } if (onlyBots) { let idx = 1 + realIds.length; botIds.forEach((bid) => { const o = others.get(bid); if (!o || !pool.length) return; const p = pool[idx % pool.length]; idx++; o.x = p.x + (Math.random() - 0.5) * 0.08; o.y = p.y + (Math.random() - 0.5) * 0.08; stampJumpBotState(o); }); return; } let idx = 0; function assignEnt(ent) { if (!ent || !pool.length) return; const p = pool[idx % pool.length]; idx++; ent.x = p.x + (Math.random() - 0.5) * 0.08; ent.y = p.y + (Math.random() - 0.5) * 0.08; stampJumpBotState(ent); } assignEnt(me); realIds.forEach((rid) => assignEnt(others.get(rid))); botIds.forEach((bid) => assignEnt(others.get(bid))); jumpSurviveInitRuntime(); } /** * ฟิสิกส์กระโดดร่วม (ผู้เล่น + บอท preview) * @returns {{ pxc: number, feetY: number, vy: number, onGround: boolean, died: boolean }} */ function jumpSurvivePhysicsIntegrate(md, opts) { const { dt, cw, ch, pxc0, feetY0, vy0, wasOnGround, jumpQueued, moveLeft, moveRight, camCenterY, halfViewH, } = opts; const w = md.width || 20, h = md.height || 15; const ts = tileSize; const bw = ts * 0.34; const bh = ts * 0.9; let pxc = pxc0; let feetY = feetY0; let vy = vy0; let onGround = wasOnGround; const gFull = Number(md.jumpSurviveGravity) > 0 ? Number(md.jumpSurviveGravity) : 3200; const g = gFull * dt; let jumpV0; if (Number(md.jumpSurviveJumpImpulse) > 0) { jumpV0 = -Number(md.jumpSurviveJumpImpulse); } else { const mult = (typeof playJumpSurviveJumpHeightMult === 'number' && playJumpSurviveJumpHeightMult > 0) ? playJumpSurviveJumpHeightMult : 1.5; const charHpixels = ch * ts; const impulse = Math.sqrt(Math.max(1e-6, 2 * gFull * mult * charHpixels)); jumpV0 = -Math.min(impulse, ts * 36); } let moveX = (Number(md.jumpSurviveMovePxPerSec) > 0 ? Number(md.jumpSurviveMovePxPerSec) : 200) * dt; if (isJumpSurviveMissionUiMapPlay()) moveX *= 1.35; if (jumpQueued && onGround) { vy = jumpV0; onGround = false; } vy += g; let nx = pxc; if (moveLeft) nx -= moveX; if (moveRight) nx += moveX; nx = Math.max(bw + 1, Math.min(w * ts - bw - 1, nx)); const headY0 = feetY - bh; const tryL = nx - bw; const tryR = nx + bw; if (!jumpSurviveOverlapsSolidForHorzMove(md, tryL, headY0, tryR, feetY)) { pxc = nx; } feetY += vy * dt; let headTop = feetY - bh; if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) { if (vy > 0) { for (let s = 0; s < 22; s++) { feetY -= ts * 0.035; headTop = feetY - bh; if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) { vy = 0; onGround = true; break; } } } else if (vy < 0) { for (let s = 0; s < 22; s++) { feetY += ts * 0.035; headTop = feetY - bh; if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) { vy = 0; break; } } } } else { onGround = false; } headTop = feetY - bh; const headBandBottom = headTop + bh * 0.42; if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY) && jumpSurviveOverlapsObjectWall(md, pxc - bw, headTop, pxc + bw, headBandBottom)) { return { pxc, feetY, vy, onGround, died: true }; } headTop = feetY - bh; const topKill = camCenterY - halfViewH - ts * 0.35; const botKill = camCenterY + halfViewH + ts * 0.85; if (feetY < topKill || feetY > botKill || headTop < -ts * 2) { return { pxc, feetY, vy, onGround, died: true }; } return { pxc, feetY, vy, onGround, died: false }; } function jumpSurviveEntityFromHitbox(o, md, pxc, feetYpx) { const { cw, ch } = getCharacterFootprintWH(md); o.x = pxc / tileSize - cw * 0.5; o.y = feetYpx / tileSize - ch; } function jumpSurviveInitRuntime() { jumpSurviveLastTickMs = performance.now(); jumpSurviveSessionStartMs = performance.now(); jumpSurviveVy = 0; jumpSurviveJumpQueued = false; jumpSurviveOnGround = false; jumpSurvivePlatformScrollPx = 0; const { cw, ch } = getCharacterFootprintWH(mapData); const ts = tileSize; if (isJumpSurviveMissionUiMapPlay()) { const w = mapData.width || 20, h = mapData.height || 15; jumpSurviveCamCenterX = (w * ts) * 0.5; jumpSurviveCamCenterY = (h * ts) * 0.5; } else { jumpSurviveCamCenterX = (me.x + cw * 0.5) * ts; jumpSurviveCamCenterY = (me.y + ch * 0.5) * ts; } } function jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom) { const ts = tileSize; const w = md.width || 20, h = md.height || 15; const x0 = Math.floor(left / ts); const x1 = Math.floor((right - 1e-6) / ts); const y0 = Math.floor(top / ts); const y1 = Math.floor((bottom - 1e-6) / ts); for (let ty = y0; ty <= y1; ty++) { for (let tx = x0; tx <= x1; tx++) { if (tx < 0 || tx >= w || ty >= h) return true; if (ty < 0) continue; const ob = md.objects && md.objects[ty] && md.objects[ty][tx]; if (ob === 1) return true; } } return false; } function jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, scrollPx) { const ts = tileSize; const w = md.width || 20, h = md.height || 15; const pa = md.jumpSurvivePlatformArea; if (!pa) return false; const period = jumpSurvivePlatformPeriodPx(md); if (period < ts) return false; const x0 = Math.max(0, Math.floor(left / ts)); const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts)); const vm = ts * 2; for (let ty = 0; ty < h; ty++) { for (let tx = x0; tx <= x1; tx++) { if (!pa[ty] || pa[ty][tx] !== 1) continue; const rawTop = ty * ts - scrollPx; const kMin = Math.ceil((top - rawTop - ts - vm) / period); const kMax = Math.floor((bottom - rawTop + vm) / period); for (let k = kMin; k <= kMax; k++) { const platTop = rawTop + k * period; const platBot = platTop + ts; const tileLeft = tx * ts; const tileRight = (tx + 1) * ts; if (platBot > top && platTop < bottom && right > tileLeft && left < tileRight) return true; } } } return false; } function jumpSurviveOverlapsSolid(md, left, top, right, bottom) { if (jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom)) return true; return jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, jumpSurvivePlatformScrollPx); } /** * ชนแนวนอน: objects เต็มกล่อง; แพลตฟอร์มไม่นับช่วงปลายเท้าเล็กน้อย * (ถ้าใช้กล่องเต็มกับแพลตฟอร์ม ขณะยืนบนแพลตฟอร์มมักทับช่องแนวตั้ง — เดินซ้ายขวาถูกปฏิเสธทั้งก้อน) */ function jumpSurviveOverlapsSolidForHorzMove(md, left, top, right, bottom) { if (jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom)) return true; const ts = tileSize; const footSlop = ts * 0.16; const platformBottom = bottom - footSlop; if (platformBottom <= top + ts * 0.05) return false; return jumpSurviveOverlapsPlatforms(md, left, top, right, platformBottom, jumpSurvivePlatformScrollPx); } /** Head/body overlaps กำแพง (objects=1) เท่านั้น — ใช้ตรวจชนฝาบนแล้วตาย */ function jumpSurviveOverlapsObjectWall(md, left, top, right, bottom) { const ts = tileSize; const w = md.width || 20, h = md.height || 15; const x0 = Math.floor(left / ts); const x1 = Math.floor((right - 1e-6) / ts); const y0 = Math.floor(top / ts); const y1 = Math.floor((bottom - 1e-6) / ts); for (let ty = y0; ty <= y1; ty++) { for (let tx = x0; tx <= x1; tx++) { if (tx < 0 || tx >= w || ty < 0 || ty >= h) continue; const ob = md.objects && md.objects[ty] && md.objects[ty][tx]; if (ob === 1) return true; } } return false; } function jumpSurviveSyncMeFromHitbox(pxc, feetYpx) { jumpSurviveEntityFromHitbox(me, mapData, pxc, feetYpx); } function jumpSurviveCameraTargetPx(md) { const ts = tileSize; const w = md.width || 20, h = md.height || 15; if (isJumpSurviveMissionUiMapPlay()) { return { px: (w * ts) * 0.5, py: (h * ts) * 0.5 }; } const { cw, ch } = getCharacterFootprintWH(md); const cx = (e) => (e.x + cw * 0.5) * ts; const cy = (e) => (e.y + ch * 0.5) * ts; if (!jumpSurviveEliminated) return { px: cx(me), py: cy(me) }; const aliveHumanIds = [...others.keys()].filter((id) => { if (isPreviewBotId(id)) return false; const o = others.get(id); return o && !o.jumpSurviveEliminated; }); const aliveBotIds = [...others.keys()].filter((id) => { if (!isPreviewBotId(id)) return false; const o = others.get(id); return o && !o.jumpSurviveEliminated; }); const useIds = aliveHumanIds.length ? aliveHumanIds : aliveBotIds; if (!useIds.length) return { px: cx(me), py: cy(me) }; let sx = 0, sy = 0; useIds.forEach((id) => { const o = others.get(id); if (!o) return; sx += cx(o); sy += cy(o); }); const n = useIds.length; return { px: sx / n, py: sy / n }; } /** มีแพลตฟอร์มใต้จุดเล็กน้อยข้างหน้าแนว wishDir หรือไม่ — กันบอทเดินหลุมแล้วตกจอ */ function jumpSurviveBotHasFloorAhead(md, pxc, feetY, wishDir, scrollPx) { if (!wishDir) return true; const ts = tileSize; const bw = ts * 0.34; const probeX = pxc + wishDir * (bw + ts * 0.82); const pad = ts * 0.24; const left = probeX - pad; const right = probeX + pad; const top = feetY - ts * 0.45; const bottom = feetY + ts * 8; return jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, scrollPx); } function stepJumpSurvivePreviewBots(dt, halfViewH) { if (!previewFillBots || !mapData || !isJumpSurvive()) return; const md = mapData; const { cw, ch } = getCharacterFootprintWH(md); const ts = tileSize; const wpx = (md.width || 20) * ts; const centerPx = wpx * 0.5; others.forEach((o, id) => { if (!isPreviewBotId(id)) return; if (o.jumpSurviveEliminated) return; if (typeof o.jumpSurviveVy !== 'number') o.jumpSurviveVy = 0; if (o.jumpSurviveOnGround == null) o.jumpSurviveOnGround = false; if (typeof o.jumpSurviveNextJumpAi !== 'number') o.jumpSurviveNextJumpAi = Date.now() + 300 + Math.floor(Math.random() * 500); if (typeof o.jumpSurviveWishNext !== 'number') o.jumpSurviveWishNext = Date.now() + 200; if (o.jumpSurviveWishX == null) o.jumpSurviveWishX = 0; let pxc = (o.x + cw * 0.5) * ts; let feetY = (o.y + ch) * ts; const camCy = jumpSurviveCamCenterY; const mission = isJumpSurviveMissionUiMapPlay(); const lowDanger = feetY > camCy + halfViewH - ts * 8.5; if (Date.now() >= o.jumpSurviveWishNext) { const wMin = mission && lowDanger ? 180 : 380; const wSpan = mission && lowDanger ? 480 : 700; o.jumpSurviveWishNext = Date.now() + wMin + Math.floor(Math.random() * wSpan); const tier = o.botTier || 'avg'; if (mission && lowDanger && Math.random() < 0.55) { o.jumpSurviveWishX = 0; } else if (pxc < centerPx - ts * 1.8) o.jumpSurviveWishX = 1; else if (pxc > centerPx + ts * 1.8) o.jumpSurviveWishX = -1; else if (tier === 'sharp') { const roam = mission ? 0.42 : 0.55; o.jumpSurviveWishX = Math.random() < roam ? (Math.random() < 0.5 ? -1 : 1) : 0; } else { const roam = mission ? 0.28 : 0.4; o.jumpSurviveWishX = Math.random() < roam ? (Math.random() < 0.5 ? -1 : 1) : 0; } } let moveLeft = o.jumpSurviveWishX < 0; let moveRight = o.jumpSurviveWishX > 0; let hazardJump = false; if (o.jumpSurviveOnGround) { const sc = jumpSurvivePlatformScrollPx; if (moveRight && !jumpSurviveBotHasFloorAhead(md, pxc, feetY, 1, sc)) { o.jumpSurviveWishX = Math.random() < 0.65 ? -1 : 0; hazardJump = true; } else if (moveLeft && !jumpSurviveBotHasFloorAhead(md, pxc, feetY, -1, sc)) { o.jumpSurviveWishX = Math.random() < 0.65 ? 1 : 0; hazardJump = true; } moveLeft = o.jumpSurviveWishX < 0; moveRight = o.jumpSurviveWishX > 0; } let jumpQ = false; if (hazardJump) o.jumpSurviveNextJumpAi = Math.min(o.jumpSurviveNextJumpAi, Date.now()); if (o.jumpSurviveOnGround && Date.now() >= o.jumpSurviveNextJumpAi) { o.jumpSurviveNextJumpAi = Date.now() + 300 + Math.floor(Math.random() * (hazardJump ? 520 : 1100)); const tier = o.botTier || 'avg'; let jp = tier === 'sharp' ? 0.5 : tier === 'weak' ? 0.25 : 0.36; if (mission) jp += 0.12; if (lowDanger) jp += 0.22; jp = Math.min(0.9, jp); if (hazardJump || Math.random() < jp) jumpQ = true; } const r = jumpSurvivePhysicsIntegrate(md, { dt, cw, ch, pxc0: pxc, feetY0: feetY, vy0: o.jumpSurviveVy, wasOnGround: o.jumpSurviveOnGround, jumpQueued: jumpQ, moveLeft, moveRight, camCenterY: jumpSurviveCamCenterY, halfViewH, }); if (r.died) { o.jumpSurviveEliminated = true; o.jumpSurviveVy = 0; jumpSurviveEntityFromHitbox(o, md, r.pxc, r.feetY); o.tx = o.x; o.ty = o.y; o.botIsWalking = false; return; } o.jumpSurviveVy = r.vy; o.jumpSurviveOnGround = r.onGround; jumpSurviveEntityFromHitbox(o, md, r.pxc, r.feetY); o.tx = o.x; o.ty = o.y; if (moveRight) o.direction = 'right'; else if (moveLeft) o.direction = 'left'; o.botIsWalking = Math.abs(r.vy) > 40 || moveLeft || moveRight; }); } function jumpSurviveTickFrame() { if (!mapData || !isJumpSurvive()) return; if (isJumpSurviveMissionUiMapPlay()) { if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return; const remEnd = jumpSurviveRemainingSecMission(); if (remEnd !== null && remEnd <= 0) { endJumpSurviveMissionRound(); return; } } const md = mapData; const ts = tileSize; const now = performance.now(); const dt = Math.min(0.05, Math.max(0, (now - jumpSurviveLastTickMs) / 1000)); jumpSurviveLastTickMs = now; const halfViewH = canvas.height / (2 * zoom); const rise = (Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42) * dt; jumpSurvivePlatformScrollPx += rise; const camTgt = jumpSurviveCameraTargetPx(md); const ck = Math.min(1, dt * 7); jumpSurviveCamCenterX += (camTgt.px - jumpSurviveCamCenterX) * ck; jumpSurviveCamCenterY += (camTgt.py - jumpSurviveCamCenterY) * ck; if (jumpSurviveEliminated) { stepJumpSurvivePreviewBots(dt, halfViewH); jumpSurviveMissionMaybeEarlyFinish(); return; } const { cw, ch } = getCharacterFootprintWH(md); let pxc = (me.x + cw * 0.5) * ts; let feetY = (me.y + ch) * ts; const jq = jumpSurviveJumpQueued; jumpSurviveJumpQueued = false; const r = jumpSurvivePhysicsIntegrate(md, { dt, cw, ch, pxc0: pxc, feetY0: feetY, vy0: jumpSurviveVy, wasOnGround: jumpSurviveOnGround, jumpQueued: jq, moveLeft: !!(keys['ArrowLeft'] || keys['KeyA']), moveRight: !!(keys['ArrowRight'] || keys['KeyD']), camCenterY: jumpSurviveCamCenterY, halfViewH, }); if (r.died) { jumpSurviveEliminated = true; jumpSurviveVy = 0; jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY); ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'KeyA', 'KeyW', 'KeyS', 'KeyD'].forEach((c) => { keys[c] = false; }); } else { jumpSurviveVy = r.vy; jumpSurviveOnGround = r.onGround; jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY); } stepJumpSurvivePreviewBots(dt, halfViewH); if (!jumpSurviveEliminated) { me.isWalking = Math.abs(jumpSurviveVy) > 40 || !!(keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD']); if (keys['ArrowRight'] || keys['KeyD']) me.direction = 'right'; else if (keys['ArrowLeft'] || keys['KeyA']) me.direction = 'left'; if (Date.now() - jumpSurviveLastEmitT > 80) { jumpSurviveLastEmitT = Date.now(); socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } } jumpSurviveMissionMaybeEarlyFinish(); } function gridImageLibIdleUrlPlay(entry) { if (entry == null) return ''; if (typeof entry === 'string') return entry; if (typeof entry === 'object' && entry.idle) return String(entry.idle); return ''; } function gridImageLibHeldUrlPlay(entry) { if (entry == null || typeof entry === 'string') return ''; if (typeof entry === 'object' && entry.held && String(entry.held).length) return String(entry.held); return ''; } function normalizeGridImageCellsOnMap(md) { if (!md) return; const w = md.width || 20, h = md.height || 15; if (!Array.isArray(md.gridImageLibrary)) md.gridImageLibrary = []; md.gridImageLibrary = md.gridImageLibrary .map((raw) => { if (typeof raw === 'string' && raw.length > 0 && raw.length < 30000000) return { idle: raw, held: null }; if (raw && typeof raw === 'object' && typeof raw.idle === 'string' && raw.idle.length > 0 && raw.idle.length < 30000000) { let held = typeof raw.held === 'string' && raw.held.length > 0 && raw.held.length < 30000000 ? raw.held : null; if (held && held === raw.idle) held = null; return { idle: raw.idle, held }; } return null; }) .filter(Boolean); const libLen = md.gridImageLibrary.length; let placements = []; if (Array.isArray(md.gridImageSprites) && md.gridImageSprites.length) { placements = md.gridImageSprites.map((raw) => { const i = Math.max(0, Math.floor(Number(raw.i))); const x = Math.floor(Number(raw.x)); const y = Math.floor(Number(raw.y)); const ww = Math.max(1, Math.floor(Number(raw.w)) || 1); const hh = Math.max(1, Math.floor(Number(raw.h)) || 1); const rawB = raw.bindCarryOption; const bn = typeof rawB === 'number' ? rawB : parseInt(String(rawB), 10); const base = { i, x, y, w: ww, h: hh }; if (Number.isFinite(bn) && bn >= 1 && bn <= QUIZ_CARRY_MAX_OPTION_SLOTS) base.bindCarryOption = bn; return base; }).filter((s) => Number.isFinite(s.i) && s.i >= 0 && s.i < libLen && Number.isFinite(s.x) && Number.isFinite(s.y) && s.w >= 1 && s.h >= 1 && s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0); placements = placements.map((s) => { let x = s.x, y = s.y, ww = s.w, hh = s.h; if (x < 0) { ww += x; x = 0; } if (y < 0) { hh += y; y = 0; } if (x + ww > w) ww = w - x; if (y + hh > h) hh = h - y; const out = { i: s.i, x, y, w: Math.max(1, ww), h: Math.max(1, hh) }; if (s.bindCarryOption != null) out.bindCarryOption = s.bindCarryOption; return out; }).filter((s) => s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0); } else { const cells = md.gridImageCells; if (Array.isArray(cells) && cells.length === h && libLen > 0) { for (let yy = 0; yy < h; yy++) { const row = cells[yy]; if (!row) continue; for (let xx = 0; xx < w; xx++) { if (row[xx] == null) continue; const iv = parseInt(row[xx], 10); if (!Number.isFinite(iv) || iv < 0 || iv >= libLen) continue; placements.push({ i: iv, x: xx, y: yy, w: 1, h: 1 }); } } } } md.gridImagePlacements = placements; const outCells = Array(h).fill(0).map(() => Array(w).fill(null)); placements.forEach((sp) => { for (let yy = sp.y; yy < sp.y + sp.h; yy++) { for (let xx = sp.x; xx < sp.x + sp.w; xx++) { if (yy >= 0 && yy < h && xx >= 0 && xx < w) outCells[yy][xx] = sp.i; } } }); md.gridImageCells = outCells; } function loadMapGridImages() { mapGridImageImgs = []; mapGridImageHeldImgs = []; if (!mapData || !Array.isArray(mapData.gridImageLibrary) || !mapData.gridImageLibrary.length) return; mapData.gridImageLibrary.forEach((entry, i) => { mapGridImageImgs[i] = null; mapGridImageHeldImgs[i] = null; const idle = gridImageLibIdleUrlPlay(entry); if (!idle) return; const im = new Image(); im.onload = () => { try { draw(); } catch (e) {} }; im.onerror = () => {}; im.src = idle; mapGridImageImgs[i] = im; const held = gridImageLibHeldUrlPlay(entry); if (held && held !== idle) { const hm = new Image(); hm.onload = () => { try { draw(); } catch (e) {} }; hm.onerror = () => {}; hm.src = held; mapGridImageHeldImgs[i] = hm; } }); } /** โซนตัวเลือกที่ทับสไปรต์มากที่สุด — quiz_carry สลับรูป idle/held · ถ้าไม่ทับเลยลองขยายขอบ 1 ช่อง (มาร์กเกอร์ชิดขอบสไปรต์) */ function dominantQuizCarryOptionInSpriteRect(md, sp) { if (!md || md.gameType !== 'quiz_carry' || !md.quizCarryOptionArea) return null; const g = md.quizCarryOptionArea; const mw = md.width || 20, mh = md.height || 15; const bx = sp.x | 0, by = sp.y | 0, bw = sp.w | 0, bh = sp.h | 0; const counts = new Map(); function addOverlapRect(x0, y0, ww, hh) { for (let yy = y0; yy < y0 + hh; yy++) { if (yy < 0 || yy >= mh) continue; const row = g[yy]; if (!row) continue; for (let xx = x0; xx < x0 + ww; xx++) { if (xx < 0 || xx >= mw) continue; const v = row[xx]; const n = typeof v === 'number' ? v : parseInt(String(v), 10); if (!Number.isFinite(n) || n < 1 || n > QUIZ_CARRY_MAX_OPTION_SLOTS) continue; const idx = n - 1; counts.set(idx, (counts.get(idx) || 0) + 1); } } } addOverlapRect(bx, by, bw, bh); if (!counts.size) { const pad = 1; const x0 = Math.max(0, bx - pad); const y0 = Math.max(0, by - pad); const x1 = Math.min(mw, bx + bw + pad); const y1 = Math.min(mh, by + bh + pad); addOverlapRect(x0, y0, x1 - x0, y1 - y0); } if (!counts.size) return null; let best = null; let bestN = -1; counts.forEach((n, k) => { if (best == null || n > bestN || (n === bestN && k < best)) { bestN = n; best = k; } }); return best; } /** 0-based choice index สำหรับสลับ idle/held — ใช้ bindCarryOption จากแมปก่อน แล้วค่อยนับทับโซน */ function quizCarryOptionIndexForGridSprite(md, sp) { if (!sp) return null; const rawB = sp.bindCarryOption; if (rawB != null && rawB !== '') { const n = typeof rawB === 'number' ? rawB : parseInt(String(rawB), 10); if (Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS) return n - 1; } return dominantQuizCarryOptionInSpriteRect(md, sp); } /** รูป held จากคลังกริดสำหรับข้อนี้ — ใช้วาดป้ายติดตัว (ไม่ผ่าน sanitize URL) */ function findQuizCarryGridHeldImgForChoiceIndex(choiceIdx) { if (choiceIdx == null || choiceIdx < 0 || !isQuizCarry() || !mapData) return null; const pl = mapData.gridImagePlacements; if (!Array.isArray(pl) || !pl.length) return null; for (let gi = 0; gi < pl.length; gi++) { const sp = pl[gi]; const opt = quizCarryOptionIndexForGridSprite(mapData, sp); if (opt !== choiceIdx) continue; const gh = mapGridImageHeldImgs[sp.i]; if (gh && gh.complete && gh.naturalWidth) return gh; } return null; } /** รูป idle จากคลังกริดสำหรับข้อนี้ — วาดบนป้ายตัวเลือกบนพื้นเมื่อไม่มีรูปจากคำถาม/ธีม */ function findQuizCarryGridIdleImgForChoiceIndex(choiceIdx) { if (choiceIdx == null || choiceIdx < 0 || !isQuizCarry() || !mapData) return null; const pl = mapData.gridImagePlacements; if (!Array.isArray(pl) || !pl.length) return null; for (let gi = 0; gi < pl.length; gi++) { const sp = pl[gi]; const opt = quizCarryOptionIndexForGridSprite(mapData, sp); if (opt !== choiceIdx) continue; const gid = mapGridImageImgs[sp.i]; if (gid && gid.complete && gid.naturalWidth) return gid; } return null; } function applyMapAndStart(gameMapData, res) { const prevWasSpaceShooter = mapData && mapData.gameType === 'space_shooter'; const prevWasBalloonBoss = mapData && mapData.gameType === 'balloon_boss'; mapData = gameMapData; if (res && res.mapId != null && String(res.mapId).trim() !== '') { playSessionMapId = String(res.mapId).trim(); } if (res && res.hostId != null) playHostId = res.hostId; if (mapData.gameType !== 'space_shooter') { spaceShooterGameEnded = false; if (prevWasSpaceShooter) { const gend = document.getElementById('gauntlet-ended-overlay'); if (gend) gend.classList.add('is-hidden'); } } if (mapData.gameType !== 'balloon_boss') { balloonBossGameEnded = false; if (prevWasBalloonBoss) { const gend = document.getElementById('gauntlet-ended-overlay'); if (gend) gend.classList.add('is-hidden'); } } 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 = []; } if (mapData.gameType === 'stack') { if (!mapData.stackReleaseArea) mapData.stackReleaseArea = []; if (!mapData.stackLandArea) mapData.stackLandArea = []; } if (mapData.gameType === 'quiz_carry') { if (!mapData.quizCarryHubArea) mapData.quizCarryHubArea = []; if (!mapData.quizCarryOptionArea) mapData.quizCarryOptionArea = []; if (!mapData.carryEmbedCountdownArea) mapData.carryEmbedCountdownArea = []; if (!mapData.quizQuestions) mapData.quizQuestions = []; if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; normalizeQuizCarryLayersInPlay(mapData); } if (mapData.gameType === 'quiz_battle') { if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = []; if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = []; normalizeQuizBattlePathInPlay(mapData); normalizeQuizBattleDomeInPlay(mapData); } tileSize = mapData.tileSize || 32; mapBackgroundImg = null; if (mapData.backgroundImage) { mapBackgroundImg = new Image(); mapBackgroundImg.src = mapData.backgroundImage; } normalizeGridImageCellsOnMap(mapData); loadMapGridImages(); var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน' : (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)' : (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : ''))))))))); 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; if (isQuizCarry()) { const hubSnap = snapPositionOutOfQuizCarryHubIfNeeded(me.x, me.y); me.x = hubSnap.x; me.y = hubSnap.y; } if (isQuizBattle()) { const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y); me.x = pathSnap.x; me.y = pathSnap.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; me.gauntletEliminated = !!(myPeer && myPeer.gauntletEliminated); } { const sh0 = Number(myPeer && myPeer.spaceShooterScore); me.spaceShooterScore = Number.isFinite(sh0) ? Math.max(0, sh0) : 0; } { const bSt = balloonBossBalloonsStartPlay(); const bs0 = Number(myPeer && myPeer.balloonBossScore); me.balloonBossScore = Number.isFinite(bs0) ? Math.max(0, bs0) : 0; const bb0 = Number(myPeer && myPeer.balloonBossBalloons); me.balloonBossBalloons = Number.isFinite(bb0) ? Math.max(0, Math.floor(bb0)) : bSt; me.balloonBossEliminated = !!(myPeer && myPeer.balloonBossEliminated); } gauntletObstacles = []; gauntletObsRenderPrev = []; gauntletObsRenderNext = []; gauntletObsBlendT0 = 0; me.playTint = playTintFromPeerId(String((myId != null && myId !== '') ? myId : (nick || 'local'))); others.clear(); plist.forEach(p => { if (p.id !== myId) { const pos = peerXYFromJoin(p, mapData.spawn); let px = pos.x, py = pos.y; if (isQuizCarry()) { const hubSnap = snapPositionOutOfQuizCarryHubIfNeeded(px, py); px = hubSnap.x; py = hubSnap.y; } if (isQuizBattle()) { const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(px, py); px = pathSnap.x; py = pathSnap.y; } others.set(p.id, { x: px, y: py, tx: px, ty: py, 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; })(), gauntletEliminated: !!p.gauntletEliminated, jumpSurviveEliminated: false, spaceShooterScore: (() => { const s = Number(p.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), balloonBossScore: (() => { const s = Number(p.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), balloonBossBalloons: (() => { const s = Number(p.balloonBossBalloons); return Number.isFinite(s) ? Math.max(0, Math.floor(s)) : balloonBossBalloonsStartPlay(); })(), balloonBossEliminated: !!p.balloonBossEliminated, quizCarryHeld: null, }); } }); 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(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) .then((r) => (r.ok ? r.json() : null)) .then((t) => { if (t) applyGauntletTimingFromServer(t); if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz; }) .catch(() => {}); } else { me.tx = null; me.ty = null; gauntletEndsAtMs = null; } rebalancePreviewBots(); if (mapData.gameType === 'space_shooter') { applySpaceShooterSpawnLayoutPlay(); } if (mapData.gameType === 'balloon_boss') { applyBalloonBossSpawnLayoutPlay(); } if (isStack()) { lastStackTickMs = performance.now(); resetStackMinigameState(); fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) .then((r) => (r.ok ? r.json() : null)) .then((t) => { if (t) applyGauntletTimingFromServer(t); else reapplyStackMiniSizingFromGlobals(); if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz; }) .catch(() => { if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz; }); } else { stackMini = null; } playPath = []; me.isWalking = false; if (mapData.gameType === 'jump_survive') { if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = []; if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = []; normalizeJumpSurvivePlatformAreaInPlay(mapData); jumpSurviveEliminated = false; jumpSurviveGameEnded = false; if (jumperMissionCountdownTimer) { clearTimeout(jumperMissionCountdownTimer); jumperMissionCountdownTimer = null; } if (isJumpSurviveMissionUiMapPlay()) { jumpSurviveMissionPhase = 'howto'; hideConflictingOverlaysForJumpSurviveMission(); applyJumpSurviveJumperPanelImages(); fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (t) { if (t) applyGauntletTimingFromServer(t); }) .catch(function () {}); setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 50); } else { jumpSurviveMissionPhase = null; jumpSurviveInitRuntime(); } } if (mapData.gameType === 'space_shooter') { spaceShooterGameEnded = false; normalizeShooterSpawnSlotsInPlay(mapData); spaceShooterBullets = []; spaceShooterAsteroids = []; spaceShooterPopups = []; spaceShooterLastTickMs = performance.now(); spaceShooterSpawnAccMs = 0; spaceShooterFireCd = 0; spaceShooterSessionStartMs = performance.now(); spaceShooterLastMoveEmit = 0; } if (mapData.gameType === 'balloon_boss') { balloonBossGameEnded = false; normalizeBalloonBossPlayerSlotsInPlay(mapData); balloonBossPendingShots = []; balloonBossPlayerBullets = []; balloonBossBossBullets = []; balloonBossHitFx = []; balloonBossLastTickMs = performance.now(); balloonBossPlayerFireCd = 0; balloonBossSessionStartMs = performance.now(); balloonBossLastMoveEmit = 0; balloonBossBossFireAcc = 0; } if (isQuiz()) setupPlayQuizUi(); else if (isQuizCarry()) setupPlayQuizCarryUi(); else if (isQuizBattle()) setupPlayQuizBattleUi(); else teardownPlayQuizUi(); if (mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay()) { hideConflictingOverlaysForGauntletCrown(); gauntletCrownPregamePhase = null; gauntletCrownLobbyReadyMap = {}; if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } const gccR = document.getElementById('gauntlet-crown-countdown'); if (gccR) gccR.classList.add('is-hidden'); setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 50); } else if (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay()) { if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } const gccRj = document.getElementById('gauntlet-crown-countdown'); if (gccRj) gccRj.classList.add('is-hidden'); gauntletCrownPregamePhase = null; gauntletCrownLobbyReadyMap = {}; } else { const hov = document.getElementById('gauntlet-crown-howto-overlay'); if (hov) { hov.classList.add('is-hidden'); gauntletCrownHowtoVisible = false; } gauntletCrownPregamePhase = null; gauntletCrownLobbyReadyMap = {}; if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } const gccR2 = document.getElementById('gauntlet-crown-countdown'); if (gccR2) gccR2.classList.add('is-hidden'); } resizeCanvas(); draw(); tick(); } socket.on('connect', () => { firstCharacterDefaultPromise.then(() => { 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); const detectiveReturn = params.get('detectiveReturn') === '1' || (function () { try { return sessionStorage.getItem('detectiveMinigameReturn') === '1'; } catch (e) { return false; } })(); if (caseLocked || detectiveReturn) { window.location.replace(buildRoomLobbyReturnHref()); } else { window.location.replace(BASE + '/lobby.html'); } return; } myId = socket.id; let botSlots = parseInt(res.botSlotCount, 10); if ((!botSlots || botSlots < 1) && res.maxPlayers > 0 && res.maxPlayers < 6) { botSlots = 6 - res.maxPlayers; } if (botSlots > 0) { detectiveCaseFillBots = true; detectiveBotSlotCount = Math.min(6, Math.max(0, botSlots)); } if (isDetectiveMinigamePlay()) { try { document.documentElement.classList.add('play-detective-minigame'); } catch (eD) { /* ignore */ } } if (res.quizCarrySettingsSnap && typeof res.quizCarrySettingsSnap === 'object') { quizCarryJoinSettingsSnap = res.quizCarrySettingsSnap; if (res.quizCarrySettingsSnap.carryMapPanelTheme && typeof res.quizCarrySettingsSnap.carryMapPanelTheme === 'object') { setQuizCarryMapPanelThemeFromApi({ carryMapPanelTheme: res.quizCarrySettingsSnap.carryMapPanelTheme }); } if (res.quizCarrySettingsSnap.carryEmbedCountdownTheme && typeof res.quizCarrySettingsSnap.carryEmbedCountdownTheme === 'object') { setQuizCarryEmbedCountdownThemeFromApi({ carryEmbedCountdownTheme: res.quizCarrySettingsSnap.carryEmbedCountdownTheme }); } if (Array.isArray(res.quizCarrySettingsSnap.carryChoicePlaqueThemes) && res.quizCarrySettingsSnap.carryChoicePlaqueThemes.length) { setQuizCarryChoicePlaqueThemeFromApi({ carryChoicePlaqueThemes: res.quizCarrySettingsSnap.carryChoicePlaqueThemes }); } else if (res.quizCarrySettingsSnap.carryChoicePlaqueTheme && typeof res.quizCarrySettingsSnap.carryChoicePlaqueTheme === 'object') { setQuizCarryChoicePlaqueThemeFromApi({ carryChoicePlaqueTheme: res.quizCarrySettingsSnap.carryChoicePlaqueTheme }); } const joinSc = Number(res.quizCarrySettingsSnap.carryChoicePlaqueMapScale); if (Number.isFinite(joinSc)) { quizCarryPlaqueMapScale = Math.max(0.85, Math.min(2.5, joinSc)); } } if (previewMode && editorEmbedReturn && res.mapData && res.mapData.gameType === 'lobby') { const q = []; q.push('space=' + encodeURIComponent(spaceId)); q.push('nick=' + encodeURIComponent(nick)); const mid = params.get('map'); if (mid) q.push('map=' + encodeURIComponent(mid)); q.push('preview=1'); q.push('editorEmbed=1'); if (params.get('defaultChar') != null) q.push('defaultChar=' + encodeURIComponent(params.get('defaultChar'))); window.location.replace(BASE + '/room-lobby.html?' + q.join('&')); return; } const playMapId = params.get('map'); if (playMapId) { fetch(BASE + '/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; })(), jumpSurviveEliminated: false, spaceShooterScore: (() => { const s = Number(data.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), balloonBossScore: (() => { const s = Number(data.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), balloonBossBalloons: (() => { const s = Number(data.balloonBossBalloons); return Number.isFinite(s) ? Math.max(0, Math.floor(s)) : balloonBossBalloonsStartPlay(); })(), balloonBossEliminated: !!data.balloonBossEliminated, quizCarryHeld: null, }); if (previewFillBots) rebalancePreviewBots(); if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud(); }); socket.on('host-changed', (d) => { if (d && d.hostId != null) playHostId = d.hostId; if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud(); }); socket.on('quiz-carry-lobby-sync', (d) => { if (!d || typeof d.readyMap !== 'object') return; quizCarryLobbyReadyMap = { ...d.readyMap }; if (quizCarryPregameActive && isQuizCarry()) { quizCarrySyncGuestReadyIfNeeded(); updateQuizCarryPregameHud(); } }); socket.on('quiz-carry-lobby-started', () => { if (!(previewMode && editorEmbedReturn && isQuizCarry())) return; endQuizCarryEmbedPregameAndStart(); }); socket.on('gauntlet-crown-lobby-sync', (d) => { if (!d || typeof d.readyMap !== 'object') return; gauntletCrownLobbyReadyMap = { ...d.readyMap }; if (gauntletCrownPregamePhase === 'howto') { gauntletCrownSyncGuestReadyIfNeeded(); updateGauntletCrownHowtoHud(); } }); socket.on('gauntlet-crown-lobby-started', () => { if (!isGauntletCrownHeistMapPlay()) return; beginGauntletCrownCountdownThenRun(); }); 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 (mapData && mapData.gameType === 'stack') { return; } if (mapData && mapData.gameType === 'jump_survive') { return; } if (mapData && mapData.gameType === 'space_shooter') { if (data.spaceShooterScore != null) { const s = Number(data.spaceShooterScore); if (Number.isFinite(s)) me.spaceShooterScore = Math.max(0, s); } return; } if (mapData && mapData.gameType === 'balloon_boss') { if (data.balloonBossScore != null) { const s = Number(data.balloonBossScore); if (Number.isFinite(s)) me.balloonBossScore = Math.max(0, s); } if (data.balloonBossBalloons != null) { const b = Number(data.balloonBossBalloons); if (Number.isFinite(b)) me.balloonBossBalloons = Math.max(0, Math.floor(b)); } if (data.balloonBossEliminated != null) me.balloonBossEliminated = !!data.balloonBossEliminated; return; } if (mapData && mapData.gameType === 'quiz_battle') { 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; } const s = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y); me.x = s.x; me.y = s.y; if (data.direction) me.direction = data.direction; if (data.characterId != null) me.characterId = data.characterId; playPath = []; 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) { if (mapData && mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData) && !isPreviewBotId(data.id)) { const px = Number(data.x); const py = Number(data.y); if (Number.isFinite(px) && Number.isFinite(py)) { const sn = snapPositionOntoQuizBattlePathIfNeeded(px, py); o.tx = sn.x; o.ty = sn.y; o.x = sn.x; o.y = sn.y; } else { o.tx = data.x; o.ty = data.y; } } else { o.tx = data.x; o.ty = data.y; } o.direction = data.direction || o.direction; if (data.characterId != null) o.characterId = data.characterId; if (mapData && mapData.gameType === 'space_shooter' && data.spaceShooterScore != null) { const s = Number(data.spaceShooterScore); if (Number.isFinite(s)) o.spaceShooterScore = Math.max(0, s); } if (mapData && mapData.gameType === 'balloon_boss') { if (data.balloonBossScore != null) { const s = Number(data.balloonBossScore); if (Number.isFinite(s)) o.balloonBossScore = Math.max(0, s); } if (data.balloonBossBalloons != null) { const b = Number(data.balloonBossBalloons); if (Number.isFinite(b)) o.balloonBossBalloons = Math.max(0, Math.floor(b)); } if (data.balloonBossEliminated != null) o.balloonBossEliminated = !!data.balloonBossEliminated; } } }); 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; meGauntletJumpVis = meGauntletJumpTicks; const sc = Number(p.gauntletScore); me.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0; me.gauntletEliminated = !!p.gauntletEliminated; } 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; o.gauntletEliminated = !!p.gauntletEliminated; } } }); if (previewFillBots) { applyGauntletPreviewBotsAfterSync(); emitGauntletPreviewRowsToServer(); } }); socket.on('gauntlet-ended', (data) => { cancelQuizCarryResultEndAfterTimeup(); gauntletEndsAtMs = null; gauntletCrownRunHeldRemote = false; gauntletCrownPregamePhase = null; gauntletCrownLobbyReadyMap = {}; if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } const gcc = document.getElementById('gauntlet-crown-countdown'); if (gcc) gcc.classList.add('is-hidden'); const hto = document.getElementById('gauntlet-crown-howto-overlay'); if (hto) hto.classList.add('is-hidden'); gauntletCrownHowtoVisible = false; if (data && data.crownMission) { showGauntletCrownMissionOverlay(data.crownMission); return; } 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; 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 = buildRoomLobbyReturnHref(); } 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 (ev.mapId != null && String(ev.mapId).trim() !== '') { playSessionMapId = String(ev.mapId).trim(); } if (mapData.gameType !== 'space_shooter') { spaceShooterGameEnded = false; } if (mapData.gameType !== 'balloon_boss') { balloonBossGameEnded = false; } 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; } normalizeGridImageCellsOnMap(mapData); loadMapGridImages(); 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; me.gauntletEliminated = !!p.gauntletEliminated; } 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; o.gauntletEliminated = !!p.gauntletEliminated; } } }); } else { meGauntletJumpTicks = 0; meGauntletJumpVis = 0; me.gauntletScore = 0; me.gauntletEliminated = false; others.forEach((o) => { o.gauntletJumpTicks = 0; o.gauntletJumpVis = 0; o.gauntletScore = 0; o.gauntletEliminated = false; }); } if (mapData.gameType === 'gauntlet') { if (ev.gauntletEndsAt != null) { const ge = Number(ev.gauntletEndsAt); gauntletEndsAtMs = Number.isFinite(ge) ? ge : null; } else { gauntletEndsAtMs = null; } fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) .then((r) => (r.ok ? r.json() : null)) .then((t) => { if (t) applyGauntletTimingFromServer(t); if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz; }) .catch(() => {}); if (String(ev.mapId || '') === GAUNTLET_FACE_RIGHT_MAP_ID) { gauntletCrownPregamePhase = null; gauntletCrownLobbyReadyMap = {}; if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } const gcc0 = document.getElementById('gauntlet-crown-countdown'); if (gcc0) gcc0.classList.add('is-hidden'); setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 60); } else { const hto2 = document.getElementById('gauntlet-crown-howto-overlay'); if (hto2) hto2.classList.add('is-hidden'); gauntletCrownHowtoVisible = false; gauntletCrownPregamePhase = null; gauntletCrownLobbyReadyMap = {}; if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } const gcc1 = document.getElementById('gauntlet-crown-countdown'); if (gcc1) gcc1.classList.add('is-hidden'); } } else { gauntletEndsAtMs = null; const skipHideCrownShell = mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay(); if (!skipHideCrownShell) { const hto3 = document.getElementById('gauntlet-crown-howto-overlay'); if (hto3) hto3.classList.add('is-hidden'); gauntletCrownHowtoVisible = false; } gauntletCrownPregamePhase = null; gauntletCrownLobbyReadyMap = {}; if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } const gcc2 = document.getElementById('gauntlet-crown-countdown'); if (gcc2) gcc2.classList.add('is-hidden'); } if (mapData.gameType !== 'gauntlet') { me.tx = null; me.ty = null; } const gauntletOv = document.getElementById('gauntlet-ended-overlay'); if (gauntletOv) gauntletOv.classList.add('is-hidden'); const gcmOv = document.getElementById('gauntlet-crown-mission-overlay'); if (gcmOv) gcmOv.classList.add('is-hidden'); if (mapData.gameType === 'stack') { if (!mapData.stackReleaseArea) mapData.stackReleaseArea = []; if (!mapData.stackLandArea) mapData.stackLandArea = []; rebalancePreviewBots(); lastStackTickMs = performance.now(); resetStackMinigameState(); fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) .then((r) => (r.ok ? r.json() : null)) .then((t) => { if (t) applyGauntletTimingFromServer(t); else reapplyStackMiniSizingFromGlobals(); if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz; }) .catch(() => { if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz; }); } else { stackMini = null; } if (mapData.gameType === 'jump_survive') { if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = []; if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = []; normalizeJumpSurvivePlatformAreaInPlay(mapData); jumpSurviveEliminated = false; jumpSurviveGameEnded = false; if (jumperMissionCountdownTimer) { clearTimeout(jumperMissionCountdownTimer); jumperMissionCountdownTimer = null; } if (isJumpSurviveMissionUiMapPlay()) { jumpSurviveMissionPhase = 'howto'; hideConflictingOverlaysForJumpSurviveMission(); applyJumpSurviveJumperPanelImages(); fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) .then((r) => (r.ok ? r.json() : null)) .then((t) => { if (t) applyGauntletTimingFromServer(t); }) .catch(() => {}); setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 60); } else { jumpSurviveMissionPhase = null; jumpSurviveInitRuntime(); } } if (mapData.gameType === 'space_shooter') { spaceShooterGameEnded = false; normalizeShooterSpawnSlotsInPlay(mapData); spaceShooterBullets = []; spaceShooterAsteroids = []; spaceShooterPopups = []; spaceShooterLastTickMs = performance.now(); spaceShooterSpawnAccMs = 0; spaceShooterFireCd = 0; spaceShooterSessionStartMs = performance.now(); spaceShooterLastMoveEmit = 0; } if (mapData.gameType === 'balloon_boss') { balloonBossGameEnded = false; normalizeBalloonBossPlayerSlotsInPlay(mapData); balloonBossPendingShots = []; balloonBossPlayerBullets = []; balloonBossBossBullets = []; balloonBossHitFx = []; balloonBossLastTickMs = performance.now(); balloonBossPlayerFireCd = 0; balloonBossSessionStartMs = performance.now(); balloonBossLastMoveEmit = 0; balloonBossBossFireAcc = 0; me.balloonBossScore = 0; me.balloonBossBalloons = balloonBossBalloonsStartPlay(); me.balloonBossEliminated = false; others.forEach((o) => { o.balloonBossScore = 0; o.balloonBossBalloons = balloonBossBalloonsStartPlay(); o.balloonBossEliminated = false; }); } if (mapData.gameType === 'quiz_battle') { if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = []; if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = []; normalizeQuizBattlePathInPlay(mapData); normalizeQuizBattleDomeInPlay(mapData); if (quizBattlePathModeActive(mapData)) { const ms = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y); me.x = ms.x; me.y = ms.y; others.forEach((o) => { const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y); o.x = s.x; o.y = s.y; }); } } var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน' : (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)' : (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : ''))))))))); var prevTag = previewMode ? '[ทดสอบ] ' : ''; document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel; if (isQuiz()) setupPlayQuizUi(); else if (isQuizCarry()) setupPlayQuizCarryUi(); else if (isQuizBattle()) setupPlayQuizBattleUi(); else teardownPlayQuizUi(); resizeCanvas(); if (mapData.gameType === 'balloon_boss') { applyBalloonBossSpawnLayoutPlay(); } if (previewFillBots && mapData.gameType === 'gauntlet') { applyGauntletPreviewSpawnLayout(true); emitGauntletPreviewRowsToServer(); } if (previewFillBots && mapData.gameType === 'jump_survive') { applyJumpSurvivePreviewSpawnLayout(true); } if (previewFillBots && mapData.gameType === 'space_shooter') { applySpaceShooterSpawnLayoutPlay(); } if (previewFillBots && mapData.gameType === 'balloon_boss') { applyBalloonBossSpawnLayoutPlay(); } }; fetch(BASE + '/api/maps/' + encodeURIComponent(ev.mapId)) .then((r) => (r.ok ? r.json() : null)) .then((md) => { if (md) applySnap(md); }); }); function resizeCanvas() { const vw = window.innerWidth || document.documentElement.clientWidth || 800; const vh = window.innerHeight || document.documentElement.clientHeight || 600; const header = document.querySelector('.game-header'); const headerH = header && header.offsetHeight ? header.offsetHeight : 48; const parent = canvas && canvas.parentElement; let cw = canvas.clientWidth || 0; let ch = canvas.clientHeight || 0; if (parent && parent.clientWidth > 80) cw = Math.max(cw, parent.clientWidth); if (parent && parent.clientHeight > 80) ch = Math.max(ch, parent.clientHeight); if (ch < 120) ch = Math.max(240, vh - headerH - 6); if (cw < 120) cw = Math.max(320, vw); canvas.width = Math.max(320, cw); canvas.height = Math.max(240, ch); syncQuizCarryEmbedCountdownLayout(); } // ให้ 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('click', (e) => { if (!mapData || !isStack() || isChatFocused()) return; tryHumanStackDrop(); }); canvas.addEventListener('dblclick', (e) => { if (!mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss() || isChatFocused() || isQuizCarryEmbedCountdownBlockingMovement()) 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 getCharacterFootprintWH(md) { if (!md) return { cw: 1, ch: 1 }; let cw = Math.floor(Number(md.characterCellsW)); let ch = Math.floor(Number(md.characterCellsH)); const hasW = Number.isFinite(cw) && cw >= 1; const hasH = Number.isFinite(ch) && ch >= 1; if (hasW && hasH) { return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) }; } const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1)); return { cw: leg, ch: leg }; } /** * ขนาดช่องที่ใช้ชนกำแพง (objects=1) เท่านั้น — ชิดเท้า (ล่าง) + กลางแนวนอน · เล็กกว่า footprint = ส่วนบนไม่ติดกำแพง */ function getCharacterCollisionFootprintWH(md) { const { cw, ch } = getCharacterFootprintWH(md); let colW = Math.floor(Number(md.characterCollisionW)); let colH = Math.floor(Number(md.characterCollisionH)); if (!Number.isFinite(colW) || colW < 1) colW = cw; if (!Number.isFinite(colH) || colH < 1) colH = ch; colW = Math.max(1, Math.min(cw, colW)); colH = Math.max(1, Math.min(ch, colH)); return { cw, ch, colW, colH }; } /** กันตัวละคร footprint ล้นออกนอกแมป — เดิมใช้ w-0.01 ทำให้ cw/ch>1 ชนขอบแล้วตรรกะเดินค้าง */ function clampPlayEntityFootprintToMap(oEnt, md) { if (!oEnt || !md) return; const w = md.width || 20, h = md.height || 15; const { cw, ch } = getCharacterFootprintWH(md); const maxX = Math.max(0, w - cw + 0.999); const maxY = Math.max(0, h - ch + 0.999); if (!Number.isFinite(oEnt.x)) oEnt.x = 0.5; if (!Number.isFinite(oEnt.y)) oEnt.y = 0.5; oEnt.x = Math.max(0, Math.min(maxX, oEnt.x)); oEnt.y = Math.max(0, Math.min(maxY, oEnt.y)); } /** แยกบอท preview ที่ทับกันบนจุดเดียว (มักชนกันที่ขอบแมป) */ function separateClumpedPreviewBots() { if (!previewFillBots || !mapData) return; const ids = [...others.keys()].filter(isPreviewBotId); const minD = 0.52; const pushAmt = MOVE_SPEED * 0.5; for (let i = 0; i < ids.length; i++) { for (let j = i + 1; j < ids.length; j++) { const a = others.get(ids[i]); const b = others.get(ids[j]); if (!a || !b) continue; let ddx = b.x - a.x, ddy = b.y - a.y; let d = Math.sqrt(ddx * ddx + ddy * ddy); if (d >= minD) continue; if (d < 1e-5) { ddx = (i + j * 2) % 2 === 0 ? 1 : -1; ddy = ((i + j) % 3) - 1; d = Math.sqrt(ddx * ddx + ddy * ddy) || 1; } const ux = -ddx / d, uy = -ddy / d; const vx = ddx / d, vy = ddy / d; const ax2 = a.x + ux * pushAmt, ay2 = a.y + uy * pushAmt; const bx2 = b.x + vx * pushAmt, by2 = b.y + vy * pushAmt; if (canWalkLikeLobbyForBot(ax2, ay2, a.x, a.y, a)) { a.x = ax2; a.y = ay2; } if (canWalkLikeLobbyForBot(bx2, by2, b.x, b.y, b)) { b.x = bx2; b.y = by2; } clampPlayEntityFootprintToMap(a, mapData); clampPlayEntityFootprintToMap(b, mapData); } } } function quizTilesFootprintPlay(px, py) { const s = new Set(); if (!mapData) return s; if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s; const { cw, ch } = getCharacterFootprintWH(mapData); 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 + cw - 1); const maxTy = Math.min(h - 1, minTy + ch - 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; } /** ร่าง (cw×ch) ของผู้เล่นที่ (px,py) ครอบคลุมช่อง (tx,ty) หรือไม่ — ใช้กับ blockPlayer แทนการเช็คแค่มุมซ้ายบน */ function playEntityFootprintContainsTile(px, py, tx, ty) { return quizTilesFootprintPlay(px, py).has(tx + ',' + ty); } /** * กล่องตรวจโซนห้ามซ้อน (blockPlayer) — วางเหมือนชนกำแพง: กลางแนวนอน + ชิดเท้า * blockPlayerSeparationW/H: 0 = ใช้เต็มความกว้าง/สูงตัว · ค่าอื่น = จำกัด 1..cw / 1..ch * รองรับ legacy blockPlayerSeparation เมื่อยังไม่มี W/H ใน JSON */ function resolveBlockPlayerNoOverlapBoxWH(md) { const m = md || mapData; if (!m) return { cw: 1, ch: 1, boxW: 1, boxH: 1 }; const { cw, ch } = getCharacterFootprintWH(m); let w = m.blockPlayerSeparationW != null && m.blockPlayerSeparationW !== '' ? Math.floor(Number(m.blockPlayerSeparationW)) : NaN; let h = m.blockPlayerSeparationH != null && m.blockPlayerSeparationH !== '' ? Math.floor(Number(m.blockPlayerSeparationH)) : NaN; const leg = m.blockPlayerSeparation != null && m.blockPlayerSeparation !== '' ? Math.floor(Number(m.blockPlayerSeparation)) : NaN; const hasW = Number.isFinite(w) && w >= 0; const hasH = Number.isFinite(h) && h >= 0; if (!hasW && !hasH && Number.isFinite(leg) && leg >= 1) { w = Math.min(leg, cw); h = Math.min(leg, ch); } else { if (!hasW) w = 0; if (!hasH) h = 0; } const boxW = !w || w <= 0 ? cw : Math.max(1, Math.min(cw, w)); const boxH = !h || h <= 0 ? ch : Math.max(1, Math.min(ch, h)); return { cw, ch, boxW, boxH }; } /** ช่องกริดที่ถือว่า “ผู้เล่นที่ (px,py) กินพื้นที่” สำหรับตรวจโซน blockPlayer — กล่องเดียวกับแนวชนกำแพง */ function quizTilesBlockPlayerPeerFootprintPlay(px, py) { const s = new Set(); if (!mapData) return s; if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s; const { cw, ch, boxW, boxH } = resolveBlockPlayerNoOverlapBoxWH(mapData); const w = mapData.width || 20, h = mapData.height || 15; const minTx = Math.floor(px); const minTy = Math.floor(py); const offX = minTx + Math.floor((cw - boxW) / 2); const offY = minTy + (ch - boxH); for (let ty = offY; ty < offY + boxH; ty++) { for (let tx = offX; tx < offX + boxW; tx++) { if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty); } } return s; } function playPeerBlockPlayerOccupiesTile(px, py, tx, ty) { return quizTilesBlockPlayerPeerFootprintPlay(px, py).has(tx + ',' + ty); } /** Footprint ชนกำแพง (objects=1) เท่านั้น — hub / interactive / blockPlayer / quiz ใช้ quizTilesFootprintPlay = เต็ม characterCells */ function quizTilesWallCollisionFootprintPlay(px, py) { const s = new Set(); if (!mapData) return s; if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s; const { cw, ch, colW, colH } = getCharacterCollisionFootprintWH(mapData); const w = mapData.width || 20, h = mapData.height || 15; const minTx = Math.floor(px); const minTy = Math.floor(py); const offX = minTx + Math.floor((cw - colW) / 2); const offY = minTy + (ch - colH); for (let ty = offY; ty < offY + colH; ty++) { for (let tx = offX; tx < offX + colW; tx++) { if (tx >= 0 && ty >= 0 && tx < w && ty < h) 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; if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false; const w = mapData.width || 20, h = mapData.height || 15; const wallTiles = quizTilesWallCollisionFootprintPlay(x, y); for (const k of wallTiles) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; const row = mapData.objects[ty]; if (!row || row[tx] === 1) return false; } if (wallTiles.size === 0) return false; const bp = mapData.blockPlayer; if (bp) { for (const k of quizTilesFootprintPlay(x, y)) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (!bp[ty] || bp[ty][tx] !== 1) continue; for (const [, o] of others) { if (playPeerBlockPlayerOccupiesTile(o.x, o.y, tx, 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; } } if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false; if (isQuizBattle() && quizBattlePathModeActive(mapData)) { if (!quizBattleFootprintFullyOnPath(mapData, x, y)) return false; } return true; } function canWalkLikeLobbyForBot(x, y, fromX, fromY, o) { if (!mapData || !mapData.objects) return false; if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false; const w = mapData.width || 20, h = mapData.height || 15; const wallTilesB = quizTilesWallCollisionFootprintPlay(x, y); for (const k of wallTilesB) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; const row = mapData.objects[ty]; if (!row || row[tx] === 1) return false; } if (wallTilesB.size === 0) return false; const bp = mapData.blockPlayer; if (bp) { for (const k of quizTilesFootprintPlay(x, y)) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (!bp[ty] || bp[ty][tx] !== 1) continue; for (const [, peer] of others) { if (o && peer === o) continue; if (playPeerBlockPlayerOccupiesTile(peer.x, peer.y, tx, ty)) return false; } if (o && playPeerBlockPlayerOccupiesTile(me.x, me.y, tx, 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; } } } if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false; if (isQuizBattle() && quizBattlePathModeActive(mapData)) { if (!quizBattleFootprintFullyOnPath(mapData, x, y)) return false; } return true; } /** ถ้าสปอว์น/โหลดมาทับโซน hub ให้หาจุดใกล้ ๆ ที่ footprint ไม่ทับ hub */ function snapPositionOutOfQuizCarryHubIfNeeded(x, y) { if (!mapData || !isQuizCarry()) return { x, y }; if (!quizCarryFootprintOverlapsHub(x, y)) return { x, y }; const w = mapData.width || 20, h = mapData.height || 15; const maxR = Math.max(w, h) + 6; for (let r = 1; r <= maxR; r++) { for (let dy = -r; dy <= r; dy++) { for (let dx = -r; dx <= r; dx++) { if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue; const nx = x + dx * 0.45; const ny = y + dy * 0.45; if (nx < 0 || ny < 0 || nx > w - 0.02 || ny > h - 0.02) continue; if (quizCarryFootprintOverlapsHub(nx, ny)) continue; if (!canWalkLikeLobby(nx, ny, NaN, NaN)) continue; return { x: nx, y: ny }; } } } return { x, y }; } 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(); } clampPlayEntityFootprintToMap(o, mapData); return; } const len = dist || 1; const pathStepMul = (previewFillBots && editorEmbedReturn) ? 0.56 : 1; const step = Math.min(MOVE_SPEED * 1.28 * pathStepMul, 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; const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData); if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) { o.x = nx; o.y = ny; } else if (!pathStrict) { 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 { /* เส้นตรงไป waypoint อาจตัดมุม hub / block — ลองก้าวแนวแกนตามทิศหลักอย่างใดอย่างหนึ่ง */ const cstep = Math.min(MOVE_SPEED * 1.2 * pathStepMul, Math.max(Math.abs(dx), Math.abs(dy), 1e-4)); const sx = dx > 1e-4 ? 1 : dx < -1e-4 ? -1 : 0; const sy = dy > 1e-4 ? 1 : dy < -1e-4 ? -1 : 0; const tryAxisX = () => { if (sx === 0) return false; if (!canWalkLikeLobbyForBot(o.x + sx * cstep, o.y, o.x, o.y, o)) return false; o.x += sx * cstep; return true; }; const tryAxisY = () => { if (sy === 0) return false; if (!canWalkLikeLobbyForBot(o.x, o.y + sy * cstep, o.x, o.y, o)) return false; o.y += sy * cstep; return true; }; if (Math.abs(dx) >= Math.abs(dy)) { if (!tryAxisX()) tryAxisY(); } else { if (!tryAxisY()) tryAxisX(); } } } clampPlayEntityFootprintToMap(o, mapData); if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) { o.botIsWalking = true; o.botPathStuckTicks = 0; } else { o.botPathStuckTicks = (o.botPathStuckTicks | 0) + 1; if ((o.botPathStuckTicks | 0) >= 20) { o.botPathStuckTicks = 0; if (path.length) path.shift(); if (isQuizCarry() && previewFillBots) o.botQuizCarryPathfindAfter = 0; } } } function pickRandomPreviewBotWanderDir() { const dirs = [[0, -1], [0, 1], [-1, 0], [1, 0]]; return dirs[Math.floor(Math.random() * dirs.length)]; } function stepPreviewBots() { if (!playBotsEnabled() || !mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss()) return; if (isQuizCarry()) { stepQuizCarryPreviewBots(); 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; const pathStrictW = isQuizBattle() && quizBattlePathModeActive(mapData); if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) { o.x = nx; o.y = ny; } else if (!pathStrictW) { 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); } } else { const d = pickRandomPreviewBotWanderDir(); o.botWanderDx = d[0]; o.botWanderDy = d[1]; o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600); } if (!Number.isFinite(o.x)) o.x = 0.5; if (!Number.isFinite(o.y)) o.y = 0.5; clampPlayEntityFootprintToMap(o, mapData); if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true; }); separateClumpedPreviewBots(); [...others.keys()].filter(isPreviewBotId).forEach((bid) => { const ob = others.get(bid); if (ob) clampPlayEntityFootprintToMap(ob, mapData); }); if (isQuizBattle() && quizBattlePathModeActive(mapData)) { others.forEach((o, id) => { if (!isPreviewBotId(id)) return; const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y); o.x = s.x; o.y = s.y; o.tx = s.x; o.ty = s.y; }); } } /** 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); } if (botRec && botRec.img.complete && botRec.img.naturalWidth > 0) { botH = Math.min(rh * 0.4, rw * botRec.img.naturalHeight / botRec.img.naturalWidth); } /* เส้นกลาง tile ทั้งความสูงคอลัมน์ แล้วค่อยวาดหัว/ท้ายทับ — ให้ลำแสงต่อเนื่องแบบสินทรัพย์รวม (รูปอ้างอิง) */ if (lineRec && lineRec.img.complete && lineRec.img.naturalWidth > 0 && rh > 1) { const iw = lineRec.img.naturalWidth; const ih = lineRec.img.naturalHeight; const scale = rw / iw; const step = Math.max(1, ih * scale); let y = ry; while (y < ry + rh) { const piece = Math.min(step, ry + rh - y); const srcH = piece / scale; try { ctx.drawImage(lineRec.img, 0, 0, iw, srcH, rx, y, rw, piece); } catch (e) { /* ignore */ } y += piece; } } if (topH > 0 && topRec) { try { ctx.drawImage(topRec.img, rx, ry, rw, topH); } catch (e) { /* ignore */ } } if (botH > 0 && botRec) { try { ctx.drawImage(botRec.img, rx, ry + rh - botH, rw, botH); } catch (e) { /* ignore */ } } 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(); } /** Stack preview: HUD แนว cyberdeck — SCORE ซ้าย, ตาปัจจุบันขวา, เทอร์มินัลล่างซ้าย */ function drawStackPreviewCyberHud(ctx, cw, ch) { if (!playBotsEnabled() || !stackMini || !mapData || !isStack()) return; const now = Date.now(); const pad = 12; const currentSeat = getStackPreviewCurrentSeat(); const order = stackPreviewTurnOrder; const mono = '11px ui-monospace, "Cascadia Mono", Consolas, monospace'; const monoSm = '9px ui-monospace, Consolas, monospace'; const lives = stackMini.lives != null ? stackMini.lives : 3; const lifeSlots = 3; const teamScore = stackMini.score || 0; const combo = stackMini.combo || 0; function roundRectPath(x, y, w, h, r) { const rr = Math.min(r, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(x + rr, y); ctx.arcTo(x + w, y, x + w, y + h, rr); ctx.arcTo(x + w, y + h, x, y + h, rr); ctx.arcTo(x, y + h, x, y, rr); ctx.arcTo(x, y, x + w, y, rr); ctx.closePath(); } function drawHudAvatar(cx, baselineY, maxSize, characterId, playTint) { const timeMs = now; const dir = 'down'; const rawImg = getAvatarImg(characterId, dir, timeMs, false); const charImg = playTint ? getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, false, playTint) : rawImg; let iw = 0; let 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; } const ax = cx - maxSize / 2; const ay = baselineY - maxSize; ctx.save(); roundRectPath(ax, ay, maxSize, maxSize, 5); ctx.clip(); if (iw > 0 && ih > 0) { const scale = Math.min(maxSize / iw, maxSize / ih, 1); const dw = iw * scale; const dh = ih * scale; ctx.drawImage(charImg, 0, 0, iw, ih, cx - dw / 2, baselineY - dh, dw, dh); } else { ctx.fillStyle = 'rgba(80, 200, 255, 0.35)'; ctx.fillRect(ax, ay, maxSize, maxSize); } ctx.restore(); ctx.strokeStyle = 'rgba(0, 234, 255, 0.5)'; ctx.lineWidth = 1.5; roundRectPath(ax, ay, maxSize, maxSize, 5); ctx.stroke(); } function resolveEntryVisual(entry) { if (entry.kind === 'human') { return { name: String(me.nickname || 'YOU').toUpperCase(), score: me.stackPreviewHumanPts || 0, characterId: me.characterId, playTint: me.playTint || playTintFromPeerId(String(myId || 'me')), }; } const o = entry.botId ? others.get(entry.botId) : null; return { name: o ? String(o.nickname || 'BOT').replace(/\s+/g, ' ').toUpperCase().slice(0, 12) : '—', score: o ? (o.stackBotScore || 0) : 0, characterId: o ? o.characterId : null, playTint: o ? (o.playTint || playTintFromPeerId(entry.botId)) : null, }; } ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); const boardW = Math.min(248, Math.max(200, cw * 0.36)); const rowH = 42; const headH = 38; const boardH = headH + stackTurnCount * rowH + 10; ctx.fillStyle = 'rgba(8, 12, 28, 0.9)'; roundRectPath(pad, 8, boardW, boardH, 8); ctx.fill(); ctx.strokeStyle = 'rgba(0, 230, 255, 0.5)'; ctx.lineWidth = 1.5; roundRectPath(pad, 8, boardW, boardH, 8); ctx.stroke(); ctx.fillStyle = '#5cefff'; ctx.font = 'bold 12px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText('SCORE', pad + 14, 16); ctx.fillStyle = 'rgba(94, 239, 255, 0.55)'; ctx.font = monoSm; ctx.fillText('shared stack · P1–P6', pad + 62, 18); let rowY = 8 + headH; const drawRows = (entries) => { entries.forEach((entry) => { const isCurrent = entry.seat === currentSeat; const v = resolveEntryVisual(entry); let actionFlash = false; if (entry.kind === 'human') { actionFlash = lastStackPreviewActorId === '__human__' && now < lastStackPreviewActorUntil; } else if (entry.botId) { const ob = others.get(entry.botId); actionFlash = !!(ob && ((now < (ob.stackBotGlowUntil || 0)) || (entry.botId === lastStackPreviewActorId && now < lastStackPreviewActorUntil))); } if (isCurrent) { ctx.fillStyle = 'rgba(255, 0, 180, 0.1)'; roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4); ctx.fill(); ctx.strokeStyle = 'rgba(0, 255, 220, 0.9)'; ctx.lineWidth = 2; roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4); ctx.stroke(); } else if (actionFlash) { ctx.strokeStyle = 'rgba(255, 214, 102, 0.65)'; ctx.lineWidth = 1.5; roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4); ctx.stroke(); } const avS = 34; drawHudAvatar(pad + 10 + avS / 2, rowY + avS - 3, avS, v.characterId, v.playTint); ctx.fillStyle = isCurrent ? '#e0f7ff' : '#89b4fa'; ctx.font = '600 10px ui-sans-serif, system-ui, sans-serif'; ctx.textBaseline = 'middle'; ctx.fillText('P' + entry.seat, pad + 14 + avS + 6, rowY + 12); ctx.fillStyle = '#c0caf5'; ctx.font = 'bold 10px ' + mono; ctx.fillText(v.name.slice(0, 11), pad + 14 + avS + 6, rowY + 28); ctx.fillStyle = actionFlash ? '#ffe066' : '#7dfcff'; ctx.font = 'bold 13px ' + mono; ctx.textAlign = 'right'; ctx.fillText(String(v.score), pad + boardW - 12, rowY + rowH / 2); ctx.textAlign = 'left'; rowY += rowH; }); }; if (order && order.length === stackTurnCount) { drawRows(order); } else { const bots = [...others.keys()].filter(isPreviewBotId).sort(); const rows = [{ kind: 'human', seat: 1 }]; for (let i = 0; i < stackTurnCount - 1; i++) { rows.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null }); } drawRows(rows); } const rightW = 136; let rightX = cw - pad - rightW; const topY = 8; if (rightX < pad + boardW + 14) { rightX = Math.max(pad, cw - pad - rightW); } const bigAv = Math.min(78, Math.max(56, ch * 0.11)); const turnPanelH = bigAv + 118; ctx.fillStyle = 'rgba(8, 12, 28, 0.9)'; roundRectPath(rightX, topY, rightW, turnPanelH, 8); ctx.fill(); ctx.strokeStyle = 'rgba(187, 154, 247, 0.55)'; ctx.lineWidth = 1.5; roundRectPath(rightX, topY, rightW, turnPanelH, 8); ctx.stroke(); let curEntry = null; if (order && order.length === stackTurnCount) { curEntry = order.find((e) => e.seat === currentSeat) || null; } const curV = curEntry ? resolveEntryVisual(curEntry) : resolveEntryVisual({ kind: 'human', seat: 1 }); const framePad = 8; const avCx = rightX + rightW / 2; const bigFrameTop = topY + framePad - 2; const bigFrameH = bigAv + 8; ctx.strokeStyle = 'rgba(255, 120, 200, 0.75)'; ctx.lineWidth = 2; roundRectPath(rightX + framePad - 2, topY + framePad - 2, rightW - (framePad - 2) * 2, bigAv + 8, 6); ctx.stroke(); drawHudAvatar(avCx, bigFrameTop + bigFrameH - 4, bigAv, curV.characterId, curV.playTint); ctx.fillStyle = '#f5c2e7'; ctx.font = 'bold 10px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText('ACTIVE NODE', rightX + rightW / 2, topY + bigAv + 22); ctx.fillStyle = '#7dfcff'; ctx.font = 'bold 11px ' + mono; ctx.fillText('P' + currentSeat + ' · ' + curV.name.slice(0, 10), rightX + rightW / 2, topY + bigAv + 38); ctx.fillStyle = '#bb9af7'; ctx.font = '600 9px ui-sans-serif, system-ui, sans-serif'; ctx.fillText('SYSTEM INTEGRITY', rightX + rightW / 2, topY + bigAv + 58); ctx.font = '14px sans-serif'; let heartStr = ''; for (let i = 0; i < lifeSlots; i++) { heartStr += i < lives ? '♥ ' : '♡ '; } ctx.fillStyle = lives <= 1 ? '#f7768e' : '#9ece6a'; ctx.fillText(heartStr.trim(), rightX + rightW / 2, topY + bigAv + 74); ctx.fillStyle = 'rgba(125, 252, 255, 0.85)'; ctx.font = monoSm; ctx.fillText('TEAM ' + teamScore + ' · COMBO x' + combo, rightX + rightW / 2, topY + bigAv + 96); const termW = Math.min(380, Math.max(220, cw * 0.42)); const termH = 86; const termY = Math.max(boardH + 24, ch - termH - 52); ctx.fillStyle = 'rgba(18, 8, 32, 0.88)'; roundRectPath(pad, termY, termW, termH, 6); ctx.fill(); ctx.strokeStyle = 'rgba(187, 100, 255, 0.45)'; ctx.lineWidth = 1.5; roundRectPath(pad, termY, termW, termH, 6); ctx.stroke(); ctx.fillStyle = '#c099ff'; ctx.font = '600 10px ' + mono; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText('// NODE_LOG', pad + 10, termY + 8); ctx.fillStyle = '#7dcfff'; ctx.font = monoSm; let ly = termY + 26; const logLines = stackPreviewHudLog.length ? stackPreviewHudLog.slice(-5) : ['>>> Awaiting stack sync…', '>>> Space / click = release (your turn only)']; logLines.forEach((ln) => { ctx.fillText(ln, pad + 10, ly); ly += 12; }); const decryptPct = Math.min(100, Math.floor((stackMini.layers.length || 0) * 11 + Math.min(40, teamScore * 3))); const barY = termY + termH - 18; const barW = termW - 20; ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(pad + 10, barY, barW, 8); ctx.fillStyle = 'rgba(0, 255, 200, 0.65)'; ctx.fillRect(pad + 10, barY, Math.max(4, barW * decryptPct / 100), 8); ctx.strokeStyle = 'rgba(0, 234, 255, 0.4)'; ctx.strokeRect(pad + 10, barY, barW, 8); ctx.fillStyle = '#94e2d5'; ctx.font = monoSm; ctx.fillText('TOWER_LINK ' + decryptPct + '%', pad + 10, barY - 14); ctx.restore(); } function useCyberPlayHud() { return !!(mapData && (isJumpSurvive() || isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry())); } /** Cyber HUD: quiz_carry ใช้ playLiveQuizScores (ถูก +QUIZ_CARRY_POINTS_PER_CORRECT ตอนส่งป้ายถูกที่ฮับ) */ function cyberHudQuizCarryPeerScore(peerId) { if (peerId == null) return 0; return Math.max(0, Number(playLiveQuizScores[peerId]) || 0); } function syncPlayCyberHud() { const root = document.getElementById('play-cyber-hud'); const stackEl = document.getElementById('play-canvas-stack'); const on = useCyberPlayHud(); if (root) { root.classList.toggle('is-hidden', !on); root.setAttribute('aria-hidden', on ? 'false' : 'true'); } if (stackEl) stackEl.classList.toggle('play-cyber-vignette', !!on); const gw = document.querySelector('.game-wrap'); if (gw) gw.classList.toggle('play-cyber-active', !!on); if (!on || !root) return; const timeLabelEl = root.querySelector('.play-cyber-time-label'); if (timeLabelEl) timeLabelEl.style.display = isQuizCarry() ? 'none' : ''; const timeVal = document.getElementById('play-cyber-time-val'); const timeSub = document.getElementById('play-cyber-time-sub'); const portrait = document.getElementById('play-cyber-portrait-img'); const statusEl = document.getElementById('play-cyber-self-status'); const hintEl = document.getElementById('play-cyber-hint'); const previewEl = document.getElementById('play-cyber-preview-line'); if (timeSub) { timeSub.textContent = isQuizCarry() ? 'QUIZ CARRY · COURT — หยิบป้ายถูกแล้วส่งที่ฮับ (F / Grab)' : (isGauntlet() ? 'GAUNTLET · SURVIVAL RUN' : (isSpaceShooter() ? 'SPACE SHOOTER · ARCADE' : (isBalloonBoss() ? 'BALLOON BOSS · MEGA VIRUS' : (isJumpSurviveMissionUiMapPlay() ? 'JUMPER · SURVIVE — เหลือเวลา (วินาที) · TIME (sec)' : 'JUMP SURVIVE · NODE UPLINK')))); } if (timeVal) { if (isQuizCarry()) { if (quizCarrySessionEnded) { timeVal.textContent = '0'; } else if (previewMode && editorEmbedReturn && quizCarryEmbedCountdownEndAt > Date.now()) { const rem = Math.max(0, Math.ceil((quizCarryEmbedCountdownEndAt - Date.now()) / 1000)); timeVal.textContent = String(rem); } else if (quizCarryPregameActive) { timeVal.textContent = '···'; } else if (!quizCarryCurrent) { timeVal.textContent = '—'; } else if (quizCarryOptionRevealAt > 0 && Date.now() < quizCarryOptionRevealAt) { timeVal.textContent = String(Math.max(0, Math.ceil((quizCarryOptionRevealAt - Date.now()) / 1000))); } else if (quizCarryAnswerCloseAt > Date.now()) { timeVal.textContent = String(Math.max(0, Math.ceil((quizCarryAnswerCloseAt - Date.now()) / 1000))); } else { timeVal.textContent = '0'; } } else if (isGauntlet()) { if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) { timeVal.textContent = '···'; } else 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; timeVal.textContent = `${mm}:${String(ss).padStart(2, '0')}`; } else if (gauntletRuntimeTimeLimitSec > 0) { timeVal.textContent = '···'; } else { timeVal.textContent = '∞'; } } else if (isSpaceShooter()) { if (spaceShooterGameEnded) { timeVal.textContent = '0'; } else { const rem = spaceShooterRemainingSecPlay(); timeVal.textContent = rem != null ? String(rem) : '∞'; } } else if (isBalloonBoss()) { if (balloonBossGameEnded) { timeVal.textContent = '0'; } else { const rem = balloonBossRemainingSecPlay(); timeVal.textContent = rem != null ? String(rem) : '∞'; } } else if (isJumpSurviveMissionUiMapPlay()) { if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) { timeVal.textContent = jumpSurviveMissionPhase === 'howto' ? '···' : (jumpSurviveMissionPhase === 'countdown' ? '···' : '0'); } else { const rem = jumpSurviveRemainingSecMission(); timeVal.textContent = rem != null ? String(rem) : '∞'; } } else { const t = jumpSurviveSessionStartMs > 0 ? Math.max(0, Math.floor((performance.now() - jumpSurviveSessionStartMs) / 1000)) : 0; timeVal.textContent = String(t); } } if (portrait) { const cid = me.characterId || getPlayCharacterId(); const dir = (isGauntletFaceRightMapMno9kb07() ? 'right' : (me.direction || 'down')); const nowT = Date.now(); const walk = !!me.isWalking; const rawImg = getAvatarImg(cid, dir, nowT, walk); const tint = me.playTint || playTintFromPeerId(String(myId != null ? myId : 'me')); const comp = rawImg && cid && tint ? getPlayTintedAvatarSource(rawImg, cid, dir, nowT, walk, tint) : rawImg; if (comp && comp.tagName === 'CANVAS' && comp.width > 0) { try { portrait.src = comp.toDataURL('image/png'); } catch (e) { if (rawImg && rawImg.src) portrait.src = rawImg.src; } } else if (comp && comp.src) { portrait.src = comp.src; } else if (rawImg && rawImg.src) { portrait.src = rawImg.src; } } if (statusEl) { if (isQuizCarry() && quizCarrySessionEnded) { statusEl.textContent = 'SESSION COMPLETE · จบชุดคำถาม'; } else if (isJumpSurvive() && jumpSurviveEliminated) { statusEl.textContent = 'SPECTATOR · LINK SEVERED'; } else if (isBalloonBoss() && me.balloonBossEliminated) { statusEl.textContent = 'SPECTATOR · BALLOONS GONE'; } else { statusEl.textContent = 'OPERATOR ONLINE'; } } if (hintEl) { if (isQuizCarry()) { hintEl.textContent = quizCarrySessionEnded ? 'ชุดคำถามจบแล้ว · Session ended — ดูคะแนนด้าน SCORE' : ('ยืนโซนตัวเลือกแล้วกด F/Grab หยิบ · ถือป้ายถูกแล้วไปฮับส่ง — ถูก +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' แต้ม (correct +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' on hub)'); } else if (isJumpSurvive()) { if (isJumpSurviveMissionUiMapPlay()) { hintEl.textContent = jumpSurviveEliminated ? 'คุณตกน้ำแล้ว — ดูเพื่อนต่อ — รอจบเพื่อรับคะแนน (ผู้รอดได้ 100 คะแนน)' : 'กระโดดตามแท่น — หลีกหนาม/กับดักด้านล่าง · หมดเวลาแล้วสรุปคะแนน (ผู้รอด 100) · Space / W / ↑ กระโดด · A D / ← → เดิน'; } else { hintEl.textContent = jumpSurviveEliminated ? 'Spectate remaining nodes · Exit room to reset session' : 'Space / W / ↑ jump · A D / arrows move · Hit ceiling/floor = out (watch others)'; } } else if (isSpaceShooter()) { hintEl.textContent = spaceShooterGameEnded ? 'จบแล้ว · Session ended — ดูอันดับบน overlay / see rankings on overlay' : 'A D / ← → = move · W S / ↑ ↓ = up/down (lower half of map only) · Space = fire · +5 per hit'; } else if (isBalloonBoss()) { hintEl.textContent = balloonBossGameEnded ? 'จบแล้ว · Session ended — see overlay' : (me.balloonBossEliminated ? 'คุณตกรอบแล้ว — ดูเพื่อนเล่น · You are out — spectate' : 'ยานมีแรงเฉื่อย — A D / arrows / W S เร่งทิศทาง · ปล่อยปุ่มแล้วยังไหล (แรงหน่วง) · Space = ยิง (ดีเลย์) · ลูกโป้งหมด = ตกรอบ'); } else if (isGauntletCrownHeistMapPlay()) { hintEl.textContent = 'Space / W / ↑ jump · Start 100 pts · Hit -10 · Fall off left edge = out · Rank bonus at end'; } else { hintEl.textContent = 'Space / W / ↑ jump · Lanes + lasers · Clear = right + score'; } } if (previewEl) { if (previewFillBots && mapData) { const human = countPlayHumans(); const bots = [...others.keys()].filter(isPreviewBotId).length; previewEl.textContent = `SIM · humans ${human} + bots ${bots} · target ${previewTargetHeadcount}`; previewEl.classList.remove('is-hidden'); } else { previewEl.textContent = ''; previewEl.classList.add('is-hidden'); } } const ul = document.getElementById('play-cyber-score-list'); if (!ul) return; const safeYv = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1); const jumpMissionHud = isJumpSurvive() && isJumpSurviveMissionUiMapPlay(); const rows = []; rows.push({ id: myId, nickname: me.nickname || nick, characterId: me.characterId || getPlayCharacterId(), score: isQuizCarry() ? cyberHudQuizCarryPeerScore(myId) : (isGauntlet() ? (me.gauntletScore || 0) : (isSpaceShooter() ? (me.spaceShooterScore || 0) : (isBalloonBoss() ? (me.balloonBossScore || 0) : (jumpMissionHud ? (jumpSurviveEliminated ? 0 : 100) : 0)))), y: safeYv(me.y), eliminated: !!(isJumpSurvive() && jumpSurviveEliminated) || !!(isBalloonBoss() && me.balloonBossEliminated) || !!(isGauntletCrownHeistMapPlay() && me.gauntletEliminated), isMe: true, }); others.forEach((o, id) => { rows.push({ id, nickname: o.nickname || id.slice(0, 8), characterId: o.characterId, score: isQuizCarry() ? cyberHudQuizCarryPeerScore(id) : (isGauntlet() ? (o.gauntletScore || 0) : (isSpaceShooter() ? (o.spaceShooterScore || 0) : (isBalloonBoss() ? (o.balloonBossScore || 0) : (jumpMissionHud ? (o.jumpSurviveEliminated ? 0 : 100) : 0)))), y: safeYv(o.y), eliminated: !!(isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated) || !!(isBalloonBoss() && o.balloonBossEliminated) || !!(isGauntletCrownHeistMapPlay() && o.gauntletEliminated), isMe: false, }); }); let leaderId = null; if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud) { let mx = -1; for (let i = 0; i < rows.length; i++) { if (rows[i].score > mx) { mx = rows[i].score; leaderId = rows[i].id; } } } else { let best = Infinity; for (let i = 0; i < rows.length; i++) { if (!rows[i].eliminated && rows[i].y < best) { best = rows[i].y; leaderId = rows[i].id; } } } rows.sort((a, b) => { if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud) { if (b.score !== a.score) return b.score - a.score; if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1; return String(a.nickname || '').localeCompare(String(b.nickname || ''), 'th'); } if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1; return a.y - b.y; }); ul.innerHTML = ''; const cap = 8; for (let r = 0; r < Math.min(cap, rows.length); r++) { const row = rows[r]; const li = document.createElement('li'); li.className = 'play-cyber-score-row' + (row.isMe ? ' is-me' : '') + (row.eliminated ? ' is-out' : ''); const av = document.createElement('img'); av.className = 'play-cyber-score-av'; av.alt = ''; const cid = row.characterId ? String(row.characterId) : ''; if (!cid) { av.src = defaultAvatarImg.src; } else { const im = getCharacterImg(cid, 'down'); if (im && im.src) av.src = im.src; } const mid = document.createElement('div'); mid.className = 'play-cyber-score-mid'; const name = document.createElement('span'); name.className = 'play-cyber-score-name'; if (row.id === leaderId && leaderId != null) { const crown = document.createElement('span'); crown.className = 'play-cyber-lead-crown'; crown.textContent = '♔'; crown.title = 'Leader'; name.appendChild(crown); } name.appendChild(document.createTextNode(String(row.nickname || '').slice(0, 14))); const st = document.createElement('span'); st.className = 'play-cyber-score-state'; st.textContent = isQuizCarry() ? '' : (row.eliminated ? (isBalloonBoss() ? 'OUT' : (isGauntletCrownHeistMapPlay() ? 'เสียชีวิต' : 'OFFLINE')) : (isBalloonBoss() ? ('♥' + Math.max(0, row.isMe ? (me.balloonBossBalloons | 0) : ((() => { const ox = others.get(row.id); return ox ? (ox.balloonBossBalloons | 0) : 0; })()))) : 'LINK_OK')); mid.appendChild(name); mid.appendChild(st); const sc = document.createElement('span'); sc.className = 'play-cyber-score-val'; if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud) { sc.textContent = String(row.score); } else { sc.textContent = row.eliminated ? '—' : String(r + 1); } li.appendChild(av); li.appendChild(mid); li.appendChild(sc); ul.appendChild(li); } } function draw() { if (!mapData) return; const w = mapData.width, h = mapData.height; const stackCam = isStack() ? getStackCameraCentersPx() : null; let camX = stackCam ? stackCam.px : me.x * tileSize; let camY = stackCam ? stackCam.py : me.y * tileSize; if (isJumpSurvive()) { camX = jumpSurviveCamCenterX; camY = jumpSurviveCamCenterY; } let zDraw = zoom; if (isSpaceShooter() || isBalloonBoss()) { const mwPx = w * tileSize, mhPx = h * tileSize; camX = mwPx * 0.5; camY = mhPx * 0.5; zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96; } const halfW = canvas.width / (2 * zDraw); const halfH = canvas.height / (2 * zDraw); // 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) { /* กล้องเห็นพื้นที่นอก [0,map] ได้ — ห้ามสุ่มต้นทาง drawImage เกินขอบภาพ (จะเกิดซ้ำ/เส้นตัด/ซ้อนกันที่ขอบจอ) */ ctx.fillStyle = (isSpaceShooter() || isBalloonBoss()) ? '#0a0e22' : '#1a1b26'; ctx.fillRect(0, 0, canvas.width, canvas.height); const natW = mapBackgroundImg.naturalWidth; const natH = mapBackgroundImg.naturalHeight; const bgMinX = Math.max(0, worldMinX); const bgMaxX = Math.min(mapWpx, worldMaxX); const bgMinY = Math.max(0, worldMinY); const bgMaxY = Math.min(mapHpx, worldMaxY); if (bgMaxX > bgMinX && bgMaxY > bgMinY) { const srcX = (bgMinX / mapWpx) * natW; const srcY = (bgMinY / mapHpx) * natH; const srcW = ((bgMaxX - bgMinX) / mapWpx) * natW; const srcH = ((bgMaxY - bgMinY) / mapHpx) * natH; const destX = (bgMinX - camX) * zDraw + canvas.width / 2; const destY = (bgMinY - camY) * zDraw + canvas.height / 2; const destW = (bgMaxX - bgMinX) * zDraw; const destH = (bgMaxY - bgMinY) * zDraw; if (srcW > 0.25 && srcH > 0.25 && destW > 0.25 && destH > 0.25) { ctx.drawImage(mapBackgroundImg, srcX, srcY, srcW, srcH, destX, destY, destW, destH); } } } else { ctx.fillStyle = (isSpaceShooter() || isBalloonBoss()) ? '#0a0e22' : '#1a1b26'; ctx.fillRect(0, 0, canvas.width, canvas.height); if (isSpaceShooter()) { ctx.save(); for (let s = 0; s < 120; s++) { const sx = (Math.sin(s * 12.9898 + w) * 0.5 + 0.5) * canvas.width; const sy = (Math.cos(s * 4.1415 + h) * 0.5 + 0.5) * canvas.height; const br = (s % 4 === 0) ? 1.6 : 0.9; ctx.fillStyle = s % 7 === 0 ? 'rgba(200, 240, 255, 0.35)' : 'rgba(255, 255, 255, 0.12)'; ctx.beginPath(); ctx.arc(sx, sy, br, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } else if (isBalloonBoss()) { ctx.save(); ctx.fillStyle = 'rgba(30, 45, 120, 0.25)'; for (let col = 0; col < 24; col++) { const x = (col + 0.5) * (canvas.width / 24); for (let r = 0; r < 18; r++) { const bit = (col * 17 + r * 31 + Math.floor(timeMs / 80)) % 2; ctx.fillStyle = bit ? 'rgba(0, 255, 200, 0.14)' : 'rgba(180, 200, 255, 0.08)'; ctx.fillRect(x - 1, (r / 18) * canvas.height + ((timeMs / 40 + col * 3) % 40), 2.2, 10); } } ctx.fillStyle = 'rgba(240, 248, 255, 0.55)'; ctx.beginPath(); ctx.moveTo(0, canvas.height); const baseY = canvas.height * 0.88; for (let k = 0; k <= 40; k++) { const bx = (k / 40) * canvas.width; const by = baseY - Math.abs(Math.sin(k * 1.7 + w)) * canvas.height * 0.08 - (k % 3) * 4; ctx.lineTo(bx, by); } ctx.lineTo(canvas.width, canvas.height); ctx.closePath(); ctx.fill(); ctx.restore(); } } function worldToScreen(wx, wy) { const sx = (wx - camX) * zDraw + canvas.width / 2; const sy = (wy - camY) * zDraw + canvas.height / 2; return [sx, sy]; } function drawGridImagePlacementsLayer() { if (!mapData.gridImagePlacements || !mapData.gridImagePlacements.length || !mapData.gridImageLibrary || !mapData.gridImageLibrary.length) return; for (let gi = 0; gi < mapData.gridImagePlacements.length; gi++) { const sp = mapData.gridImagePlacements[gi]; const wx0 = sp.x * tileSize; const wy0 = sp.y * tileSize; const ww = sp.w * tileSize; const wh = sp.h * tileSize; if (wx0 + ww <= worldMinX || wx0 >= worldMaxX || wy0 + wh <= worldMinY || wy0 >= worldMaxY) continue; let gim = mapGridImageImgs[sp.i]; let carrySpriteAlpha = 1; if (isQuizCarry()) { const domOpt = quizCarryOptionIndexForGridSprite(mapData, sp); const onHub = spriteOverlapsQuizCarryHubArea(mapData, sp); const meHolding = me && me.quizCarryHeld != null; let carryVisualActive = false; if (domOpt != null && quizCarryOptionHeldByAnyone(domOpt)) carryVisualActive = true; else if (domOpt == null && onHub && meHolding) carryVisualActive = true; if (carryVisualActive) { const gh = mapGridImageHeldImgs[sp.i]; if (gh && gh.complete && gh.naturalWidth) gim = gh; else carrySpriteAlpha = 0.32; } } if (!gim || !gim.complete || !gim.naturalWidth) continue; const sx0 = (wx0 - camX) * zDraw + canvas.width / 2; const sy0 = (wy0 - camY) * zDraw + canvas.height / 2; const sw = ww * zDraw; const sh = wh * zDraw; ctx.save(); if (carrySpriteAlpha < 1) ctx.globalAlpha = carrySpriteAlpha; ctx.beginPath(); ctx.rect(sx0, sy0, sw, sh); ctx.clip(); ctx.drawImage(gim, sx0, sy0, sw, sh); ctx.restore(); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; ctx.strokeRect(sx0 + 0.5, sy0 + 0.5, sw - 1, sh - 1); } } 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)); if (showGrid) { 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)'; } else if (isStack()) { rowFill = (y % 2 === 0) ? 'rgba(122, 162, 247, 0.06)' : 'rgba(187, 154, 247, 0.05)'; } else if (isJumpSurvive()) { rowFill = (y % 2 === 0) ? 'rgba(90, 200, 255, 0.07)' : 'rgba(60, 140, 200, 0.06)'; } else if (isSpaceShooter()) { rowFill = (y % 2 === 0) ? 'rgba(30, 45, 110, 0.22)' : 'rgba(20, 32, 78, 0.28)'; } else if (isBalloonBoss()) { rowFill = (y % 2 === 0) ? 'rgba(40, 25, 80, 0.2)' : 'rgba(25, 18, 60, 0.24)'; } else if (isQuizBattle()) { rowFill = (y % 2 === 0) ? 'rgba(110, 175, 255, 0.06)' : 'rgba(230, 110, 140, 0.055)'; } for (let x = startTileX; x <= endTileX; x++) { const wx = x * tileSize, wy = y * tileSize; const [sx, sy] = worldToScreen(wx, wy); const size = tileSize * zDraw; 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); } } } } } drawGridImagePlacementsLayer(); if (showGrid) { for (let y = startTileY; y <= endTileY; y++) { for (let x = startTileX; x <= endTileX; x++) { const wx = x * tileSize, wy = y * tileSize; const [sx, sy] = worldToScreen(wx, wy); const size = tileSize * zDraw; 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 (isQuizCarry()) { const hub = mapData.quizCarryHubArea && mapData.quizCarryHubArea[y] && mapData.quizCarryHubArea[y][x] === 1; if (hub) { ctx.fillStyle = 'rgba(187, 154, 247, 0.35)'; ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4); ctx.strokeStyle = 'rgba(200, 170, 255, 0.75)'; ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); } const isCarryQ = mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1; if (isCarryQ) { ctx.fillStyle = 'rgba(255, 214, 102, 0.3)'; ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4); ctx.strokeStyle = 'rgba(224, 185, 70, 0.82)'; ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); } const ov = mapData.quizCarryOptionArea && mapData.quizCarryOptionArea[y] && mapData.quizCarryOptionArea[y][x]; if (ov >= 1 && ov <= QUIZ_CARRY_MAX_OPTION_SLOTS) { ctx.fillStyle = quizCarryMinimapOptionFillCss(ov); ctx.fillRect(sx + 3, sy + 3, size - 6, size - 6); } } if (isQuizBattle() && showGrid && mapData.quizBattlePathArea && mapData.quizBattlePathArea[y] && mapData.quizBattlePathArea[y][x] === 1) { ctx.fillStyle = 'rgba(186, 130, 255, 0.4)'; ctx.fillRect(sx + 1, sy + 1, size - 2, size - 2); ctx.strokeStyle = 'rgba(220, 170, 255, 0.55)'; ctx.strokeRect(sx + 1, sy + 1, size - 2, size - 2); } if (isQuizBattle() && showGrid && mapData.quizBattleDomeArea && mapData.quizBattleDomeArea[y] && mapData.quizBattleDomeArea[y][x] === 1) { const cid = mapData.quizBattleDomeComp && mapData.quizBattleDomeComp[y] && mapData.quizBattleDomeComp[y][x]; const done = cid > 0 && quizBattleAnsweredComps.has(cid); ctx.fillStyle = done ? 'rgba(90, 90, 110, 0.4)' : 'rgba(100, 200, 255, 0.36)'; ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4); ctx.strokeStyle = done ? 'rgba(140, 140, 160, 0.65)' : 'rgba(255, 100, 130, 0.82)'; ctx.lineWidth = 2; ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); ctx.lineWidth = 1; } if (isStack()) { const sr = mapData.stackReleaseArea, sl = mapData.stackLandArea; if (sr && sr[y] && sr[y][x] === 1) { ctx.fillStyle = 'rgba(125, 207, 255, 0.28)'; ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4); ctx.strokeStyle = 'rgba(122, 220, 255, 0.55)'; ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); } if (sl && sl[y] && sl[y][x] === 1) { ctx.fillStyle = 'rgba(247, 118, 190, 0.26)'; ctx.fillRect(sx + 3, sy + 3, size - 6, size - 6); ctx.strokeStyle = 'rgba(255, 130, 200, 0.5)'; ctx.strokeRect(sx + 3, sy + 3, size - 6, size - 6); } } if (isBalloonBoss() && showGrid && mapData.balloonBossPlayerSlots) { const sv = mapData.balloonBossPlayerSlots[y] && mapData.balloonBossPlayerSlots[y][x]; if (sv >= 1 && sv <= 6) { ctx.fillStyle = 'rgba(255, 120, 200, 0.12)'; ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4); ctx.strokeStyle = 'rgba(255, 160, 220, 0.45)'; ctx.lineWidth = 1.5; ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); ctx.lineWidth = 1; ctx.fillStyle = 'rgba(255, 228, 250, 0.92)'; ctx.font = `bold ${Math.max(8, 10 * zDraw)}px sans-serif`; ctx.textAlign = 'center'; ctx.fillText('P' + sv, sx + size / 2, sy + size / 2 + 4); ctx.textAlign = 'left'; } } if (isSpaceShooter() && showGrid && mapData.shooterSpawnSlots) { const sv = mapData.shooterSpawnSlots[y] && mapData.shooterSpawnSlots[y][x]; if (sv >= 1 && sv <= 6) { ctx.fillStyle = 'rgba(0, 255, 240, 0.14)'; ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4); ctx.strokeStyle = 'rgba(120, 255, 255, 0.45)'; ctx.lineWidth = 1.5; ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); ctx.lineWidth = 1; ctx.fillStyle = 'rgba(220, 250, 255, 0.9)'; ctx.font = `bold ${Math.max(8, 10 * zDraw)}px sans-serif`; ctx.textAlign = 'center'; ctx.fillText('P' + sv, sx + size / 2, sy + size / 2 + 4); ctx.textAlign = 'left'; } } } } if (isJumpSurvive() && showGrid && mapData.jumpSurvivePlatformArea) { const pa = mapData.jumpSurvivePlatformArea; const sc = jumpSurvivePlatformScrollPx; const tsz = tileSize; const period = h * tsz; const visTop = worldMinY; const visBot = worldMaxY; const eps = 1e-6; for (let py = 0; py < h; py++) { for (let px = 0; px < w; px++) { if (!pa[py] || pa[py][px] !== 1) continue; const tileWorldLeft = px * tsz; const tileWorldRight = tileWorldLeft + tsz; if (tileWorldRight <= worldMinX || tileWorldLeft >= worldMaxX) continue; const rawTop = py * tsz - sc; const kMin = Math.ceil((visTop - rawTop - tsz + eps) / period); const kMax = Math.floor((visBot - rawTop - eps) / period); if (kMax < kMin) continue; for (let kk = kMin; kk <= kMax; kk++) { const platTop = rawTop + kk * period; if (platTop + tsz <= visTop || platTop >= visBot) continue; const [psx, psy] = worldToScreen(tileWorldLeft, platTop); const psz = tsz * zDraw; ctx.fillStyle = 'rgba(0, 40, 52, 0.72)'; ctx.fillRect(psx + 3, psy + 3, psz - 6, psz - 6); ctx.strokeStyle = 'rgba(0, 255, 240, 0.55)'; ctx.lineWidth = 2; ctx.strokeRect(psx + 3, psy + 3, psz - 6, psz - 6); ctx.strokeStyle = 'rgba(120, 255, 255, 0.35)'; ctx.lineWidth = 1; ctx.strokeRect(psx + 5, psy + 5, psz - 10, psz - 10); const grad = ctx.createLinearGradient(psx, psy, psx, psy + psz); grad.addColorStop(0, 'rgba(0, 255, 240, 0.12)'); grad.addColorStop(0.5, 'rgba(0, 0, 0, 0)'); grad.addColorStop(1, 'rgba(0, 180, 200, 0.08)'); ctx.fillStyle = grad; ctx.fillRect(psx + 4, psy + 4, psz - 8, psz - 8); ctx.lineWidth = 1; if (zDraw >= 1.15 && psz > 22) { ctx.save(); ctx.font = `600 ${Math.max(7, Math.min(11, psz * 0.14))}px "Share Tech Mono", ui-monospace, monospace`; ctx.fillStyle = 'rgba(180, 255, 255, 0.5)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('DATA', psx + psz * 0.5, psy + psz * 0.38); ctx.font = `500 ${Math.max(5, Math.min(8, psz * 0.1))}px "Share Tech Mono", ui-monospace, monospace`; ctx.fillStyle = 'rgba(120, 230, 255, 0.38)'; ctx.fillText('/SEC/BLOCK', psx + psz * 0.5, psy + psz * 0.62); ctx.restore(); } } } } } 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 * zDraw * (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); } } } } if (isQuizCarry()) { drawQuizCarryChoiceLabels(ctx, worldToScreen, zDraw); } if (isStack() && stackMini) { drawStackMinigame(ctx, worldToScreen, zDraw); } 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 size = tileSize * zDraw; const inner = Math.max(2, size - 4); const [sxC, syCellBottom] = worldToScreen((o.drawX + 0.5) * tileSize, (o.y + 1) * tileSize); const dx = sxC - inner / 2; const dy = syCellBottom - inner; const laneRec = pickGauntletLaneImageRec(o.id); if (laneRec && laneRec.img.complete && laneRec.img.naturalWidth > 0) { try { ctx.drawImage(laneRec.img, dx, dy, inner, inner); } catch (e) { ctx.fillStyle = '#f7768e'; ctx.fillRect(dx, dy, inner, inner); } } else { ctx.fillStyle = '#f7768e'; ctx.fillRect(dx, dy, inner, inner); } } else if (o.kind === 'laser' && typeof o.drawX === 'number') { if (o.drawX < stx - 2 || o.drawX > enx + 2) continue; const { y0: laserRow0, y1: laserRow1 } = gauntletLaserRowHitRange(o, h); const wx0 = o.drawX * tileSize; const wx1 = (o.drawX + 1) * tileSize; const wy0 = laserRow0 * tileSize; const wy1 = (laserRow1 + 1) * 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 drawQuizCarryHeldSignBoard(sxFeet, feetSy, halfSpriteW, spriteTopY, signText, _faceDir, choiceIndex, imageUrl, imageElementOpt) { const imgUrlSan = sanitizeQuizCarryImageUrlClient(imageUrl); const gridHeldDraw = imageElementOpt && imageElementOpt.complete && imageElementOpt.naturalWidth > 0 ? imageElementOpt : null; const raw = String(signText || '').trim(); if (!raw && !imgUrlSan && !gridHeldDraw) return; ctx.save(); const heldPs = quizCarryPlaqueMapScaleClampedPlay(); const bodyH = Math.max(18, feetSy - spriteTopY); const midBodyY = spriteTopY + bodyH * 0.48; const bodyCx = sxFeet; const halfW = Math.max(10, halfSpriteW || 0); const baseMaxW = Math.min(280, Math.max(100, halfW * 2.4 + tileSize * zDraw * 0.5)); const maxPlaqueW = Math.min(340, Math.round(baseMaxW * heldPs)); const fontPx = Math.max(12, Math.min(22, tileSize * zDraw * 0.22 * heldPs)); ctx.font = `600 ${fontPx}px system-ui, "Segoe UI", "Kanit", sans-serif`; const padX = 12; const padY = 9; const maxInner = Math.min(maxPlaqueW - padX * 2, Math.max(72, tileSize * zDraw * 4.2)); let lines; if (!raw) { lines = []; } else { lines = canvasWordWrapLines(ctx, raw, maxInner); if (!lines.length) lines = [raw]; if (lines.length > 3) lines = lines.slice(0, 3); for (let i = 0; i < lines.length; i++) { let s = lines[i]; if (ctx.measureText(s).width <= maxInner) continue; while (s.length > 2 && ctx.measureText(s + '…').width > maxInner) s = s.slice(0, -1); lines[i] = s + '…'; } } let maxLineW = 0; for (let i = 0; i < lines.length; i++) { const lw = ctx.measureText(lines[i]).width; if (lw > maxLineW) maxLineW = lw; } const lineH = fontPx * 1.22; let w; let h; if (!lines.length && (imgUrlSan || gridHeldDraw)) { w = Math.ceil(Math.min(maxPlaqueW, Math.max(104, maxInner + padX * 2))); h = Math.ceil(Math.max(56, fontPx * 2.8 + padY * 2)); } else { w = Math.ceil(Math.max(maxLineW, 56) + padX * 2); h = Math.ceil(lines.length * lineH + padY * 2); w = Math.min(maxPlaqueW, Math.max(96, w)); h = Math.max(40, h); } let signX = bodyCx - w / 2; let signY = midBodyY - h / 2; if (signX + w > canvas.width - 6) signX = canvas.width - w - 6; if (signX < 6) signX = 6; if (signY < 6) signY = 6; if (signY + h > feetSy + 8) signY = feetSy + 8 - h; drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, null, { imageUrl: imgUrlSan, imageElement: gridHeldDraw }); ctx.restore(); } function drawAvatar(ax, ay, isMe, name, characterId, direction, isWalking, playTint, gauntletAirTicks, gauntletScoreLabel, quizCarrySignPayload) { const { cw, ch } = getCharacterFootprintWH(mapData); const air = Number(gauntletAirTicks) || 0; let liftWorldY = 0; if (isGauntlet() && air > 0) { liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52; } const cxWorld = (ax + cw * 0.5) * tileSize; /* ใช้ ay แบบต่อเนื่อง — ห้าม floor(ay) เดิมทำให้เท้ากระโดดทีละช่องขณะเดิน (กระตุกแนวตั้ง) */ const cyBottomWorld = (ay + ch) * tileSize - liftWorldY; const [sx, sy] = worldToScreen(cxWorld, cyBottomWorld); const cellSpan = Math.max(cw, ch); const r = Math.max(14, (tileSize * zDraw * cellSpan) / 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; } let halfSpriteW = r; let spriteTopY = sy - 2 * r; if (iw > 0 && ih > 0) { const scale = Math.min(size / iw, size / ih, 1); const drawW = iw * scale; const drawH = ih * scale; halfSpriteW = drawW / 2; spriteTopY = sy - drawH; 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(); spriteTopY = cy - r; } 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); if (isQuizCarry() && quizCarrySignPayload) { drawQuizCarryHeldSignBoard( sx, sy, halfSpriteW, spriteTopY, quizCarrySignPayload.text, dir, quizCarrySignPayload.choiceIndex, quizCarrySignPayload.imageUrl, quizCarrySignPayload.imageElement, ); } } function quizCarrySignForEntity(ent) { if (!isQuizCarry() || !quizCarryCurrent || !ent || ent.quizCarryHeld == null) return null; const idx = ent.quizCarryHeld; const ch = quizCarryCurrent.choices; if (!Array.isArray(ch) || idx < 0 || idx >= ch.length) return null; const t = String(ch[idx] != null ? ch[idx] : '').trim(); const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, idx); const gridHeldEl = findQuizCarryGridHeldImgForChoiceIndex(idx); if (!t && !imgUrl && !gridHeldEl) return null; return { text: t, choiceIndex: idx, imageUrl: imgUrl, imageElement: gridHeldEl || undefined }; } 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 { ch: sortCh } = getCharacterFootprintWH(mapData); /** ใช้แถวกริด (floor y) เป็นหลัก — ลดการสลับลำดับวาดทุกเฟรมเมื่อ y เป็น float เลอร์ป */ function avatarDrawDepth(ent) { return Math.floor(safeY(ent.y)) + sortCh; } if (!isStack() && !isSpaceShooter() && !isBalloonBoss()) { let mt = me.playTint; if (!mt || typeof mt.head !== 'string' || typeof mt.hair !== 'string' || typeof mt.body !== 'string') { mt = playTintFromPeerId(String((myId != null && myId !== '') ? myId : (nick || 'local'))); me.playTint = mt; } const meTag = isFrogger() ? (' (กบ) ' + froggerScore) : (isGauntlet() && meGauntletJumpTicks > 0 ? ' (กระโดด)' : (isJumpSurvive() && jumpSurviveEliminated ? ' (ตกรอบ)' : ' (คุณ)')); const meWalking = isFrogger() ? false : (isGauntlet() ? (meGauntletJumpTicks > 0 || meGauntletJumpVis > 0.08) : !!me.isWalking); const drawRows = []; others.forEach((o, id) => { if (!o) return; drawRows.push({ isMe: false, id, o }); }); drawRows.push({ isMe: true, id: '__me', o: me }); drawRows.sort((a, b) => { const da = avatarDrawDepth(a.o); const db = avatarDrawDepth(b.o); if (da !== db) return da - db; const xa = Math.floor(safeX(a.o.x) * 4); const xb = Math.floor(safeX(b.o.x) * 4); if (xa !== xb) return xa - xb; const sa = a.isMe ? '\uFFFFme' : String(a.id); const sb = b.isMe ? '\uFFFFme' : String(b.id); return sa < sb ? -1 : sa > sb ? 1 : 0; }); drawRows.forEach((row) => { if (!row.isMe) { const id = row.id; const o = row.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); const faceDirOther = isGauntletFaceRightMapMno9kb07() ? 'right' : o.direction; const botOut = isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated; const peerName = botOut ? (o.nickname + ' (ตกรอบ)') : o.nickname; if (botOut) ctx.save(); if (botOut) ctx.globalAlpha = 0.4; drawAvatar(safeX(o.x) + off.ax, safeY(o.y) + off.ay, false, peerName, o.characterId, faceDirOther, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0, o.gauntletScore || 0, quizCarrySignForEntity(o)); if (botOut) ctx.restore(); } else { if (isJumpSurvive() && jumpSurviveEliminated) ctx.save(); if (isJumpSurvive() && jumpSurviveEliminated) ctx.globalAlpha = 0.4; const faceDirMe = isGauntletFaceRightMapMno9kb07() ? 'right' : me.direction; drawAvatar(safeX(me.x), safeY(me.y), true, me.nickname + meTag, me.characterId, faceDirMe, meWalking, mt, meGauntletJumpVis, me.gauntletScore || 0, quizCarrySignForEntity(me)); if (isJumpSurvive() && jumpSurviveEliminated) ctx.restore(); } }); } if (isFrogger()) { ctx.fillStyle = '#7aa2f7'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('โหมดกบ | คะแนน: ' + froggerScore, 10, 24); } if (isJumpSurvive() && !useCyberPlayHud()) { ctx.fillStyle = 'rgba(148, 226, 213, 0.95)'; ctx.font = '600 12px system-ui, "Segoe UI", "Kanit", sans-serif'; ctx.textAlign = 'left'; if (jumpSurviveEliminated) { ctx.fillStyle = 'rgba(247, 118, 190, 0.95)'; ctx.fillText('คุณตกรอบแล้ว (ชนหรือหลุดเพดาน/พื้นจอ) · เล่นต่อไม่ได้ — ดูเพื่อนได้ · รอรอบใหม่หรือออกจากห้อง', 10, 22); } else { ctx.fillText('กระโดดให้รอด · Space / W / ↑ · A D / ← → · แพลตฟอร์มวน · ชนหรือหลุดบน/ล่างจอ = ตกรอบ (ดูเพื่อนต่อ)', 10, 22); } } if (isGauntlet() && !useCyberPlayHud()) { ctx.fillStyle = '#f7768e'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; if (isGauntletCrownHeistMapPlay()) { ctx.fillText('พรมแดงสุดท้าย | คะแนน ' + (me.gauntletScore || 0) + ' · ชน -10 · ขอบซ้ายตาย · สูงสุด 6 คน', 10, 24); ctx.font = '12px sans-serif'; ctx.fillStyle = '#c0caf5'; ctx.fillText('Space / W / ↑ = กระโดด · เลน + เลเซอร์ · จบเกมมีโบนัสอันดับ + เกรดทีม', 10, 42); } else { 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 (isStack() && stackMini) { if (previewFillBots) { ctx.fillStyle = 'rgba(125, 220, 255, 0.9)'; ctx.font = '600 12px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.fillText('Stack · shared tower · Space / click = release on your turn', 10, 22); drawStackPreviewCyberHud(ctx, canvas.width, canvas.height); } else { ctx.fillStyle = '#7dcfff'; ctx.font = 'bold 15px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('Stack | คะแนน ' + stackMini.score + ' · ชีวิต ' + stackMini.lives + ' · combo x' + (stackMini.combo || 0), 10, 24); ctx.font = '12px sans-serif'; ctx.fillStyle = '#c0caf5'; ctx.fillText('Space / คลิก = ปล่อย', 10, 44); } } if (isQuiz() || isQuizCarry() || isQuizBattle()) { syncPlayQuizMapPanel(); syncQuizCarryEmbedQuestionStrip(); } /* timeup ต้องซิงก์ทุกเฟรมแม้จบเซสชัน — อย่าให้อยู่ในเงื่อนไขด้านบนอย่างเดียว */ syncQuizCarryTimeupDeskLayerPosition(); if (previewFillBots && mapData && !useCyberPlayHud()) { 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)'; const hudW = isStack() ? Math.min(canvas.width - 12, 520) : Math.min(canvas.width - 12, 320); ctx.fillRect(6, canvas.height - 38, hudW, 30); ctx.fillStyle = '#a9b1d6'; ctx.font = '12px sans-serif'; ctx.textAlign = 'left'; let line = 'ทดสอบ: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + previewTargetHeadcount + ' คน)'; if (isStack()) { line += ' · ตึกร่วม + HUD ซ้าย/ขวา'; } ctx.fillText(line, 12, canvas.height - 18); ctx.restore(); } if (isSpaceShooter()) { drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs); } if (isBalloonBoss()) { drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs); } if (isQuizCarry()) { drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zDraw, timeMs); } if (previewMode && editorEmbedReturn && isQuizCarry() && quizCarryEmbedCountdownEndAt > Date.now()) { syncQuizCarryEmbedCountdownLayout(); } syncPlayCyberHud(); syncQuizCarryGrabButton(); } document.addEventListener('keydown', (e) => { if (isMovementKey(e.code) && isChatFocused()) return; if ((isSpaceShooter() || isBalloonBoss()) && mapData && !isChatFocused()) { if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) { e.preventDefault(); } } if (isStack() && mapData && !isChatFocused()) { if (e.code === 'Space') { e.preventDefault(); tryHumanStackDrop(); return; } if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) { e.preventDefault(); return; } } if (isGauntlet() && mapData && !isChatFocused()) { if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') { e.preventDefault(); if (isGauntletCrownPregameBlockingPlay()) return; if (isGauntletCrownHeistMapPlay() && me.gauntletEliminated) return; 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; } } if (isJumpSurvive() && mapData && !isChatFocused()) { if (isJumpSurviveMissionPregameBlockingPlay()) { if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault(); return; } if (jumpSurviveEliminated) { if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault(); return; } if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') { e.preventDefault(); if (!e.repeat) jumpSurviveJumpQueued = true; return; } if (e.code === 'ArrowDown' || e.code === 'KeyS') { e.preventDefault(); return; } } if (isQuizBattle() && mapData && !isChatFocused()) { if (e.code === 'KeyE') { e.preventDefault(); if (!e.repeat && myId != null) tryOpenQuizBattleFromKey(); return; } } if (isQuizCarry() && mapData && !isChatFocused()) { if (e.code === 'KeyF') { e.preventDefault(); if (!e.repeat && myId != null) tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true }); 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 (isStack()) { stackTickFrame(); me.isWalking = false; draw(); requestAnimationFrame(tick); return; } if (isJumpSurvive()) { if (isJumpSurviveMissionPregameBlockingPlay()) { me.isWalking = false; draw(); requestAnimationFrame(tick); return; } jumpSurviveTickFrame(); draw(); requestAnimationFrame(tick); return; } if (isSpaceShooter()) { spaceShooterTickFrame(); draw(); requestAnimationFrame(tick); return; } if (isBalloonBoss()) { balloonBossTickFrame(); draw(); requestAnimationFrame(tick); return; } if (isFrogger()) { if (checkFroggerCollision()) respawnFrogger(); me.isWalking = false; draw(); requestAnimationFrame(tick); return; } if (isGauntlet()) { if (isGauntletCrownPregameBlockingPlay()) { me.isWalking = false; draw(); requestAnimationFrame(tick); return; } 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; }); tickQuizCarryEmbedCountdown(); tickQuizCarryRoundTimers(); updateQuizCarryCarryPhaseHud(); stepPreviewBots(); if (isChatFocused()) { me.isWalking = false; enforceQuizBattleLaneOnMePlay(); draw(); requestAnimationFrame(tick); return; } if (isQuizCarryEmbedLobbyBlockingMovement() || isQuizCarryEmbedCountdownBlockingMovement()) { me.isWalking = false; playPath = []; enforceQuizBattleLaneOnMePlay(); 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)); enforceQuizBattleLaneOnMePlay(); 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; /** Quiz Battle + เส้นทาง: ห้ามเลื่อนแกนเดียว (slide) — ไม่งั้นเดินทแยงหลุดออกนอกเลนได้ */ const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData); if (canWalkLikeLobby(nx, ny, me.x, me.y)) { me.x = nx; me.y = ny; } else if (!pathStrict) { 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)); enforceQuizBattleLaneOnMePlay(); 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'); } }); (function wireQuizBattleMcqModal() { const ov = document.getElementById('quiz-battle-mcq-overlay'); if (!ov) return; ov.addEventListener('click', (e) => { const t = e.target; if (!t) return; if (t.classList && t.classList.contains('quiz-battle-choice')) { const idx = parseInt(t.getAttribute('data-idx'), 10); if (Number.isFinite(idx) && idx >= 0 && idx <= 2) trySubmitQuizBattleChoice(idx); return; } if (t.id === 'quiz-battle-mcq-close') { quizBattleModalCompId = null; hideQuizBattleMcqModal(); return; } if (t.classList && t.classList.contains('quiz-battle-mcq-backdrop')) { quizBattleModalCompId = null; hideQuizBattleMcqModal(); } }); })(); (function wireQuizCarryGrabButton() { const btn = document.getElementById('quiz-carry-grab-btn'); if (!btn) return; btn.addEventListener('click', (ev) => { if (!isQuizCarry() || !mapData || isChatFocused()) return; if (!btn.classList.contains('quiz-carry-grab-btn--active')) return; ev.preventDefault(); if (myId == null) return; tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true }); }); })(); (function wireGauntletCrownHowtoPrimary() { const btn = document.getElementById('btn-gch-ready'); if (!btn) return; btn.addEventListener('click', () => { if (isJumpSurviveMissionUiMapPlay()) { if (jumpSurviveMissionPhase !== 'howto') return; const humans = quizCarryPregameHumanIds(); if (!(humans.length === 1 || isMePlayHost())) return; beginJumpSurviveMissionCountdownThenRun(); return; } if (!isGauntletCrownHeistMapPlay()) return; if (gauntletCrownPregamePhase !== 'howto') return; if (myId == null || !isMePlayHost()) return; const humans = quizCarryPregameHumanIds(); const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); if (humansReady) { if (humans.length === 1 && isMePlayHost()) { beginGauntletCrownCountdownThenRun(); return; } socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } }); return; } const sid = String(myId); const next = !gauntletCrownLobbyReadyMap[sid]; gauntletCrownLobbyReadyMap[sid] = next; updateGauntletCrownHowtoHud(); socket.emit('gauntlet-crown-lobby-ready', { ready: next }); }); })(); (function wireQuizCarryPregameOverlay() { const primary = document.getElementById('quiz-carry-pregame-primary'); if (!primary) return; primary.addEventListener('click', () => { if (!quizCarryPregameActive || myId == null) return; if (!isMePlayHost()) return; const humans = quizCarryPregameHumanIds(); const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]); if (humansReady) { /* ผู้เล่นจริงคนเดียว + พรีวิว: ไม่ต้องรอ ACK เซิร์ฟ (ห้องที่ map บนเซิร์ฟยังไม่ใช่ quiz_carry จะเคยล้มเหลว) */ if (humans.length === 1 && isMePlayHost()) { endQuizCarryEmbedPregameAndStart(); return; } socket.emit('quiz-carry-lobby-start', {}, (r) => { if (!r || !r.ok) showQuizCarryToast((r && r.error) || 'START ไม่ได้', false); }); return; } const sid = String(myId); const next = !quizCarryLobbyReadyMap[sid]; quizCarryLobbyReadyMap[sid] = next; updateQuizCarryPregameHud(); socket.emit('quiz-carry-lobby-ready', { ready: next }); }); })(); window.addEventListener('beforeunload', () => socket.emit('leave-space')); window.addEventListener('resize', () => { if (!mapData || !canvas) return; resizeCanvas(); draw(); }); })();