(function () { const BASE = '/Game'; function playDesignMainMenuHref() { try { if (typeof globalThis !== 'undefined' && typeof globalThis.designMainMenuHref === 'function') { return globalThis.designMainMenuHref(); } } catch (e) { /* ignore */ } return '/Main-Menu/'; } function playDesignMainLobbyHref() { try { if (typeof globalThis !== 'undefined' && typeof globalThis.designMainLobbyHref === 'function') { return globalThis.designMainLobbyHref(); } } catch (e) { /* ignore */ } return '/Main-Lobby/'; } /** รูปเลข 3–2–1 — เสิร์ฟที่ /Game/img/QUESTION/ (nginx alias /Game/ → public; อย่าใช้ /Game/public/img/) */ const COUNTDOWN_321_IMG_BASE = BASE + '/img/QUESTION/'; /** แผงคำถามบนแมป 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'); function readStoredDisplayName() { try { return (localStorage.getItem('roomLobbyDisplayName') || localStorage.getItem('playerName') || '').trim(); } catch (e) { return ''; } } let nick = (params.get('nick') || '').trim() || readStoredDisplayName() || 'ผู้เล่น'; const previewMode = params.get('preview') === '1'; /* Card 3 Ban — ผู้ถูกแบนรอบนี้: เข้ามาดูเพื่อนเล่นได้ แต่ควบคุมตัวละคร/ทำแอ็กชันไม่ได้ (รอบหน้าเล่นปกติ) */ const playBannedSpectator = params.get('banned') === '1'; /* Card 3 Ban (กรณีโหวตแบน "บอท") — slot ของ __pv_bot_N ที่ถูกแบน → ไม่ spawn ลงเล่นรอบนี้ (-1 = ไม่มี) */ const playBannedBotSlot = (() => { const v = parseInt(params.get('banBot'), 10); return Number.isFinite(v) && v >= 0 ? v : -1; })(); /* shield โปร่งใสคลุมทั้งจอ (z 200) กิน pointer/touch ทุกอย่าง → ปุ่มเกม(jump z59/drop z60/joystick/shoot)+canvas กดไม่ได้ แต่ต่ำกว่า modal (z 10000+) เลย special quiz / สรุปผล ยังกดได้ปกติ + ป้ายบอกสถานะด้านบน */ function setupBannedSpectatorUi() { if (!playBannedSpectator || document.getElementById('banned-spectator-shield')) return; const shield = document.createElement('div'); shield.id = 'banned-spectator-shield'; shield.style.cssText = 'position:fixed;inset:0;z-index:200;background:transparent;pointer-events:auto;cursor:not-allowed;touch-action:none;-webkit-tap-highlight-color:transparent'; ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick', 'touchstart', 'touchend', 'touchmove', 'wheel', 'contextmenu'].forEach(function (ev) { shield.addEventListener(ev, function (e) { e.stopPropagation(); e.preventDefault(); }, { passive: false }); }); document.body.appendChild(shield); const banner = document.createElement('div'); banner.id = 'banned-spectator-banner'; banner.style.cssText = 'position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:201;pointer-events:none;background:linear-gradient(180deg,#2a1430,#160a1d);border:1px solid rgba(247,118,142,.6);border-radius:14px;padding:10px 20px;text-align:center;box-shadow:0 10px 30px rgba(0,0,0,.55)'; banner.innerHTML = '
BAN CARD
' + '
คุณถูกแบนรอบนี้ · กำลังดูเพื่อนเล่น
' + '
รอบหน้ากลับมาเล่นได้ตามปกติ
'; document.body.appendChild(banner); } const specialQuizForceDebug = params.get('sqForce') === '1'; const forceDefaultCharacter = params.get('defaultChar') === '1'; const editorEmbedReturn = params.get('editorEmbed') === '1'; if (previewMode && editorEmbedReturn) { try { document.documentElement.classList.add('play-preview-editor-embed'); } catch (e) { /* ignore */ } } 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'; /** Space shooter ฉาก Violent Crime — flow เดียวกับ crown/howto (รูปใน /img/ViolentCrime/) */ const SPACE_SHOOTER_MISSION_MAP_ID = 'mnpz6rkp'; /** Quiz ฉากสอบสวน — flow เดียวกับ crown/howto แต่รูปใน /img/QUESTION/ */ const QUIZ_QUESTION_MISSION_MAP_ID = 'mng8a80o'; /** Stack ซ้อนตึก — ฉากภารกิจ (HOWTO → นับถอยหลัง → เล่น → สรุป) รูปใน `/Game/img/TowerBlock` */ const STACK_TOWER_MISSION_MAP_ID = 'mnn93hpi'; /** Stack Tower: บัฟเฟอร์วาดคงที่ 16:9 — 1920×1080 */ const STACK_TOWER_FIXED_RENDER_W = 1920; const STACK_TOWER_FIXED_RENDER_H = 1080; /** คูณ zDraw ให้มุมมองโลกเท่าเดิมเมื่อบัฟเฟอร์กว้างกว่าฐานอ้างอิง 1280w (= 1.5 ที่ 1920w) */ const STACK_TOWER_FIXED_RENDER_Z_MUL = STACK_TOWER_FIXED_RENDER_W / 1280; /** เชือก Stack Tower: ปลายล่างตามเส้น (1 = ถึงจุดยึดบล็อก) */ const STACK_TOWER_ROPE_DRAW_T1 = 1.0; /** ปลายบนเชือก (จอ): ยืดเหนือขอบบนแคนวาสเป็นเศษส่วนของความสูง (เช่น 0.25 = 25% นอกจอด้านบน) */ const STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC = 0.25; /** progress ≥ นี้ (ฉาก Tower mnn93hpi): รูป heavy + ความกว้าง heavy = เท่าเดียวกับปกติ × ค่าด้านล่าง + แอนิเมตซูม/เลื่อน */ const STACK_TOWER_POST50_PROGRESS_THRESH = 60; const STACK_TOWER_POST50_ZOOM_MUL = 1.1; /** เลื่อน BG แนวตั้งเท่าเศษส่วนของความสูงแคนวาส (จอ) */ const STACK_TOWER_POST50_MAP_SHIFT_SCREEN_FRAC = 0.1; const STACK_TOWER_POST50_ANIM_MS = 680; /** แมป mnn93hpi: สเกลความกว้าง/ความสูงชั้นของบล็อกในโลก (1 = ขนาดเดิม) */ const STACK_TOWER_BLOCK_WORLD_SCALE = 1.5; /** หลังเกณฑ์ progress (Tower): เลื่อนกล้องลงในมุมมองจอเท่าเศษส่วนของความสูงแคนวาส (เช่น 0.25 = 25% ของความสูงจอ) */ const STACK_TOWER_POST50_VIEW_SHIFT_SCREEN_FRAC = 0.25; /** Mega Virus — balloon_boss ฉากภารกิจ (flow เดียวกับ crown / mno9kb07) รูปใน `/Game/img/MegaVirus` */ const BALLOON_BOSS_MISSION_MAP_ID = 'mnq1eml7'; /** ครบชั้นนี้ขึ้นไป — ยกจุดแกว่ง/เชือกขึ้น (world px) ป้องกันชนกองสูง (เฉพาะ Tower mission) */ const STACK_TOWER_SWING_LIFT_FROM_LAYER = 9; /** Stack — เกณฑ์ใกล้ความจริง: ทับมากขึ้น, จำกัดการเยื้องจากกลางฐาน, ทับน้อยแล้วโคลงง่ายขึ้น */ const STACK_OVERLAP_MISS_MIN_ON_LAND = 0.42; const STACK_OVERLAP_MISS_FRAC_ON_LAND = 0.14; const STACK_OVERLAP_MISS_MIN_ON_LAYER = 0.4; const STACK_OVERLAP_MISS_FRAC_ON_LAYER = 0.3; /** จุดกลางแท่งที่วาง vs กลางโซน land — เกิน (ความกว้างโซน × นี้) = พลาด (ชั้นที่ 2 ขึ้นไป) */ const STACK_MAX_CENTER_DRIFT_FRAC_OF_LAND = 0.38; /** ไม่มี stackLandArea: ใช้กลางแมป — เยื้องได้ไม่เกิน fallW × นี้ (รวม) */ const STACK_MAX_CENTER_DRIFT_NO_LAND_MULT = 1.0; /** overlap/fallW ต่ำกว่านี้ → เข้า settling (แท่งไม่นิ่ง) */ const STACK_STABLE_SUPPORT_RATIO_MIN = 0.62; /** Stack ทั่วไป (ไม่ใช่ Tower post-50%): บล็อก heavy กว้างกว่าปกติ (~382×72 vs 314×103) */ const STACK_BLOCK_HEAVY_WIDTH_MULT = 382 / 314; /** Tower live + progress ≥เกณฑ์ + heavy: ความกว้างบล็อก = ปกติ × ค่านี้ (เช่น 0.6 = 60% ของช่องปกติ — รูปใหญ่วาดในกรอบเดียวกัน) */ const STACK_TOWER_POST50_WIDTH_MULT = 0.6; function stackBlockWidthTilesForPlay(baseTiles, heavy) { const b = Number(baseTiles); const base = Number.isFinite(b) ? Math.max(0.85, Math.min(3.2, b)) : 2; const towerHeavyNarrow = !!heavy && isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live' && stackMini && Number.isFinite(Number(stackMini.progressPct)) && Number(stackMini.progressPct) >= STACK_TOWER_POST50_PROGRESS_THRESH; if (towerHeavyNarrow) { const w = base * STACK_TOWER_POST50_WIDTH_MULT; return Math.max(0.85, Math.min(3.2, Math.round(w * 200) / 200)); } if (!heavy) return base; const w = base * STACK_BLOCK_HEAVY_WIDTH_MULT; return Math.min(3.2, Math.round(w * 200) / 200); } /** Violent Crime mission: asteroid strikes ship → invuln → 3 strikes = eliminated */ const SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS = 3; const SPACE_SHOOTER_MISSION_HIT_INVULN_MS = 1400; const SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS = 12; /** อุกาบาต: HP สุ่ม 1–5 ต่อก้อน (ไม่วาดหัวใจบนก้อน) — ขนาดตาม HP เทียบสเกล 5 */ const SPACE_SHOOTER_ASTEROID_MAX_HP = 5; const SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL = 48; const SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP = 11; /* PRNG แบบ seed ได้ (mulberry32) — ใช้สุ่มสิ่งแวดล้อมมินิเกมแบบ deterministic seed เดียวกัน (อิง liveBeginsAt ของ server) → ทุกเครื่องได้ลำดับสุ่มเดียวกัน = อุกาบาต/บอลลูนชุดเดียวกัน */ function makeSeededRng(seed) { let t = (seed >>> 0) || 1; return function () { t = (t + 0x6D2B79F5) >>> 0; let r = Math.imul(t ^ (t >>> 15), 1 | t); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } /* seed ต่อ index ของ spawn — รวม anchor (liveBeginsAt) กับ index ให้ก้อนแต่ละลำดับสุ่มต่างกันแต่ตรงกันทุกเครื่อง */ function envSpawnSeed(anchorMs, index) { return (((anchorMs >>> 0) ^ Math.imul((index | 0) + 1, 2654435761)) >>> 0); } function spaceShooterAsteroidRadiusFromHpPlay(hp) { const cap = SPACE_SHOOTER_ASTEROID_MAX_HP; const h = Math.max(0, Math.min(cap, Math.floor(Number(hp)) || 0)); const frac = h <= 0 ? 0 : h / cap; return SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP + (SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL - SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP) * frac; } function spaceShooterShipFootprintCellsPlay(md) { const m = md || mapData; if (!m) return { cw: 1, ch: 1 }; const { cw, ch } = getCharacterFootprintWH(m); return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)), }; } /** ขนาดวาดยานบนจอ — สเกลตาม footprint แผนที่ (editor: กว้าง/สูงตัวละคร) เทียบ tileSize */ function spaceShooterShipBodyScreenPxPlay(zDrawVal) { const zD = Number(zDrawVal) > 0 ? zDrawVal : (typeof zoom === 'number' && zoom > 0 ? zoom : 1); const ts = tileSize || 32; const { cw, ch } = spaceShooterShipFootprintCellsPlay(mapData); const kw = 14 / 32; const kh = 22 / 32; return { bodyW: cw * ts * zD * kw, bodyH: ch * ts * zD * kh, }; } /** รัศมีชนอุกาบาต (พิกัดโลก) — โตตาม footprint เพื่อให้สมกับยานที่วาดใหญ่ขึ้น */ function spaceShooterShipHitRadiusWorldPxPlay() { if (!mapData || mapData.gameType !== 'space_shooter') return SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS; const ts = tileSize || 32; const { cw, ch } = spaceShooterShipFootprintCellsPlay(mapData); const base = SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS; const geom = Math.sqrt(Math.max(1, cw * ch)); const cap = Math.min(54, 0.42 * Math.min(cw * ts, ch * ts)); return Math.max(9, Math.min(cap, base * geom)); } /** ระยะห่างจากขอบโลกซ้าย/ขวาเมื่อบังคับยาน — กันยานใหญ่หลุดกรอบ */ function spaceShooterShipEdgePadWorldPxPlay() { if (!mapData || mapData.gameType !== 'space_shooter') return 18; const ts = tileSize || 32; const { cw } = spaceShooterShipFootprintCellsPlay(mapData); return Math.max(18, cw * ts * 0.48); } function spaceShooterResetMissionShipStateForAllPlay() { function resetEnt(ent) { if (!ent) return; ent.spaceShooterHits = 0; ent.spaceShooterEliminated = false; ent.spaceShooterInvulnUntil = 0; } resetEnt(me); others.forEach(function (o) { resetEnt(o); }); } function spaceShooterMissionResolveShipAsteroidHitsPlay() { if (!mapData || mapData.gameType !== 'space_shooter') return; if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) return; const now = performance.now(); const shipR = spaceShooterShipHitRadiusWorldPxPlay(); function tryHit(ent, cx, cy) { if (!ent || ent.spaceShooterEliminated || cx == null || cy == null) return; if (ent.spaceShooterInvulnUntil != null && now < ent.spaceShooterInvulnUntil) return; for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) { const a = spaceShooterAsteroids[ai]; if (!a) continue; const dx = cx - a.x; const dy = cy - a.y; const rr = (a.r + shipR) * (a.r + shipR); if (dx * dx + dy * dy > rr) continue; ent.spaceShooterHits = Math.max(0, Number(ent.spaceShooterHits) || 0) + 1; ent.spaceShooterInvulnUntil = now + SPACE_SHOOTER_MISSION_HIT_INVULN_MS; spaceShooterSpawnAsteroidExplosion(a.x, a.y, a.r); spaceShooterAsteroids.splice(ai, 1); spaceShooterPopups.push({ x: cx, y: cy - 28, text: 'HIT', until: Date.now() + 650 }); if (ent.spaceShooterHits >= SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS) { ent.spaceShooterEliminated = true; spaceShooterPopups.push({ x: cx, y: cy - 50, text: 'OUT', until: Date.now() + 1400 }); } break; } } const wasElim = !!me.spaceShooterEliminated; if (myId != null) tryHit(me, me.spaceShooterCx, me.spaceShooterCy); /* คิดการชนเฉพาะยานของเรา (เจ้าของตัดสินเอง) + บอท (host ตัดสิน) เท่านั้น ผู้เล่นมนุษย์คนอื่นรอสถานะ eliminated/hits จาก move ของเจ้าตัว — กัน "ตายไม่พร้อมกัน" (เดิมทุกเครื่องคิดชนให้ทุกยานด้วยตำแหน่งดาวเคราะห์ local ที่ต่างกัน → ตัดสินไม่ตรงกัน) */ others.forEach(function (o, id) { if (!o) return; if (isPreviewBotId(id) && isMePlayHost()) tryHit(o, o.spaceShooterCx, o.spaceShooterCy); }); /* เพิ่งตาย → broadcast ทันที ไม่รอ throttle 90ms ให้ทุกเครื่องเห็นพร้อมกัน */ if (!wasElim && me.spaceShooterEliminated && socket && myId != null) { try { spaceShooterLastMoveEmit = Date.now(); socket.emit('move', { x: me.x, y: me.y, direction: me.direction, spaceShooterScore: Math.max(0, me.spaceShooterScore | 0), spaceShooterHits: Math.max(0, Number(me.spaceShooterHits) || 0), spaceShooterEliminated: true, }); } catch (eE) { /* ignore */ } } if (spaceShooterMissionEveryParticipantEliminatedPlay()) { /* host ตัดสิน all_dead + broadcast คะแนน; non-host รอรับ space-shooter-over */ if (isMePlayHost()) triggerSpaceShooterOverPlay('all_dead'); } } /** Space Shooter ภารกิจ: ผู้เข้าร่วมทุกคนถูกกำจัดแล้ว (รวมเรา) */ function spaceShooterMissionEveryParticipantEliminatedPlay() { if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) return false; if (myId == null) return false; if (!me.spaceShooterEliminated) return false; let anyAlive = false; others.forEach(function (o) { if (!o) return; if (!o.spaceShooterEliminated) anyAlive = true; }); return !anyAlive; } /** จนกว่าโหลด /api/characters — placeholder ชั่วคราวก่อน join */ const LEGACY_PLACEHOLDER_CHARACTER_ID = 'Chatest'; let firstCharacterDefaultResolved = null; /** รหัสตัวละครจาก GET /api/characters — ใช้สลับบอทพรีวิวให้ไม่ซ้ำรูปกับผู้เล่น */ let playCharacterIdRoster = []; const firstCharacterDefaultPromise = fetch(BASE + '/api/characters') .then((r) => r.json()) .then((list) => { playCharacterIdRoster = []; if (Array.isArray(list)) { list.forEach((c) => { if (c && c.id) playCharacterIdRoster.push(String(c.id)); }); } if (Array.isArray(list) && list.length > 0 && list[0] && list[0].id) { firstCharacterDefaultResolved = String(list[0].id); } else { firstCharacterDefaultResolved = ''; } }) .catch(() => { firstCharacterDefaultResolved = ''; playCharacterIdRoster = []; }) .finally(() => { try { if (playBotsEnabled() && mapData) rebalancePreviewBots(); } catch (e) { /* map ยังไม่พร้อม */ } }); 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); const drParam = (params.get('displayRoom') || '').trim(); if (drParam) { h += '&displayRoom=' + encodeURIComponent(drParam); } else { 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; cancelQuizCarryAnswerRoundTimeupAuto(); quizCarryAnswerTimeupAwaitNext = false; hideQuizCarryTimeupOnDeskLayer(); try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ } const go = function () { window.location.href = buildRoomLobbyReturnHref(); }; /* รายงาน score/survived ของเกมที่ client นับเอง (stack/jump_survive) → server ใช้จัดอันดับเหรียญ (เดิมส่ง {} เปล่า → reportedMiniScore/Survived ไม่ถูกตั้ง → coin ranking MG3/MG5 เพี้ยน) */ let finishReport = {}; try { if (isStack() && stackMini) finishReport = { score: Math.max(0, stackMini.score | 0), survived: true }; else if (isJumpSurvive()) finishReport = { score: 0, survived: !jumpSurviveEliminated }; } catch (eFR) { /* ignore */ } if (socket && socket.connected) { socket.emit('detective-minigame-finished', finishReport, function (res) { try { if (res && Array.isArray(res.myPlayerEvidence)) { var hasAny = res.myPlayerEvidence.some(function (r) { return Array.isArray(r) && r.length; }); if (hasAny) sessionStorage.setItem('justiceDetectiveMyEvidence', JSON.stringify(res.myPlayerEvidence)); else sessionStorage.removeItem('justiceDetectiveMyEvidence'); } } catch (e) { /* ignore */ } /* MG2: เข้าเส้นชัยแล้วแต่ยังมีคนวิ่งอยู่ → รอในฉาก (server จะพากลับ LobbyB พร้อมกันตอนทุกคนจบ) */ if (res && res.holdInScene) { showGauntletFinishWaitOverlay(); return; } /* โชว์การ์ดหลักฐานที่เพิ่งได้ "ในฉากมินิเกม" ก่อนกลับ LobbyB */ if (res && typeof res.suspectIndex === 'number' && res.suspectIndex >= 0) { showInSceneEvidenceReveal(res, go); } else { go(); } }); } else { go(); } } /** MG2: ป้าย "เข้าเส้นชัยแล้ว — รอผู้เล่นอื่น" ระหว่างรอคนที่ยังวิ่งอยู่ (จะกลับ LobbyB พร้อมกันตอนเกมจบ) */ function showGauntletFinishWaitOverlay() { var ov = document.getElementById('gauntlet-finish-wait'); if (!ov) { ov = document.createElement('div'); ov.id = 'gauntlet-finish-wait'; ov.style.cssText = 'position:fixed;inset:0;z-index:100000;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;background:radial-gradient(ellipse at center,rgba(10,16,32,.82),rgba(6,9,20,.95));backdrop-filter:blur(3px)'; ov.innerHTML = '
🏁 เข้าเส้นชัยแล้ว!
' + '
รอผู้เล่นคนอื่นเข้าเส้นชัย...
' + '
• • •
'; document.body.appendChild(ov); } ov.style.display = 'flex'; } /* แจ้ง host ว่าทำไม START ยังกดเริ่มไม่ได้ (เดิม reply ถูก ignore เงียบ → ปุ่มเหมือนเสีย) ใช้ร่วม MG2/5/6/7 (crown lobby shell) — โชว์ "รอผู้เล่น x/N" หรือ "ยังมีคนไม่ Ready" แล้วซ่อนเอง */ function showCrownStartBlockedNoticePlay(r) { if (!r) return; let msg = r.error || 'ยังเริ่มไม่ได้'; if (r.waitingPlayers) msg = 'รอผู้เล่นเข้าเกมให้ครบก่อน (' + (r.present || 0) + '/' + (r.total || 0) + ')'; let ov = document.getElementById('crown-start-notice'); if (!ov) { ov = document.createElement('div'); ov.id = 'crown-start-notice'; ov.style.cssText = 'position:fixed;left:50%;top:14%;transform:translateX(-50%);z-index:100001;padding:12px 22px;border-radius:14px;background:rgba(20,12,16,.92);border:1px solid rgba(255,120,120,.5);color:#ffd9d9;font:700 18px/1.3 Kanit,system-ui,sans-serif;text-align:center;box-shadow:0 6px 24px rgba(0,0,0,.5);pointer-events:none'; document.body.appendChild(ov); } ov.textContent = '⏳ ' + msg; ov.style.display = 'block'; clearTimeout(ov._hideT); ov._hideT = setTimeout(function () { ov.style.display = 'none'; }, 2600); } /* ===== หน้าโชว์การ์ดหลักฐานในฉากมินิเกม (ก่อนกลับ LobbyB) ===== */ var __evidenceConfigCache = null; function ensureEvidenceConfigLoaded(cb) { if (__evidenceConfigCache) { cb(__evidenceConfigCache); return; } try { fetch(BASE + '/api/evidence-cards?_=' + Date.now(), { cache: 'no-store' }) .then(function (r) { return r.json(); }) .then(function (j) { __evidenceConfigCache = (j && j.cases) ? j : { cases: {} }; cb(__evidenceConfigCache); }) .catch(function () { cb({ cases: {} }); }); } catch (e) { cb({ cases: {} }); } } var EVIDENCE_RARITY_GLOW = { common: 'rgba(168,180,200,.55)', rare: 'rgba(94,170,255,.7)', legendary: 'rgba(255,186,72,.85)', }; var EVIDENCE_RARITY_LABEL = { common: 'การ์ดธรรมดา', rare: 'การ์ด Rare', legendary: 'การ์ดชี้คนร้าย' }; /** กล่องดูการ์ดหลักฐานแบบขยาย (zoom) — ปิดด้วยปุ่ม ×, คลิกพื้นหลัง, หรือ Esc */ function openEvidenceCardLightboxPlay(imageUrl, label, rarity) { if (!imageUrl) return; var rar = String(rarity || 'common').toLowerCase(); var glow = EVIDENCE_RARITY_GLOW[rar] || EVIDENCE_RARITY_GLOW.common; var ov = document.getElementById('evidence-card-lightbox'); if (!ov) { ov = document.createElement('div'); ov.id = 'evidence-card-lightbox'; ov.style.cssText = 'position:fixed;inset:0;z-index:100001;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(5,8,18,.88);backdrop-filter:blur(4px)'; ov.innerHTML = '' + '
' + '' + '
' + '
'; document.body.appendChild(ov); var closeFn = function () { ov.style.display = 'none'; }; ov.addEventListener('click', function (e) { if (e.target === ov || (e.target.classList && e.target.classList.contains('ecl-close'))) closeFn(); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && ov.style.display !== 'none') closeFn(); }); } var img = ov.querySelector('.ecl-img'); var cap = ov.querySelector('.ecl-cap'); if (img) { img.style.boxShadow = '0 0 44px ' + glow + ',0 22px 60px rgba(0,0,0,.6)'; img.src = imageUrl; } if (cap) cap.textContent = label || ''; ov.style.display = 'flex'; } function showInSceneEvidenceReveal(res, onDone) { var done = false; var finish = function () { if (done) return; done = true; onDone(); }; var si = res.suspectIndex; /* การ์ดที่เพิ่งได้รอบนี้ (object: rarity/imageUrl) — fallback ใช้ N ใบท้ายของ owned */ var awarded = (res.awardedCards && Array.isArray(res.awardedCards)) ? res.awardedCards.filter(function (c) { return c && c.imageUrl; }) : []; if (!awarded.length) { var owned = (res.myPlayerEvidence && res.myPlayerEvidence[si]) || []; var freeN = Math.max(0, Number(res.freeEvidenceCount) || 0); var newN = Math.min(owned.length, 1 + freeN); awarded = owned.slice(owned.length - newN).filter(function (c) { return c && c.imageUrl; }); } if (!awarded.length) { finish(); return; } var newN = awarded.length; var ov = document.createElement('div'); ov.id = 'inscene-evidence-reveal'; ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(5,8,18,.9);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;backdrop-filter:blur(3px)'; var title = document.createElement('div'); title.style.cssText = 'font:900 30px/1.2 Kanit,system-ui,sans-serif;color:#ffe7a3;text-shadow:0 0 18px rgba(255,214,102,.5);text-align:center'; title.textContent = newN > 1 ? ('คุณได้รับหลักฐาน ' + newN + ' ใบ!') : 'คุณได้รับหลักฐาน!'; if (res.grade) { var gradeEl = document.createElement('div'); gradeEl.style.cssText = 'font:800 16px/1 Kanit,system-ui,sans-serif;color:#bfe3ff;opacity:.9'; gradeEl.textContent = 'ผลงานทีม: เกรด ' + res.grade; title.appendChild(document.createElement('br')); title.appendChild(gradeEl); } var freeBonus = Math.max(0, Number(res.freeEvidenceCount) || 0); if (freeBonus > 0) { var bonusEl = document.createElement('div'); bonusEl.style.cssText = 'font:800 15px/1.3 Kanit,system-ui,sans-serif;color:#9ece6a;margin-top:4px'; bonusEl.textContent = '⭐ การ์ด Free Evidence: ทุกคนรับหลักฐานฟรี +' + freeBonus + ' ใบ'; title.appendChild(document.createElement('br')); title.appendChild(bonusEl); } var row = document.createElement('div'); row.style.cssText = 'display:flex;gap:18px;flex-wrap:wrap;justify-content:center;align-items:center;max-width:92vw'; awarded.forEach(function (c, i) { var rar = String(c.rarity || 'common').toLowerCase(); var glow = EVIDENCE_RARITY_GLOW[rar] || EVIDENCE_RARITY_GLOW.common; var cardWrap = document.createElement('div'); cardWrap.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:8px;opacity:0;transform:translateY(24px) scale(.92);transition:opacity .45s ease,transform .45s cubic-bezier(.2,.9,.3,1.3)'; var img = document.createElement('img'); img.src = c.imageUrl; img.alt = EVIDENCE_RARITY_LABEL[rar] || 'หลักฐาน'; img.style.cssText = 'display:block;width:min(51vw,390px);height:auto;border-radius:14px;box-shadow:0 0 36px ' + glow + ',0 18px 50px rgba(0,0,0,.6);cursor:zoom-in'; img.title = 'แตะเพื่อดูใหญ่'; img.addEventListener('click', function () { openEvidenceCardLightboxPlay(c.imageUrl, EVIDENCE_RARITY_LABEL[rar] || '', rar); }); img.onerror = function () { this.onerror = null; this.style.width = '200px'; this.style.height = '300px'; this.style.background = '#1b2240'; this.removeAttribute('src'); }; var tag = document.createElement('div'); tag.style.cssText = 'font:800 14px/1 Kanit,system-ui,sans-serif;color:#fff;opacity:.92'; tag.textContent = EVIDENCE_RARITY_LABEL[rar] || ''; cardWrap.appendChild(img); cardWrap.appendChild(tag); row.appendChild(cardWrap); setTimeout(function () { cardWrap.style.opacity = '1'; cardWrap.style.transform = 'translateY(0) scale(1)'; }, 120 + i * 160); }); var btn = document.createElement('button'); btn.type = 'button'; btn.textContent = 'คุณได้หลักฐาน · เก็บเข้าแฟ้ม'; btn.style.cssText = 'cursor:pointer;border:none;border-radius:999px;padding:14px 40px;font:800 18px/1 Kanit,system-ui,sans-serif;color:#0a0e1a;background:linear-gradient(180deg,#ffd666,#f0b429);box-shadow:0 10px 30px rgba(240,180,41,.45)'; btn.addEventListener('click', function () { ov.style.transition = 'opacity .25s'; ov.style.opacity = '0'; setTimeout(finish, 220); }); ov.appendChild(title); ov.appendChild(row); ov.appendChild(btn); document.body.appendChild(ov); /* กันค้าง: ถ้าไม่กดภายใน 12 วิ ให้ไปต่อเอง */ setTimeout(function () { if (!done) { try { ov.remove(); } catch (e) {} finish(); } }, 12000); } /** จบมินิเกมโหมดสืบสวน → LobbyB; คืน true ถ้า redirect แล้ว */ function tryFinishDetectiveMinigameAndReturnLobby() { if (!isDetectiveMinigamePlay()) return false; finishDetectiveMinigameAndReturnLobby(); return true; } /** ทดสอบจากเอดิเตอร์: เติมบอทให้ครบจำนวน (รวมผู้เล่นจริง) — ?fillTotal=6 (ค่าเริ่ม 6) */ const previewFillBots = previewMode && editorEmbedReturn; let detectiveCaseFillBots = false; let detectiveBotSlotCount = 0; function playBotsEnabled() { return previewFillBots || detectiveCaseFillBots; } function playBotTargetHeadcount() { if (previewFillBots) return previewTargetHeadcount; if (detectiveCaseFillBots) return countPlayHumans() + detectiveBotSlotCount; return countPlayHumans(); } /** พรีวิวใน iframe เอดิเตอร์: ซูมมุมมองได้ (เดิมไม่มี — zDraw จาก zoom คงที่) */ const PLAY_EMBED_USER_ZOOM_MIN = 0.5; const PLAY_EMBED_USER_ZOOM_MAX = 3; const PLAY_EMBED_ZOOM_STEP_KEY = 1.12; let playEmbedUserZoomMul = 1; let lastPlayZDrawForInput = 1.4; /** คูณ world→พิกเซลบนแคนวาสให้ตรงกับ zDraw ใน draw() (รวม preview+embed zoom) — ใช้ซิงก์ DOM ทับฉาก */ function playDomSyncZoom() { const z = lastPlayZDrawForInput; return Number.isFinite(z) && z > 0 ? z : zoom; } const previewTargetHeadcount = Math.min(24, Math.max(1, parseInt(params.get('fillTotal'), 10) || 6)); const PREVIEW_BOT_PREFIX = '__pv_bot_'; let previewBotSeq = 0; /** Stack preview: บอทไม่วาบบนแผนที่ — ใช้ไฮไลต์แถบแข่งแทน */ let lastStackPreviewActorId = null; let lastStackPreviewActorUntil = 0; /** Stack preview: สลับตา Player 1 = มนุษย์, 2–6 = บอท (เฉพาะตาปัจจุบันเล่นได้) */ const STACK_PREVIEW_TURN_COUNT = 6; let stackPreviewTurnOrder = null; let stackPreviewTurnIndex = 0; let stackPreviewBotThinkUntil = 0; /** ลำดับตา MG3 = ผู้เล่นจริงทุกคน + บอท เรียงตาม id แบบคงที่ → ทุกเครื่องได้ลำดับ/ที่นั่งเดียวกัน (shared tower) (เดิม [human=ตัวเอง seat1 + bots] ต่อเครื่อง → แต่ละเครื่องมองตัวเองเป็นคนเดียว เลยแยกตึกกัน) */ function rebuildStackPreviewTurnOrder() { if (!playBotsEnabled() || !mapData || mapData.gameType !== 'stack') { stackPreviewTurnOrder = null; return; } if (!detectiveCaseFillBots) { /* editor preview (คนเดียว): คงพฤติกรรมเดิม [human ตัวเอง seat1 + บอท] */ const bots = [...others.keys()].filter(isPreviewBotId).sort(); stackPreviewTurnOrder = [{ kind: 'human', seat: 1, id: myId != null ? String(myId) : '__me__' }]; for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) { stackPreviewTurnOrder.push({ kind: 'bot', seat: i + 2, id: bots[i] || null, botId: bots[i] || null }); } stackPreviewTurnIndex = 0; stackPreviewBotThinkUntil = 0; markStackNextDropVisualDirty(); return; } /* เล่นจริง: non-host ไม่สร้าง order เอง — ใช้ของ host จาก stack-state (กัน order ไม่ตรง/client ตกหล่นช่วงโหลด) */ if (!isMePlayHost()) return; /* host: ผู้เล่นจริงทุกคน (ตัด banned spectator กัน stall) + บอท เรียง id → ที่นั่งคงที่; preserve คนที่กำลังถึงตา */ const prevActorId = (stackCurrentTurnEntryPlay() && stackCurrentTurnEntryPlay().id) || null; const parts = []; if (myId != null && !playBannedSpectator) parts.push({ kind: 'human', id: String(myId) }); others.forEach((o, id) => { if (isPreviewBotId(id)) { parts.push({ kind: 'bot', id: String(id), botId: String(id) }); return; } if (o && o.bannedSpectator) return; /* คนถูกแบน = ดูเฉยๆ ไม่เข้าคิววาง */ parts.push({ kind: 'human', id: String(id) }); }); parts.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); const capped = parts.slice(0, STACK_PREVIEW_TURN_COUNT); stackPreviewTurnOrder = capped.map((p, i) => ({ kind: p.kind, seat: i + 1, id: p.id, botId: p.kind === 'bot' ? p.botId : null, })); let ni = -1; if (prevActorId != null) ni = stackPreviewTurnOrder.findIndex((e) => String(e.id) === String(prevActorId)); if (ni >= 0) stackPreviewTurnIndex = ni; else if (stackPreviewTurnIndex >= stackPreviewTurnOrder.length) stackPreviewTurnIndex = 0; stackPreviewBotThinkUntil = 0; markStackNextDropVisualDirty(); broadcastStackStatePlay(); } /** host broadcast ลำดับตา + index ปัจจุบัน → non-host ใช้ตรงกัน (single source of truth) */ function broadcastStackStatePlay() { if (!detectiveCaseFillBots || !isMePlayHost() || !socket || myId == null || !stackTurnOrderReadyPlay()) return; try { socket.emit('stack-state', { idx: stackPreviewTurnIndex, order: stackPreviewTurnOrder.map((e) => ({ kind: e.kind, seat: e.seat, id: e.id, botId: e.botId || null })), }); } catch (e) { /* ignore */ } } /** order พร้อมใช้ (มีอย่างน้อย 1 ที่นั่ง) */ function stackTurnOrderReadyPlay() { return !!(stackPreviewTurnOrder && stackPreviewTurnOrder.length >= 1); } function stackCurrentTurnEntryPlay() { if (!stackTurnOrderReadyPlay()) return null; return stackPreviewTurnOrder[stackPreviewTurnIndex % stackPreviewTurnOrder.length] || null; } function isMyStackTurn() { if (!playBotsEnabled()) return true; /* เล่นเดี่ยว → กดได้ */ if (detectiveCaseFillBots && !stackTurnOrderReadyPlay()) return false; /* เล่นจริง: รอ order จาก host ก่อน */ if (!stackTurnOrderReadyPlay()) return true; const cur = stackCurrentTurnEntryPlay(); if (!cur || cur.kind !== 'human') return false; if (!detectiveCaseFillBots) return true; /* editor preview: human = previewer คนเดียว */ return String(cur.id) === String(myId); } function isStackCurrentBotTurnPlay() { const cur = stackCurrentTurnEntryPlay(); return !!(cur && cur.kind === 'bot'); } /** เจ้าของตาที่วาง broadcast ผลการวาง → ทุกเครื่อง apply เหมือนกัน + เลื่อนตาพร้อมกัน */ function broadcastStackDropPlay(hit, seat, heavy, actorId, isBot) { if (!detectiveCaseFillBots || !socket || myId == null || !hit) return; /* broadcast เฉพาะเล่นจริง */ try { socket.emit('stack-drop', { placedCx: hit.placedCx, placedW: hit.placedW, miss: !!hit.miss, perfect: !!hit.perfect, pts: hit.pts || 0, progressDelta: hit.progressDelta || 0, landOffsetTiles: hit.landOffsetTiles || 0, overlap: hit.overlap, supportRatio: hit.supportRatio, delta: hit.delta || 0, seat: seat || 1, heavy: !!heavy, actorId: actorId != null ? String(actorId) : '', isBot: !!isBot, }); } catch (e) { /* ignore */ } } function advanceStackPreviewTurn() { if (!playBotsEnabled() || !stackTurnOrderReadyPlay()) return; /* เล่นจริง: index คุมโดย host เท่านั้น (non-host รับจาก stack-state) กันลำดับตาเหลื่อมกัน */ if (detectiveCaseFillBots && !isMePlayHost()) return; stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % stackPreviewTurnOrder.length; stackPreviewBotThinkUntil = 0; markStackNextDropVisualDirty(); broadcastStackStatePlay(); } function isStackPreviewHumanTurn() { if (!playBotsEnabled() || !stackTurnOrderReadyPlay()) return true; const cur = stackCurrentTurnEntryPlay(); return !!(cur && cur.kind === 'human'); } function getStackPreviewCurrentSeat() { if (!stackTurnOrderReadyPlay()) return 1; const cur = stackCurrentTurnEntryPlay(); return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1; } function getStackDropActorSeat() { if (playBotsEnabled() && stackTurnOrderReadyPlay()) { return getStackPreviewCurrentSeat(); } return 1; } function markStackNextDropVisualDirty() { if (stackMini) stackMini.nextDropVisualDirty = true; } function pickStackBlockHeavyVisual(seat) { const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1)); const hU = normalizeGauntletAssetUrlForPlay(playStackBlockHeavyUrls[slot] || ''); /** Tower mnn93hpi: ตั้งแต่เกณฑ์ progress (ตาม stackTowerProgressBlocks) → ใช้รูป heavy จาก admin — นอก live ไม่สุ่ม heavy */ if (isStackTowerMissionUiMapPlay()) { if (!(stackTowerMissionPhase === 'live' && stackMini)) return false; const p = Number(stackMini.progressPct); if (!Number.isFinite(p) || p < STACK_TOWER_POST50_PROGRESS_THRESH) return false; return !!hU; } const pct = Math.max(0, Math.min(100, Math.floor(Number(playStackHeavyBlockPercent) || 0))); if (pct <= 0) return false; if (!hU) return false; if (Math.random() * 100 >= pct) return false; return true; } function ensureStackNextDropVisual() { if (!stackMini || !stackMini.nextDropVisualDirty) return; stackMini.pendingDropSeat = getStackDropActorSeat(); stackMini.pendingDropHeavy = pickStackBlockHeavyVisual(stackMini.pendingDropSeat); stackMini.widthTiles = stackBlockWidthTilesForPlay(stackMini.initialWidthTiles, !!stackMini.pendingDropHeavy); stackMini.nextDropVisualDirty = false; } function normalizePlayStackSixUrlsFromTiming(arr) { const out = ['', '', '', '', '', '']; if (!Array.isArray(arr)) return out; for (let i = 0; i < 6; i++) { out[i] = normalizeGauntletAssetUrlForPlay(typeof arr[i] === 'string' ? arr[i] : ''); if (out[i]) ensureGauntletAssetImage(out[i]); } return out; } function resolveStackBlockSpriteRec(seat, heavy) { const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1)); const tryHeavy = !!heavy; const order = tryHeavy ? [playStackBlockHeavyUrls[slot], playStackBlockNormalUrls[slot]] : [playStackBlockNormalUrls[slot], playStackBlockHeavyUrls[slot]]; for (let ti = 0; ti < order.length; ti++) { const u = normalizeGauntletAssetUrlForPlay(order[ti] || ''); if (!u) continue; return ensureGauntletAssetImage(u); } return null; } /** * @returns {boolean} true = วาดสไปรต์แล้ว · false = วาดสี่เหลี่ยมสี fallback * ใช้ scale แบบ cover + clip ให้ขอบภาพตรงกล่องฟิสิกส์ (drawW×drawH) — ไม่เว้นขอบซ้ายขวาแบบ contain * เพื่อให้สายตาตรงกับ overlap / miss ใน computeStackDropResult */ function drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seat, heavy, hueFallback, opts) { const o = opts || {}; const rec = resolveStackBlockSpriteRec(seat, heavy); const img = rec && rec.img; if (rec && rec.ready && img && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) { const iw = img.naturalWidth; const ih = img.naturalHeight; if (drawW > 0 && drawH > 0) { const scale = Math.max(drawW / iw, drawH / ih); const dw = iw * scale; const dh = ih * scale; const dx = sx + (drawW - dw) * 0.5; const dy = sy + (drawH - dh) * 0.5; const prevSmooth = ctx.imageSmoothingEnabled; ctx.save(); ctx.beginPath(); ctx.rect(sx, sy, drawW, drawH); ctx.clip(); try { ctx.imageSmoothingEnabled = false; ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh); } finally { ctx.imageSmoothingEnabled = prevSmooth; } ctx.restore(); if (o.missOverlay) { ctx.fillStyle = 'rgba(247, 118, 190, 0.42)'; ctx.fillRect(sx, sy, drawW, drawH); } return true; } } const hue = ((hueFallback % 360) + 360) % 360; ctx.fillStyle = o.missTint ? 'rgba(247, 118, 190, 0.88)' : ('hsla(' + hue + ', 68%, 56%, 0.9)'); ctx.fillRect(sx, sy, drawW, drawH); return false; } if (!spaceId) { window.location.replace(playDesignMainMenuHref()); return; } const socket = io({ path: BASE + '/socket.io' }); window.__jdGameSocket = socket; const canvas = document.getElementById('game-canvas'); const ctx = canvas.getContext('2d'); /** popup แจ้งเตือน/ผิดพลาด ดีไซน์เอง (แทน alert() ของเบราว์เซอร์ที่หน้าตาไม่เข้าธีม) opts: { type:'error'|'info', okText, onClose } — onClose เรียกเมื่อกดตกลง/ปิด */ function showPlayAlert(message, opts) { opts = opts || {}; const prev = document.getElementById('play-alert-modal'); if (prev) prev.remove(); const overlay = document.createElement('div'); overlay.id = 'play-alert-modal'; overlay.className = 'play-alert-modal' + (opts.type === 'error' ? ' play-alert-modal--error' : ''); const card = document.createElement('div'); card.className = 'play-alert-card'; card.setAttribute('role', 'alertdialog'); card.setAttribute('aria-modal', 'true'); const backdrop = document.createElement('div'); backdrop.className = 'play-alert-backdrop'; const icon = document.createElement('div'); icon.className = 'play-alert-icon'; icon.textContent = opts.type === 'error' ? '!' : 'i'; const msg = document.createElement('div'); msg.className = 'play-alert-msg'; msg.textContent = (message == null ? '' : String(message)); const ok = document.createElement('button'); ok.type = 'button'; ok.className = 'play-alert-ok'; ok.textContent = opts.okText || 'ตกลง'; card.appendChild(icon); card.appendChild(msg); card.appendChild(ok); overlay.appendChild(backdrop); overlay.appendChild(card); document.body.appendChild(overlay); let closed = false; const done = function () { if (closed) return; closed = true; overlay.remove(); document.removeEventListener('keydown', onKey, true); if (typeof opts.onClose === 'function') { try { opts.onClose(); } catch (e) { /* ignore */ } } }; const onKey = function (e) { if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); done(); } }; ok.addEventListener('click', done); if (!opts.requireButton) backdrop.addEventListener('click', done); document.addEventListener('keydown', onKey, true); setTimeout(function () { try { ok.focus(); } catch (e) { /* ignore */ } }, 30); return { close: done }; } window.__jdShowPlayAlert = showPlayAlert; let mapData = null, tileSize = 32, myId = null; let stackMini = null; let stackFall = null; let stackLastSwingEmit = 0; /* throttle broadcast เฟสแกว่ง MG3 (เจ้าของตา → คนอื่น mirror) */ let stackLastStateEmit = 0; /* throttle broadcast ลำดับตา+index MG3 (host → non-host adopt) */ let stackRemoteDropQueue = []; /* คิวดรอปจาก host/คนอื่น (MG3) — ทยอย apply ทีละอันกันทิ้ง */ /** เศษบล็อกที่พลาด/หล่น → ตกแบบ physics หลุดขอบจอ (visual เท่านั้น ไม่กระทบคะแนน/sync) */ let stackMissDebris = []; let _stackDebrisLastT = 0; /** Tower / Stack — เชือกเหวี่ยงจาก `img/TowerBlock/sling.png` */ let stackTowerSlingImg = null; /** Tower mnn93hpi — +คะแนน (`score+10.png` / `score+20.png`) + เอฟเฟกต์ ×2 (`score-light.png`, `scorex2.png`) */ let stackTowerScorePlus10Img = null; let stackTowerScorePlus20Img = null; let stackTowerScoreLightImg = null; let stackTowerScoreX2Img = null; /** พลาดแล้วโชว์ heart-1.png กลางเหนือกอง (mock Tower) — heart เต็มใน HUD ใช้ไฟล์เดียวกัน */ let stackTowerHeartMinusImg = null; /** HUD มุมขวา: `life-bar.png` + `life-full.png` / `life-hit.png` (mock รูป2) */ let stackTowerLifeBarImg = null; let stackTowerLifeHitImg = null; let stackTowerLifeFullImg = null; /** Space Shooter (violent_crime): life HUD โหลดจาก /img/ViolentCrime/ แยกจาก TowerBlock ของ Stack */ let violentCrimeLifeBarImg = null; let violentCrimeLifeHitImg = null; let violentCrimeLifeFullImg = null; /** @type {{ until: number } | null} */ let stackTowerHeartMinusFx = null; const STACK_TOWER_HEART_MINUS_FX_MS = 1500; const STACK_TOWER_PERFECT_GLOW_MS = 1500; /** ป๊อปอัป +10 / +20 เหนือบล็อก */ const STACK_TOWER_SCORE_POPUP_MS = 2000; /** ระยะโชว์ score-light + scorex2 ข้างชั้นบน (คู่กับ +20 / perfect full) */ const STACK_TOWER_PERFECT_X2_MS = 2200; /** ทับบล็อกก่อนหน้า + ชั้นใหม่: support เต็มแทบไม่มีเพี้ยน */ const STACK_TOWER_PERFECT_SUPPORT_MIN = 0.998; let lastStackTickMs = performance.now(); let lastStackTowerScrollBgTickMs = performance.now(); let mapBackgroundImg = null; /** space_shooter + แมป mnpz6rkp — พื้นหลังเลื่อนแนวตั้ง (ข้อมูลจาก editorBgScroll ในแมป) */ const PLAY_SCROLL_BG_MAP_ID = 'mnpz6rkp'; const PLAY_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnpz6rkp/intro.png'; const PLAY_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnpz6rkp/loop.png'; let playScrollBgIntroImg = null; let playScrollBgLoopImg = null; /** ขอบบนของ viewport ใน strip — เพิ่มค่า = เลื่อนลง strip (เข้า loop ใต้ intro) */ let playScrollBgPx = 0; let playScrollBgSpeedPxPerSec = 56; let playScrollBgOff = false; /** true = บน→ล่าง: พลิกมุมมองใน viewport (ยังเลื่อน S+ เข้า loop ใต้ intro) */ let playScrollBgFlowDown = false; function playScrollBgMapEligible() { return !!(mapData && mapData.gameType === 'space_shooter' && (currentPlayMapId() || '').trim() === PLAY_SCROLL_BG_MAP_ID); } function playScrollBgDrawActive() { if (!playScrollBgMapEligible() || playScrollBgOff) return false; const a = playScrollBgIntroImg; const b = playScrollBgLoopImg; return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth); } function reloadPlayScrollBgFromMap() { playScrollBgIntroImg = null; playScrollBgLoopImg = null; playScrollBgPx = 0; if (!playScrollBgMapEligible()) return; const raw = mapData.editorBgScroll && typeof mapData.editorBgScroll === 'object' ? mapData.editorBgScroll : {}; playScrollBgOff = raw.enabled === false; playScrollBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 56)); const dirRaw = String(raw.scrollDirection || raw.direction || 'up').toLowerCase(); playScrollBgFlowDown = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom'); if (playScrollBgOff) return; const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : PLAY_SCROLL_BG_DEFAULT_INTRO; const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : PLAY_SCROLL_BG_DEFAULT_LOOP; let pending = 2; const bump = () => { pending--; if (pending <= 0) { playScrollBgSyncInitialScrollToBottom(); try { draw(); } catch (e) { /* ignore */ } } }; const im1 = new Image(); im1.onload = () => { playScrollBgIntroImg = im1; bump(); }; im1.onerror = () => { bump(); }; im1.src = introSrc; const im2 = new Image(); im2.onload = () => { playScrollBgLoopImg = im2; bump(); }; im2.onerror = () => { bump(); }; im2.src = loopSrc; } function playScrollBgSyncInitialScrollToBottom() { const intro = playScrollBgIntroImg; if (!canvas || !intro || !intro.complete || !intro.naturalWidth) return; const cwR = Math.max(1, Math.round(canvas.width)); const chR = Math.max(1, Math.round(canvas.height)); const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth)); /* ล่าง→บน: S = HI−ch ให้ขอบล่าง intro ชิดล่างจอ · บน→ล่าง (พลิก dest): S=0 ถึงได้ขอบล่าง intro ชิดล่างจอ */ if (playScrollBgFlowDown) { playScrollBgPx = 0; } else { playScrollBgPx = Math.max(0, drawHIntro - chR); } } function drawPlayScrollBgFullCanvas(cw, ch) { const intro = playScrollBgIntroImg; const loop = playScrollBgLoopImg; if (!intro || !intro.complete || !loop || !loop.complete) return; const cwR = Math.max(1, Math.round(cw)); const chR = Math.max(1, Math.round(ch)); const scaleI = cwR / intro.naturalWidth; const drawHIntro = Math.round(intro.naturalHeight * scaleI); const scaleL = cwR / loop.naturalWidth; const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL)); const S = playScrollBgPx; const vp0 = S; const vp1 = S + chR; const yLimit = vp1 + drawHLoop * 2; const flowDown = playScrollBgFlowDown; function stripTopToDestY(stripTop, drawH) { if (!flowDown) return stripTop - S; return S + chR - (stripTop + drawH); } ctx.save(); /* BG อวกาศเป็นภาพ illustration (loop 1024w สเกลขึ้น ~1.9x) — เปิด smoothing ไม่ให้แตกเป็นบล็อก (stack tower BG กว้าง ~1920 จึงคงปิดไว้) */ ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.beginPath(); ctx.rect(0, 0, cwR, chR); ctx.clip(); if (vp0 < 0) { let k = 1; if (vp0 < -drawHLoop * 4) { k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2); } for (; k < 50000; k++) { const y0 = -k * drawHLoop; if (y0 >= vp1 + drawHLoop) break; if (y0 + drawHLoop <= vp0) continue; const dy = Math.round(stripTopToDestY(y0, drawHLoop)); ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop); } } if (drawHIntro > vp0 && vp1 > 0) { const dy = Math.round(stripTopToDestY(0, drawHIntro)); ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro); } let y = drawHIntro; if (y < yLimit && vp1 > drawHIntro) { if (vp0 > drawHIntro) { const n = Math.floor((vp0 - drawHIntro) / drawHLoop); y = drawHIntro + Math.max(0, n - 1) * drawHLoop; } while (y < yLimit) { const dy = Math.round(stripTopToDestY(y, drawHLoop)); ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop); y += drawHLoop; } } ctx.restore(); } function tickPlayScrollBg(dtSec) { if (!playScrollBgDrawActive()) return; playScrollBgPx += (playScrollBgSpeedPxPerSec || 56) * dtSec; } /** Last Light (mno9kb07) — พื้นหลังแนวนอน: start → loop 2→3→4 → finish ครั้งเดียว (ไม่วน) แล้วจบเกม + ซ่อนอุปสรรค (gauntletCrownRunwayBg ในแมป) */ const GAUNTLET_CROWN_RUNWAY_BG_MAP_ID = 'mno9kb07'; /** หลังหยุดเลื่อนที่จุด FINISH — รอก่อน latch + โฟลว์สรุป (มิลลิวิ) */ const GAUNTLET_CROWN_RUNWAY_POST_STOP_MS = 2000; const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START = BASE + '/img/editor-bg-mno9kb07/start.png'; const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2 = BASE + '/img/editor-bg-mno9kb07/loop2.png'; const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3 = BASE + '/img/editor-bg-mno9kb07/loop3.png'; const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4 = BASE + '/img/editor-bg-mno9kb07/loop4.png'; const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH = BASE + '/img/editor-bg-mno9kb07/finish.png'; let gauntletCrownRunwayBgImgs = [null, null, null, null, null]; let gauntletCrownRunwayBgOff = false; let gauntletCrownRunwayBgSpeedPxPerSec = 48; let gauntletCrownRunwayBgScrollPx = 0; let gauntletCrownRunwayBgFinishLatched = false; /** ส่งสัญญาณ "ถึงเส้นชัย" ให้ server แล้วหรือยัง (กันส่งซ้ำ) — server จะหยุด obstacle/collision */ let gauntletFinishPhaseSignaled = false; /** >0 = หยุดเลื่อนแล้ว (timestamp ms) รอ POST_STOP ก่อน latch */ let gauntletCrownRunwayBgStripFreezeSinceMs = 0; /** คอลัมน์แผนที่ที่ล็อกตอน freeze — แนบตัวนำแทนขอบขวาสูงสุดของแมป */ let gauntletCrownRunwayFinishAlignLatchedWorldX = null; /** พรีวิว embed: แสดง GCM ครั้งเดียวเมื่อรันเวย์ครบแนว (ไม่รอเซิร์ฟเวอร์) */ let gauntletCrownRunwayClientMissionShown = false; /** พรมแดง mno9kb07 — จำกัดขอบขวาของตัว (ขอบขวา footprint ไม่เกินสัดส่วนนี้ของความกว้างแมปเป็นช่อง) */ const GAUNTLET_CROWN_HEIST_MAX_X_WORLD_FRAC = 0.8; const GAUNTLET_CROWN_SCORE_POP_URL = BASE + '/img/gauntlet-assets/score-.png'; let gauntletCrownScorePenaltyImg = null; function ensureGauntletCrownScorePenaltyImgPlay() { if (gauntletCrownScorePenaltyImg) return gauntletCrownScorePenaltyImg; const im = new Image(); im.src = GAUNTLET_CROWN_SCORE_POP_URL; gauntletCrownScorePenaltyImg = im; return im; } let lastGauntletCrownRunwayBgTickMs = 0; /** จัด offset เลื่อนรันเวย์ให้จุดเกิดตรงกับงานศิลป์ START (รูป 1) — รีเซ็ตตอน howto / โหลดแมปใหม่ */ let gauntletCrownRunwaySpawnScrollSnapped = false; function gauntletCrownRunwayBgMapEligiblePlay() { return !!(mapData && mapData.gameType === 'gauntlet' && (currentPlayMapId() || '').trim() === GAUNTLET_CROWN_RUNWAY_BG_MAP_ID); } function gauntletCrownRunwayBgNatDims(im) { if (!im) return null; if (im.tagName === 'CANVAS' && im.width > 0 && im.height > 0) return { nw: im.width, nh: im.height }; if (im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) return { nw: im.naturalWidth, nh: im.naturalHeight }; return null; } function gauntletCrownRunwayBgImageSlotReadyPlay(im) { return !!gauntletCrownRunwayBgNatDims(im); } function gauntletCrownRunwayBgStripWidthsAtCh(chR) { const ch0 = Math.max(1, Math.round(chR)); const wOf = (im) => { const d = gauntletCrownRunwayBgNatDims(im); if (!d) return 0; return Math.max(1, Math.round(d.nw * (ch0 / d.nh))); }; return { w0: wOf(gauntletCrownRunwayBgImgs[0]), w1: wOf(gauntletCrownRunwayBgImgs[1]), w2: wOf(gauntletCrownRunwayBgImgs[2]), w3: wOf(gauntletCrownRunwayBgImgs[3]), w4: wOf(gauntletCrownRunwayBgImgs[4]), }; } function gauntletCrownRunwayBgImagesReadyPlay() { for (let i = 0; i < 5; i++) { if (!gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) return false; } return true; } /** ถ้า URL บนเซิร์ฟยังไม่มีไฟล์ (404) ให้แผ่นเลื่อนได้ — พื้นผิวนุ่ม ไม่ใส่ข้อความใหญ่ (กันเหมือน debug) */ function ensureGauntletCrownRunwayBgPlaceholdersPlay() { if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return; const W = 960; const H = 540; const hues = [352, 210, 165, 225, 38]; for (let i = 0; i < 5; i++) { if (gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) continue; const c = document.createElement('canvas'); c.width = W; c.height = H; const g = c.getContext('2d'); const h0 = hues[i]; const grd = g.createLinearGradient(0, 0, W, H * 0.9); grd.addColorStop(0, `hsl(${h0},26%,20%)`); grd.addColorStop(0.45, `hsl(${h0},18%,14%)`); grd.addColorStop(1, `hsl(${h0},12%,10%)`); g.fillStyle = grd; g.fillRect(0, 0, W, H); g.strokeStyle = 'rgba(255,255,255,0.06)'; g.lineWidth = 1; for (let x = 0; x < W; x += 48) { g.beginPath(); g.moveTo(x, 0); g.lineTo(x + H * 0.04, H); g.stroke(); } const vg = g.createRadialGradient(W * 0.45, H * 0.35, 20, W * 0.5, H * 0.55, W * 0.85); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(0,0,0,0.38)'); g.fillStyle = vg; g.fillRect(0, 0, W, H); g.fillStyle = 'rgba(255,255,255,0.1)'; g.font = '10px system-ui,sans-serif'; g.textAlign = 'right'; g.fillText(String(i + 1), W - 10, H - 8); gauntletCrownRunwayBgImgs[i] = c; } } function gauntletCrownRunwayBgDrawActivePlay() { if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return false; return gauntletCrownRunwayBgImagesReadyPlay(); } function gauntletCrownRunwayFinishLineInArtFracPlay() { const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; let lineInArt = Number(raw.finishLineInFinishArtFrac); if (!Number.isFinite(lineInArt)) { lineInArt = gauntletCrownRunwayBgMapEligiblePlay() ? 0.34 : 0.12; } return Math.max(0, Math.min(0.92, lineInArt)); } function gauntletCrownRunwayFinishLineStripXPlay(g) { if (!g) return null; return g.finishStart + g.w4Safe * gauntletCrownRunwayFinishLineInArtFracPlay(); } /** โลกแมป X ของจุดอ้างอิงในงานศิลป์ FINISH = stripX − scrollPx (ไม่ขึ้นกับกล้อง) */ function gauntletCrownRunwayFinishLineMapWorldXPlay() { const g = gauntletCrownRunwayScrollGeometryPlay(); if (!g) return null; const stripX = gauntletCrownRunwayFinishLineStripXPlay(g); if (stripX == null) return null; return stripX - gauntletCrownRunwayBgScrollPx; } function gauntletCrownRunwayStripFrozenPlay() { return gauntletCrownRunwayBgStripFreezeSinceMs > 0 || gauntletCrownRunwayBgFinishLatched; } /** จุดอ้างอิงกลางตัวนำ (map world px) — ใช้หยุด FINISH ตรงตัวละคร ไม่ใช่ขอบขวาแมป */ function gauntletCrownRunwayLeadingEntityAlignWorldXPlay() { if (!mapData || !isGauntletCrownHeistMapPlay()) return null; const ts = mapData.tileSize || 32; const { cw } = getCharacterFootprintWH(mapData); const mx = getGauntletCrownHeistMaxEntityXPlay(mapData); /* แนบเส้นชัยกับ "ตัวนำหน้าสุดที่ยังไม่ตาย" (tx มากสุด) → ตัวนำหยุดที่เส้นพอดี ไม่เลยเส้น (ตอนหยุด เราจะ snap ตัวที่เหลือมายืนที่เส้นเดียวกันด้วย ดู gauntletSnapLivingToFinishPlay) */ let leadTx = -Infinity; const consider = (ent) => { if (!ent || ent.gauntletEliminated) return; let tx = Number.isFinite(ent.tx) ? ent.tx : Number(ent.x); if (!Number.isFinite(tx)) return; leadTx = Math.max(leadTx, tx); }; consider(me); others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; consider(o); }); if (!Number.isFinite(leadTx) || leadTx === -Infinity) return null; let alignWorld = (leadTx + cw * 0.5) * ts; if (mx != null && Number.isFinite(mx)) { alignWorld = Math.min(alignWorld, (mx + cw * 0.5) * ts); } return alignWorld; } /** ตอนถึงเส้นชัย: ดึงตัวที่ยังไม่ตายทุกตัวมายืนที่ "เส้นชัย" — ไม่มีใครเลยเส้น, คนตายไม่ย้าย เป้าหมาย = ตำแหน่งเส้นชัยที่ latch ไว้ (คงที่) ถ้ายังไม่ latch ใช้ tx ตัวนำสุด เรียกทุกเฟรมระหว่าง freeze/latched เพื่อกันบอทที่ sim ยังขยับ "ไหล" หลุดจากเส้น */ function gauntletSnapLivingToFinishPlay() { if (!mapData || !isGauntletCrownHeistMapPlay()) return; const ts = mapData.tileSize || 32; const { cw } = getCharacterFootprintWH(mapData); let targetTx = null; if (gauntletCrownRunwayFinishAlignLatchedWorldX != null) { targetTx = gauntletCrownRunwayFinishAlignLatchedWorldX / ts - cw * 0.5; } else { let leadTx = -Infinity; const consider = (ent) => { if (!ent || ent.gauntletEliminated) return; const tx = Number.isFinite(ent.tx) ? ent.tx : Number(ent.x); if (Number.isFinite(tx)) leadTx = Math.max(leadTx, tx); }; consider(me); others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; consider(o); }); if (Number.isFinite(leadTx) && leadTx !== -Infinity) targetTx = leadTx; } if (targetTx == null || !Number.isFinite(targetTx)) return; const snap = (ent) => { if (!ent || ent.gauntletEliminated) return; if (Number.isFinite(ent.x)) ent.x = targetTx; if (ent.tx != null) ent.tx = targetTx; }; snap(me); others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; snap(o); }); } /** * คอลัมน์บนกริดที่เส้น FINISH ต้องแนบ (map world px) * gauntletCrownRunwayBg.finishAlignMapTileX | finishAlignMapTileFrac — ถ้าไม่ตั้ง ใช้ตำแหน่งตัวนำ */ function gauntletCrownRunwayFinishAlignMapWorldXPlay() { if (!mapData) return null; if (gauntletCrownRunwayFinishAlignLatchedWorldX != null) { return gauntletCrownRunwayFinishAlignLatchedWorldX; } const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; const ts = mapData.tileSize || 32; const tileX = Number(raw.finishAlignMapTileX); if (Number.isFinite(tileX)) return (tileX + 0.5) * ts; const tileFrac = Number(raw.finishAlignMapTileFrac); if (Number.isFinite(tileFrac)) { const w = mapData.width || 20; return (Math.max(0, Math.min(1, tileFrac)) * w + 0.5) * ts; } if (gauntletCrownRunwayBgMapEligiblePlay()) { return gauntletCrownRunwayLeadingEntityAlignWorldXPlay(); } return null; } /** ตำแหน่งเส้น FINISH บนจอ (0=ซ้าย 1=ขวา) จากขอบซ้าย viewport — ใช้หยุดเลื่อน + fade สิ่งกีดขวาง */ function gauntletCrownRunwayFinishLineScreenFracPlay(g) { if (!g) return null; const finishLineWorldX = gauntletCrownRunwayFinishLineStripXPlay(g); if (finishLineWorldX == null) return null; return (finishLineWorldX - g.scrollView) / Math.max(1e-6, g.cwWorld); } function gauntletCrownRunwayFinishStopViewportFracPlay() { const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; const stopHi = gauntletCrownRunwayBgMapEligiblePlay() ? 0.85 : 0.75; let vp = Number(raw.finishStopViewportFrac); if (Number.isFinite(vp)) return Math.max(0.08, Math.min(stopHi, vp)); let legacy = Number(raw.finishStopScreenFrac); if (Number.isFinite(legacy)) { legacy = Math.max(0.08, Math.min(0.96, legacy)); return Math.max(0.12, Math.min(0.62, 0.14 + legacy * 0.42)); } /* mno9kb07: หยุดเลื่อนเร็วขึ้น (เส้น FINISH หยุดที่ ~0.55 จากซ้าย — ขอบขวาของแถบ FINISH ใกล้กลางจอ) */ return gauntletCrownRunwayBgMapEligiblePlay() ? 0.55 : 0.34; } function gauntletCrownRunwayObstacleFadeStartViewportFracPlay() { const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; let v = Number(raw.obstacleFadeStartViewportFrac); /* mno9kb07: เริ่มจางอุปสรรคให้เร็วขึ้น (1.0 = ตอนเส้น FINISH ยังอยู่ขอบขวา) เพื่อให้อุปสรรคหายก่อนถึงเส้นชัย */ if (!Number.isFinite(v)) v = gauntletCrownRunwayBgMapEligiblePlay() ? 1.05 : 0.9; return Math.max(0.45, Math.min(1.15, v)); } /** 1 = สิ่งกีดขวางเต็ม · 0 = หาย — ค่อยๆ จางเมื่อเส้น FINISH เข้าใกล้จุดหยุดบนจอ */ function gauntletCrownRunwayObstacleDrawAlphaPlay() { if (!gauntletCrownRunwayBgDrawActivePlay()) return 1; if (gauntletCrownRunwayBgFinishLatched) return 0; const g = gauntletCrownRunwayScrollGeometryPlay(); if (!g) return 1; const screenFrac = gauntletCrownRunwayFinishLineScreenFracPlay(g); if (screenFrac == null) return 1; const stopAt = gauntletCrownRunwayFinishStopViewportFracPlay(); const fadeStart = gauntletCrownRunwayObstacleFadeStartViewportFracPlay(); if (screenFrac >= fadeStart) return 1; if (screenFrac <= stopAt) return 0; return (screenFrac - stopAt) / Math.max(1e-6, fadeStart - stopAt); } function gauntletCrownRunwayBgHideObstaclesPlay() { return gauntletCrownRunwayObstacleDrawAlphaPlay() <= 0.001; } /** Last Light: หยุดแอนิเมชันวิ่งเมื่อหยุดที่ FINISH หรือ latch แล้ว */ function gauntletCrownRunwayAvatarRunAllowedPlay() { if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayBgDrawActivePlay()) { if (gauntletCrownRunwayBgFinishLatched) return false; if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) return false; return true; } return true; } function drawImageCoverCanvasPlay(ctx, img, cwR, chR) { const d = gauntletCrownRunwayBgNatDims(img); if (!d) return; const iw = d.nw; const ih = d.nh; if (!iw || !ih) return; const scale = Math.max(cwR / iw, chR / ih); const dw = iw * scale; const dh = ih * scale; const dx = (cwR - dw) / 2; const dy = (chR - dh) / 2; ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh); } function drawRunwayHSlicePlay(ctx, img, worldX0, segW, scrollLeft, cwR, chR) { const d0 = gauntletCrownRunwayBgNatDims(img); if (!d0 || segW <= 0) return; const natW = d0.nw; const natH = d0.nh; const v0 = scrollLeft; const v1 = scrollLeft + cwR; const o0 = Math.max(worldX0, v0); const o1 = Math.min(worldX0 + segW, v1); if (o1 <= o0) return; const u0 = ((o0 - worldX0) / segW) * natW; const u1 = ((o1 - worldX0) / segW) * natW; const dx = o0 - scrollLeft; const dw = o1 - o0; /** ทาบแนวตั้งเล็กน้อยกันช่องว่างย่อย 1px (seam) ระหว่างแถบลูป */ ctx.drawImage(img, u0, 0, u1 - u0, natH, dx - 0.25, 0, dw + 0.55, chR); } /** วาดแถบรันเวย์ในระบบพิกัด X = ความกว้างมองเห็น (cwR) × สูงแถบ chR · scrollView = จุดเริ่ม viewport ในโลกรันเวย์ */ function drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView) { const start = gauntletCrownRunwayBgImgs[0]; const L2 = gauntletCrownRunwayBgImgs[1]; const L3 = gauntletCrownRunwayBgImgs[2]; const L4 = gauntletCrownRunwayBgImgs[3]; const { w0, w1, w2, w3 } = gauntletCrownRunwayBgStripWidthsAtCh(chR); const cycle = w1 + w2 + w3; const S = scrollView; ctx.imageSmoothingEnabled = true; ctx.fillStyle = '#1a1b26'; ctx.fillRect(0, 0, cwR, chR); if (w0 > 0) drawRunwayHSlicePlay(ctx, start, 0, w0, S, cwR, chR); if (cycle <= 0) return; const ws = [w1, w2, w3]; const ims = [L2, L3, L4]; const uTotal = Math.max(0, S - w0); let cycN = Math.floor(uTotal / cycle); let uRem = uTotal - cycN * cycle; let si = 0; while (si < 3 && uRem >= ws[si]) { uRem -= ws[si]; si++; } if (si >= 3) { cycN++; si = 0; uRem = 0; } let acc = 0; for (let k = 0; k < si; k++) acc += ws[k]; let curLeft = w0 + cycN * cycle + acc; let guard = 0; let idx = si; while (curLeft < S + cwR && guard++ < 500) { drawRunwayHSlicePlay(ctx, ims[idx], curLeft, ws[idx], S, cwR, chR); curLeft += ws[idx]; idx = (idx + 1) % 3; } } /** Last Light mno9kb07: แถบเดียว START → 2 → 3 → 4 → FINISH (ไม่วนลูป) */ function drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView) { const S = scrollView; const { w0, w1, w2, w3, w4 } = gauntletCrownRunwayBgStripWidthsAtCh(chR); ctx.imageSmoothingEnabled = true; ctx.fillStyle = '#1a1b26'; ctx.fillRect(0, 0, cwR, chR); const segs = [ { im: gauntletCrownRunwayBgImgs[0], w: w0, x0: 0 }, { im: gauntletCrownRunwayBgImgs[1], w: w1, x0: w0 }, { im: gauntletCrownRunwayBgImgs[2], w: w2, x0: w0 + w1 }, { im: gauntletCrownRunwayBgImgs[3], w: w3, x0: w0 + w1 + w2 }, { im: gauntletCrownRunwayBgImgs[4], w: w4, x0: w0 + w1 + w2 + w3 }, ]; for (let i = 0; i < segs.length; i++) { const g = segs[i]; if (g.w > 0 && g.im) drawRunwayHSlicePlay(ctx, g.im, g.x0, g.w, S, cwR, chR); } } /** * @param {number} canvasW * @param {number} canvasH * @param {null|{ worldMinX:number, worldMaxX:number, camX:number, camY:number, zDraw:number, mapWpx:number, mapHpx:number }} worldAlign — ถ้ามี จะคลิป+สเกลให้ตรงกริดแมป (ตัว/สิ่งกีดขวางตรงพื้นที่ตั้ง) */ function drawGauntletCrownRunwayBgFullCanvasPlay(canvasW, canvasH, worldAlign) { gauntletCrownRunwayMaintainFinishScrollPlay(); const finish = gauntletCrownRunwayBgImgs[4]; const align = worldAlign && typeof worldAlign === 'object' && Number.isFinite(worldAlign.worldMinX) && Number.isFinite(worldAlign.worldMaxX) && Number.isFinite(worldAlign.camX) && Number.isFinite(worldAlign.camY) && Number.isFinite(worldAlign.zDraw) && Number.isFinite(worldAlign.mapWpx) && Number.isFinite(worldAlign.mapHpx); /* หลัง latch ยังวาดแถบรันเวย์ที่ scroll ค้าง — อย่า switch เป็น cover เต็มแผนที่ (ทำให้ FINISH กระโดดซ้าย) */ /* ใช้ความกว้างมองเห็นแบบ float ให้เท่ากับ canvas.width/zDraw — ห้าม Math.round ไม่งั้นซูมแล้วแถบ runway กว้างไม่ตรง worldToScreen (แผนหลุดจากตัว) */ const cwR = align ? Math.max(1e-6, worldAlign.worldMaxX - worldAlign.worldMinX) : Math.max(1, Math.round(canvasW)); const chR = align ? Math.max(1, Math.round(worldAlign.mapHpx)) : Math.max(1, Math.round(canvasH)); const scrollView = align ? (gauntletCrownRunwayBgScrollPx + worldAlign.worldMinX) : gauntletCrownRunwayBgScrollPx; if (align) { const { camX, camY, zDraw, mapWpx, mapHpx, worldMinX } = worldAlign; ctx.fillStyle = '#1a1b26'; ctx.fillRect(0, 0, canvasW, canvasH); ctx.save(); ctx.setTransform(zDraw, 0, 0, zDraw, canvasW / 2 - camX * zDraw, canvasH / 2 - camY * zDraw); ctx.beginPath(); ctx.rect(0, 0, mapWpx, mapHpx); ctx.clip(); ctx.translate(worldMinX, 0); if (gauntletCrownRunwayBgMapEligiblePlay()) { drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView); } else { drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView); } ctx.restore(); return; } ctx.save(); if (gauntletCrownRunwayBgMapEligiblePlay()) { drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView); } else { drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView); } ctx.restore(); } /** * Shared runway strip math (viewport in strip space). null if not drawable. * Map tuning (mno9kb07): finishStopViewportFrac 0.08–0.75 = เส้น FINISH อยู่ที่สัดส่วนนี้จากซ้ายจอก่อนหยุด (ค่าเริ่ม 0.34) * finishLineInFinishArtFrac = ตำแหน่งเส้นใน finish.png (ค่าเริ่ม 0.12) * obstacleFadeStartViewportFrac = เริ่มจางสิ่งกีดขวางเมื่อเส้น FINISH อยู่ที่สัดส่วนนี้บนจอ (ค่าเริ่ม 0.9) */ function gauntletCrownRunwayScrollGeometryPlay() { if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null; const zGaRaw = computePlayCameraZDrawPlay(); const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGaRaw); if (!gCam) return null; const halfW = canvas.width / (2 * zGaRaw); const worldMinX = gCam.px - halfW; const cwWorld = canvas.width / zGaRaw; const h = mapData.height || 15; const ts = mapData.tileSize || 32; const mapHpx = Math.max(1, Math.round(h * ts)); const { w0, w1, w2, w3, w4 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx); const w4Safe = Math.max(w4, 1); const totalStrip = w0 + w1 + w2 + w3 + w4Safe; if (!(totalStrip > 8)) return null; const scrollView = gauntletCrownRunwayBgScrollPx + worldMinX; const right = scrollView + cwWorld; const finishStart = w0 + w1 + w2 + w3; const geoMinScroll = finishStart + Math.max(0, cwWorld - w4Safe); const wideVpPad = cwWorld > w4Safe * 1.02 ? w4Safe * 0.38 : 0; const minScrollForFinishPass = geoMinScroll + wideVpPad; return { scrollView, right, cwWorld, w4Safe, totalStrip, finishStart, minScrollForFinishPass, }; } /** True when เส้น FINISH ถึงตำแหน่งหยุด (แนบคอลัมน์แผนที่ หรือสัดส่วนจอแบบเดิม) */ function gauntletCrownRunwayScrollStripStopZoneReachedPlay() { const g = gauntletCrownRunwayScrollGeometryPlay(); if (!g) return false; if (g.scrollView < g.minScrollForFinishPass - 0.01) return false; const alignWorldX = gauntletCrownRunwayFinishAlignMapWorldXPlay(); if (alignWorldX != null) { const finishMapX = gauntletCrownRunwayFinishLineMapWorldXPlay(); if (finishMapX == null) return false; return finishMapX <= alignWorldX + 1.5; } const screenFrac = gauntletCrownRunwayFinishLineScreenFracPlay(g); if (screenFrac == null) return false; return screenFrac <= gauntletCrownRunwayFinishStopViewportFracPlay() + 0.008; } /** ค่าตำแหน่ง scroll ที่ทำให้เส้น FINISH หยุดตรงจุด (คืน null ถ้าคำนวณไม่ได้) — ใช้ทั้ง snap และ clamp กันวิ่งเลยเส้นชัย */ function gauntletCrownRunwayFinishStopScrollPxPlay() { if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null; const g = gauntletCrownRunwayScrollGeometryPlay(); if (!g) return null; const finishLineStripX = gauntletCrownRunwayFinishLineStripXPlay(g); if (finishLineStripX == null) return null; const alignWorldX = gauntletCrownRunwayFinishAlignMapWorldXPlay(); if (alignWorldX != null) { return finishLineStripX - alignWorldX; } const stopVp = gauntletCrownRunwayFinishStopViewportFracPlay(); const targetScrollView = finishLineStripX - stopVp * g.cwWorld; const zGaRaw = computePlayCameraZDrawPlay(); const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGaRaw); if (!gCam) return null; const worldMinX = gCam.px - canvas.width / (2 * zGaRaw); return targetScrollView - worldMinX; } /** จัด scroll ให้จุดอ้างอิงใน FINISH ตรงคอลัมน์แผนที่ (หรือ finishStopViewportFrac บนจอ) */ function gauntletCrownRunwaySnapScrollToFinishStopPlay() { const t = gauntletCrownRunwayFinishStopScrollPxPlay(); if (t == null) return; gauntletCrownRunwayBgScrollPx = t; } /** ลำดับการหยุดที่เส้นชัย: ล็อก align (ครั้งเดียว) → snap เป๊ะ → เริ่ม freeze → (latch+mission ทีหลังใน tick) */ function gauntletCrownRunwayBeginFinishStopPlay() { if (gauntletCrownRunwayFinishAlignLatchedWorldX == null) { const leadAlign = gauntletCrownRunwayLeadingEntityAlignWorldXPlay(); if (leadAlign != null) gauntletCrownRunwayFinishAlignLatchedWorldX = leadAlign; } gauntletSnapLivingToFinishPlay(); /* ทุกคนที่ยังไม่ตายมายืนที่เส้นชัย ไม่มีใครเลยเส้น */ gauntletCrownRunwaySnapScrollToFinishStopPlay(); gauntletCrownRunwayBgStripFreezeSinceMs = Date.now(); /* แจ้ง server ครั้งเดียว: ถึงเส้นชัยแล้ว → หยุด obstacle + collision (กัน -10 ตอน obstacle หายแล้ว) */ if (!gauntletFinishPhaseSignaled) { gauntletFinishPhaseSignaled = true; try { socket.emit('gauntlet-finish-phase'); } catch (_e) { /* ignore */ } } try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ } try { draw(); } catch (_d) { /* ignore */ } } /** โหมดจอเท่านั้น: กล้องเลื่อนหลัง freeze ทำให้ FINISH หลุด — รีสแนปทุกเฟรม */ function gauntletCrownRunwayMaintainFinishScrollPlay() { if (!gauntletCrownRunwayBgStripFreezeSinceMs && !gauntletCrownRunwayBgFinishLatched) return; if (!gauntletCrownRunwayBgDrawActivePlay()) return; if (gauntletCrownRunwayFinishAlignMapWorldXPlay() != null) return; gauntletCrownRunwaySnapScrollToFinishStopPlay(); } function resetGauntletCrownRunwaySpawnScrollSnap() { gauntletCrownRunwaySpawnScrollSnapped = false; } /** * จัด gauntletCrownRunwayBgScrollPx ครั้งแรกให้คอลัมน์เกิด (ซ้ายสุด) ตรง ~runwaySpawnAlignFrac ของความกว้างแถบ START (w0) * ในแมป: gauntletCrownRunwayBg.runwaySpawnAlignFrac 0.02–0.95 (ค่าเริ่ม 0.32) * Anchor: คอลัมน์ซ้ายสุดระหว่างช่องเกิดในแมปกับตำแหน่งจริงของทุกคนในรอบ (รวม me + บอทพรีวิว) — ถ้า me ถูก resolve ไปคอลัมน์ซ้ายกว่า min ของช่องวาด แต่ runway ยึดแค่ช่องวาด จะเห็นตัวลอยซ้ายของพรมแดง * Use min(editor spawn column, floor of every alive entity x) so runway START art stays tied to where characters actually stand. */ function trySnapGauntletCrownRunwayScrollToSpawnsPlay() { if (gauntletCrownRunwaySpawnScrollSnapped) return; if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff || !mapData) return; if (!gauntletCrownRunwayBgImagesReadyPlay()) return; const h = mapData.height || 15; const ts = mapData.tileSize || 32; const mapHpx = Math.max(1, Math.round(h * ts)); const { w0 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx); if (!(w0 > 0)) return; const slots = collectGauntletSpawnSlotsPlay(mapData); let minTX = Infinity; if (slots.length) minTX = Math.min(...slots.map((s) => s.x)); if (Number.isFinite(me.x)) minTX = Math.min(minTX, Math.floor(me.x)); others.forEach((o, id) => { if (!o || o.gauntletEliminated) return; if (Number.isFinite(o.x)) minTX = Math.min(minTX, Math.floor(o.x)); }); if (!Number.isFinite(minTX) || minTX === Infinity) minTX = 1; const anchorWorldX = (Math.max(0, minTX) + 0.5) * ts; const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; const frac = Math.max(0.02, Math.min(0.95, Number(raw.runwaySpawnAlignFrac) || 0.32)); gauntletCrownRunwayBgScrollPx = frac * w0 - anchorWorldX; gauntletCrownRunwaySpawnScrollSnapped = true; } function reloadGauntletCrownRunwayBgFromMap() { gauntletCrownRunwayBgImgs = [null, null, null, null, null]; gauntletCrownRunwayBgScrollPx = 0; gauntletCrownRunwayBgFinishLatched = false; gauntletFinishPhaseSignaled = false; gauntletCrownRunwayBgStripFreezeSinceMs = 0; gauntletCrownRunwayFinishAlignLatchedWorldX = null; gauntletCrownRunwayClientMissionShown = false; lastGauntletCrownRunwayBgTickMs = 0; resetGauntletCrownRunwaySpawnScrollSnap(); if (!gauntletCrownRunwayBgMapEligiblePlay()) return; const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; gauntletCrownRunwayBgOff = raw.enabled === false; gauntletCrownRunwayBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 48)); if (gauntletCrownRunwayBgOff) return; const srcs = [ (typeof raw.startImage === 'string' && raw.startImage.length) ? raw.startImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START, (typeof raw.loopImage2 === 'string' && raw.loopImage2.length) ? raw.loopImage2 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2, (typeof raw.loopImage3 === 'string' && raw.loopImage3.length) ? raw.loopImage3 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3, (typeof raw.loopImage4 === 'string' && raw.loopImage4.length) ? raw.loopImage4 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4, (typeof raw.finishImage === 'string' && raw.finishImage.length) ? raw.finishImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH, ]; let pending = 5; const bump = () => { pending--; if (pending <= 0) { ensureGauntletCrownRunwayBgPlaceholdersPlay(); try { draw(); } catch (e) { /* ignore */ } } }; for (let i = 0; i < 5; i++) { const im = new Image(); const idx = i; im.onload = () => { gauntletCrownRunwayBgImgs[idx] = im; bump(); }; im.onerror = () => { bump(); }; im.src = srcs[i]; } } function tickGauntletCrownRunwayBgPlay(dtSec) { if (!gauntletCrownRunwayBgDrawActivePlay()) return; /* หยุดเลื่อน BG ระหว่างคำถามพิเศษ (special-quiz freeze) — กัน BG/เส้นชัยเดินตอนค้างตอบ */ if (specialQuizFreeze) return; /** พรีรัน (howto / countdown) — ห้ามเลื่อนก่อน GO · Mega Virus ใช้ shell เดียวกัน */ if (usesCrownLobbyShellPlay() && gauntletCrownPregamePhase !== 'live') return; if (gauntletCrownRunwayBgFinishLatched) { gauntletSnapLivingToFinishPlay(); return; } if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) { gauntletCrownRunwayMaintainFinishScrollPlay(); gauntletSnapLivingToFinishPlay(); /* ตรึงทุกคนที่ยังไม่ตายไว้ที่เส้นชัยทุกเฟรม กันบอทไหลหลุด */ if (Date.now() - gauntletCrownRunwayBgStripFreezeSinceMs >= GAUNTLET_CROWN_RUNWAY_POST_STOP_MS) { gauntletCrownRunwayBgFinishLatched = true; gauntletCrownRunwayBgStripFreezeSinceMs = 0; if (playBotsEnabled() && gauntletCrownRunwayBgMapEligiblePlay() && !gauntletCrownRunwayClientMissionShown) { gauntletCrownRunwayClientMissionShown = true; window.setTimeout(function () { try { gauntletCrownPregamePhase = null; gauntletCrownHowtoVisible = false; const hto = document.getElementById('gauntlet-crown-howto-overlay'); if (hto) hto.classList.add('is-hidden'); const gcc = document.getElementById('gauntlet-crown-countdown'); if (gcc) gcc.classList.add('is-hidden'); if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } showGauntletCrownMissionOverlay(gauntletCrownHeistBuildLocalCrownMissionPlay()); } catch (_e) { /* ignore */ } try { draw(); } catch (_e2) { /* ignore */ } }, 340); } } return; } if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayScrollStripStopZoneReachedPlay()) { if (gauntletCrownRunwayFinishAlignLatchedWorldX == null) { const leadAlign = gauntletCrownRunwayLeadingEntityAlignWorldXPlay(); if (leadAlign != null) gauntletCrownRunwayFinishAlignLatchedWorldX = leadAlign; } gauntletSnapLivingToFinishPlay(); /* ทุกคนที่ยังไม่ตายมายืนที่เส้นชัย ไม่มีใครเลยเส้น */ gauntletCrownRunwaySnapScrollToFinishStopPlay(); gauntletCrownRunwayBgStripFreezeSinceMs = Date.now(); try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ } try { draw(); } catch (_d) { /* ignore */ } return; } const gcrInc = (gauntletCrownRunwayBgSpeedPxPerSec || 48) * dtSec; /* ถ้าก้าวถัดไป "จะถึง/เลย" จุดหยุด FINISH → หยุดเป๊ะตรงนั้น (ไม่วิ่งเลย) แล้วเริ่มจบเกมทันที */ const gcrLiveStopPx = gauntletCrownRunwayFinishStopScrollPxPlay(); if (gauntletCrownRunwayBgMapEligiblePlay() && gcrLiveStopPx != null && gcrLiveStopPx >= gauntletCrownRunwayBgScrollPx - 0.01 && gauntletCrownRunwayBgScrollPx + gcrInc >= gcrLiveStopPx) { gauntletCrownRunwayBgScrollPx = gcrLiveStopPx; gauntletCrownRunwayBeginFinishStopPlay(); return; } gauntletCrownRunwayBgScrollPx += gcrInc; } /** stack + แมป mnn93hpi — พื้นหลัง intro+loop เลื่อนแนวตั้ง (stackTowerBgScroll ในแมป) */ const STACK_TOWER_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnn93hpi/intro.png'; const STACK_TOWER_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnn93hpi/loop.png'; let stackTowerScrollBgIntroImg = null; let stackTowerScrollBgLoopImg = null; let stackTowerScrollBgPx = 0; let stackTowerScrollBgSpeedPxPerSec = 40; let stackTowerScrollBgOff = false; let stackTowerScrollBgFlowDown = false; /** Sraw = px+boost+post ครั้งแรกที่คำนวณ world offset — ใช้หา delta แบบคาบ loop (อย่าใช้ fold(Sraw) ลบตรงๆ จะดันโลกขึ้นหลายร้อย px) */ let stackTowerScrollBgSrawWorldBaselinePlay = null; /** ความสูงวาดของไทล์ loop (พิกเซลจอ) — ใช้ขั้นเลื่อนเมื่อ stepScrollPx = 0 */ let stackTowerScrollBgLoopDrawHPlay = 0; /** { from, to, t0, dur } — เลื่อนแถบ BG เป็นขั้นตามจำนวนชั้น */ let stackTowerBgScrollStepAnimPlay = null; let stackTowerBgScrollPendingStepDeltaPlay = 0; function stackTowerScrollBgMapEligible() { return !!(mapData && mapData.gameType === 'stack' && (currentPlayMapId() || '').trim() === STACK_TOWER_MISSION_MAP_ID); } function stackTowerScrollBgImagesReady() { if (!stackTowerScrollBgMapEligible()) return false; const a = stackTowerScrollBgIntroImg; const b = stackTowerScrollBgLoopImg; return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth); } /** แถบ intro+loop เต็มจอ — เปิดเฉพาะเมื่อแมปตั้ง stackTowerBgScroll.enabled: true และรูปโหลดครบ (ไม่โหลด strip เมื่อปิด → ใช้รูปแผนที่ backgroundImage) */ function stackTowerScrollBgDrawActive() { return stackTowerScrollBgImagesReady() && !stackTowerScrollBgOff; } function reloadStackTowerScrollBgFromMap() { stackTowerScrollBgIntroImg = null; stackTowerScrollBgLoopImg = null; stackTowerScrollBgPx = 0; stackTowerScrollBgSrawWorldBaselinePlay = null; stackTowerScrollBgLoopDrawHPlay = 0; stackTowerBgScrollStepAnimPlay = null; stackTowerBgScrollPendingStepDeltaPlay = 0; if (!stackTowerScrollBgMapEligible()) return; const raw = mapData.stackTowerBgScroll && typeof mapData.stackTowerBgScroll === 'object' ? mapData.stackTowerBgScroll : {}; stackTowerScrollBgOff = raw.enabled !== true; { const spNum = Number(raw.speedPxPerSec); stackTowerScrollBgSpeedPxPerSec = Number.isFinite(spNum) ? Math.max(0, Math.min(400, Math.floor(spNum))) : 0; } /* คีย์เวิร์ด down/top = ตัวเลือก "บน→ล่าง" ในเอดิเตอร์ — ใช้สูตรเลื่อนแบบ stripTop−S (ฉากไหลขึ้น) ไม่ใช่ S+chR−… (ไหลลง) เพราะผู้เล่นคาดว่าหอสูง = มองขึ้น */ const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase(); const rawIsDownKeyword = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom'); stackTowerScrollBgFlowDown = !rawIsDownKeyword; if (stackTowerScrollBgOff) return; const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : STACK_TOWER_SCROLL_BG_DEFAULT_INTRO; const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : STACK_TOWER_SCROLL_BG_DEFAULT_LOOP; let pending = 2; const bump = () => { pending--; if (pending <= 0) { const intro0 = stackTowerScrollBgIntroImg; const loop0 = stackTowerScrollBgLoopImg; if (loop0 && loop0.complete && intro0 && intro0.complete && canvas && canvas.width) { const cwR0 = Math.max(1, Math.round(canvas.width)); stackTowerScrollBgLoopDrawHPlay = Math.max(1, Math.round(loop0.naturalHeight * (cwR0 / loop0.naturalWidth))); } stackTowerScrollBgSyncInitialScrollToBottom(); try { draw(); } catch (e) { /* ignore */ } } }; const im1 = new Image(); im1.onload = () => { stackTowerScrollBgIntroImg = im1; bump(); }; im1.onerror = () => { bump(); }; im1.src = introSrc; const im2 = new Image(); im2.onload = () => { stackTowerScrollBgLoopImg = im2; bump(); }; im2.onerror = () => { bump(); }; im2.src = loopSrc; } function stackTowerScrollBgSyncInitialScrollToBottom() { const intro = stackTowerScrollBgIntroImg; if (!canvas || !intro || !intro.complete || !intro.naturalWidth) return; const cwR = Math.max(1, Math.round(canvas.width)); const chR = Math.max(1, Math.round(canvas.height)); const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth)); if (stackTowerScrollBgFlowDown) { stackTowerScrollBgPx = 0; } else { /* ขอบล่าง intro ชิดขอบล่างแคนวาส — คู่กับ stripTop−S */ stackTowerScrollBgPx = drawHIntro - chR; } } /** * พับค่าเลื่อนแนวตั้ง S (หน่วยเดียวกับ drawStackTowerScrollBgFullCanvas) เข้าช่วงหนึ่งคาบของ loop * เมื่อ vp0 ≥ drawHIntro — รูปบนจอซ้ำทุก drawHLoop; พับค่า S ก่อนส่งเข้า worldToScreen กัน px โตไม่จำกัดดันโลกหลุดจอ */ function foldStackTowerScrollStripViewSForPhase(S, drawHIntro, drawHLoop) { const dH = Math.max(1, Math.round(Number(drawHLoop) || 1)); const dI = Math.max(0, Math.round(Number(drawHIntro) || 0)); const s = Number(S) || 0; if (s < dI) return s; return s - Math.floor((s - dI) / dH) * dH; } /** * offset แนวตั้งจอให้โลกซิงก์กับ draw ที่ใช้ S = fold(Sraw) — ใช้ความต่างเฟส fold(Sraw)−fold(Sraw0) * เวลาเลื่อนครบคาบ loop ค่า fold วนกลับ → offset ไม่โตไม่จำกัด · ห้ามคืน fold(Sraw) ล้วนเมื่อ Sraw0 ยังอยู่ intro (baseline ไม่เข้า loop ทำให้สูตร f0+mod ไม่ทำงานและเฟสหลุด) */ function stackTowerScrollWorldTotalYFromStripPlay(Sraw, drawHIntro, drawHLoop, Sraw0) { if (Sraw0 == null) return 0; const dH = Math.max(1, Math.round(Number(drawHLoop) || 1)); const dI = Math.max(0, Math.round(Number(drawHIntro) || 0)); const s = Number(Sraw) || 0; const s0 = Number(Sraw0) || 0; const fs = foldStackTowerScrollStripViewSForPhase(s, dI, dH); const fs0 = foldStackTowerScrollStripViewSForPhase(s0, dI, dH); return fs - fs0; } /** zDrawPan ส่งต่อเพื่อคูณ boost กับ z — ไม่ scale แคนวาสทั้งแผง (เคย scale รอบกลางทำให้จอบางส่วนว่างเมื่อ z ≠ zRef) */ function drawStackTowerScrollBgFullCanvas(cw, ch, zDrawPan) { const intro = stackTowerScrollBgIntroImg; const loop = stackTowerScrollBgLoopImg; if (!intro || !intro.complete || !loop || !loop.complete) return; const zPan = Number(zDrawPan) > 0 ? zDrawPan : zoom; const cwR = Math.max(1, Math.round(cw)); const chR = Math.max(1, Math.round(ch)); const scaleI = cwR / intro.naturalWidth; const drawHIntro = Math.round(intro.naturalHeight * scaleI); const scaleL = cwR / loop.naturalWidth; const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL)); const camFollowScreen = isStackTowerMissionUiMapPlay() ? getStackTowerBgScrollHeightBoostPx() * zPan : 0; const post50Scroll = isStackTowerMissionUiMapPlay() ? getStackTowerPost50BgScrollExtraPxPlay(chR) : 0; const Sraw = stackTowerScrollBgPx + camFollowScreen + post50Scroll; const S = foldStackTowerScrollStripViewSForPhase(Sraw, drawHIntro, drawHLoop); const vp0 = S; const vp1 = S + chR; const yLimit = vp1 + drawHLoop * 96; const flowDown = stackTowerScrollBgFlowDown; function stripTopToDestY(stripTop, drawH) { if (!flowDown) return stripTop - S; return S + chR - (stripTop + drawH); } ctx.save(); ctx.imageSmoothingEnabled = false; ctx.beginPath(); ctx.rect(0, 0, cwR, chR); ctx.clip(); if (vp0 < 0) { let k = 1; if (vp0 < -drawHLoop * 4) { k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2); } for (; k < 50000; k++) { const y0 = -k * drawHLoop; if (y0 >= vp1 + drawHLoop) break; if (y0 + drawHLoop <= vp0) continue; const dy = Math.round(stripTopToDestY(y0, drawHLoop)); ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop); } } if (drawHIntro > vp0 && vp1 > 0) { const dy = Math.round(stripTopToDestY(0, drawHIntro)); ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro); } let y = drawHIntro; if (y < yLimit && vp1 > drawHIntro) { if (vp0 > drawHIntro) { const n = Math.floor((vp0 - drawHIntro) / drawHLoop); /* ใช้ n ไม่ใช่ n−1 — เดิมทำให้แถบ loop เริ่มสูงกว่า vp0 หนึ่งความสูง จอล่างว่างดำเมื่อ S ใหญ่ */ y = drawHIntro + n * drawHLoop; } while (y < yLimit) { const dy = Math.round(stripTopToDestY(y, drawHLoop)); ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop); y += drawHLoop; } } ctx.restore(); } /** กล้องมองเหนือขอบบนแมป (worldMinY<0) + ใช้รูปแผนที่ — ยัด loop ท้องฟ้าเติมครึ่งบนจอ */ function drawStackTowerLoopSkyFillAboveMap(ctx, worldMinY, zDraw, cw, ch) { if (!isStackTowerMissionUiMapPlay() || worldMinY >= 0) return; const loop = stackTowerScrollBgLoopImg; if (!loop || !loop.complete || !loop.naturalWidth) return; const cwR = Math.max(1, Math.round(cw)); const chR = Math.max(1, Math.round(ch)); const scaleL = cwR / loop.naturalWidth; const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL)); const skyScreenH = Math.min(chR, Math.ceil(-worldMinY * zDraw) + 4); const stripPx = stackTowerScrollBgOff ? 0 : stackTowerScrollBgPx; const rawScroll = stripPx + getStackTowerBgScrollHeightBoostPx() * zDraw + getStackTowerPost50BgScrollExtraPxPlay(chR); const scroll = ((rawScroll % drawHLoop) + drawHLoop) % drawHLoop; let y = -scroll; ctx.save(); ctx.imageSmoothingEnabled = false; while (y < skyScreenH + drawHLoop) { if (y + drawHLoop > 0) { ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, y, cwR, drawHLoop); } y += drawHLoop; } ctx.restore(); } function tickStackTowerBgScrollStepAnimPlay() { const q = stackTowerBgScrollStepAnimPlay; if (!q) return; const t = Math.min(1, (performance.now() - q.t0) / Math.max(1, q.dur)); const e = 1 - Math.pow(1 - t, 3); stackTowerScrollBgPx = q.from + (q.to - q.from) * e; if (t >= 1) { stackTowerScrollBgPx = q.to; stackTowerBgScrollStepAnimPlay = null; stackTowerScrollBgSrawWorldBaselinePlay = null; if (stackTowerBgScrollPendingStepDeltaPlay !== 0) { const d = stackTowerBgScrollPendingStepDeltaPlay; stackTowerBgScrollPendingStepDeltaPlay = 0; startStackTowerBgScrollStepAnimPlay(d); } } } function getStackTowerBgScrollStepEveryLayersPlay() { const raw = mapData && mapData.stackTowerBgScroll; if (!raw || typeof raw !== 'object') return 0; const n = Math.floor(Number(raw.stepEveryLayers)); return Number.isFinite(n) && n > 0 ? Math.min(200, n) : 0; } function getStackTowerBgScrollStepAnimMsPlay() { const raw = mapData && mapData.stackTowerBgScroll; if (!raw || typeof raw !== 'object') return 520; const t = Math.floor(Number(raw.stepAnimMs)); return Number.isFinite(t) ? Math.max(120, Math.min(4000, t)) : 520; } function getStackTowerBgScrollStepScrollPxPlay() { const raw = mapData && mapData.stackTowerBgScroll; if (!raw || typeof raw !== 'object') return 0; const d = Math.floor(Number(raw.stepScrollPx)); return Number.isFinite(d) ? Math.max(0, Math.min(8000, d)) : 0; } function startStackTowerBgScrollStepAnimPlay(deltaPx) { if (!stackTowerScrollBgDrawActive() || !deltaPx) return; if (stackTowerBgScrollStepAnimPlay) { stackTowerBgScrollPendingStepDeltaPlay += deltaPx; return; } const dur = getStackTowerBgScrollStepAnimMsPlay(); stackTowerBgScrollStepAnimPlay = { from: stackTowerScrollBgPx, to: stackTowerScrollBgPx + deltaPx, t0: performance.now(), dur, }; } function maybeQueueStackTowerBgScrollStepPlay(layerCountAfter) { if (!stackTowerScrollBgDrawActive()) return; const every = getStackTowerBgScrollStepEveryLayersPlay(); if (every <= 0 || layerCountAfter <= 0 || layerCountAfter % every !== 0) return; let delta = getStackTowerBgScrollStepScrollPxPlay(); if (!delta) delta = Math.max(8, stackTowerScrollBgLoopDrawHPlay || 200); delta = Math.min(8000, Math.max(8, delta)); startStackTowerBgScrollStepAnimPlay(delta); } function getStackTowerStripReleaseGapWorldPxPlay() { const raw = mapData && mapData.stackTowerBgScroll; if (!isStackTowerMissionUiMapPlay() || !raw || typeof raw !== 'object') return null; const g = Number(raw.releaseGapWorldPx); if (!Number.isFinite(g) || g < 0) return null; return Math.min(800, g); } function updateStackTowerSwingYForStripGapPlay() { if (!stackMini || !isStackTowerMissionUiMapPlay()) return; const gap = getStackTowerStripReleaseGapWorldPxPlay(); if (gap == null) return; const m = stackMini; const lh = m.layerWorldH || Math.max(14, tileSize * 0.3); const nLay = m.layers ? m.layers.length : 0; const topBlockTopY = m.floorWorldY - nLay * lh; const swingLift = getStackTowerSwingLiftWorldPx(nLay, lh); m.swingWorldY = topBlockTopY - gap + swingLift; } function tickStackTowerScrollBg(dtSec) { if (!stackTowerScrollBgMapEligible() || stackTowerScrollBgOff) return; if (!stackTowerScrollBgImagesReady()) return; tickStackTowerBgScrollStepAnimPlay(); if (stackTowerBgScrollStepAnimPlay) return; const spd = Math.max(0, Number(stackTowerScrollBgSpeedPxPerSec) || 0) * dtSec; if (spd <= 0) return; /* !flowDown (stripTop−S): S เพิ่ม → dy ลด → ฉากไหลขึ้น — ห้ามใช้ −= (จะลด S แล้วศิลป์ไหลลง) */ stackTowerScrollBgPx += spd; } /** * ค่าลบจาก screen Y ใน worldToScreen — ซิงก์กับ drawStackTowerScrollBgFullCanvas (auto + boost + post-50) * แถบ BG วาด 1:1 กับแคนวาส — ห้ามใช้ fold(Sraw) เป็นค่าลบตรงๆ (จะดันโลกขึ้นหลายร้อย px เหมือนครึ่งล่างจอว่าง) */ function getStackTowerWorldLayerScrollScreenOffsetYPlay(zPan) { if (!isStackTowerMissionUiMapPlay() || !stackMini) return 0; const zPn = Number(zPan) > 0 ? zPan : zoom; const chPx = Math.max(1, Math.round(canvas && canvas.height ? canvas.height : 720)); const boostScreen = getStackTowerBgScrollHeightBoostPx() * zPn; const post50Screen = getStackTowerPost50BgScrollExtraPxPlay(chPx); const intro = stackTowerScrollBgIntroImg; const loop = stackTowerScrollBgLoopImg; let totalScreen = boostScreen + post50Screen; if (stackTowerScrollBgDrawActive() && intro && intro.complete && intro.naturalWidth && loop && loop.complete && loop.naturalWidth && canvas && canvas.width) { const cwR = Math.max(1, Math.round(canvas.width)); const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth)); const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * (cwR / loop.naturalWidth))); const SrawDraw = stackTowerScrollBgPx + boostScreen + post50Screen; if (stackTowerScrollBgSrawWorldBaselinePlay == null) stackTowerScrollBgSrawWorldBaselinePlay = SrawDraw; const S0 = stackTowerScrollBgSrawWorldBaselinePlay; let stripY; if (stackTowerScrollBgFlowDown) { const Sview = foldStackTowerScrollStripViewSForPhase(SrawDraw, drawHIntro, drawHLoop); stripY = (boostScreen + post50Screen) * 2 - Sview; } else { stripY = stackTowerScrollWorldTotalYFromStripPlay(SrawDraw, drawHIntro, drawHLoop, S0); } totalScreen = stripY; } else if (stackTowerScrollBgDrawActive()) { const px = stackTowerScrollBgFlowDown ? -stackTowerScrollBgPx : stackTowerScrollBgPx; totalScreen = px + boostScreen + post50Screen; } return totalScreen; } /** โหลดจาก 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, gauntletCrownPenaltyFxUntil: 0 }; const others = new Map(); /** ===== MG3 terminal log (เกมจริง) — hacking-terminal ตามสเปก #mg3-terminal-log ===== */ let mg3TermLog = []; let mg3TermLineNo = 0; const MG3_TERM_MAX = 240; function mg3TermPush(lines) { const arr = Array.isArray(lines) ? lines : [lines]; for (let i = 0; i < arr.length; i++) mg3TermLog.push(String(arr[i] == null ? '' : arr[i])); while (mg3TermLog.length > MG3_TERM_MAX) mg3TermLog.shift(); } function mg3TermReset() { mg3TermLog = []; mg3TermLineNo = 0; } function mg3TermBoot() { mg3TermLog = []; mg3TermLineNo = 0; mg3TermPush([ '[INIT] Booting decryption sequence...', '[SYS] Locating target server...', '[SYS] Connection established. Status: SECURE.', '[HACK] Bypassing "Vault Lockdown" protocol...', '[HACK] Disabling Cyber Security Check...', '[LOAD] Injecting Decryption_Core.exe...', '[READY] Awaiting manual code block alignment...', '>>> STACKING SEQUENCE START <<<', ]); } function mg3TermOnPlace(perfect, progressPct) { mg3TermLineNo += 1; const n = mg3TermLineNo; if (n >= 100 || (Number.isFinite(Number(progressPct)) && Number(progressPct) >= 99.5)) { mg3TermPush([ '>>> Compile Line [100]: SUCCESS. Maximum stack reached.', '[DATA]: Decryption progress: 100%. Master key generated.', '[HACK]: "Vault Lockdown" bypassed. Firewall disabled.', '[SYS]: Evidence files unlocked. Initiating secure download...', '>>> MISSION ACCOMPLISHED. Disconnecting safely. <<<', ]); } else if (perfect) { mg3TermPush('>>> Compile Line [' + n + ']: PERFECT. Synergy bonus!'); } else { mg3TermPush('>>> Compile Line [' + n + ']: SUCCESS. Stack stable.'); } } function mg3TermOnMiss(attemptsRemaining) { if (attemptsRemaining <= 0) { mg3TermPush([ '[CRITICAL]: Packet loss detected. Compile FAILED. (Attempts remaining: 0)', '[ALERT]: Cyber Security Check triggered! Intruder traced.', '[SYS]: Initiating "Vault Lockdown" protocol...', '>>> FATAL ERROR: Connection Terminated. SYSTEM LOCKED. <<<', ]); } else { mg3TermPush('[WARNING]: Packet loss detected. Compile FAILED. (Attempts remaining: ' + attemptsRemaining + '/3)'); } } /** Stack preview HUD: เทอร์มินัลล็อก (cyber UI) */ const STACK_PREVIEW_HUD_LOG_MAX = 8; let stackPreviewHudLog = []; function stackPreviewPushHudLog(line) { if (!playBotsEnabled()) return; stackPreviewHudLog.push(String(line || '').slice(0, 96)); while (stackPreviewHudLog.length > STACK_PREVIEW_HUD_LOG_MAX) stackPreviewHudLog.shift(); } function stackPreviewLogStackDrop(hit, actor) { if (!playBotsEnabled() || !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 = {}; /** data URL จาก canvas compose สี — กันยิง toDataURL ทุกเฟรมตอน sync HUD */ const cyberHudScoreAvatarUrlCache = new Map(); const CYBER_HUD_AV_URL_CACHE_CAP = 56; 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 ด้วยจำนวนเฟรมที่โหลดได้แล้ว (ไม่งั้นเฟรมกลางหาย/ลูปสั้นลง) */ /** true เมื่ออยู่ในเกมวิ่งหลบ (gauntlet runner) ช่วงเล่นจริง — ใช้เร่งจังหวะขาให้ดูเหมือน "วิ่ง" */ function gauntletRunnerAnimActivePlay() { try { return isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay(); } catch (e) { return false; } } function walkAnimPhaseIndex(now, isWalking) { if (!isWalking) return 0; const t = (typeof now === 'number' ? now : Date.now()); /* gauntlet วิ่งหลบ: เร่งจังหวะขาให้ "วิ่ง" — 0.40 (80ms/เฟรม) เร็วสุดที่ยังลื่น ไม่กระตุก (sprite มีแค่ 4 เฟรม ถ้าเร็วกว่านี้ เช่น 0.27 เฟรมจะสลับถี่เกินจนดูสั่น/กระตุก) */ const frameMs = gauntletRunnerAnimActivePlay() ? CHARACTER_ANIM_FRAME_MS * 0.40 : CHARACTER_ANIM_FRAME_MS; return Math.floor(t / frameMs) % 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', '#fec1fe']; const PLAY_TINT_BODY = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#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); playCharacterIdRoster = []; if (Array.isArray(list)) { list.forEach((c) => { if (!c || !c.id) return; playCharacterIdRoster.push(String(c.id)); 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(() => { playCharacterIdRoster = []; 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], }; } const LOBBY_PLAYER_TINT_KEY = 'justiceLobbyPlayerTint'; /** ธีมสี 8 ช่อง — ตรง room-lobby / customize */ const PLAY_LOBBY_THEME_HEX = [ '#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe', ]; const PLAY_LOBBY_SKIN_HEX = ['#eaa78a', '#fbd5c4', '#fae9e1']; let playLobbyBotThemes = []; function parsePlayLobbyThemeIndex(n) { const v = Math.floor(Number(n)); return (v >= 1 && v <= 8) ? v : null; } function parsePlayLobbySkinIndex(n) { const v = Math.floor(Number(n)); return (v >= 1 && v <= 3) ? v : null; } function playTintFromLobbyThemeIndices(themeIndex, skinIndex) { const ti = parsePlayLobbyThemeIndex(themeIndex); const si = parsePlayLobbySkinIndex(skinIndex); const bodyHex = ti ? PLAY_LOBBY_THEME_HEX[ti - 1] : ''; const headHex = si ? PLAY_LOBBY_SKIN_HEX[si - 1] : ''; if (!bodyHex && !headHex) return null; return playTintFromLobbyRgb(bodyHex || null, headHex || null); } function playTintFromJoinPeer(peerRow) { if (!peerRow) return null; return playTintFromLobbyThemeIndices(peerRow.lobbyColorThemeIndex, peerRow.lobbySkinToneIndex); } function playTintForPreviewBotId(peerId) { if (!isPreviewBotId(peerId)) return null; const idx = parseInt(String(peerId).slice(PREVIEW_BOT_PREFIX.length), 10); if (!Number.isFinite(idx) || idx < 0) return null; /* ใช้ธีมจาก server (กันสีซ้ำอยู่แล้ว) — ถ้าไม่มี ตกไปธีมแบบ "กระจายตามดัชนี" (ไม่สุ่ม hash ที่ซ้ำได้) กัน bot สีเดียวกัน: bot N → ธีม ((N % 8)+1), สกิน ((N % 3)+1) */ /* 1) สีที่ lobby บันทึกไว้ (ให้ตรงกับ lobby เป๊ะ) — ไม่เขียนทับ */ try { const savedMap = JSON.parse(sessionStorage.getItem(lobbyBotTintsStorageKeyPlay()) || '{}'); const saved = savedMap['__lobby_bot_' + idx]; if (saved && (saved.colorTheme || saved.colorSkin)) { const st = playTintFromLobbyRgb(saved.colorTheme, saved.colorSkin); if (st) return st; } } catch (e) { /* ignore */ } /* 2) ไม่มีค่าจาก lobby → ใช้ธีม server / กระจายตามดัชนี แล้วบันทึกไว้ */ const slot = playLobbyBotThemes[idx] || { themeIndex: (idx % 8) + 1, skinToneIndex: (idx % 3) + 1 }; const tint = playTintFromLobbyThemeIndices(slot.themeIndex, slot.skinToneIndex); if (!tint) return null; try { const map = JSON.parse(sessionStorage.getItem(lobbyBotTintsStorageKeyPlay()) || '{}'); map['__lobby_bot_' + idx] = { colorTheme: PLAY_LOBBY_THEME_HEX[parsePlayLobbyThemeIndex(slot.themeIndex) - 1] || '', colorSkin: PLAY_LOBBY_SKIN_HEX[parsePlayLobbySkinIndex(slot.skinToneIndex) - 1] || '', }; sessionStorage.setItem(lobbyBotTintsStorageKeyPlay(), JSON.stringify(map)); } catch (e) { /* ignore */ } return tint; } function lobbyBotTintsStorageKeyPlay() { const sp = (typeof spaceId === 'string' && spaceId) ? spaceId : (new URLSearchParams(window.location.search).get('space') || 'default'); return 'justiceLobbyBotTints:' + sp; } function rgbCssToHexPlay(rgb) { if (!rgb) return ''; const s = String(rgb).trim(); if (s.charAt(0) === '#') return s; const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); if (!m) return ''; return '#' + [m[1], m[2], m[3]].map((n) => { const v = Math.max(0, Math.min(255, parseInt(n, 10))); return v.toString(16).padStart(2, '0'); }).join(''); } function playTintFromLobbyRgb(themeRgb, skinRgb) { const bodyHex = rgbCssToHexPlay(themeRgb); const headHex = rgbCssToHexPlay(skinRgb); if (!headHex && !bodyHex) return null; return { head: headHex || '#fbd5c4', hair: bodyHex || '#fb4941', body: bodyHex || '#fb4941', }; } function loadPlayTintFromLobbyPlayerStorage() { try { const raw = localStorage.getItem(LOBBY_PLAYER_TINT_KEY); if (raw) { const j = JSON.parse(raw); if (j && typeof j === 'object') { if (j.head && j.body) { return { head: String(j.head), hair: String(j.hair || j.body), body: String(j.body), }; } const fromJson = playTintFromLobbyRgb(j.themeRgb, j.skinRgb); if (fromJson) return fromJson; } } } catch (e) { /* ignore */ } try { const theme = localStorage.getItem('lobbyThemeColor') || ''; const skin = localStorage.getItem('lobbySkinTone') || ''; return playTintFromLobbyRgb(theme, skin); } catch (e2) { return null; } } /** แปลง hex/rgb → {r,g,b} (กันค่าเพี้ยน) */ function hexOrRgbToRgbPlay(v) { const s0 = String(v || '').trim(); const m = s0.match(/rgba?\(\s*(\d+)\D+(\d+)\D+(\d+)/i); if (m) return { r: +m[1], g: +m[2], b: +m[3] }; let s = s0.replace('#', ''); if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2]; if (!/^[0-9a-fA-F]{6}$/.test(s)) return null; const n = parseInt(s, 16); return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; } function nearestIndexFromHexPlay(value, palette) { const asIdx = Math.floor(Number(value)); if (asIdx >= 1 && asIdx <= palette.length) return asIdx; /* เก็บเป็น index อยู่แล้ว */ const t = hexOrRgbToRgbPlay(value); if (!t) return null; let best = -1; let bestD = Infinity; for (let i = 0; i < palette.length; i++) { const c = hexOrRgbToRgbPlay(palette[i]); if (!c) continue; const d = (c.r - t.r) * (c.r - t.r) + (c.g - t.g) * (c.g - t.g) + (c.b - t.b) * (c.b - t.b); if (d < bestD) { bestD = d; best = i; } } return best >= 0 ? best + 1 : null; } /** theme/skin index ที่ผู้เล่นเลือกไว้ (จาก localStorage) — ส่งให้ server เพื่อกันบอทใช้สีเดียวกับเรา */ function getMyChosenLobbyColorIndicesPlay() { let body = ''; let skin = ''; try { const raw = localStorage.getItem(LOBBY_PLAYER_TINT_KEY); if (raw) { const j = JSON.parse(raw); if (j) { body = j.body || j.themeRgb || ''; skin = j.skinRgb || j.skin || ''; } } } catch (e) { /* ignore */ } try { if (!body) body = localStorage.getItem('lobbyThemeColor') || ''; if (!skin) skin = localStorage.getItem('lobbySkinTone') || ''; } catch (e2) { /* ignore */ } return { themeIndex: nearestIndexFromHexPlay(body, PLAY_LOBBY_THEME_HEX), skinIndex: nearestIndexFromHexPlay(skin, PLAY_LOBBY_SKIN_HEX), }; } function loadPlayTintForBotPeerId(peerId) { const sid = String(peerId || ''); let botKey = sid; if (sid.indexOf(PREVIEW_BOT_PREFIX) === 0) { botKey = '__lobby_bot_' + sid.slice(PREVIEW_BOT_PREFIX.length); } try { const raw = sessionStorage.getItem(lobbyBotTintsStorageKeyPlay()); if (!raw) return null; const map = JSON.parse(raw); const saved = map && map[botKey]; if (!saved) return null; return playTintFromLobbyRgb(saved.colorTheme, saved.colorSkin); } catch (e) { return null; } } /** สีจากล็อบบี้ (ผู้เล่น + บอท) — fallback hash ตาม peer id */ function resolvePlayTintForPeer(peerId, peerRow) { const sid = String(peerId || ''); /* ตัวเราเอง: ยึด "สีที่เลือกไว้" (localStorage) ก่อนทุกอย่าง — กัน peerRow/theme-index จาก server มาทับ (สีกระพริบ) */ if (myId != null && sid === String(myId)) { const meChosen = loadPlayTintFromLobbyPlayerStorage(); if (meChosen) return meChosen; } const fromRow = peerRow ? playTintFromJoinPeer(peerRow) : null; if (fromRow) return fromRow; const previewTint = playTintForPreviewBotId(sid); if (previewTint) return previewTint; if (myId != null && sid === String(myId)) { const meTint = loadPlayTintFromLobbyPlayerStorage(); if (meTint) return meTint; } if ((sid === '__me' || sid === '__local_me') && me && me.playTint) return me.playTint; const botTint = loadPlayTintForBotPeerId(sid); if (botTint) return botTint; return playTintFromPeerId(sid); } function applyPlayTintFromLobbyIndices(peerId, themeIndex, skinIndex) { const sid = peerId != null ? String(peerId) : ''; if (myId != null && sid === String(myId)) { /* ตัวเราเอง: ยึด "สีที่เลือกไว้" (localStorage) เป็นหลักเสมอ — ไม่ให้ theme-index จาก server มาทับ (สาเหตุสีกระพริบ/สลับ: server sync ส่ง index ที่ไม่ตรงสีที่เลือก แล้วทับ me.playTint ทุกครั้ง) */ const meChosen = loadPlayTintFromLobbyPlayerStorage(); if (meChosen) { me.playTint = meChosen; return; } const tintMe = playTintFromLobbyThemeIndices(themeIndex, skinIndex); if (tintMe) me.playTint = tintMe; return; } const tint = playTintFromLobbyThemeIndices(themeIndex, skinIndex); if (!tint) return; const o = others.get(peerId) || others.get(sid); if (o) o.playTint = tint; if (isPreviewBotId(sid)) playTintForPreviewBotId(sid); } function applyPlayLobbyTintSync(data) { if (!data || typeof data !== 'object') return; if (Array.isArray(data.lobbyBotThemes)) playLobbyBotThemes = data.lobbyBotThemes; if (Array.isArray(data.peers)) { data.peers.forEach(function (p) { if (!p || p.id == null) return; applyPlayTintFromLobbyIndices(p.id, p.lobbyColorThemeIndex, p.lobbySkinToneIndex); }); } if (playBotsEnabled()) rebalancePreviewBots(); try { preloadPlayOccupantsTints(); preloadPlayLobbyIdleAvatarsForOccupants(); } catch (e) { /* ignore */ } } /** วินิจฉัยสีในเกม: รันใน console บนแต่ละเครื่อง → เทียบสีที่ render ของทุกตัว */ window.__colorDebug = function () { var rows = []; rows.push({ who: '*ME*', id: String(myId == null ? '' : myId).slice(0, 6), renderTint: JSON.stringify(me && me.playTint) }); others.forEach(function (o, id) { rows.push({ who: isPreviewBotId(id) ? 'BOT' : 'peer', id: String(id).slice(0, 10), name: o.nickname || '', renderTint: JSON.stringify(o.playTint) }); }); try { console.table(rows); } catch (e) { console.log(JSON.stringify(rows, null, 2)); } var lsTheme = '?'; var lsTint = '?'; try { lsTheme = localStorage.getItem('lobbyThemeColor'); lsTint = localStorage.getItem(LOBBY_PLAYER_TINT_KEY); } catch (e) { /* ignore */ } console.log('[color-dbg/game] myId=' + myId + ' playLobbyBotThemes=' + JSON.stringify(playLobbyBotThemes) + ' LS.lobbyThemeColor=' + lsTheme + ' LS.PLAYER_TINT=' + lsTint); return rows; }; function getPlayTintForMissionAvatar(peerId) { const sid = peerId != null ? String(peerId) : ''; if (myId != null && sid === String(myId)) { return me.playTint || resolvePlayTintForPeer(sid); } const o = others.get(peerId) || others.get(sid); if (o && o.playTint) return o.playTint; return resolvePlayTintForPeer(sid); } /** สรุปภารกิจ — ผู้เล่นจริงต้องอยู่ใน top N เสมอ */ function missionEnsureHumanInTopRanked(rows, maxN) { const cap = Math.max(1, maxN || 5); if (!Array.isArray(rows) || !rows.length) return []; const humanId = myId != null ? String(myId) : null; const humanRow = humanId ? rows.find((r) => r && r.id != null && String(r.id) === humanId) : rows.find((r) => r && r.isLocalHuman); let top = rows.slice(0, cap); if (!humanRow) return top; const hid = humanRow.id != null ? String(humanRow.id) : ''; if (top.some((r) => r && r.id != null && String(r.id) === hid)) return top; if (top.length >= cap) top = top.slice(0, cap - 1); top.push(humanRow); return top; } 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; } /** อุ่น cache เลเยอร์สีของตัวละคร (ทุกทิศ + เดิน 0-3 + idle) ก่อนเข้าเกม กันกระตุกตอนโหลดสีกลางเกม * ยิงเฉพาะ layer ที่ "มีจริง" (อ่านจาก manifest/disk set) เพื่อกัน 404 รัว — รอ manifest พร้อมก่อน */ const _playPreloadedChars = new Set(); const _playPreloadedTintKeys = new Set(); function preloadPlayCharacterLayers(charId, _tries) { if (!charId || _playPreloadedChars.has(charId)) return; try { ensurePlayCharLayerListFetch(); } catch (e) { /* ignore */ } try { ensurePlayLayerProbesAllDirections(charId); } catch (e) { /* ignore */ } const diskSet = playCharDiskLayersSet(charId); if (!diskSet) { // manifest ยังไม่พร้อม — ลองใหม่อีกครู่ (กันยิง layer ที่ไม่มี = 404) const t = _tries || 0; if (t < 8) setTimeout(function () { preloadPlayCharacterLayers(charId, t + 1); }, 450); return; } _playPreloadedChars.add(charId); const dirs = ['down', 'up', 'left', 'right']; const warm = function (urls) { if (urls && urls.length) ensurePlayLayerImage(urls[0]); }; dirs.forEach(function (dir) { PLAY_LAYER_ORDER.forEach(function (ln) { if (ln !== 'shadow' && !diskSet.has(ln)) return; // ข้าม layer ที่ไม่มีไฟล์ if (ln === 'face' && dir === 'up' && isUploadedCharAssetId(charId)) return; // up ไม่มี face for (let fi = 0; fi < CHARACTER_ANIM_FRAMES; fi++) { warm(ln === 'shadow' ? shadowUrlCandidates(charId, dir, fi) : layerUrlCandidates(charId, dir, ln, fi)); } warm(ln === 'shadow' ? shadowUrlCandidatesIdle(charId, dir, 0) : layerUrlCandidatesIdle(charId, dir, ln, 0)); }); }); } /** อุ่น canvas ย้อมสี (head/hair/body) ต่อตัวละคร+สี */ function preloadPlayTintForAvatar(charId, tint) { if (!charId || !tint || typeof tint.head !== 'string') return; const key = [charId, tint.head, tint.hair, tint.body].join('|'); if (_playPreloadedTintKeys.has(key)) return; _playPreloadedTintKeys.add(key); preloadPlayCharacterLayers(charId); const dirs = ['down', 'up', 'left', 'right']; const now = Date.now(); dirs.forEach(function (dir) { const rawWalk = getCharacterFrame(charId, dir, now, true); const rawIdle = getCharacterFrame(charId, dir, now, false); if (rawWalk) getPlayTintedAvatarSource(rawWalk, charId, dir, now, true, tint); if (rawIdle) getPlayTintedAvatarSource(rawIdle, charId, dir, now + 400, false, tint); }); } function preloadPlayOccupantsTints() { const meCid = me.characterId || getPlayCharacterId(); if (meCid) { preloadPlayTintForAvatar(meCid, me.playTint || resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')))); } others.forEach(function (o, id) { if (!o || !o.characterId) return; preloadPlayTintForAvatar(o.characterId, o.playTint || resolvePlayTintForPeer(id)); }); } /** ลำดับลอง 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); } // idle เฟรมเดียว — ชื่อจริงของ char-* คือ *_idle_layer_*.png (ไม่มี frame index) ลองตัวนี้ก่อนกัน 404 รัว add(base + '_layer_' + layerName + '.png'); add(base + '_' + frameIndex + '_layer_' + layerName + '.png'); if (frameIndex !== 0) add(base + '_0_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) { /* 'Chatest' = placeholder ที่ไม่มีไฟล์ sprite จริง → อย่าโหลด (กัน 404 รัว) ใช้ default avatar generated แทน */ const validId = characterId && characterId !== LEGACY_PLACEHOLDER_CHARACTER_ID; const img = validId ? getCharacterFrame(characterId, direction, now, isWalking) : null; if (img) return img; return defaultAvatarImg; } /** composite idle ลงหน้า — เดียวกับ #room-lobby-profile-avatar ในล็อบบี้ */ const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:'; const PLAY_LOBBY_AVATAR_SS_PREFIX = 'justicePlayLobbyAvatar:'; const playLobbyIdleAvatarCache = new Map(); function playLobbyIdleAvatarSrcForCharacter(characterId) { const id = String(characterId || '').trim(); if (!id) return ''; try { const ss = sessionStorage.getItem(PLAY_LOBBY_AVATAR_SS_PREFIX + id) || ''; if (ss && ss.indexOf('data:image/') === 0) return ss; const ls = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id) || ''; if (ls && ls.indexOf('data:image/') === 0) return ls; } catch (e) { /* ignore */ } return ''; } function ensurePlayLobbyIdleAvatarImage(characterId) { const id = String(characterId || '').trim(); if (!id) return; const hit = playLobbyIdleAvatarCache.get(id); if (hit && hit.complete && hit.naturalWidth) return; if (playLobbyIdleAvatarCache.get(id + '_loading')) return; const src = playLobbyIdleAvatarSrcForCharacter(id); if (!src) return; const img = new Image(); img.onload = function () { playLobbyIdleAvatarCache.set(id, img); playLobbyIdleAvatarCache.delete(id + '_loading'); }; img.onerror = function () { playLobbyIdleAvatarCache.delete(id + '_loading'); }; img.src = src; playLobbyIdleAvatarCache.set(id + '_loading', img); } function getPlayLobbyIdleAvatarImageSync(characterId) { const id = String(characterId || '').trim(); if (!id) return null; const hit = playLobbyIdleAvatarCache.get(id); if (hit && hit.complete && hit.naturalWidth) return hit; ensurePlayLobbyIdleAvatarImage(id); return null; } /** วาดตัวละคร — ทิศลง + useLobbyIdleComposite: composite เดียวกับล็อบบี้ (เฉพาะผู้เล่นจริง ตอนยืน); ตอนเดินใช้ strip เดิน down_0..3 */ function resolvePlayDrawCharSource(characterId, direction, now, isWalking, playTint, useLobbyIdleComposite) { /* placeholder 'Chatest' ไม่มี sprite/เลเยอร์จริง → ปฏิบัติเป็นว่าง (ใช้ default avatar) กัน 404 รัวทุกเส้นทางวาด */ if (characterId === LEGACY_PLACEHOLDER_CHARACTER_ID) characterId = ''; const dir = direction || 'down'; const walk = !!isWalking; if (dir === 'down' && useLobbyIdleComposite && !walk) { const lobbyImg = getPlayLobbyIdleAvatarImageSync(characterId); if (lobbyImg) return lobbyImg; } const rawImg = getAvatarImg(characterId, dir, now, walk); if (!playTint || !characterId) return rawImg; return getPlayTintedAvatarSource(rawImg, characterId, dir, now, walk, playTint) || rawImg; } function preloadPlayLobbyIdleAvatarsForOccupants() { const meCid = me.characterId || getPlayCharacterId(); if (meCid) ensurePlayLobbyIdleAvatarImage(meCid); others.forEach(function (o) { if (o && o.characterId) ensurePlayLobbyIdleAvatarImage(o.characterId); }); } function playHideTopChrome() { try { return document.documentElement.classList.contains('play-hide-top-chrome'); } catch (e) { return true; } } function getStoredCharacterId() { try { const v = localStorage.getItem('gameCharacterId'); 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(); } /** playerKey ของผู้เล่น (ผูกบัญชีเหรียญ/คะแนน) — สร้างถ้ายังไม่มี เหมือน Main-Lobby */ function getJdPlayerKey() { try { let k = (localStorage.getItem('jdPlayerKey') || '').trim(); if (!k || k.length < 8) { k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); localStorage.setItem('jdPlayerKey', k); } return k; } catch (e) { return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); } } /** บอทพรีวิว: เลือกรหัสคนละตัวจาก roster (ไม่ซ้ำผู้เล่นถ้ามีตัวเลือก) */ function pickPreviewBotCharacterId(botSlotIndex) { const idx = Math.max(0, Math.floor(Number(botSlotIndex)) || 0); const roster = playCharacterIdRoster; const humanId = String(getPlayCharacterId() || ''); if (!roster.length) return humanId || LEGACY_PLACEHOLDER_CHARACTER_ID; const alts = roster.filter((id) => id !== humanId); const pool = alts.length ? alts : roster.slice(); return pool[idx % pool.length] || humanId || roster[0]; } const MOVE_SPEED = 0.15; /** quiz_carry — ค่าเริ่มเมื่อไม่มี override จาก Admin (รหัสฉากตรงกันใน quiz-settings.json) */ const QUIZ_CARRY_WALK_SPEED_MULT = 1.42; /** ค่าที่ใช้จริงต่อเฟรม — อัปเดตจาก /api/quiz-settings + join snap */ let quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT; const PATH_ARRIVE_THRESH = 0.15; function clampQuizCarryWalkSpeedMultClient(n, def) { const v = Number(n); if (!Number.isFinite(v)) return def; return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100; } function resolveQuizCarryWalkSpeedMultFromSettingsObj(s) { const base = QUIZ_CARRY_WALK_SPEED_MULT; if (!s || typeof s !== 'object') return base; const forId = String(s.carryWalkSpeedMultForMapId ?? '').trim(); const mult = Number(s.carryWalkSpeedMult); const mapId = (currentPlayMapId() || '').trim(); if (forId && mapId && forId === mapId && Number.isFinite(mult)) { return clampQuizCarryWalkSpeedMultClient(mult, base); } return base; } function applyQuizCarryWalkSpeedFromSettingsObj(s) { if (!isQuizCarry()) { quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT; return; } quizCarryWalkSpeedMultActive = resolveQuizCarryWalkSpeedMultFromSettingsObj(s); } function moveSpeedTilesThisFrameForWalk() { return MOVE_SPEED * (isQuizCarry() ? quizCarryWalkSpeedMultActive : 1); } 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; /** ภารกิจ Tower (mnn93hpi): จำกัดเวลารอบ (วินาที) — จาก game-timing.json */ let playStackTowerMissionTimeSec = 90; /** ภารกิจ Tower: ทีมพลาดได้กี่ครั้งก่อนเกมจบ — จาก game-timing.json */ let playStackTeamMissesMax = 3; /** ภารกิจ Tower: ชั้นสำเร็จกี่ชั้น (ฐาน) = Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */ let playStackTowerProgressBlocks = 50; /** รูปบล็อก Stack ต่อที่นั่ง 1–6 (game-timing) — ว่าง = วาดสีเดิม */ let playStackBlockNormalUrls = ['', '', '', '', '', '']; let playStackBlockHeavyUrls = ['', '', '', '', '', '']; /** 0–100: โอกาสใช้สไปรต์ “ใหญ่” ต่อการปล่อย (ถ้ามี URL ใหญ่ช่องนั้น) */ let playStackHeavyBlockPercent = 35; /** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัว (ch×tile) — จาก GET /api/game-timing */ let playJumpSurviveJumpHeightMult = 1.5; /** จำกัดเวลารอบภารกิจกระโดดขึ้นแท่น (วินาที) — จาก GET /api/game-timing; 0 = ใช้ค่าเริ่ม 60 ในเกม */ let playJumpSurviveMissionTimeSec = 0; /** รูปแพลตฟอร์ม jump_survive ช่อง 1–3 จาก game-timing — url ว่าง = วาด cyan; w/h 0 = ใช้ขนาดไทล์ */ let playJumpSurvivePlatformTiles = [ { url: '', w: 0, h: 0 }, { url: '', w: 0, h: 0 }, { url: '', w: 0, h: 0 }, ]; /** ยิงยานอวกาศ (space_shooter): เวลารอบจาก game-timing; 0 = ใช้ค่าเริ่ม 90 ในเกมถ้าแมปไม่ทับ */ let playSpaceShooterMissionTimeSec = 0; /** รูปยานช่อง 1–6 จาก game-timing (spawn slot); ว่าง = วาดยานเวกเตอร์ */ let playSpaceShooterShipImageUrls = ['', '', '', '', '', '']; /** อุกาบาต: [0]=ตก, [1..]=แตก — จาก game-timing */ let playSpaceShooterAsteroidSpriteUrls = []; let playSpaceShooterAsteroidExplodeFrameMs = 70; /** ระยะห่างเกิดอุกาบาต (ms) จาก game-timing — แมป spaceShooterAsteroidIntervalMs ≥200 ทับ */ let playSpaceShooterAsteroidIntervalMs = 1040; /** ทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (PNG โปร่ง) — ภารกิจ Violent Crime */ let playSpaceShooterShipDamageOverlayUrls = ['', '', '']; /** balloon_boss / Mega Virus — จาก game-timing */ let playBalloonBossMissionTimeSec = 0; let playBalloonBossBossImageUrl = ''; let playBalloonBossPlayerBalloonImageUrls = ['', '', '', '', '', '']; let playBalloonBossPlayerBalloonFallbackUrl = ''; /** 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 = {}; /** จำนวนผู้เล่น (มนุษย์) ที่ server คาดว่าจะเข้าเล่นรอบนี้ — host เริ่มได้ต่อเมื่อมาครบ + กด Ready ครบ */ let missionExpectedActiveCount = 0; 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(); function encodeSpacesInUrlPath(t) { const s = String(t || ''); const q = s.indexOf('?'); const pathPart = q === -1 ? s : s.slice(0, q); const query = q === -1 ? '' : s.slice(q); return pathPart.replace(/ /g, '%20') + query; } function decodeAssetUrlPercentRuns(t) { let s = String(t || ''); const q = s.indexOf('?'); let base = q === -1 ? s : s.slice(0, q); const query = q === -1 ? '' : s.slice(q); for (let i = 0; i < 2; i += 1) { try { const d = decodeURIComponent(base); if (d === base) break; base = d; } catch (e) { break; } } return base + query; } function normalizeGameAssetUrlForWebPlay(t) { let s = String(t || ''); s = s.replace(/^\/Game\/public\/img\//i, '/Game/img/'); s = s.replace(/^Game\/public\/img\//i, 'Game/img/'); return s; } /** แปลง URL จาก Admin/game-timing ให้โหลดได้ (nginx เสิร์ฟจาก /Game/...) */ function normalizeGauntletAssetUrlForPlay(u) { if (typeof u !== 'string') return ''; let raw = normalizeGameAssetUrlForWebPlay(decodeAssetUrlPercentRuns(u.trim())); const bare0 = raw.split('?')[0].replace(/\/+$/, ''); if (/^\/Game\/img\/MegaVirus\/Artboard$/i.test(bare0) || /^Game\/img\/MegaVirus\/Artboard$/i.test(bare0)) { raw = '/Game/img/MegaVirus/Artboard%209.png'; } if (!raw) return ''; if (/^https?:\/\//i.test(raw)) { const z = raw.replace(/\/+$/, ''); return z ? encodeSpacesInUrlPath(z) : ''; } const qIdx = raw.indexOf('?'); const base = (qIdx >= 0 ? raw.slice(0, qIdx) : raw).trim().replace(/\/+$/, ''); const qs = qIdx >= 0 ? raw.slice(qIdx) : ''; if (!base) return ''; if (/\.{4,}/.test(base)) return ''; if (base.startsWith('/')) return encodeSpacesInUrlPath(base + qs); if (/^Game\//i.test(base)) return encodeSpacesInUrlPath('/' + base.replace(/^\/+/, '') + qs); return encodeSpacesInUrlPath(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; /** รูปจากแคชบางครั้ง complete ก่อน onload — ต้องเซ็ต ready ทันทีไม่งั้นวาดลูกโป่งไม่ขึ้น */ if (rec.img.complete && rec.img.naturalWidth > 0) rec.ready = true; else if (rec.img.complete && rec.img.naturalWidth <= 0) rec.ready = false; gauntletAssetImageCache.set(u, rec); return rec; } /** ลูกโป่ง Mega Virus: ถ้ามี URL ต่อที่นั่งแต่โหลดไม่สำเร็จ (404) ให้ใช้ fallback แทน · Per-seat URL wins only when image loads */ function resolveBalloonBossBalloonSpriteRec(perSeatRaw, fallbackRaw) { const per = normalizeGauntletAssetUrlForPlay(String(perSeatRaw || '').trim()); const fb = normalizeGauntletAssetUrlForPlay(String(fallbackRaw || '').trim()); if (per) { const rPer = ensureGauntletAssetImage(per); const img = rPer && rPer.img; if (img && img.complete && img.naturalWidth > 0) return rPer; if (img && img.complete && img.naturalWidth <= 0 && fb) return ensureGauntletAssetImage(fb); return rPer; } if (fb) return ensureGauntletAssetImage(fb); return null; } 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; /** เกมถูก/ผิด + ภารกิจ mng8a80o: เลขข้อจากเซิร์ฟเวอร์ (1-based) / จำนวนข้อในรอบ — ใช้บรรทัดเล็กเหนือคำถามบนแผนที่ */ let playQuizQuestionIndex = 0; let playQuizQuestionTotal = 0; /** Preview-only: real question pool + phased timer (matches server quiz-settings / map quizQuestions). */ let previewQuizPool = []; /** ดัชนีข้อในรอบพรีวิว (0..len-1) — หลังสับแล้วตัดตาม quizRoundQuestionCount เหมือนเซิร์ฟเวอร์ */ let previewQuizQIndex = 0; let previewQuizTiming = { readMs: 10000, answerMs: 5000, betweenMs: 3500 }; let previewQuizStep = 'read'; let previewQuizCurrent = null; let playLiveQuizScores = {}; /** เกมถูก/ผิด (multiplayer): ใครเคยตอบผิดในเซสชันนี้ — ใช้โชว์แสตมป์ในสรุปภารกิจ mng8a80o */ let playQuizEverWrong = {}; /** MG1: กันสั่งจบซ้ำเมื่อตรวจพบ "ตายหมด" (รวมบอท ฝั่ง client) */ let mg1AllDeadEndScheduled = false; /** MG1: ตรวจว่าทุกคน(เรา+บอท+ผู้เล่นอื่น)ถูกล็อกตอบผิดหมด → จบเกมทันที (เฉพาะโหมดสืบสวน) */ function checkMg1AllDeadAndEnd() { if (mg1AllDeadEndScheduled || previewMode) return; if (!isQuiz() || !isDetectiveMinigamePlay()) return; const meLocked = !!(playQuizPlayerLocal && playQuizPlayerLocal.cannotTrue && playQuizPlayerLocal.cannotFalse); if (!meLocked) return; let anyAlive = false; others.forEach(function (o, id) { if (!o || o.bannedSpectator) return; if (!playBotsEnabled() && isPreviewBotId(id)) return; if (!(o.quizCannotTrue && o.quizCannotFalse)) anyAlive = true; }); if (anyAlive) return; mg1AllDeadEndScheduled = true; /* ทุกคนตอบผิดหมด → host ขอให้ server จบเกม → server ส่ง quiz-ended → โชว์ผล/รอกดรับการ์ด (เหมือนเกมอื่น) เดิม: auto tryFinish = รับผล/การ์ดเองทันที ไม่รอกด */ setTimeout(function () { try { if (isMePlayHost() && socket && socket.connected) socket.emit('quiz-end-request'); /* non-host: ไม่ต้องทำอะไร รอ server ส่ง quiz-ended มาโชว์ผล */ } catch (e) { /* ignore */ } }, 1600); } /** ผีตามตัวเมื่อตอบผิด — ใช้เฉพาะแมป mng8a80o (เป็นสัญลักษณ์/บทลงโทษเชิงสนุก) */ const QUIZ_WRONG_GHOST_URL = BASE + '/img/QUESTION/ghost/ghost-front.png'; let quizWrongGhostImg = null; function getQuizWrongGhostImg() { if (quizWrongGhostImg) return quizWrongGhostImg; const img = new Image(); img.decoding = 'async'; img.src = QUIZ_WRONG_GHOST_URL; quizWrongGhostImg = img; return img; } /** ===== โซนผี (quizGhostArea): ตอบผิด→วาร์ปมาขังในโซน เดินออกไม่ได้ — gated: ทำงานเฉพาะเมื่อแมปมีการวาดโซนผี ===== */ function quizGhostAreaGridPlay() { const g = mapData && mapData.quizGhostArea; if (!Array.isArray(g)) return null; for (let y = 0; y < g.length; y++) { if (g[y] && g[y].some((v) => v === 1)) return g; } return null; } function quizMeIsGhostPlay() { return !!(isQuiz() && playQuizPlayerLocal && (playQuizPlayerLocal.cannotTrue || playQuizPlayerLocal.cannotFalse)); } function quizGhostConfineActivePlay() { return quizMeIsGhostPlay() && !!quizGhostAreaGridPlay(); } /** footprint ของผู้เล่นที่ (x,y) อยู่ในโซนผีครบทุกช่องไหม */ function quizGhostFootprintInsidePlay(x, y) { const g = quizGhostAreaGridPlay(); if (!g) return true; for (const k of quizTilesFootprintPlay(x, y)) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (!g[ty] || g[ty][tx] !== 1) return false; } return true; } /** วาร์ป me เข้าโซนผี (ช่องที่ footprint ลงได้ ใกล้กลางโซนสุด) — คืน true ถ้ามีโซนผี */ function warpMeIntoQuizGhostZonePlay() { const g = quizGhostAreaGridPlay(); if (!g) return false; if (quizGhostFootprintInsidePlay(me.x, me.y)) return true; const xs = [], ys = []; for (let y = 0; y < g.length; y++) { const row = g[y] || []; for (let x = 0; x < row.length; x++) { if (row[x] === 1) { xs.push(x); ys.push(y); } } } if (!xs.length) return false; const cx = Math.round((Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2); const cy = Math.round((Math.min.apply(null, ys) + Math.max.apply(null, ys)) / 2); const cand = []; for (let i = 0; i < xs.length; i++) cand.push([xs[i], ys[i], Math.abs(xs[i] - cx) + Math.abs(ys[i] - cy)]); cand.sort((a, b) => a[2] - b[2]); let tx = xs[0], ty = ys[0]; for (const c of cand) { if (quizGhostFootprintInsidePlay(c[0], c[1])) { tx = c[0]; ty = c[1]; break; } } me.x = tx; me.y = ty; me.tx = tx; me.ty = ty; try { if (socket && socket.connected) socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } catch (e) { /* ignore */ } return true; } /** สุ่มช่องในโซนผีที่ footprint ลงได้ (ใช้คุมบอทผีให้อยู่ในโซน) — คืน {x,y} หรือ null */ function randomGhostZoneCellPlay() { const g = quizGhostAreaGridPlay(); if (!g) return null; const cells = []; for (let y = 0; y < g.length; y++) { const row = g[y] || []; for (let x = 0; x < row.length; x++) { if (row[x] === 1 && quizGhostFootprintInsidePlay(x, y)) cells.push({ x: x, y: y }); } } if (!cells.length) { for (let y = 0; y < g.length; y++) { const row = g[y] || []; for (let x = 0; x < row.length; x++) if (row[x] === 1) cells.push({ x: x, y: y }); } } return cells.length ? cells[Math.floor(Math.random() * cells.length)] : null; } /** ตรวจว่า peer/me ตอบผิดในแมป mng8a80o แล้ว → คุมผีแทน avatar */ function isQuizWrongGhostActiveForEnt(entId) { if (!isQuizQuestionMissionUiMapPlay()) return false; const sid = String(entId == null ? '' : entId); const isMeEnt = (myId != null && sid === String(myId)); return isMeEnt ? !!(playQuizPlayerLocal && (playQuizPlayerLocal.cannotTrue || playQuizPlayerLocal.cannotFalse)) : !!playQuizEverWrong[sid]; } /** วาด "ผี" แทน avatar ทั้งตัวในแมป mng8a80o เมื่อตอบผิด — คุมผีเลย * args ทุกตัวเป็น "screen pixels" แล้ว (คำนวณจาก worldToScreen ที่ caller) * - sxCenter = พิกัด X กึ่งกลาง avatar บนจอ (px) * - syBottom = พิกัด Y เท้า avatar บนจอ (px) * - heightPx = ความสูง avatar บนจอ (px) ใช้คุมขนาดผีให้พอดี */ function drawQuizWrongGhostOverlayPlay(sxCenter, syBottom, heightPx, entId, nameLabel) { const img = getQuizWrongGhostImg(); if (!img || !img.complete || !img.naturalWidth) return; /* ผีสูงเท่า avatar ตามจริง — คงสัดส่วนภาพ */ const hPx = Math.max(24, Number(heightPx) || 0); const ratio = img.naturalWidth / Math.max(1, img.naturalHeight); const wPx = hPx * ratio; const sid = String(entId == null ? '' : entId); const tBob = Math.sin(performance.now() / 360 + (sid ? sid.charCodeAt(0) || 0 : 0)) * (hPx * 0.04); /* วาดให้ "เท้าผี" ตรงกับเท้า avatar */ const drawX = sxCenter - wPx / 2; const drawY = syBottom - hPx + tBob; ctx.save(); ctx.globalAlpha = 0.98; ctx.drawImage(img, drawX, drawY, wPx, hPx); ctx.restore(); /* ชื่อใต้ผี (ถ้ามี — เพื่อระบุว่าเป็นใคร) */ if (nameLabel) { ctx.save(); ctx.font = '600 12px system-ui, "Segoe UI", "Kanit", sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(255,255,255,0.95)'; ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 3; const ny = syBottom + 14; ctx.strokeText(nameLabel, sxCenter, ny); ctx.fillText(nameLabel, sxCenter, ny); ctx.restore(); } } /** ช่วยแปลง tile coords → screen pos สำหรับ ghost (ใช้เฉพาะที่ caller ใน render scope) * รับ helpers ที่เป็น scope-local (worldToScreen, zDraw) เพราะถูกเรียกในรอบ draw เท่านั้น */ function _quizGhostScreenAnchorPlay(axTile, ayTile, w2s, zDrawIn) { const md = mapData; const { cw, ch } = getCharacterFootprintWH(md); const cxWorld = (axTile + cw * 0.5) * tileSize; const cyBottomWorld = (ayTile + ch) * tileSize; const [sx, sy] = w2s(cxWorld, cyBottomWorld); /* ความสูง = สูง avatar บนจอ (สอดคล้องกับ drawAvatar: boxH = tileSize * zDraw * ch * 1.35) */ const sizeH = tileSize * zDrawIn * Math.max(ch, 1) * 1.35; return { sx, sy, sizeH }; } /** เกมถูก/ผิด — ตรงกับ server QUIZ_TF_POINTS_PER_CORRECT */ const QUIZ_TF_POINTS_PER_CORRECT = 10; const QUIZ_TF_SCORE_PLUS_URL = BASE + '/img/QUESTION/score+.png'; const QUIZ_TF_SCORE_POPUP_MS = 1700; /** ขนาดป๊อปอัป score+ เทียบกับ tile — เดิม ~1.35×tile; +100% = 2× → 2.7×tile */ const QUIZ_TF_SCORE_POPUP_TILE_MULT = 2.7; const QUIZ_TF_SCORE_POPUP_MIN_BASE_PX = 112; let quizTfScorePopups = []; let quizTfScorePlusImg = null; /** คะแนนต่อคำตอบที่ถูกเมื่อส่งป้ายที่ฮับ/โซนส่ง — quiz_carry เท่านั้น */ const QUIZ_CARRY_POINTS_PER_CORRECT = 10; /** ครบเซสชัน (แมปสรุปภารกิจ): โชว์ result-complete / gameover กี่ ms ก่อนแผงสรุปผล */ const QUIZ_CARRY_SESSION_END_SPLASH_MS = 3000; /** quiz_carry: หยิบตัวเลือกมาวางโซนกลาง — เล่นพร้อมกัน (ไม่สลับตา) */ let quizCarryPool = []; let quizCarryCurrent = null; /** editor embed + preview: นับ 3–2–1 ก่อนโชว์คำถามและให้เดิน/หยิบได้ */ let quizCarryEmbedPendingQuestion = null; let quizCarryEmbedCountdownStartAt = 0; let quizCarryEmbedCountdownEndAt = 0; /** editor embed: นับ 3–2–1 รอบสองหลังโชว์คำถาม ก่อนเปิดป้ายตัวเลือกบนแมป */ let quizCarryEmbedPreOptionCountdownStartAt = 0; let quizCarryEmbedPreOptionCountdownEndAt = 0; /** quiz_carry: epoch ให้หยิบตัวเลือกได้ · epoch ปิดรอบตอบ (ตั้งที่ Admin แท็บหยิบมาวาง) */ let quizCarryOptionRevealAt = 0; let quizCarryAnswerCloseAt = 0; /** หมดเวลาตอบรอบนี้แล้ว — โชว์ TIME'S UP ~3s แล้วเรียก quizCarryAfterRoundResolved */ let quizCarryAnswerTimeupAwaitNext = false; let quizCarryAnswerRoundTimeupAutoT = null; let quizCarryAnswerRoundTimeupAdvanceAt = 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; /** ครบเซสชัน (mission summary แมป): รอ splash แล้วเปิด #quiz-carry-mission-overlay */ let quizCarrySessionCompleteResultToSummaryT = 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 quizMapPanelTheme — แผงคำถามบนแผนที่ เกมถูก/ผิด (ไม่ใช่ quiz_carry) */ let quizMapPanelTheme = 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 เพิ่ม world Y ของแพลตฟอร์ม) ไม่ลากทั้งฉาก */ let jumpSurviveCamCenterX = 0; let jumpSurviveCamCenterY = 0; let jumpSurviveEliminated = false; /** จับเวลาโหมดกระโดดให้รอด (วินาทีบน HUD) */ let jumpSurviveSessionStartMs = 0; /* เวลา server (epoch ms) ที่ live เริ่มจริง — ใช้คำนวณ scroll/เวลานับถอยหลังจาก serverNow() ร่วมกัน ทุกเครื่องเห็นแพลตฟอร์มเลื่อนเท่ากัน + จบพร้อมกัน (เดิมสะสม dt local → เลื่อน/จบไม่ตรงกัน = desync) */ let jumpSurviveLiveAnchorServerMs = 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 = []; /** { x, y, r, fi, acc } — fi = index เฟรมแตก (0 = urls[1]) */ let spaceShooterAsteroidExplosions = []; let spaceShooterPopups = []; let spaceShooterLastTickMs = 0; let spaceShooterSpawnAccMs = 0; /* เวลา server ที่ live เริ่ม + ตัวนับลำดับก้อนที่เกิดแล้ว — ใช้ทำ deterministic spawn ให้ทุกเครื่องตรงกัน */ let spaceShooterLiveAnchorServerMs = 0; let spaceShooterSpawnCount = 0; let spaceShooterFireCd = 0; let spaceShooterSessionStartMs = 0; let spaceShooterLastMoveEmit = 0; let spaceShooterGameEnded = false; let spaceShooterMissionPhase = null; let spaceShooterMissionCountdownTimer = null; /** mng8a80o: howto → countdown → live → ended (สรุปแบบ crown mission) */ let quizQuestionMissionPhase = null; let quizQuestionMissionCountdownTimer = null; let quizQuestionMissionDeferredPhase = null; /** mng8a80o live: virtual joystick (+x right, +y down) normalized ~[-1,1] */ let quizQuestionMissionJoyVecX = 0; let quizQuestionMissionJoyVecY = 0; let quizQuestionMissionJoyPointerId = null; /** Stack Tower (mnn93hpi) — flow เดียวกับ crown / quiz mission */ let stackTowerMissionPhase = null; let stackTowerMissionCountdownTimer = null; let stackTowerMissionDeferredPhase = null; let stackTowerSessionStartAt = 0; let stackTowerMissionEndedOnce = false; /** progress ข้ามเกณฑ์ Tower: เริ่มแอนิเมตซูม+เลื่อนแมป — null ยังไม่ข้าม, ตัวเลข = performance.now() ตอนเริ่ม */ let stackTowerPost50AnimStartMs = null; /** แฟลชรูปผล Tower (timeup / gameover / complete) ก่อน GCM 5 วิ */ let stackTowerResultFlashTimer = null; const STACK_TOWER_RESULT_FLASH_MS = 5000; /** รูปอุกาบาตขณะตก: 0 = เต็ม HP (Meteo-1); 1 = โดนยิงแล้วแต่ยังมี HP (Meteo-2 เมื่อมี URL ≥3) */ function spaceShooterAsteroidLiveSpriteIndexPlay(a) { const urls = playSpaceShooterAsteroidSpriteUrls; if (!urls || !urls.length) return 0; const maxHp = Math.max(1, Math.floor(Number(a && a.maxHp)) || SPACE_SHOOTER_ASTEROID_MAX_HP); const hp = Math.max(0, Math.floor(Number(a && a.hp)) || maxHp); if (hp >= maxHp) return 0; if (urls.length >= 3) return 1; return 0; } /** เริ่มแอนิเมชันแตกที่เฟรมถัดจาก «โดนแล้ว» เพื่อไม่ซ้ำ Meteo-2 — แสดง Meteo-3 แล้วจบตาม maxFi */ function spaceShooterAsteroidExplosionStartFiPlay() { const urls = playSpaceShooterAsteroidSpriteUrls; if (!urls || urls.length < 2) return 0; return urls.length >= 3 ? 1 : 0; } function spaceShooterSpawnAsteroidExplosion(worldX, worldY, r) { const urls = playSpaceShooterAsteroidSpriteUrls; if (!urls || urls.length < 2) return; spaceShooterAsteroidExplosions.push({ x: worldX, y: worldY, r: Math.max(6, Number(r) || 14), fi: spaceShooterAsteroidExplosionStartFiPlay(), acc: 0, }); } function spaceShooterTickAsteroidExplosions(dt) { const urls = playSpaceShooterAsteroidSpriteUrls; if (!urls || urls.length < 2) { spaceShooterAsteroidExplosions.length = 0; return; } const fms = Math.max(30, Math.min(500, Number(playSpaceShooterAsteroidExplodeFrameMs) || 70)); const maxFi = urls.length - 2; for (let i = spaceShooterAsteroidExplosions.length - 1; i >= 0; i--) { const ex = spaceShooterAsteroidExplosions[i]; ex.acc += dt * 1000; while (ex.acc >= fms) { ex.acc -= fms; ex.fi++; if (ex.fi > maxFi) { spaceShooterAsteroidExplosions.splice(i, 1); break; } } } } /** balloon_boss — ลูกโป้งยิงบอส (Mega Virus) */ let balloonBossPendingShots = []; let balloonBossPlayerBullets = []; let balloonBossBossBullets = []; let balloonBossSessionStartMs = 0; let balloonBossLastTickMs = 0; let balloonBossLastMoveEmit = 0; let balloonBossPlayerFireCd = 0; /* ปุ่ม SHOOT บนจอ (MG7) กด 1 ครั้ง = ยิง 1 นัด — ตั้ง flag ให้ tick ยิงตามมุมลูกศร เหมือนกด Space */ let balloonBossFireBtnQueued = false; let balloonBossGameEnded = false; let balloonBossHitFx = []; /** Mega Virus — ป๊อปดาเมจต่อบอส (ข้อความ +2) เมื่อกระสุนโดนบอส */ let balloonBossScorePopups = []; let balloonBossBossFireAcc = 0; /* anchor เวลา server + ตัวนับกระสุนบอส — deterministic boss fire ให้ทุกเครื่องยิงทิศ/เวลาเดียวกัน */ let balloonBossLiveAnchorServerMs = 0; let balloonBossBossShotCount = 0; /* MG7 host-authoritative boss HP: host broadcast ดาเมจรวมเป็นระยะ (balloon-boss-sync) → non-host แสดง HP บอสตามค่า host เป๊ะ (null = ยังไม่ได้รับ → fallback ผลรวมท้องถิ่น) */ let balloonBossHostBossDmg = null; let balloonBossLastSyncEmit = 0; /** quiz_battle — โดม MCQ กด E (ข้อจาก battleQuizMcq) */ let quizBattleMcqPool = []; let quizBattleAnsweredComps = new Set(); let quizBattleModalCompId = null; let qbAutoLastComp = null; // โดมล่าสุดที่ trigger อัตโนมัติ (กันเด้งซ้ำขณะยืนค้าง) /** ประวัติคำตอบต่อโดม (สำหรับ MY RESULT): compId → { qIndex, firstTry(bool), correct(bool), submittedIdx, q } */ let quizBattleAnswerLog = new Map(); /** เวลาเริ่มเล่นห้อง quiz_battle (ms) — สำหรับ Total time */ let quizBattleSessionStartMs = 0; let quizBattleScoreSubmittedAt = 0; /** ล็อกตัวเลือกระหว่างโชว์ผลถูก/ผิด (กันกดซ้ำ + กันเด้งซ้ำ) */ let quizBattleChoiceLocked = false; /** โชว์ popup Completed แล้วหรือยัง (กันเด้งซ้ำเมื่อตอบครบทุกโดม) */ let quizBattleCompletedShown = false; /** ถึงจุดจบแล้ว — ล็อกตำแหน่ง ไม่ให้ echo/restore เด้งกลับ start หรือจุดเดิม */ let quizBattleFinishLock = false; /** โดมที่ "ผ่านแล้ว" (ตอบถูกหรือผิดก็ผ่าน) — ใช้ปลดล็อกเส้นทาง + จำว่าเคยตอบ ไม่ต้องตอบซ้ำ */ let quizBattlePassedComps = new Set(); /** ซูมที่ผู้เล่นปรับเองในฉาก quiz_battle (ล้อเมาส์ / ปุ่ม +−) */ let quizBattleUserZoomMul = 1; 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'); } /* แมป mng8a80o: คะแนนอยู่ที่ cyber SCORE ซ้ายแล้ว — ซ่อนกล่อง "คะแนน" มุมขวาไม่ให้ซ้ำกับ mock */ if (isQuizQuestionMissionUiMapPlay()) { wrap.classList.add('is-hidden'); } /* Quiz Battle: ใช้ HUD ใหม่ (#qb-game-hud) — ซ่อนกล่อง "คะแนน" เดิม */ if (isQuizBattle()) { wrap.classList.add('is-hidden'); } } function initPlayLiveQuizScoresZeros() { if ((!isQuiz() && !isQuizCarry() && !isQuizBattle()) || myId == null) return; playLiveQuizScores = {}; playLiveQuizScores[myId] = 0; others.forEach((_, id) => { playLiveQuizScores[id] = 0; }); if (isQuiz()) { playQuizEverWrong = {}; mg1AllDeadEndScheduled = false; } 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 = playBotsEnabled() && (botRight + botWrong > 0) ? ' · บอท ถูก ' + botRight + ' / ผิด ' + botWrong + ' (Bots: ' + botRight + ' right / ' + botWrong + ' wrong)' : ''; if (mine.right) { el.className = 'play-quiz-feedback play-quiz-feedback-ok'; el.textContent = 'คุณตอบถูก · +' + QUIZ_TF_POINTS_PER_CORRECT + ' · คะแนนรวม ' + (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 ensureQuizTfScorePlusImgPlay() { if (quizTfScorePlusImg && quizTfScorePlusImg.src) return; quizTfScorePlusImg = new Image(); quizTfScorePlusImg.src = QUIZ_TF_SCORE_PLUS_URL; quizTfScorePlusImg.onerror = function () { this.onerror = null; }; } /** โชว์ score+.png กลางโซนจริง/เท็จที่เป็นคำตอบข้อนี้ (world px) */ function spawnQuizTrueFalseScorePopupPlay(correctTrue) { if (!isQuiz() || !mapData) return; const grid = correctTrue ? mapData.quizTrueArea : mapData.quizFalseArea; const b = getTileBoundsForOnesGrid(grid); if (!b) return; const ts = tileSize || 32; const wx = ((b.minX + b.maxX + 1) / 2) * ts; const wy = ((b.minY + b.maxY + 1) / 2) * ts; const now = Date.now(); ensureQuizTfScorePlusImgPlay(); quizTfScorePopups.push({ wx, wy, startMs: now, untilMs: now + QUIZ_TF_SCORE_POPUP_MS }); } function drawQuizTfScorePopupsLayer(ctx, worldToScreen, zDraw, timeMs) { if (!quizTfScorePopups.length) return; const img = quizTfScorePlusImg; if (!img || !img.complete || !img.naturalWidth) return; const now = typeof timeMs === 'number' ? timeMs : Date.now(); quizTfScorePopups = quizTfScorePopups.filter((p) => p.untilMs > now); const baseW = Math.max(QUIZ_TF_SCORE_POPUP_MIN_BASE_PX, (tileSize || 32) * zDraw * QUIZ_TF_SCORE_POPUP_TILE_MULT); for (let i = 0; i < quizTfScorePopups.length; i++) { const p = quizTfScorePopups[i]; const span = Math.max(1, p.untilMs - p.startMs); const t = Math.max(0, Math.min(1, (now - p.startMs) / span)); const [sx, sy] = worldToScreen(p.wx, p.wy); const rise = t * 56 * zDraw; const fade = t < 0.72 ? 1 : Math.max(0, 1 - (t - 0.72) / 0.28); const pulse = 1 + Math.sin(t * Math.PI) * 0.1; const scale = (baseW / img.naturalWidth) * pulse; const w = img.naturalWidth * scale; const h = img.naturalHeight * scale; ctx.save(); ctx.globalAlpha = fade; ctx.drawImage(img, sx - w / 2, sy - h / 2 - rise, w, h); ctx.restore(); } } 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.classList.remove('quiz-map-question-panel--has-kicker'); panel.classList.remove('quiz-map-question-panel--question-mission-live'); const qKick = document.getElementById('quiz-map-question-kicker'); if (qKick) { qKick.textContent = ''; qKick.classList.add('is-hidden'); qKick.setAttribute('aria-hidden', 'true'); } panel.style.removeProperty('background-image'); panel.style.removeProperty('background-size'); panel.style.removeProperty('background-position'); delete panel.dataset.qmPlaqueTry; 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) ไม่ให้บรรทัดถูกตัด * เกมถูก/ผิด: แผงมีคลาส quiz-map-question-panel--true-false จัดข้อความกึ่งกลางแนวตั้งในโซน */ 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; let kickerReserve = 0; if (panel.classList.contains('quiz-map-question-panel--question-mission-live') && panel.classList.contains('quiz-map-question-panel--has-kicker')) { kickerReserve = 30; } const availH = Math.max(12, panel.clientHeight - padT - padB - kickerReserve); 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 setQuizMapPanelThemeFromApi(s) { quizMapPanelTheme = null; if (!s || typeof s !== 'object') return; const parsed = parseCarryMapPanelThemeObject(s.quizMapPanelTheme); if (!parsed) return; quizMapPanelTheme = 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(6, 8, 14, 0.52)', innerBg: 'rgba(0, 0, 0, 0.78)', innerBorder: 'rgba(94, 234, 212, 0.82)', innerBorderWpx: 1, innerRadiusPx: 14, digitColor: '#ffe066', mapDigitCqmin: 78, mapDigitCqh: 82, mapDigitMaxPx: 220, screenDigitVw: 32, screenDigitMaxPx: 200, }; } 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 && String(numEl.tagName || '').toUpperCase() !== 'IMG') { numEl.style.textShadow = '0 0 0.45em currentColor, 0 4px 14px rgba(0,0,0,0.55)'; } else if (numEl) { try { numEl.style.removeProperty('text-shadow'); } catch (e) { /* ignore */ } } } function setCountdown321QuestionAssetGraphic(numEl, n) { if (!numEl) return; const d = Math.max(1, Math.min(3, n)); if (String(numEl.tagName || '').toUpperCase() === 'IMG') { numEl.src = COUNTDOWN_321_IMG_BASE + d + '.png'; numEl.alt = String(d); } else { numEl.textContent = String(d); } } /** 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; } /** เกมถูก/ผิด: default ← quizMapPanelTheme (API) ← quizMapPanelTheme บนแมป (ถ้ามี) */ function getEffectiveQuizMapPanelThemeForApply() { const d = defaultCarryMapPanelThemePlay(); const api = quizMapPanelTheme != null && typeof quizMapPanelTheme === 'object' ? quizMapPanelTheme : null; let merged = api ? { ...d, ...api } : { ...d }; if (mapData && mapData.quizMapPanelTheme != null) { const mp = parseCarryMapPanelThemeMapOverlay(mapData.quizMapPanelTheme); if (mp) merged = { ...merged, ...mp }; } return merged; } function paintQuizMapPanelTheme(panel, textEl, th) { if (!panel || !textEl || !th) return; 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 applyQuizCarryMapPanelThemeIfNeeded(panel, textEl) { if (!panel || !textEl) return; if (!isQuizCarry()) { clearQuizMapPanelThemeInline(panel, textEl); return; } paintQuizMapPanelTheme(panel, textEl, getEffectiveCarryMapPanelThemeForApply()); } function applyQuizTrueFalseMapPanelThemeIfNeeded(panel, textEl) { if (!panel || !textEl) return; if (!isQuiz() || isQuizCarry()) return; paintQuizMapPanelTheme(panel, textEl, getEffectiveQuizMapPanelThemeForApply()); } 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 ทั่วไป — แมปภารกิจ mng8a80o ต้องโชว์คำถามในโซนที่วาด (quizQuestionArea) */ if (previewMode && editorEmbedReturn && !isQuizCarry() && !(isQuiz() && isQuizQuestionMissionUiMapPlay())) { 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 qmCenter = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null; const carryCam = !qmCenter && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null; const camX = qmCenter ? qmCenter.cx : (carryCam ? carryCam.cx : me.x * tileSize); const camY = qmCenter ? qmCenter.cy : (carryCam ? carryCam.cy : me.y * tileSize); const zDom = playDomSyncZoom(); const left = (bounds.minX * tileSize - camX) * zDom + canvas.width / 2; const top = (bounds.minY * tileSize - camY) * zDom + canvas.height / 2; const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom; const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom; 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'); if (isQuiz() && !isQuizCarry()) { panel.classList.add('quiz-map-question-panel--true-false'); } else { panel.classList.remove('quiz-map-question-panel--true-false'); } if (isQuizCarry()) { applyQuizCarryMapPanelThemeIfNeeded(panel, textEl); } else if (isQuiz()) { applyQuizTrueFalseMapPanelThemeIfNeeded(panel, textEl); } else { clearQuizMapPanelThemeInline(panel, textEl); } const thFont = isQuizCarry() ? getEffectiveCarryMapPanelThemeForApply() : getEffectiveQuizMapPanelThemeForApply(); const startFontPx = quizMapQuestionFontPxLikeCarryPlaques(thFont, zDom); const mnFont = Math.max(8, Math.round(Number(thFont.questionFontMinPx) || 10)); /* ไม่โหลด hud-question-plaque.png อัตโนมัติ — บนเซิร์ฟมักไม่มีไฟล์ → 404 ใน Network; ใช้คลาส + CSS แทน (อัปโหลด PNG แล้วค่อยผูกทีหลังได้) */ if (isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live') { panel.classList.add('quiz-map-question-panel--question-mission-live'); } else { panel.classList.remove('quiz-map-question-panel--question-mission-live'); if (panel.dataset.qmPlaqueTry) { panel.style.removeProperty('background-image'); panel.style.removeProperty('background-size'); panel.style.removeProperty('background-position'); delete panel.dataset.qmPlaqueTry; } } panel.classList.toggle( 'quiz-map-question-panel--tf-mock-qz', !!(isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live' && isMissionMockHudPlay()), ); const kickerEl = document.getElementById('quiz-map-question-kicker'); const missionLiveQ = isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live'; const inReadOrAnswer = playQuizPhaseLocal === 'read' || playQuizPhaseLocal === 'answer'; if (kickerEl) { let showK = false; let line = ''; if (missionLiveQ && inReadOrAnswer) { const qi = Math.max(0, Number(playQuizQuestionIndex) || 0); const qt = Math.max(0, Number(playQuizQuestionTotal) || 0); if (qi > 0 && qt > 0) { line = '- Quiz ' + qi + ' / ' + qt + ' -'; showK = true; } else if (qi > 0) { line = '- Quiz ' + qi + ' -'; showK = true; } } if (showK && line) { kickerEl.textContent = line; kickerEl.classList.remove('is-hidden'); kickerEl.setAttribute('aria-hidden', 'false'); panel.classList.add('quiz-map-question-panel--has-kicker'); } else { kickerEl.textContent = ''; kickerEl.classList.add('is-hidden'); kickerEl.setAttribute('aria-hidden', 'true'); panel.classList.remove('quiz-map-question-panel--has-kicker'); } } 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 cc = getQuizCarryMapCameraWorldCenterPxPlay(); const camX = cc ? cc.cx : me.x * tileSize; const camY = cc ? cc.cy : me.y * tileSize; const zDom = playDomSyncZoom(); const left = (bounds.minX * tileSize - camX) * zDom + canvas.width / 2; const top = (bounds.minY * tileSize - camY) * zDom + canvas.height / 2; const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom; const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom; 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 (!quizCarryEmbedMissionFlowActive() || !mapData) { 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 cc = getQuizCarryMapCameraWorldCenterPxPlay(); const camX = cc ? cc.cx : me.x * tileSize; const camY = cc ? cc.cy : me.y * tileSize; const zDom = playDomSyncZoom(); const l = (bounds.minX * tileSize - camX) * zDom + canvasEl.width / 2; const t = (bounds.minY * tileSize - camY) * zDom + canvasEl.height / 2; const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom; const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom; 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 - serverNow()) / 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 shufflePreviewQuizQuestionsPlay(arr) { const a = (arr || []).slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } /** ตรงกับ server clampQuizRoundQuestionCount — 1–50 ค่าเริ่ม 10 */ function clampPreviewQuizRoundQuestionCount(settings) { const v = Number(settings && settings.quizRoundQuestionCount); if (!Number.isFinite(v)) return 10; return Math.max(1, Math.min(50, Math.floor(v))); } function finishPreviewQuizSessionPlay() { previewQuizStep = 'done'; playQuizPhaseLocal = null; previewQuizCurrent = null; playQuizPhaseEndsAt = 0; if (playQuizTimerInterval) { clearInterval(playQuizTimerInterval); playQuizTimerInterval = null; } const qov = document.getElementById('quiz-game-overlay'); if (qov) qov.classList.add('is-hidden'); const mapPanel = document.getElementById('quiz-map-question-panel'); if (mapPanel) { mapPanel.classList.add('is-hidden'); mapPanel.setAttribute('aria-hidden', 'true'); } if (isQuizQuestionMissionUiMapPlay()) { quizQuestionMissionPhase = 'ended'; applyQuizQuestionMissionPanelImages(); const mission = quizQuestionMissionBuildPayload(); showGauntletCrownMissionOverlay(mission); return; } playQuizText = 'ครบทุกข้อในรอบทดสอบแล้ว'; if (qov) qov.classList.remove('is-hidden'); 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 = 'จำนวนข้อต่อรอบใช้ค่าเดียวกับ Admin (quizRoundQuestionCount)'; } function clearPreviewBotAnswerPaths() { if (!playBotsEnabled()) return; others.forEach((o, id) => { if (!isPreviewBotId(id)) return; o.botPath = []; o.botAnswerWander = false; }); } function resetPreviewBotsQuizState() { if (!playBotsEnabled()) 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; playQuizQuestionIndex = previewQuizQIndex + 1; playQuizQuestionTotal = Math.max(0, Number(poolLen) || 0); const phaseEl = document.getElementById('quiz-game-phase-label'); if (phaseEl) { phaseEl.textContent = '[ทดสอบ] อ่านคำถาม · ข้อ ' + (previewQuizQIndex + 1) + ' / ' + 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); } /** ทั้ง footprint ทับโซน จริง/เท็จ — หลังตอบผิดควรถูกดีดออกแล้ว ไม่วาดทับ (กันจุดเล็กค้างกลาง SAFE/SCAM) */ function playQuizPeerFootprintOverlapsAnswerZone(ax, ay) { if (!mapData || !isQuiz()) return false; for (const k of quizTilesFootprintPlay(ax, ay)) { const parts = k.split(','); const fx = +parts[0]; const fy = +parts[1]; if (quizCellOnPlay(mapData.quizTrueArea, fx, fy) || quizCellOnPlay(mapData.quizFalseArea, fx, fy)) return true; } return false; } /** ไม่วาดตัวที่โดนล็อก/เคยตอบผิดแล้วแต่ยังทับโซน (กันจุดเล็กค้าง + desync มัลติเพลย์) */ function playQuizAvatarHideWhileOverlappingAnswerZone(ent, entId, ax, ay) { if (!playQuizPeerFootprintOverlapsAnswerZone(ax, ay)) return false; if (entId === myId || ent === me) { return !!(playQuizPlayerLocal && playQuizPlayerLocal.cannotTrue && playQuizPlayerLocal.cannotFalse); } const locked = !!(ent && ent.quizCannotTrue && ent.quizCannotFalse); const everWrong = !!playQuizEverWrong[String(entId)]; return locked || everWrong; } 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; } function spawnFootprintFitsPlay(md, anchorX, anchorY) { if (!spawnTileWalkablePlay(md, anchorX, anchorY)) return false; const cellsW = Math.max(1, Math.min(4, Math.floor(Number(md.characterCellsW) || Number(md.characterCells) || 1))); const cellsH = Math.max(1, Math.min(4, Math.floor(Number(md.characterCellsH) || Number(md.characterCells) || 1))); const w = md.width || 20; const h = md.height || 15; const maxX = Math.min(w, anchorX + cellsW); const maxY = Math.min(h, anchorY + cellsH); for (let ty = anchorY; ty < maxY; ty++) { for (let tx = anchorX; tx < maxX; tx++) { if (!spawnTileWalkablePlay(md, tx, ty)) 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 && spawnFootprintFitsPlay(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; } /** jump_survive: ช่อง P1–P6 ที่วาดแบบยาน (shooterSpawnSlots) เติมใน slots6 เมื่อ lobbyPlayerSpawns ว่าง */ function augmentLobbySlotsFromShooterPaintJumpSurvivePlay(md, slots6) { if (!md || md.gameType !== 'jump_survive' || !Array.isArray(slots6)) return; const g = md.shooterSpawnSlots; if (!g) return; const w = md.width || 20, h = md.height || 15; for (let slot = 1; slot <= 6; slot++) { const idx = slot - 1; if (slots6[idx]) continue; let found = false; for (let yy = 0; yy < h && !found; yy++) { const row = g[yy]; if (!row) continue; for (let xx = 0; xx < w; xx++) { if (row[xx] === slot) { slots6[idx] = { x: xx, y: yy }; found = true; break; } } } } } /** จุดเกิด Quiz Battle — ต้องอยู่บนเส้นทาง (กล่องสไปรต์) ไม่ใช่แค่ snap จาก spawnArea นอกเลน */ function collectQuizBattleValidSpawnCentersPlay(md) { const out = []; if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActive(md)) return out; const w = md.width || 20, h = md.height || 15; const g = md.quizBattlePathArea; const seen = new Set(); const prevMap = mapData; mapData = md; try { for (let ty = 0; ty < h; ty++) { for (let tx = 0; tx < w; tx++) { if (!g[ty] || g[ty][tx] !== 1) continue; for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) { const key = nx.toFixed(3) + ',' + ny.toFixed(3); if (seen.has(key)) continue; if (!quizBattleSnapTargetValidPlay(nx, ny)) continue; seen.add(key); out.push({ tx, ty, x: nx, y: ny }); } } } } finally { mapData = prevMap; } out.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.x - b.x); return out; } /** จุดโลกใกล้ pref — ลองกลางช่องก่อน แล้วหาใน spawnArea (ฟ้า) ที่วางตัวได้ */ function quizBattleNearestValidSpawnWorldPlay(md, prefTx, prefTy) { const ptx = Math.floor(Number(prefTx)) || 0; const pty = Math.floor(Number(prefTy)) || 0; const prevMap = mapData; mapData = md; try { for (const [nx, ny] of [[ptx + 0.5, pty + 0.5], [ptx + 0.01, pty + 0.01]]) { if (quizBattleSpawnWallClearPlay(md, nx, ny)) return { x: nx, y: ny }; } const pool = collectQuizBattleSpawnPoolPlay(md); if (pool.length) { let best = pool[0], bestD = Infinity; for (const c of pool) { const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty); if (d < bestD) { bestD = d; best = c; } } return { x: best.x, y: best.y }; } const valid = collectQuizBattleValidSpawnCentersPlay(md); if (!valid.length) return { x: ptx + 0.5, y: pty + 0.5 }; let best = valid[0], bestD = Infinity; for (const c of valid) { const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty); if (d < bestD) { bestD = d; best = c; } } return { x: best.x, y: best.y }; } finally { mapData = prevMap; } } /** ช่อง spawnArea (ฟ้า) ที่วางตัวได้ — สุ่มในพื้นที่ฟ้า ไม่บังคับอยู่บนเลนม่วง */ function collectQuizBattleSpawnPoolPlay(md) { const out = []; if (!md) return out; const prevMap = mapData; mapData = md; try { const w = md.width || 20, h = md.height || 15; const grid = md.spawnArea; const seen = new Set(); const cells = []; if (grid && Array.isArray(grid)) { for (let ty = 0; ty < h; ty++) { const row = grid[ty]; if (!row) continue; for (let tx = 0; tx < w; tx++) { if (Number(row[tx]) === 1) cells.push({ tx, ty }); } } } if (!cells.length && md.spawn) { cells.push({ tx: Math.max(0, Math.min(w - 1, Math.floor(Number(md.spawn.x)) || 1)), ty: Math.max(0, Math.min(h - 1, Math.floor(Number(md.spawn.y)) || 1)), }); } for (const { tx, ty } of cells) { for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) { const key = nx.toFixed(3) + ',' + ny.toFixed(3); if (seen.has(key)) continue; if (quizBattlePathModeActive(md)) { if (!quizBattleSpawnWallClearPlay(md, nx, ny)) continue; } else if (!spawnFootprintFitsPlay(md, tx, ty)) { continue; } seen.add(key); out.push({ tx, ty, x: nx, y: ny }); } } if (!out.length && quizBattlePathModeActive(md)) { return collectQuizBattleValidSpawnCentersPlay(md); } } finally { mapData = prevMap; } return out; } function pickQuizBattleRandomSpawnWorldPlay(md) { const pool = collectQuizBattleSpawnPoolPlay(md); const fb = md.spawn || { x: 1, y: 1 }; if (!pool.length) { return quizBattleNearestValidSpawnWorldPlay(md, fb.x, fb.y); } const u = new Uint32Array(1); if (typeof crypto !== 'undefined' && crypto.getRandomValues) crypto.getRandomValues(u); else u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0); const pick = pool[u[0] % pool.length]; return { x: pick.x, y: pick.y }; } function quizBattleSpawnWorldFromJoinOrderPlay(md, joinOrderIndex) { if (!md) return { x: 1.5, y: 1.5 }; const ord = joinOrderIndex | 0; const mode = md.lobbySpawnMode; if (mode === 'slots6') { const slots = parseLobbyPlayerSpawnsFromMapPlay(md); const j = Math.min(Math.max(0, ord), 5); const slot = slots[j]; if (slot) return quizBattleNearestValidSpawnWorldPlay(md, slot.x, slot.y); return pickQuizBattleRandomSpawnWorldPlay(md); } if (mode === 'fixed' && md.spawn) { const sx = Number(md.spawn.x) || 1, sy = Number(md.spawn.y) || 1; return quizBattleNearestValidSpawnWorldPlay(md, sx, sy); } /* random (ค่าเริ่มต้นใน editor) — สุ่มในพื้นที่ฟ้า ไม่เรียงตาม join order */ return pickQuizBattleRandomSpawnWorldPlay(md); } function pickQuizBattleSpawnFromMapPlay(md, joinOrderIndex) { const world = quizBattleSpawnWorldFromJoinOrderPlay(md, joinOrderIndex); return { x: Math.floor(world.x), y: Math.floor(world.y) }; } /** สอดคล้องกับ server pickSpawnForJoin — ใช้พรีวิวบอท / ทดสอบ */ function pickSpawnForJoinPlay(md, joinOrderIndex) { if (!md) return { x: 1, y: 1 }; if (md.gameType === 'quiz_battle' && quizBattlePathModeActive(md)) { const qb = pickQuizBattleSpawnFromMapPlay(md, joinOrderIndex); if (qb) return qb; } 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 (spawnFootprintFitsPlay(md, x, y)) return { x, y }; return pickRandomSpawnFromMapPlay(md); } if (mode === 'slots6') { const slots = parseLobbyPlayerSpawnsFromMapPlay(md); augmentLobbySlotsFromShooterPaintJumpSurvivePlay(md, slots); const pick = slots[j]; if (pick && spawnFootprintFitsPlay(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; } /** ป้ายชื่อบอท "บอท N (tier)" — เรียงตาม id (เหมือนทุกเครื่อง) ใช้ o.botTier (host เป็นเจ้าของ ผ่าน fill-bot-state) */ function relabelPreviewBotsPlay() { 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; }); } function rebalancePreviewBots() { if (!playBotsEnabled() || !mapData) return; const human = countPlayHumans(); let wantBots = Math.max(0, playBotTargetHeadcount() - human); /* Card 3 Ban — บอทที่ถูกแบนรอบนี้ (slot N) ไม่ลงเล่น: ลบทิ้ง + ลดเป้าจำนวนลง 1 + ข้าม slot นั้นตอนสร้าง */ if (playBannedBotSlot >= 0) { others.delete(PREVIEW_BOT_PREFIX + playBannedBotSlot); wantBots = Math.max(0, wantBots - 1); } [...others.keys()].filter(isPreviewBotId).forEach((bid) => { const o = others.get(bid); if (!o) return; o.playTint = playTintForPreviewBotId(bid) || resolvePlayTintForPeer(bid); if (o.botTier == null) { const tierRoll = Math.random(); const stackPrev = mapData && mapData.gameType === 'stack'; o.botTier = stackPrev ? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak') : (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) { /* หา slot ว่างเล็กสุด (0,1,2,…) — IDX ของ ID ต้องตรงกับ playLobbyBotThemes[idx] เพื่อให้สีตรง lobby */ let nextSlot = 0; while (others.has(PREVIEW_BOT_PREFIX + nextSlot) || nextSlot === playBannedBotSlot) nextSlot++; previewBotSeq = Math.max(previewBotSeq, nextSlot + 1); const id = PREVIEW_BOT_PREFIX + nextSlot; const joinIdx = countPlayHumans() + botIds.length; let x; let y; if (mapData.gameType === 'jump_survive') { const pos = jumpSurviveSpawnWorldFromJoinOrderPlay(mapData, joinIdx); x = pos.x; y = pos.y; } else if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) { const pos = quizBattleSpawnWorldFromJoinOrderPlay(mapData, joinIdx); x = pos.x; y = pos.y; } else { const sp = pickSpawnForJoinPlay(mapData, joinIdx); const jx = (Math.random() - 0.5) * 0.4; const jy = (Math.random() - 0.5) * 0.4; x = sp.x + 0.5 + jx; y = sp.y + 0.5 + jy; } const tierRoll = Math.random(); const stackPrev = mapData && mapData.gameType === 'stack'; const botTier = stackPrev ? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak') : (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: pickPreviewBotCharacterId(botIds.length), spawnJoinOrder: joinIdx, playTint: playTintForPreviewBotId(id) || resolvePlayTintForPeer(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, balloonBossBossDmg: 0, balloonBossBalloons: mapData && mapData.gameType === 'balloon_boss' ? balloonBossBalloonsStartPlay() : 5, balloonBossEliminated: false, botQuizCarryPathfindAfter: performance.now() + previewBotSeq * 75 + Math.floor(Math.random() * 160), }); /* preload เลเยอร์สี + tint ของบอททันทีตอนสร้าง — กัน score HUD บน host โชว์ avatar บอท "ไม่ย้อมสี" (เดิม preloadPlayOccupantsTints รันตอน scene-load ก่อนบอทถูกสร้าง → เลเยอร์ยังไม่โหลด) */ try { const bo = others.get(id); if (bo && bo.characterId) preloadPlayTintForAvatar(bo.characterId, bo.playTint || resolvePlayTintForPeer(id)); } catch (ePB) { /* ignore */ } botIds.push(id); } relabelPreviewBotsPlay(); [...others.keys()].filter(isPreviewBotId).sort().forEach((bid, botIdx) => { const o = others.get(bid); if (o) o.characterId = pickPreviewBotCharacterId(botIdx); }); 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(); fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) .then((r) => (r.ok ? r.json() : null)) .then((t) => { if (t) applyGauntletTimingFromServer(t); }) .catch(() => {}); } if (quizCarryPregameActive && mapData && mapData.gameType === 'quiz_carry') updateQuizCarryPregameHud(); try { preloadPlayOccupantsTints(); preloadPlayLobbyIdleAvatarsForOccupants(); } catch (e) { /* ignore */ } } 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 (!playBotsEnabled() || !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 }; } /** ลำดับจุดเกิดเดียวกับ pickSpawnForJoinPlay — ห้ามสุ่มทั้ง spawnArea หลังตอบผิด (จะหลุดจาก P1–P6) */ function quizPreviewJoinOrderForRespawn(actorId) { if (actorId === myId) { const jm = Number(me && me.spawnJoinOrder); return Number.isFinite(jm) ? Math.max(0, Math.floor(jm)) : 0; } const o = others.get(actorId); if (o && typeof o.spawnJoinOrder === 'number' && Number.isFinite(o.spawnJoinOrder)) { return Math.max(0, Math.floor(o.spawnJoinOrder)); } if (!isPreviewBotId(actorId)) { const othersReal = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); const idx = othersReal.indexOf(actorId); return idx >= 0 ? 1 + idx : 1; } const bots = [...others.keys()].filter(isPreviewBotId).sort(); const bi = bots.indexOf(actorId); if (bi < 0) return 0; return countPlayHumans() + bi; } /** เฉลยรอบทดสอบบนเครื่อง — ให้พฤติกรรมใกล้เคียง 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) + QUIZ_TF_POINTS_PER_CORRECT; } else { playQuizPlayerLocal.cannotTrue = true; playQuizPlayerLocal.cannotFalse = true; if (!warpMeIntoQuizGhostZonePlay()) { const ordMe = quizPreviewJoinOrderForRespawn(myId); const sp = pickSpawnForJoinPlay(mapData, ordMe); const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5); me.x = pos.x; me.y = pos.y; me.tx = me.x; me.ty = me.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 (playBotsEnabled()) { 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) + QUIZ_TF_POINTS_PER_CORRECT; } else { o.quizCannotTrue = true; o.quizCannotFalse = true; const ordB = quizPreviewJoinOrderForRespawn(id); const sp = pickSpawnForJoinPlay(mapData, ordB); 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; o.tx = o.x; o.ty = o.y; } results.push({ id, right: br.right, choice: br.choice, score: playLiveQuizScores[id] != null ? playLiveQuizScores[id] : 0, }); }); } const r = { results, scores: { ...playLiveQuizScores } }; results.forEach(function (row) { if (!row || row.id == null) return; if (!row.right) playQuizEverWrong[String(row.id)] = true; }); renderPlayQuizScoreboard(playLiveQuizScores); if (results.some(function (row) { return row && row.right; })) { spawnQuizTrueFalseScorePopupPlay(correctTrue); } showPlayQuizFeedback(r); } function advancePreviewQuizIfDue() { if (isQuizQuestionMissionUiMapPlay() && isQuizQuestionMissionPregameBlockingPlay()) return; if (previewQuizStep === 'done') return; 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') { previewQuizQIndex += 1; if (previewQuizQIndex >= pool.length) { finishPreviewQuizSessionPlay(); return; } const q = pool[previewQuizQIndex]; if (q) applyPreviewReadPhase(q, pool.length); } } async function loadPreviewQuizAndStart() { resetPreviewBotsQuizState(); playQuizEverWrong = {}; 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); const cap = clampPreviewQuizRoundQuestionCount(settings); const shuffled = shufflePreviewQuizQuestionsPlay(pool); previewQuizPool = shuffled.slice(0, Math.min(cap, shuffled.length)); previewQuizQIndex = 0; 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; playQuizQuestionIndex = 0; playQuizQuestionTotal = 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 session = previewQuizPool; const q = session[0]; if (q) applyPreviewReadPhase(q, session.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; playQuizQuestionIndex = 0; playQuizQuestionTotal = 0; previewQuizPool = []; previewQuizQIndex = 0; previewQuizCurrent = null; previewQuizStep = 'read'; playLiveQuizScores = {}; playQuizEverWrong = {}; quizTfScorePopups = []; playPath = []; hidePlayQuizHud(); quizCarryJoinSettingsSnap = null; quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT; quizCarryMapPanelTheme = null; quizMapPanelTheme = null; quizCarryChoicePlaqueThemes = null; resetQuizCarryPlayState(); resetQuizBattlePlayState(); quizQuestionMissionPhase = null; quizQuestionMissionDeferredPhase = null; if (quizQuestionMissionCountdownTimer) { clearTimeout(quizQuestionMissionCountdownTimer); quizQuestionMissionCountdownTimer = null; } const gcmTeardown = document.getElementById('gauntlet-crown-mission-overlay'); if (gcmTeardown) gcmTeardown.classList.add('is-hidden'); cancelEmbedPreviewLobbyReturnTimer(); 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'; } const gchJumpTeardown = document.getElementById('gauntlet-crown-jump-btn'); if (gchJumpTeardown) { gchJumpTeardown.classList.add('is-hidden'); gchJumpTeardown.setAttribute('aria-hidden', 'true'); gchJumpTeardown.style.pointerEvents = ''; } const stackDropTeardown = document.getElementById('stack-tower-drop-btn'); if (stackDropTeardown) { stackDropTeardown.classList.add('is-hidden'); stackDropTeardown.setAttribute('aria-hidden', 'true'); stackDropTeardown.style.pointerEvents = ''; stackDropTeardown.classList.remove('stack-tower-drop-btn--active'); } hideStackTowerResultFlashDomOnlyPlay(); } function setupPlayQuizUi() { if (playQuizTimerInterval) { clearInterval(playQuizTimerInterval); playQuizTimerInterval = null; } const ov = document.getElementById('quiz-game-overlay'); if (ov) { const hideBottomQuizBar = (previewMode && editorEmbedReturn && !isQuizCarry()) || (isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live'); if (hideBottomQuizBar) 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 = ''; if (isQuizQuestionMissionUiMapPlay()) { if (phaseEl) phaseEl.textContent = '[ทดสอบ] รอกด READY เพื่อเริ่มภารกิจ'; if (qEl) qEl.textContent = 'โหมดภารกิจ — หน้าปก HOWTO จาก /img/QUESTION/'; if (leg) leg.textContent = ''; } else { loadPreviewQuizAndStart(); } } else { playQuizPhaseLocal = null; playQuizText = 'รอคำถามจากโฮสต์…'; playQuizPhaseEndsAt = 0; playQuizQuestionIndex = 0; playQuizQuestionTotal = 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 = ''; } if (isQuiz() && !isQuizCarry()) { (async () => { try { let s = {}; 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('}')) { try { s = JSON.parse(trimmed); } catch (e) { /* ignore */ } } } s = await mergeQuizCarrySettingsFromDisk(s && typeof s === 'object' ? s : {}); setQuizMapPanelThemeFromApi(s); try { syncPlayQuizMapPanel(); } catch (e2) { /* ignore */ } } catch (e) { /* ignore */ } })(); } } /** อัปเดตแถบคำถาม quiz จากเซิร์ฟเวอร์ — แยกไว้ให้ภารกิจ mng8a80o เรียกหลังจบ countdown */ function applyQuizPhaseFromServer(p) { if (!p || !mapData || !isQuiz()) return; if (p.text) playQuizText = p.text; playQuizPhaseLocal = p.phase; playQuizPhaseEndsAt = p.endsAt || 0; playQuizQuestionIndex = Math.max(0, Number(p.questionIndex) || 0); playQuizQuestionTotal = Math.max(0, Number(p.questionTotal) || 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 (isQuizQuestionMissionHudActivePlay()) { if (ov) ov.classList.add('is-hidden'); } else if (ov) { ov.classList.remove('is-hidden'); } if (playQuizTimerInterval) clearInterval(playQuizTimerInterval); playQuizTimerInterval = setInterval(updatePlayQuizTimerDisplay, 200); updatePlayQuizTimerDisplay(); if (!previewMode && isQuiz() && !Object.keys(playLiveQuizScores).length) initPlayLiveQuizScoresZeros(); if (!previewMode && isQuiz() && p.phase === 'read' && Number(p.questionIndex) === 1) playQuizEverWrong = {}; try { syncPlayQuizMapPanel(); } catch (eQm) { /* ignore */ } } function isFrogger() { return mapData && mapData.gameType === 'frogger'; } function isGauntlet() { return mapData && mapData.gameType === 'gauntlet'; } function isGauntletFaceRightMapMno9kb07() { if (!isGauntlet()) return false; /** ใช้ currentPlayMapId (session → mapData.id → ?map=) ให้สอดคล้อง runway / timing — กัน HUD crown หลุดค้างเป็นแผงเก่าแนวตั้งทับ TIME */ return (currentPlayMapId() || '').trim() === GAUNTLET_FACE_RIGHT_MAP_ID; } /** Crown heist rules + UI (เดียวกับหันขวา — ฉาก mno9kb07) */ function isGauntletCrownHeistMapPlay() { return isGauntletFaceRightMapMno9kb07(); } function isMegaVirusMissionShellMapPlay() { return !!(mapData && mapData.gameType === 'balloon_boss' && currentPlayMapId() === BALLOON_BOSS_MISSION_MAP_ID); } /** Crown gauntlet หรือ Mega Virus — lobby HOWTO / Ready / นับถอยหลัง / held run */ function usesCrownLobbyShellPlay() { return isGauntletCrownHeistMapPlay() || isMegaVirusMissionShellMapPlay(); } /** Last Light (mno9kb07) embed — ค่าเริ่มต้นเมื่อโหลดแมป (ปรับซูมได้ด้วยล้อ / [ ]) */ const PLAY_EMBED_LAST_LIGHT_DEFAULT_ZOOM_MUL = 1.49; /** Stack Tower (mnn93hpi) ใน embed พรีวิว: ซูมคงที่ ×1 — ล้อ/ปุ่มไม่ปรับได้ */ const PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL = 1; function isStackTowerEmbedZoomLockedPlay() { return !!(previewMode && editorEmbedReturn && mapData && isStackTowerMissionUiMapPlay()); } function applyPlayEmbedZoomForCurrentMapPlay() { if (isStackTowerEmbedZoomLockedPlay()) { playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL; } else if (previewMode && editorEmbedReturn && mapData && isGauntletCrownHeistMapPlay()) { playEmbedUserZoomMul = PLAY_EMBED_LAST_LIGHT_DEFAULT_ZOOM_MUL; } } /** smoothstep 0..1 */ function stackTowerPost50SmoothstepPlay(t) { const x = Math.max(0, Math.min(1, t)); return x * x * (3 - 2 * x); } /** Tower live + progress ≥เกณฑ์: 0→1 ระหว่าง STACK_TOWER_POST50_ANIM_MS — ใช้เฉพาะเมื่อเปิดกล้องตามหอ (ไม่งั้นฉากธรรมดาไม่เลื่อน/ซูมหลังเกณฑ์) */ function getStackTowerPost50AnimEasePlay() { if (!getStackTowerCameraFollowPlayConfig().enabled) { stackTowerPost50AnimStartMs = null; return 0; } if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'live' || !stackMini) return 0; const p = Number(stackMini.progressPct) || 0; if (p < STACK_TOWER_POST50_PROGRESS_THRESH) { stackTowerPost50AnimStartMs = null; return 0; } if (stackTowerPost50AnimStartMs == null) stackTowerPost50AnimStartMs = performance.now(); const u = Math.min(1, Math.max(0, (performance.now() - stackTowerPost50AnimStartMs) / STACK_TOWER_POST50_ANIM_MS)); return stackTowerPost50SmoothstepPlay(u); } function getStackTowerPost50ZMulPlay() { const e = getStackTowerPost50AnimEasePlay(); if (e <= 0) return 1; return 1 + (STACK_TOWER_POST50_ZOOM_MUL - 1) * e; } function getStackTowerPost50BgScrollExtraPxPlay(ch) { const e = getStackTowerPost50AnimEasePlay(); if (e <= 0) return 0; const chR = Math.max(1, Number(ch) || 720); return STACK_TOWER_POST50_MAP_SHIFT_SCREEN_FRAC * chR * e; } /** * Same zDraw chain as draw() (zoom, special maps, Last Light embed lock, preview embed mul). * Runway end checks must not rely on lastPlayZDrawForInput alone — it can lag map/canvas or default 1.4 vs real zoom. */ function computePlayCameraZDrawPlay() { if (!canvas || !mapData) return zoom; const w = mapData.width || 20; const h = mapData.height || 15; let zDraw = zoom; if (isSpaceShooter() || isBalloonBoss()) { const mwPx = w * tileSize; const mhPx = h * tileSize; zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96; /* MG7 (balloon_boss) — ขยายให้เต็มจอกว่า fit ทั้งแมป: ซูมพอดีกับพื้นที่ผู้เล่น+บอส (crop ขอบบน-ล่างที่ว่าง) แมป 20×15 (aspect 1.33) บนจอ 16:9 ถ้า fit ทั้งแมปจะเหลือขอบดำซ้าย-ขวาเยอะ */ if (isBalloonBoss()) { const win = getBalloonBossVisibleHalfTilesPlay(mapData); if (win) { const needWpx = win.halfW * 2 * tileSize; const needHpx = win.halfH * 2 * tileSize; const zContent = Math.min(canvas.width / needWpx, canvas.height / needHpx); if (Number.isFinite(zContent) && zContent > zDraw) zDraw = zContent; } } } else if (isQuizQuestionMissionHudActivePlay()) { const qmc = getQuizQuestionMissionMapCenterWorldPxPlay(); if (qmc) { /* ซูมออกเล็กน้อยให้เห็นพื้นหลัง/โซน SAFE-SCAM ครบแบบ mock (0.96 เคย crop ขอบล่างบนจอแคบ) */ zDraw = Math.min(canvas.width / qmc.mwPx, canvas.height / qmc.mhPx) * 0.90; } } else if (isQuizCarry()) { const mwPx = w * tileSize; const mhPx = h * tileSize; zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96; } else if (isQuizBattle()) { zDraw *= quizBattleUserZoomMul; } if (isStackTowerEmbedZoomLockedPlay()) { playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL; } if (previewMode && editorEmbedReturn && mapData && !isQuizQuestionMissionHudActivePlay()) { zDraw *= playEmbedUserZoomMul; } if (isStackTowerMissionUiMapPlay()) { zDraw *= STACK_TOWER_FIXED_RENDER_Z_MUL; zDraw *= getStackTowerPost50ZMulPlay(); } return zDraw; } let gauntletCrownHowtoVisible = false; function isGauntletCrownPregameBlockingPlay() { if (!usesCrownLobbyShellPlay()) return false; if (gauntletCrownRunHeldRemote) return true; if (gauntletCrownPregamePhase != null && gauntletCrownPregamePhase !== 'live') return true; return false; } /** * Last Light (crown heist): center camera on the whole party bbox instead of only `me` * — reduces empty void on the side when you fall behind and keeps bots + you in frame together when possible. */ function getGauntletCrownHeistGroupCameraCenterPxPlay(tileSizeRef, canvasW, canvasH, zDrawVal) { if (!isGauntletCrownHeistMapPlay() || !mapData) return null; const md = mapData; const { cw, ch } = getCharacterFootprintWH(md); const ww = md.width || 20; const hh = md.height || 15; const mapWpx = ww * tileSizeRef; const mapHpx = hh * tileSizeRef; const halfW = canvasW / (2 * zDrawVal); const halfH = canvasH / (2 * zDrawVal); const ents = []; if (!me.gauntletEliminated && Number.isFinite(me.x) && Number.isFinite(me.y)) { ents.push({ cx: (me.x + cw * 0.5) * tileSizeRef, cy: (me.y + ch * 0.5) * tileSizeRef }); } others.forEach((o, id) => { if (!o || o.gauntletEliminated) return; if (!playBotsEnabled() && isPreviewBotId(id)) return; if (!Number.isFinite(o.x) || !Number.isFinite(o.y)) return; ents.push({ cx: (o.x + cw * 0.5) * tileSizeRef, cy: (o.y + ch * 0.5) * tileSizeRef }); }); if (!ents.length) return null; let minCx = Infinity; let maxCx = -Infinity; let sumCy = 0; for (let i = 0; i < ents.length; i++) { const e = ents[i]; minCx = Math.min(minCx, e.cx); maxCx = Math.max(maxCx, e.cx); sumCy += e.cy; } let px = (minCx + maxCx) * 0.5; /* แกน Y ตามผู้เล่นเครื่องตัวเอง (ไม่เฉลี่ยทั้งกลุ่ม) → เลนของเราคงที่ใต้กล้อง, host/คนอื่นอยู่เลนของเขาเอง (เดิมเฉลี่ย Y ทั้งกลุ่ม → ทุกคนถูกเลื่อนจากเลนจริง ทำให้ host ดูมาซ้อนเลนของ client) ; ตายแล้ว → fallback เฉลี่ยกลุ่ม */ let py = sumCy / ents.length; if (!me.gauntletEliminated && Number.isFinite(me.y)) { py = (me.y + ch * 0.5) * tileSizeRef; } const minCamX = halfW; const maxCamX = Math.max(minCamX, mapWpx - halfW); const minCamY = halfH; const maxCamY = Math.max(minCamY, mapHpx - halfH); const viewW = halfW * 2; const viewH = halfH * 2; /* * ถ้ามุมมองกว้าง/สูงกว่าแมป: อย่าใช้ clamp แบบ minCamX (= กล้องชิดซ้ายโลก) — จะดูเหมือนแมปติดมุมซ้ายบน (เด่นใน embed / ซูมออก) * ให้จัดกลางแมปแทน — ภารกิจคำถาม mng8a80o ช่วง live ใช้กล้องกลางแมปเหมือนกัน (ดู isQuizQuestionMissionHudActivePlay + draw) */ if (viewW >= mapWpx) px = mapWpx * 0.5; else px = Math.max(minCamX, Math.min(maxCamX, px)); if (viewH >= mapHpx) py = mapHpx * 0.5; else py = Math.max(minCamY, Math.min(maxCamY, py)); return { px, py }; } /** Preview bots only — full tail-off loss needs server support for real humans */ const GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX = 40; function maybeEliminateGauntletPreviewBotsTailOffCameraPlay(camX, camY, zDrawVal) { if (!playBotsEnabled() || !isMePlayHost() || !isGauntletCrownHeistMapPlay() || !mapData) return; if (gauntletCrownPregamePhase !== 'live') return; const halfW = canvas.width / (2 * zDrawVal); const worldMinX = camX - halfW; const gate = worldMinX - GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX; const { cw } = getCharacterFootprintWH(mapData); const ts = mapData.tileSize || 32; others.forEach((o, id) => { if (!isPreviewBotId(id) || !o || o.gauntletEliminated) return; const rightWorld = (Number(o.x) + cw) * ts; if (rightWorld < gate) o.gauntletEliminated = true; }); } 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 isQuizQuestionMissionUiMapPlay() { return isQuiz() && currentPlayMapId() === QUIZ_QUESTION_MISSION_MAP_ID; } function isQuizQuestionMissionPregameBlockingPlay() { if (!isQuizQuestionMissionUiMapPlay()) return false; return quizQuestionMissionPhase != null && quizQuestionMissionPhase !== 'live' && quizQuestionMissionPhase !== 'ended'; } function isStackTowerMissionUiMapPlay() { return isStack() && currentPlayMapId() === STACK_TOWER_MISSION_MAP_ID; } function getStackTowerBlockWorldScalePlay() { return isStackTowerMissionUiMapPlay() ? STACK_TOWER_BLOCK_WORLD_SCALE : 1; } function isStackTowerMissionPregameBlockingPlay() { if (!isStackTowerMissionUiMapPlay()) return false; return stackTowerMissionPhase != null && stackTowerMissionPhase !== 'live' && stackTowerMissionPhase !== 'ended'; } function isStackTowerMissionHudActivePlay() { return isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live'; } /** mnptfts2 Jumper — ใช้ cyber HUD แบบภารกิจคำถาม (TIME plaque + SCORE แถว QM + กรอบโปรไฟล์) */ function isJumpSurviveMissionHudActivePlay() { return isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'live'; } /** Space Shooter mnpz6rkp — ช่วงเล่นจริง: HUD เดียวกับ Stack mission (คลาส question-mission + TIME plaque) */ function isSpaceShooterMissionHudActivePlay() { return isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'live'; } /** Mega Virus mnq1eml7 (MG7) — ช่วงเล่นจริง: ใช้ HUD/score-row แบบเดียวกับ MG6 (qm-mock) กันคะแนน/ชื่อใช้ layout เก่าที่ text ทับ/ซ่อน */ function isBalloonBossMissionHudActivePlay() { return isMegaVirusMissionShellMapPlay() && balloonBossSessionStartMs > 0 && !balloonBossGameEnded; } function stackTowerAssetUrl(file) { return BASE + '/img/TowerBlock/' + String(file || '').replace(/^\/+/, ''); } function ensureStackTowerSlingImagePlay() { if (!stackTowerSlingImg) { stackTowerSlingImg = new Image(); stackTowerSlingImg.decoding = 'async'; stackTowerSlingImg.src = stackTowerAssetUrl('sling.png'); } const im = stackTowerSlingImg; return im && im.complete && im.naturalWidth > 0 ? im : null; } function ensureStackTowerScorePopupImagesPlay() { if (!stackTowerScorePlus10Img) { stackTowerScorePlus10Img = new Image(); stackTowerScorePlus10Img.decoding = 'async'; stackTowerScorePlus10Img.src = stackTowerAssetUrl('score+10.png'); } if (!stackTowerScorePlus20Img) { stackTowerScorePlus20Img = new Image(); stackTowerScorePlus20Img.decoding = 'async'; stackTowerScorePlus20Img.src = stackTowerAssetUrl('score+20.png'); } return { p10: stackTowerScorePlus10Img, p20: stackTowerScorePlus20Img }; } function ensureStackTowerPerfectFxImagesPlay() { if (!stackTowerScoreLightImg) { stackTowerScoreLightImg = new Image(); stackTowerScoreLightImg.decoding = 'async'; stackTowerScoreLightImg.src = stackTowerAssetUrl('score-light.png'); } if (!stackTowerScoreX2Img) { stackTowerScoreX2Img = new Image(); stackTowerScoreX2Img.decoding = 'async'; stackTowerScoreX2Img.src = stackTowerAssetUrl('scorex2.png'); } return { light: stackTowerScoreLightImg, x2: stackTowerScoreX2Img }; } /** score-light หลัง scorex2 — วาดเป็นคู่ข้างชั้นบน (mock รูป2) */ function drawStackTowerPerfectX2ClusterPlay(ctx, lightImg, x2Img, sx, sy, drawW, drawH, nowT, fxUntil) { if (!x2Img || !x2Img.complete || !x2Img.naturalWidth || drawW <= 0 || drawH <= 0) return; const iw = x2Img.naturalWidth; const ih = x2Img.naturalHeight; const targetH = Math.max(drawH * 0.92, Math.min(drawH * 1.35, 72)); const scale = targetH / ih; const dw = iw * scale; const dh = ih * scale; const cx = sx + drawW + drawW * 0.06; const cy = sy + drawH * 0.5; const life = fxUntil != null ? Math.max(0, Math.min(1, (fxUntil - nowT) / STACK_TOWER_PERFECT_X2_MS)) : 1; const pulse = 0.4 + 0.48 * Math.sin((1 - life) * Math.PI); if (lightImg && lightImg.complete && lightImg.naturalWidth) { const lp = 2.7; const lw = dw * lp; const lh = dh * lp; const lnx = lightImg.naturalWidth; const lny = lightImg.naturalHeight; /* จัดแสงให้ตรงกลางไอคอน ×2 (ไอคอนวาดเยื้องซ้าย cx - dw*0.48 → กลางไอคอน ≈ cx + dw*0.02) */ const iconCx = cx + dw * 0.02; ctx.save(); ctx.globalAlpha = Math.min(0.98, pulse + 0.34); ctx.globalCompositeOperation = 'screen'; try { ctx.drawImage(lightImg, 0, 0, lnx, lny, iconCx - lw * 0.5, cy - lh * 0.5, lw, lh); } catch (e) { /* ignore */ } ctx.restore(); } ctx.save(); ctx.globalAlpha = 0.98; ctx.globalCompositeOperation = 'source-over'; try { ctx.drawImage(x2Img, 0, 0, iw, ih, cx - dw * 0.48, cy - dh * 0.5, dw, dh); } catch (e) { /* ignore */ } ctx.restore(); } function drawStackTowerScorePopupsPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY) { if (!m.scorePopups || !m.scorePopups.length) return; const now = performance.now(); const dur = STACK_TOWER_SCORE_POPUP_MS; const imgs = ensureStackTowerScorePopupImagesPlay(); m.scorePopups = m.scorePopups.filter((p) => p.until > now); const hidPop = getStackTowerVisualHiddenLayerCountPlay(); for (let pi = 0; pi < m.scorePopups.length; pi++) { const p = m.scorePopups[pi]; const li = Math.floor(Number(p.layerIndex)) || 0; if (li < 0 || li >= m.layers.length) continue; if (li < hidPop) continue; const L = m.layers[li]; const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize * getStackTowerBlockWorldScalePlay(); const cxW = L.cx * tileSize; const yb = floorY - (li + 1) * layerWorldH; const xl = cxW - lw / 2; const [sx, sy] = worldToScreen(xl, yb); const drawW = lw * zoom; const drawH = layerWorldH * zoom; const t0 = p.until - dur; const t = Math.max(0, Math.min(1, (now - t0) / dur)); const alpha = 0.2 + 0.78 * Math.sin(Math.PI * t); const rise = Math.sin((t * Math.PI) / 2) * 20 * zoom; const im = p.kind === '20' ? imgs.p20 : imgs.p10; const targetW = Math.min(drawW * 0.92, Math.max(52, 64 * zoom)); let iw = targetW; let ih = targetW * 0.38; if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) { ih = (targetW * im.naturalHeight) / im.naturalWidth; } const cx = sx + drawW / 2; const cy = sy - Math.max(8, 6 * zoom) - rise - ih * 0.5; ctx.save(); ctx.globalAlpha = Math.min(0.98, Math.max(0.08, alpha)); if (im && im.complete && im.naturalWidth > 0) { try { ctx.drawImage(im, 0, 0, im.naturalWidth, im.naturalHeight, cx - iw / 2, cy - ih / 2, iw, ih); } catch (e) { /* ignore */ } } else { ctx.font = 'bold ' + Math.round(Math.max(12, 15 * zoom)) + 'px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = p.kind === '20' ? '#7af8ff' : '#b4f9cd'; ctx.fillText(p.kind === '20' ? '+20' : '+10', cx, cy); } ctx.restore(); } } /** สร้างเศษบล็อกตกจากบล็อกที่พลาด (ใช้ตำแหน่ง/หน้าตาเดิมของ stackFall) */ function spawnStackMissDebrisPlay(sf) { if (!sf || !stackMini) return; const lh = stackMini.layerWorldH || Math.max(14, tileSize * 0.3); const cxW = sf.xLeft1 + sf.tw / 2; const landOff = sf.hit && sf.hit.landOffsetTiles ? sf.hit.landOffsetTiles : 0; const dir = landOff >= 0 ? 1 : -1; stackMissDebris.push({ cxW, topY: sf.y1, w: sf.tw, h: lh, vx: dir * (tileSize * (1.4 + Math.random() * 0.9)), vy: -tileSize * (0.4 + Math.random() * 0.5), rot: sf.tiltMax || 0, vrot: dir * (2.0 + Math.random() * 1.8), seat: sf.actorSeat, heavy: !!sf.actorHeavy, hue: (stackMini.layers.length * 47) % 360, }); while (stackMissDebris.length > 6) stackMissDebris.shift(); _stackDebrisLastT = 0; } /** อัปเดต+วาดเศษบล็อกที่ตก (gravity + หมุน + ลอยออกข้าง) จนหลุดขอบจอล่าง */ function drawStackMissDebrisPlay(ctx, worldToScreen, zoom, floorWorldY) { if (!stackMissDebris.length) return; const now = performance.now(); let dt = _stackDebrisLastT ? (now - _stackDebrisLastT) / 1000 : 0.016; _stackDebrisLastT = now; if (dt > 0.05) dt = 0.05; const g = tileSize * 34; const chCv = canvas && canvas.height ? canvas.height : 720; const offLimit = floorWorldY + chCv / Math.max(0.2, zoom) + tileSize * 5; for (let i = stackMissDebris.length - 1; i >= 0; i--) { const d = stackMissDebris[i]; d.vy += g * dt; d.topY += d.vy * dt; d.cxW += d.vx * dt; d.rot += d.vrot * dt; if (d.topY > offLimit) { stackMissDebris.splice(i, 1); continue; } const sp = worldToScreen(d.cxW - d.w / 2, d.topY); const drawW = d.w * zoom; const drawH = d.h * zoom; ctx.save(); ctx.translate(sp[0] + drawW / 2, sp[1] + drawH / 2); ctx.rotate(d.rot); drawStackBlockSpriteOrHue(ctx, -drawW / 2, -drawH / 2, drawW, drawH, d.seat, d.heavy, d.hue, { missTint: true, missOverlay: true }); ctx.restore(); } } function ensureStackTowerHeartMinusImgPlay() { if (!stackTowerHeartMinusImg) { stackTowerHeartMinusImg = new Image(); stackTowerHeartMinusImg.decoding = 'async'; stackTowerHeartMinusImg.src = stackTowerAssetUrl('heart-1.png'); } const im = stackTowerHeartMinusImg; return im && im.complete && im.naturalWidth > 0 ? im : null; } function ensureStackTowerLifeHudImagesPlay() { if (!stackTowerLifeBarImg) { stackTowerLifeBarImg = new Image(); stackTowerLifeBarImg.decoding = 'async'; stackTowerLifeBarImg.src = stackTowerAssetUrl('life-bar.png'); } if (!stackTowerLifeHitImg) { stackTowerLifeHitImg = new Image(); stackTowerLifeHitImg.decoding = 'async'; stackTowerLifeHitImg.src = stackTowerAssetUrl('life-hit.png'); } if (!stackTowerLifeFullImg) { stackTowerLifeFullImg = new Image(); stackTowerLifeFullImg.decoding = 'async'; stackTowerLifeFullImg.src = stackTowerAssetUrl('life-full.png'); } } /** Space Shooter (mnpz6rkp): โหลด life HUD จาก /img/ViolentCrime/ แล้วคืน image set ให้ drawStackTowerLifeIntegrityBarPlay */ function ensureViolentCrimeLifeHudImagesPlay() { if (!violentCrimeLifeBarImg) { violentCrimeLifeBarImg = new Image(); violentCrimeLifeBarImg.decoding = 'async'; violentCrimeLifeBarImg.src = violentCrimeAssetUrl('life-bar.png'); } if (!violentCrimeLifeHitImg) { violentCrimeLifeHitImg = new Image(); violentCrimeLifeHitImg.decoding = 'async'; violentCrimeLifeHitImg.src = violentCrimeAssetUrl('life-hit.png'); } if (!violentCrimeLifeFullImg) { violentCrimeLifeFullImg = new Image(); violentCrimeLifeFullImg.decoding = 'async'; violentCrimeLifeFullImg.src = violentCrimeAssetUrl('life-full.png'); } return { bar: violentCrimeLifeBarImg, full: violentCrimeLifeFullImg, hit: violentCrimeLifeHitImg }; } /** * สัดส่วนสูงของ life-bar.png ที่เป็นข้อความ "SYSTEM INTEGRITY" (ที่เหลือ = แถวหัวใจแยกวาดเอง ไม่ทับขอบ `[` `]` ใน PNG) */ const STACK_TOWER_LIFE_BAR_TITLE_FRAC = 0.38; /** * วาดหัวข้อจาก life-bar.png (ครอปบน) + แถวหัวใจ life-full / life-hit กึ่งกลาง — คืนความสูงที่ใช้จริง */ function drawStackTowerLifeIntegrityBarPlay(ctx, destX, destY, maxW, maxH, lifeSlots, livesRem, opts) { const imgs = (opts && opts.images) ? opts.images : (ensureStackTowerLifeHudImagesPlay(), { bar: stackTowerLifeBarImg, full: stackTowerLifeFullImg, hit: stackTowerLifeHitImg }); const bar = imgs.bar; const fullIm = (imgs.full && imgs.full.complete && imgs.full.naturalWidth > 0) ? imgs.full : ensureStackTowerHeartMinusImgPlay(); const hitIm = imgs.hit && imgs.hit.complete && imgs.hit.naturalWidth > 0 ? imgs.hit : null; const slots = Math.max(1, Math.min(20, Math.floor(lifeSlots) || 1)); const lives = Math.max(0, Math.min(slots, Math.floor(livesRem) || 0)); if (!bar || !bar.complete || !bar.naturalWidth || maxW <= 4 || maxH <= 4) { ctx.save(); ctx.fillStyle = '#bb9af7'; ctx.font = '600 9px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; let heartStr = ''; for (let i = 0; i < slots; i++) heartStr += i < lives ? '♥ ' : '♡ '; ctx.fillText('SYSTEM INTEGRITY ' + heartStr.trim(), destX, destY + maxH * 0.5); ctx.restore(); return Math.min(maxH, 28); } const bw0 = bar.naturalWidth; const bh0 = bar.naturalHeight; const titleSrcH = Math.max(2, Math.floor(bh0 * STACK_TOWER_LIFE_BAR_TITLE_FRAC)); const scale = Math.min(maxW / bw0, 1.45); const bw = bw0 * scale; const titleDh = titleSrcH * scale; const gap = 3; const bx = destX; const by0 = destY + Math.max(0, (maxH - titleDh - gap - Math.min(40, maxH * 0.45)) * 0.35); const heartsTop = by0 + titleDh + gap; const roomHearts = Math.max(10, destY + maxH - heartsTop - 2); const heartsH = Math.max(18, Math.min(roomHearts, 40, Math.floor(maxH * 0.5))); ctx.save(); try { ctx.drawImage(bar, 0, 0, bw0, titleSrcH, bx, by0, bw, titleDh); } catch (e) { /* ignore */ } const cy = heartsTop + heartsH * 0.5; const bracketW = Math.min(14, Math.max(9, bw * 0.07)); ctx.fillStyle = 'rgba(94, 239, 255, 0.88)'; ctx.font = '600 ' + Math.round(Math.min(17, heartsH * 0.55)) + 'px ui-monospace, "Cascadia Mono", Consolas, monospace'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; ctx.shadowColor = 'rgba(0, 234, 255, 0.45)'; ctx.shadowBlur = 4; ctx.fillText('[', bx + 1, cy); ctx.textAlign = 'right'; ctx.fillText(']', bx + bw - 1, cy); ctx.shadowBlur = 0; const ix = bx + bracketW + 4; const iwInner = Math.max(24, bw - (bracketW + 4) * 2); const slotW = iwInner / slots; const iconMax = Math.min(heartsH * 0.78, slotW * 0.72, 34); for (let i = 0; i < slots; i++) { const cx = ix + slotW * (i + 0.5); const useFull = i < lives; const im = useFull ? fullIm : hitIm; if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) { const nw = im.naturalWidth; const nh = im.naturalHeight; let ih = iconMax; let iw = (ih * nw) / nh; const capW = slotW * 0.82; const capH = heartsH * 0.88; if (iw > capW) { iw = capW; ih = (iw * nh) / nw; } if (ih > capH) { ih = capH; iw = (ih * nw) / nh; } if (iw < 6 && nh > 0) { iw = 6; ih = (iw * nh) / nw; if (ih > capH) { ih = capH; iw = (ih * nw) / nh; } } try { ctx.drawImage(im, 0, 0, nw, nh, cx - iw / 2, cy - ih / 2, iw, ih); } catch (e2) { /* ignore */ } } else { ctx.fillStyle = useFull ? '#9ece6a' : '#565f89'; ctx.font = 'bold ' + Math.round(Math.max(10, iconMax * 0.72)) + 'px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(useFull ? '♥' : '♡', cx, cy); } } ctx.restore(); const usedH = heartsTop + heartsH - by0 + 2; return Math.min(maxH, Math.max(titleDh, usedH)); } function triggerStackTowerHeartMinusFxPlay() { if (!isStackTowerMissionHudActivePlay()) return; stackTowerHeartMinusFx = { until: performance.now() + STACK_TOWER_HEART_MINUS_FX_MS }; } function drawStackTowerHeartMinusFxPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY, nLay) { if (!stackTowerHeartMinusFx) return; const nt = performance.now(); if (nt >= stackTowerHeartMinusFx.until) { stackTowerHeartMinusFx = null; return; } const dur = STACK_TOWER_HEART_MINUS_FX_MS; const t0 = stackTowerHeartMinusFx.until - dur; const u = Math.max(0, Math.min(1, (nt - t0) / dur)); const alpha = 1 - u * u; const worldCx = m.topCenterX * tileSize; const stackTopY = floorY - nLay * layerWorldH; const worldCy = stackTopY - tileSize * 1.55 - u * tileSize * 0.5; const [sx, sy] = worldToScreen(worldCx, worldCy); const im = ensureStackTowerHeartMinusImgPlay(); const maxW = Math.max(72, Math.min(240, 6.8 * tileSize * zoom)); let dw = maxW; let dh = maxW * 0.52; if (im && im.naturalWidth > 0 && im.naturalHeight > 0) { dh = (maxW * im.naturalHeight) / im.naturalWidth; } ctx.save(); ctx.globalAlpha = Math.max(0, Math.min(0.98, alpha)); if (im) { ctx.drawImage(im, sx - dw / 2, sy - dh / 2, dw, dh); } else { ctx.fillStyle = '#f7768e'; ctx.font = 'bold ' + Math.round(Math.max(14, 18 * zoom)) + 'px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('♥ −1', sx, sy); } ctx.restore(); } /** Glow สี่เหลี่ยมสีเหลือง — ขอบเขตเท่ากล่องบล็อก (drawW×drawH) ไม่ล้ำออกนอกสไปรต์ */ function drawStackTowerPerfectYellowGlowBehindBlockPlay(ctx, sx, sy, drawW, drawH, nowT, untilT) { if (drawW <= 0 || drawH <= 0) return; const life = Math.max(0, Math.min(1, (untilT - nowT) / STACK_TOWER_PERFECT_GLOW_MS)); const pulse = 0.38 + 0.48 * Math.sin((1 - life) * Math.PI); ctx.save(); ctx.globalCompositeOperation = 'source-over'; /* เติมเหลืองทับบล็อกให้เด่น */ ctx.globalAlpha = Math.min(0.6, 0.3 + pulse * 0.32); ctx.fillStyle = 'rgba(255, 224, 60, 0.55)'; ctx.fillRect(sx, sy, drawW, drawH); /* กรอบเหลืองหนา "รอบ" บล็อก (เลยขอบออกมา) + เรืองแสงออกนอก → เห็นชัด */ const pad = Math.max(3, Math.min(12, drawW * 0.05)); const sw = Math.max(3, Math.min(9, drawW * 0.055)); ctx.shadowColor = 'rgba(255, 196, 24, 0.98)'; ctx.shadowBlur = Math.max(10, Math.min(30, 14 + pulse * 16)); ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; ctx.globalAlpha = Math.min(1, 0.62 + pulse * 0.38); ctx.strokeStyle = 'rgba(255, 238, 110, 1)'; ctx.lineWidth = sw; ctx.strokeRect(sx - pad + sw * 0.5, sy - pad + sw * 0.5, drawW + pad * 2 - sw, drawH + pad * 2 - sw); /* เส้นในขาวอมเหลือง เพิ่มความคม */ ctx.shadowBlur = 0; ctx.shadowColor = 'transparent'; ctx.globalAlpha = Math.min(0.95, 0.55 + pulse * 0.4); ctx.strokeStyle = 'rgba(255, 252, 214, 0.96)'; ctx.lineWidth = Math.max(1.5, sw * 0.42); ctx.strokeRect(sx - pad + sw, sy - pad + sw, drawW + pad * 2 - sw * 2, drawH + pad * 2 - sw * 2); ctx.restore(); } /** * เชือกจากจุดหนีบบน (จอ) ลงจุดยึดบล็อก — รูป sling แนวตั้ง (แกน Y = สาย) ยืดตามความยาว */ function drawStackSlingSegmentPlay(ctx, sx0, sy0, sx1, sy1, zoom) { const dx = sx1 - sx0; const dy = sy1 - sy0; const len = Math.hypot(dx, dy); if (len < 1.5) return; const sling = ensureStackTowerSlingImagePlay(); if (sling) { const nw = sling.naturalWidth; const nh = sling.naturalHeight; const ropeW = Math.max(3, Math.min(40, nw * Math.min(1.1, zoom * 0.11) + 2)); const ang = Math.atan2(dy, dx); ctx.save(); ctx.translate(sx0, sy0); ctx.rotate(ang - Math.PI / 2); ctx.globalAlpha = 0.96; try { ctx.drawImage(sling, -ropeW / 2, 0, ropeW, len); } catch (e) { /* ignore */ } ctx.globalAlpha = 1; ctx.restore(); } else { ctx.strokeStyle = 'rgba(90, 88, 86, 0.82)'; ctx.lineWidth = Math.max(2.2, zoom * 1.4); ctx.beginPath(); ctx.moveTo(sx0, sy0); ctx.lineTo(sx1, sy1); ctx.stroke(); ctx.lineWidth = 1; } } const GCHOWTO_STACK_TOWER_CLASS = 'gauntlet-crown-howto-overlay--stack-tower'; function setGauntletCrownHowtoStackTowerArtMaskPlay(on) { const hov = document.getElementById('gauntlet-crown-howto-overlay'); if (!hov) return; hov.classList.toggle(GCHOWTO_STACK_TOWER_CLASS, !!on); } function hideStackTowerHowtoRulesDom() { /* กล่องกติกา Stack Tower ถูกถอนออกจาก DOM — เก็บชื่อฟังก์ชันเพื่อไม่ให้จุดเรียกพัง */ } function teardownStackTowerMissionUiPlay() { stackTowerMissionPhase = null; stackTowerMissionDeferredPhase = null; stackTowerSessionStartAt = 0; stackTowerMissionEndedOnce = false; stackTowerPost50AnimStartMs = null; hideStackTowerHowtoRulesDom(); setGauntletCrownHowtoStackTowerArtMaskPlay(false); if (stackTowerMissionCountdownTimer) { clearTimeout(stackTowerMissionCountdownTimer); stackTowerMissionCountdownTimer = null; } hideStackTowerResultFlashDomOnlyPlay(); } function hideStackTowerResultFlashDomOnlyPlay() { if (stackTowerResultFlashTimer) { clearTimeout(stackTowerResultFlashTimer); stackTowerResultFlashTimer = null; } const ov = document.getElementById('stack-tower-result-flash'); const img = document.getElementById('stack-tower-result-flash-img'); if (ov) { ov.classList.add('is-hidden'); ov.setAttribute('aria-hidden', 'true'); } if (img) { img.onerror = null; img.removeAttribute('src'); } } /** โชว์รูปผล Tower จาก TowerBlock 5 วิ แล้วเปิด GCM สรุปเดิม */ function beginStackTowerResultFlashThenGcm(mission) { if (!mission || mission.uiSkin !== 'stack_tower') { showGauntletCrownMissionOverlay(mission); return; } hideStackTowerResultFlashDomOnlyPlay(); const ov = document.getElementById('stack-tower-result-flash'); const imgEl = document.getElementById('stack-tower-result-flash-img'); if (!ov || !imgEl) { showGauntletCrownMissionOverlay(mission); return; } const outc = String(mission.stackTowerOutcome || 'time_up'); let file = 'result-timeup.png'; if (outc === 'decrypt_complete') file = 'result-complete.png'; else if (outc === 'misses_exhausted') file = 'result-gameover.png'; const url = stackTowerAssetUrl(file); const finishToGcm = function () { hideStackTowerResultFlashDomOnlyPlay(); showGauntletCrownMissionOverlay(mission); }; imgEl.onerror = function () { imgEl.onerror = null; finishToGcm(); }; imgEl.src = url; ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); stackTowerResultFlashTimer = setTimeout(function () { stackTowerResultFlashTimer = null; finishToGcm(); }, STACK_TOWER_RESULT_FLASH_MS); } /** Jumper mnptfts2: เกรด F = ภาพ gameover · อื่น = complete (ก่อนแผงสรุปผล) */ function jumpSurviveMissionOutcomeImageFile(mission) { const g = mission && String(mission.grade || '').toUpperCase(); if (g === 'F') return 'result-gameover.png'; return 'result-complete.png'; } /** * Jumper ภารกิจ: ใช้ #stack-tower-result-flash เหมือน Tower — time_up = timeup แล้ว outcome; * all_dead = ข้าม timeup แล้ว outcome อย่างเดียว แล้วค่อย GCM */ function beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, endKind) { if (!mission || mission.uiSkin !== 'jumper') { showGauntletCrownMissionOverlay(mission); return; } hideStackTowerResultFlashDomOnlyPlay(); const ov = document.getElementById('stack-tower-result-flash'); const imgEl = document.getElementById('stack-tower-result-flash-img'); if (!ov || !imgEl) { showGauntletCrownMissionOverlay(mission); return; } const outcomeFile = jumpSurviveMissionOutcomeImageFile(mission); const finishToGcm = function () { hideStackTowerResultFlashDomOnlyPlay(); showGauntletCrownMissionOverlay(mission); }; const showOneFlash = function (file, onDone) { imgEl.onerror = function () { imgEl.onerror = null; onDone(); }; imgEl.src = jumperAssetUrl(file); ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); stackTowerResultFlashTimer = setTimeout(function () { stackTowerResultFlashTimer = null; onDone(); }, STACK_TOWER_RESULT_FLASH_MS); }; if (endKind === 'time_up') { showOneFlash('result-timeup.png', function () { hideStackTowerResultFlashDomOnlyPlay(); showOneFlash(outcomeFile, finishToGcm); }); } else { showOneFlash(outcomeFile, finishToGcm); } } /** * Space Shooter mnpz6rkp: หมดเวลา = แฟลช result-timeup แล้ว result-gameover แล้ว GCM; * ทุกคนตาย = แฟลช result-gameover อย่างเดียวแล้ว GCM */ function beginSpaceShooterMissionResultFlashSequenceThenGcm(mission, endKind) { if (!mission || mission.uiSkin !== 'violent_crime') { showGauntletCrownMissionOverlay(mission); return; } hideStackTowerResultFlashDomOnlyPlay(); const ov = document.getElementById('stack-tower-result-flash'); const imgEl = document.getElementById('stack-tower-result-flash-img'); if (!ov || !imgEl) { showGauntletCrownMissionOverlay(mission); return; } const finishToGcm = function () { hideStackTowerResultFlashDomOnlyPlay(); showGauntletCrownMissionOverlay(mission); }; const showOneFlash = function (file, onDone) { imgEl.onerror = function () { imgEl.onerror = null; onDone(); }; imgEl.src = violentCrimeAssetUrl(file); ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); stackTowerResultFlashTimer = setTimeout(function () { stackTowerResultFlashTimer = null; onDone(); }, STACK_TOWER_RESULT_FLASH_MS); }; if (endKind === 'time_up') { showOneFlash('result-timeup.png', function () { hideStackTowerResultFlashDomOnlyPlay(); showOneFlash('result-gameover.png', finishToGcm); }); } else { showOneFlash('result-gameover.png', finishToGcm); } } /** * Mega Virus (balloon_boss shell): แฟลชรูปเดียวแล้วค่อย GCM * — แพ้ (ทุกคนตาย / หมดเวลา) = result-gameover.png (TowerBlock เหมือนภารกิจอื่น — รูป2 flow) * — ชนะ = end-victory-2.png ใน MegaVirus */ function beginBalloonBossMegaVirusResultFlashSequenceThenGcm(mission, endReason) { if (!mission || mission.uiSkin !== 'mega_virus') { showGauntletCrownMissionOverlay(mission); return; } hideStackTowerResultFlashDomOnlyPlay(); const ov = document.getElementById('stack-tower-result-flash'); const imgEl = document.getElementById('stack-tower-result-flash-img'); if (!ov || !imgEl) { showGauntletCrownMissionOverlay(mission); return; } const lose = endReason === 'all_dead' || endReason === 'time'; const flashSrc = lose ? stackTowerAssetUrl('result-gameover.png') : megaVirusAssetUrl('end-victory-2.png'); const finishToGcm = function () { hideStackTowerResultFlashDomOnlyPlay(); showGauntletCrownMissionOverlay(mission); }; imgEl.onerror = function () { imgEl.onerror = null; finishToGcm(); }; imgEl.src = flashSrc; ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); stackTowerResultFlashTimer = setTimeout(function () { stackTowerResultFlashTimer = null; finishToGcm(); }, STACK_TOWER_RESULT_FLASH_MS); } function stackTowerMissionTimeLimitSecPlay() { const t = Number(playStackTowerMissionTimeSec); if (Number.isFinite(t) && t > 0) return Math.max(10, Math.min(7200, Math.floor(t))); return 90; } function stackTowerProgressBlocksPlay() { const n = Number(playStackTowerProgressBlocks); if (Number.isFinite(n) && n >= 1) return Math.max(1, Math.min(500, Math.floor(n))); return 50; } function stackTowerDecryptPctPlay() { if (!stackMini) return 0; if (isStackTowerMissionUiMapPlay()) { const p = Number(stackMini.progressPct); const x = Math.min(100, Math.max(0, Number.isFinite(p) ? p : 0)); if (x >= 99.999) return 100; return Math.floor(x); } const teamScore = stackMini.score || 0; return Math.min(100, Math.floor((stackMini.layers.length || 0) * 11 + Math.min(40, teamScore * 3))); } function stackTowerMissionElapsedSecPlay() { if (stackTowerSessionStartAt <= 0) return 0; return (performance.now() - stackTowerSessionStartAt) / 1000; } function applyStackTowerMissionPanelImages() { const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); if (howtoBg) { howtoBg.src = stackTowerAssetUrl('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 = stackTowerAssetUrl('popup-result.png'); resBg.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/popup-result.png'; }; } ensureStackTowerSlingImagePlay(); } function showStackTowerMissionHowtoOverlay() { if (!isStackTowerMissionUiMapPlay()) return; hideConflictingOverlaysForGauntletCrown(); applyStackTowerMissionPanelImages(); if (stackTowerMissionCountdownTimer) { clearTimeout(stackTowerMissionCountdownTimer); stackTowerMissionCountdownTimer = null; } const cd = document.getElementById('gauntlet-crown-countdown'); if (cd) cd.classList.add('is-hidden'); stackTowerMissionPhase = 'howto'; const ov = document.getElementById('gauntlet-crown-howto-overlay'); if (!ov) return; gauntletCrownHowtoVisible = true; ov.classList.remove('is-hidden'); setGauntletCrownHowtoStackTowerArtMaskPlay(true); const st = document.getElementById('gauntlet-crown-howto-status'); if (st) st.classList.remove('gch-status--jumper'); const btn = document.getElementById('btn-gch-ready'); const humans = quizCarryPregameHumanIds(); const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); const soleParticipant = totPlayers === 1; if (soleParticipant) { if (st) { st.textContent = ''; st.classList.add('is-hidden'); } if (btn) { const canGo = isMePlayHost() || humans.length === 1; 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'); } } else { if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); gauntletCrownSyncGuestReadyIfNeeded(); updateStackTowerMissionHowtoHud(); } } /** Stack Tower mnn93hpi — Ready Status / ปุ่ม แบบ quiz mission (mng8a80o) */ function updateStackTowerMissionHowtoHud() { if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'howto') return; const st = document.getElementById('gauntlet-crown-howto-status'); const btn = document.getElementById('btn-gch-ready'); if (!st || !btn) return; applyGauntletCrownReadyButtonPlay(st, btn); } /** Jump Survival mnptfts2 — Ready Status / ปุ่ม เดียวกับ Stack Tower */ function updateJumpSurviveMissionHowtoHud() { if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'howto') return; const st = document.getElementById('gauntlet-crown-howto-status'); const btn = document.getElementById('btn-gch-ready'); if (!st || !btn) return; applyGauntletCrownReadyButtonPlay(st, btn); } /** Space Shooter mnpz6rkp — Ready Status / ปุ่ม เดียวกับ Jumper */ function updateSpaceShooterMissionHowtoHud() { if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'howto') return; const st = document.getElementById('gauntlet-crown-howto-status'); const btn = document.getElementById('btn-gch-ready'); if (!st || !btn) return; applyGauntletCrownReadyButtonPlay(st, btn); } function beginStackTowerMissionCountdownThenRun() { if (!isStackTowerMissionUiMapPlay()) return; if (stackTowerMissionCountdownTimer) { clearTimeout(stackTowerMissionCountdownTimer); stackTowerMissionCountdownTimer = null; } stackTowerMissionPhase = 'countdown'; const howto = document.getElementById('gauntlet-crown-howto-overlay'); if (howto) { howto.classList.add('is-hidden'); howto.classList.remove(GCHOWTO_STACK_TOWER_CLASS); } gauntletCrownHowtoVisible = false; hideStackTowerHowtoRulesDom(); 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 (stackTowerMissionCountdownTimer) { clearTimeout(stackTowerMissionCountdownTimer); stackTowerMissionCountdownTimer = null; } stackTowerMissionPhase = 'live'; stackTowerMissionEndedOnce = false; stackTowerSessionStartAt = performance.now(); lastStackTickMs = performance.now(); resetStackMinigameState(); }; if (!cd || !numEl) { runFinish(); return; } cd.classList.remove('is-hidden'); syncGauntletCrownCountdownMg2ModePlay(cd); runAlignedCountdownPlay( function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, runFinish, function (id) { stackTowerMissionCountdownTimer = id; } ); } function stackTowerMissionBuildPayload(endOpts) { const outcome = endOpts && endOpts.outcome ? String(endOpts.outcome) : 'decrypt_complete'; const rows = []; const teamPts = Math.max(0, Number(stackMini && stackMini.score) || 0); function pushEnt(id, nickname, characterId, score) { rows.push({ id: id, nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id), characterId: characterId ?? null, baseScore: Math.max(0, Number(score) || 0), eliminated: false, hadQuizWrong: false, rankBonus: 0, finalScore: Math.max(0, Number(score) || 0), }); } if (playBotsEnabled() && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) { if (myId != null) { pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), me.stackPreviewHumanPts || 0); } stackPreviewTurnOrder.forEach(function (entry) { if (!entry || entry.kind !== 'bot' || !entry.botId) return; const o = others.get(entry.botId); pushEnt(entry.botId, o ? o.nickname : entry.botId, o ? o.characterId : null, o ? (o.stackBotScore || 0) : 0); }); } else { if (myId != null) { pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), teamPts); } others.forEach(function (o, id) { if (!o) return; if (playBotsEnabled() && isPreviewBotId(id)) return; pushEnt(id, o.nickname, o.characterId, teamPts); }); } const seen = new Set(); const uniq = []; rows.forEach(function (r) { const sid = String(r.id); if (seen.has(sid)) return; seen.add(sid); uniq.push(r); }); uniq.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)); }); const top = uniq.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: false, hadQuizWrong: false, 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); let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; if (totalSum <= 0 && uniq.length) grade = 'F'; const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); return { ranked: ranked, totalSum: totalSum, averageScore: averageScore, grade: grade, rewardCard: rewardCard, totalParts: totalParts, uiSkin: 'stack_tower', survivorCount: uniq.length, participantCount: uniq.length, stackTowerOutcome: outcome, }; } function stackTowerMissionMergePreviewBotsMission(mission) { if (!mission || mission.uiSkin !== 'stack_tower' || !playBotsEnabled() || !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: false, hadQuizWrong: false, rankBonus: 0, finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 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: Math.max(0, Number(o.stackBotScore) || 0), eliminated: false, hadQuizWrong: false, rankBonus: 0, finalScore: Math.max(0, Number(o.stackBotScore) || 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)); }); 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: false, hadQuizWrong: false, 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 n2 = ranked.length || 1; const averageScore = Math.floor(totalSum / n2); let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; if (totalSum <= 0 && baseRows.length) grade = 'F'; const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); return { ranked: ranked, totalSum: totalSum, averageScore: averageScore, grade: grade, rewardCard: rewardCard, totalParts: totalParts, uiSkin: 'stack_tower', survivorCount: baseRows.length, participantCount: baseRows.length, stackTowerOutcome: mission.stackTowerOutcome, }; } function stackTowerMissionMaybeEndPlay() { if (!isStackTowerMissionUiMapPlay()) return; if (stackTowerMissionPhase !== 'live') return; if (stackTowerMissionEndedOnce) return; const pct = stackTowerDecryptPctPlay(); const elapsed = stackTowerMissionElapsedSecPlay(); const timeLim = stackTowerMissionTimeLimitSecPlay(); const missesOut = stackMini && stackMini.teamMissesLeft != null && stackMini.teamMissesLeft <= 0; const timeOut = elapsed >= timeLim; if (pct < 100 && !timeOut && !missesOut) return; let outcome = 'time_up'; if (pct >= 100) outcome = 'decrypt_complete'; else if (missesOut) outcome = 'misses_exhausted'; /* เล่นจริง: host เป็นคนตัดสินจบ + broadcast → ทุกเครื่องจบพร้อมกัน (เดิมต่างคนต่างจับเวลา local → จบไม่พร้อม) */ if (detectiveCaseFillBots && !isMePlayHost()) return; /* non-host รอ stack-over จาก host */ if (detectiveCaseFillBots && socket && myId != null) { try { socket.emit('stack-over', { outcome: outcome }); } catch (e) { /* ignore */ } } finishStackTowerMissionLocalPlay(outcome); } /** จบรอบ MG3 จริง (host เรียกเอง / non-host เรียกตอนรับ stack-over) */ function finishStackTowerMissionLocalPlay(outcome) { if (stackTowerMissionEndedOnce || !isStackTowerMissionUiMapPlay()) return; stackTowerMissionEndedOnce = true; stackTowerMissionPhase = 'ended'; applyStackTowerMissionPanelImages(); beginStackTowerResultFlashThenGcm(stackTowerMissionBuildPayload({ outcome: outcome || 'time_up' })); } 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; /* นับเวลาจาก anchor server (serverNow) → จบพร้อมกันทุกเครื่อง; fallback local ถ้ายังไม่มี anchor */ const elapsed = jumpSurviveLiveAnchorServerMs > 0 ? (serverNow() - jumpSurviveLiveAnchorServerMs) / 1000 : (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.classList.remove('gch-status--jumper'); const btn = document.getElementById('btn-gch-ready'); const humans = quizCarryPregameHumanIds(); const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); const soleParticipant = totPlayers === 1; if (soleParticipant) { if (st) { st.textContent = ''; st.classList.add('is-hidden'); } if (btn) { const canGo = humans.length === 1 || 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'); } } else { if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); gauntletCrownSyncGuestReadyIfNeeded(); updateJumpSurviveMissionHowtoHud(); } } 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; /* anchor เวลาเริ่ม live = liveBeginsAt ของ server (ถ้ามี) → scroll/timer ของทุกเครื่องอ้างอิงจุดเดียวกัน */ jumpSurviveLiveAnchorServerMs = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : serverNow(); jumpSurvivePlatformScrollPx = 0; applyJumpSurvivePreviewSpawnLayout(false); }; if (!cd || !numEl) { runFinish(); return; } cd.classList.remove('is-hidden'); syncGauntletCrownCountdownMg2ModePlay(cd); runAlignedCountdownPlay( function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, runFinish, function (id) { jumperMissionCountdownTimer = id; } ); } /** 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; } /** จบทันทีเมื่อไม่มีผู้รอด — เหลือคนสุดท้ายยังเล่นต่อจนหมดเวลา (ไม่จบเร็วแบบ “ชนะขาดลอย”) */ function jumpSurviveMissionMaybeEarlyFinish() { if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return; const alive = jumpSurviveMissionCountAlive(); if (alive === 0) { endJumpSurviveMissionRound('all_dead'); } } 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 = missionEnsureHumanInTopRanked(rows, 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' || !playBotsEnabled() || !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(endKind) { if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveGameEnded) return; jumpSurviveGameEnded = true; jumpSurviveMissionPhase = 'ended'; applyJumpSurviveJumperPanelImages(); const mission = jumpSurviveBuildMissionPayload(); const kind = endKind === 'all_dead' ? 'all_dead' : 'time_up'; beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, kind); } function isSpaceShooter() { return mapData && mapData.gameType === 'space_shooter'; } function isSpaceShooterMissionUiMapPlay() { return isSpaceShooter() && currentPlayMapId() === SPACE_SHOOTER_MISSION_MAP_ID; } function isSpaceShooterMissionPregameBlockingPlay() { if (!isSpaceShooterMissionUiMapPlay()) return false; return spaceShooterMissionPhase != null && spaceShooterMissionPhase !== 'live' && spaceShooterMissionPhase !== 'ended'; } function violentCrimeAssetUrl(file) { return BASE + '/img/ViolentCrime/' + String(file || '').replace(/^\//, ''); } function megaVirusAssetUrl(file) { return BASE + '/img/MegaVirus/' + String(file || '').replace(/^\//, ''); } function applyGauntletCrownHeistMissionPanelImages() { const howtoOv = document.getElementById('gauntlet-crown-howto-overlay'); if (howtoOv) { const tfHowto = isGauntletCrownHeistMapPlay(); howtoOv.classList.toggle('gch-quiz-tf-mock', tfHowto); howtoOv.classList.toggle('gch-mg2-mock', tfHowto); if (tfHowto) { ensureQuizTfScaleResizeListener(); updateGchTfMockScale(howtoOv); } else { howtoOv.style.removeProperty('--gch-tf-scale'); } } const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); if (howtoBg) { howtoBg.src = missionMockHudAssetUrl('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 && isGauntletCrownHeistMapPlay()) { resBg.src = missionMockHudAssetUrl('popup-result.png'); resBg.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/popup-result.png'; }; } } function applyMegaVirusMissionPanelImages() { const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); if (howtoBg) { howtoBg.src = megaVirusAssetUrl('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 = megaVirusAssetUrl('popup-result.png'); resBg.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/popup-result.png'; }; } } function applySpaceShooterMissionPanelImages() { const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); if (howtoBg) { howtoBg.src = violentCrimeAssetUrl('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 = violentCrimeAssetUrl('popup-result.png'); resBg.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/popup-result.png'; }; } } function showSpaceShooterMissionHowtoOverlay() { if (!isSpaceShooterMissionUiMapPlay()) return; hideConflictingOverlaysForGauntletCrown(); applySpaceShooterMissionPanelImages(); if (spaceShooterMissionCountdownTimer) { clearTimeout(spaceShooterMissionCountdownTimer); spaceShooterMissionCountdownTimer = null; } const cd = document.getElementById('gauntlet-crown-countdown'); if (cd) cd.classList.add('is-hidden'); spaceShooterMissionPhase = '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.classList.remove('gch-status--jumper'); const btn = document.getElementById('btn-gch-ready'); const humans = quizCarryPregameHumanIds(); const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); const soleParticipant = totPlayers === 1; if (soleParticipant) { if (st) { st.textContent = ''; st.classList.add('is-hidden'); } if (btn) { const canGo = humans.length === 1 || 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'); } } else { if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); gauntletCrownSyncGuestReadyIfNeeded(); updateSpaceShooterMissionHowtoHud(); } spaceShooterResetMissionShipStateForAllPlay(); } function beginSpaceShooterMissionCountdownThenRun() { if (!isSpaceShooterMissionUiMapPlay()) return; if (spaceShooterMissionCountdownTimer) { clearTimeout(spaceShooterMissionCountdownTimer); spaceShooterMissionCountdownTimer = null; } spaceShooterMissionPhase = '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 (spaceShooterMissionCountdownTimer) { clearTimeout(spaceShooterMissionCountdownTimer); spaceShooterMissionCountdownTimer = null; } spaceShooterMissionPhase = 'live'; spaceShooterGameEnded = false; spaceShooterBullets = []; spaceShooterAsteroids = []; spaceShooterAsteroidExplosions = []; spaceShooterPopups = []; spaceShooterLastTickMs = performance.now(); spaceShooterSpawnAccMs = 0; spaceShooterSpawnCount = 0; /* anchor = liveBeginsAt ของ server (ทุกเครื่องได้ค่าเดียวกัน) → spawn/timer อ้างอิงจุดเดียวกัน */ spaceShooterLiveAnchorServerMs = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : serverNow(); spaceShooterFireCd = 0; spaceShooterSessionStartMs = performance.now(); spaceShooterLastMoveEmit = 0; if (myId != null) me.spaceShooterScore = 0; others.forEach(function (o) { if (o) o.spaceShooterScore = 0; }); spaceShooterResetMissionShipStateForAllPlay(); if (mapData) applySpaceShooterSpawnLayoutPlay(); }; if (!cd || !numEl) { runFinish(); return; } cd.classList.remove('is-hidden'); syncGauntletCrownCountdownMg2ModePlay(cd); runAlignedCountdownPlay( function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, runFinish, function (id) { spaceShooterMissionCountdownTimer = id; } ); } function spaceShooterBuildMissionPayload() { const rows = []; function pushEnt(id, nickname, characterId, score, eliminated) { rows.push({ id: id, nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id), characterId: characterId ?? null, baseScore: Math.max(0, Number(score) || 0), eliminated: !!eliminated, rankBonus: 0, finalScore: Math.max(0, Number(score) || 0), }); } if (myId != null) { pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), me.spaceShooterScore, !!me.spaceShooterEliminated); } others.forEach(function (o, id) { if (!o) return; pushEnt(id, o.nickname, o.characterId, o.spaceShooterScore, !!o.spaceShooterEliminated); }); 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)); }); const top = rows.slice(0, 5); const ranked = top.map(function (row, idx) { const pos = idx + 1; /* โบนัสอยู่รอดจนจบเกม = +20 (เฉพาะคนไม่ตาย) — รวมใน finalScore เหมือน rankBonus ของ MG2 */ const surviveBonus = row.eliminated ? 0 : 20; 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: surviveBonus, finalScore: row.baseScore + surviveBonus, }; }); const totalParts = ranked.map(function (r) { return r.finalScore; }); const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); const n = ranked.length || 1; const averageScore = Math.floor(totalSum / n); const survivorCount = rows.filter(function (r) { return !r.eliminated; }).length; let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; if (survivorCount <= 0) grade = 'F'; const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); return { ranked: ranked, totalSum: totalSum, averageScore: averageScore, grade: grade, rewardCard: rewardCard, totalParts: totalParts, uiSkin: 'violent_crime', survivorCount: survivorCount, participantCount: rows.length, }; } function spaceShooterMergePreviewBotsMission(mission) { if (!mission || mission.uiSkin !== 'violent_crime' || !playBotsEnabled() || !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, finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 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: Math.max(0, Number(o.spaceShooterScore) || 0), eliminated: !!o.spaceShooterEliminated, rankBonus: 0, finalScore: Math.max(0, Number(o.spaceShooterScore) || 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)); }); 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 n2 = ranked.length || 1; const averageScore = Math.floor(totalSum / n2); const survivorCount = baseRows.filter(function (r) { return !r.eliminated; }).length; let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; if (survivorCount <= 0) grade = 'F'; const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); return { ranked: ranked, totalSum: totalSum, averageScore: averageScore, grade: grade, rewardCard: rewardCard, totalParts: totalParts, uiSkin: 'violent_crime', survivorCount: survivorCount, participantCount: baseRows.length, }; } function balloonBossMergePreviewBotsMission(mission) { if (!mission || mission.uiSkin !== 'mega_virus' || !playBotsEnabled() || !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, finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 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: Math.max(0, Number(o.balloonBossScore) || 0), eliminated: !!o.balloonBossEliminated, rankBonus: 0, finalScore: Math.max(0, Number(o.balloonBossScore) || 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)); }); const top = baseRows.slice(0, 5); /* โบนัสจบเกม: ฆ่าบอส(+100)→ทุกคนที่ร่วมเล่น · รอดชีวิต(+10)→เฉพาะคนไม่ตาย (รวมใน finalScore, clamp ≥0) */ const endRBonus = mission && mission.balloonBossEndReason; const killBonusAll = endRBonus === 'victory' ? 100 : 0; const ranked = top.map(function (row, idx) { const pos = idx + 1; const bs = Math.max(0, Number(row.baseScore) || 0); const bonus = killBonusAll + (row.eliminated ? 0 : 10); 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: bonus, finalScore: Math.max(0, bs + bonus), }; }); const totalParts = ranked.map(function (r) { return r.finalScore; }); const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); const n2 = ranked.length || 1; const averageScore = Math.floor(totalSum / n2); const maxHp = balloonBossMaxHpPlay(); let bossDmgSum = Math.max(0, me.balloonBossBossDmg | 0); others.forEach(function (o) { if (o) bossDmgSum += Math.max(0, o.balloonBossBossDmg | 0); }); const progress = Math.min(1, bossDmgSum / Math.max(1, maxHp)); const score100 = Math.min(100, Math.floor(progress * 100)); let grade = 'C'; const endR = mission && mission.balloonBossEndReason; if (endR === 'victory' || progress >= 1) grade = 'A'; else if (endR === 'all_dead') grade = 'F'; else if (score100 >= 50) grade = 'B'; else if (totalSum <= 0) grade = 'F'; const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); return { ranked: ranked, totalSum: totalSum, averageScore: averageScore, grade: grade, rewardCard: rewardCard, totalParts: totalParts, uiSkin: 'mega_virus', survivorCount: baseRows.filter(function (r) { return !r.eliminated; }).length, participantCount: baseRows.length, balloonBossEndReason: mission.balloonBossEndReason, }; } function balloonBossBuildMissionPayloadPlay(reason) { const rows = []; if (myId != null) { rows.push({ id: myId, nickname: ((me.nickname || nick || 'คุณ').trim() || 'คุณ'), characterId: me.characterId ?? null, baseScore: Math.max(0, me.balloonBossScore | 0), eliminated: !!me.balloonBossEliminated, }); } others.forEach((o, id) => { rows.push({ id: id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, characterId: o && o.characterId != null ? o.characterId : null, baseScore: Math.max(0, o.balloonBossScore | 0), eliminated: !!(o && o.balloonBossEliminated), }); }); rows.sort((a, b) => b.baseScore - a.baseScore || String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id))); const top = rows.slice(0, 5); /* โบนัสจบเกม: ฆ่าบอส(+100)→ทุกคนที่ร่วมเล่น · รอดชีวิต(+10)→เฉพาะคนไม่ตาย (รวมใน finalScore, clamp ≥0) */ const killBonusAll = reason === 'victory' ? 100 : 0; const ranked = top.map((row, idx) => { const pos = idx + 1; const bs = Math.max(0, Number(row.baseScore) || 0); const bonus = killBonusAll + (row.eliminated ? 0 : 10); 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: bonus, finalScore: Math.max(0, bs + bonus), }; }); const totalParts = ranked.map((r) => r.finalScore); const totalSum = totalParts.reduce((s, n) => s + n, 0); const n2 = ranked.length || 1; const averageScore = Math.floor(totalSum / n2); const maxHp = balloonBossMaxHpPlay(); let bossDmgSum = Math.max(0, me.balloonBossBossDmg | 0); others.forEach((o) => { if (o) bossDmgSum += Math.max(0, o.balloonBossBossDmg | 0); }); const progress = Math.min(1, bossDmgSum / Math.max(1, maxHp)); const score100 = Math.min(100, Math.floor(progress * 100)); let grade = 'C'; if (reason === 'victory' || progress >= 1) grade = 'A'; else if (reason === 'all_dead') grade = 'F'; else if (score100 >= 50) grade = 'B'; else if (totalSum <= 0) grade = 'F'; const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); return { ranked: ranked, totalSum: totalSum, averageScore: averageScore, grade: grade, rewardCard: rewardCard, totalParts: totalParts, uiSkin: 'mega_virus', survivorCount: rows.filter((r) => !r.eliminated).length, participantCount: rows.length, balloonBossEndReason: reason, }; } /* host เป็นผู้ตัดสินจบ MG6 + แนบคะแนน/สถานะสุดท้าย → ทุกเครื่องจบพร้อมกันและอันดับตรงกัน */ function triggerSpaceShooterOverPlay(kind) { if (spaceShooterGameEnded) return; const k = (kind === 'all_dead') ? 'all_dead' : 'time_up'; try { if (socket && myId != null && isMePlayHost()) { const scores = buildStableShipParticipantRefsPlay().map((e) => ({ id: e.id, score: Math.max(0, (e.ref && (e.ref.spaceShooterScore | 0)) || 0), hits: Math.max(0, Number(e.ref && e.ref.spaceShooterHits) || 0), eliminated: !!(e.ref && e.ref.spaceShooterEliminated), })); socket.emit('space-shooter-over', { kind: k, scores: scores }); } } catch (e) { /* ignore */ } endSpaceShooterMissionRound(k); } function applySpaceShooterOverScoresPlay(scores) { if (!Array.isArray(scores)) return; scores.forEach((s) => { if (!s || s.id == null) return; const sc = Math.max(0, Number(s.score) || 0); const hits = Math.max(0, Number(s.hits) || 0); const ent = (myId != null && String(s.id) === String(myId)) ? me : (others.get(String(s.id)) || others.get(s.id)); if (!ent) return; ent.spaceShooterScore = sc; ent.spaceShooterHits = hits; if (s.eliminated) ent.spaceShooterEliminated = true; }); } function endSpaceShooterMissionRound(endKind) { if (!isSpaceShooterMissionUiMapPlay() || spaceShooterGameEnded) return; const kind = endKind === 'all_dead' ? 'all_dead' : 'time_up'; spaceShooterGameEnded = true; spaceShooterMissionPhase = 'ended'; applySpaceShooterMissionPanelImages(); spaceShooterBullets = []; spaceShooterAsteroids = []; spaceShooterAsteroidExplosions = []; spaceShooterSpawnAccMs = 0; spaceShooterPopups = []; const mission = spaceShooterBuildMissionPayload(); mission.spaceShooterEndKind = kind; beginSpaceShooterMissionResultFlashSequenceThenGcm(mission, kind); } function questionMissionAssetUrl(file) { return BASE + '/img/QUESTION/' + String(file || '').replace(/^\//, ''); } /** HUD กลางจอ (เวลา / แผ่นคำถาม) — ชื่อไฟล์คู่กับ public/img/QUESTION บนดิสก์ → URL /Game/img/QUESTION */ function questionMissionHudAssetUrl(file) { if (isMissionMockHudPlay()) return missionMockHudAssetUrl(file); return questionMissionAssetUrl(file); } /** สกิน HUD แบบ mock Minigame 1–7 (fix 1920×1080 + score-bar / profile) */ function getMissionMockHudSkinPlay() { if (isQuizQuestionMissionUiMapPlay()) return 'question_mission'; if (isStackTowerMissionUiMapPlay()) return 'stack_tower'; if (isJumpSurviveMissionUiMapPlay()) return 'jumper'; if (isSpaceShooterMissionUiMapPlay()) return 'violent_crime'; if (isMegaVirusMissionShellMapPlay()) return 'mega_virus'; if (isQuizCarry() && quizCarryEmbedMissionFlowActive()) return 'quiz_carry'; if (isGauntletCrownHeistMapPlay()) return 'gauntlet_crown'; return ''; } function isMissionMockHudPlay() { return !!getMissionMockHudSkinPlay(); } function missionMockHudAssetUrl(file) { const skin = getMissionMockHudSkinPlay(); const f = String(file || '').replace(/^\//, ''); if (skin === 'jumper') return jumperAssetUrl(f); if (skin === 'violent_crime') return violentCrimeAssetUrl(f); if (skin === 'mega_virus') return megaVirusAssetUrl(f); if (skin === 'stack_tower') return stackTowerAssetUrl(f); if (skin === 'quiz_carry') return BASE + '/img/quiz-carry/' + f; if (skin === 'gauntlet_crown') return BASE + '/img/gauntlet-assets/' + f; return questionMissionAssetUrl(f); } function applyMissionMockHudCssVarsPlay(root) { if (!root) return; if (!isMissionMockHudPlay()) { root.style.removeProperty('--mmh-score-bar'); root.style.removeProperty('--mmh-score-label'); root.style.removeProperty('--mmh-score-avatar'); root.style.removeProperty('--mmh-mic-on'); root.style.removeProperty('--mmh-btn-ready'); root.style.removeProperty('--mmh-btn-start'); delete root.dataset.missionMockSkin; return; } const skin = getMissionMockHudSkinPlay(); root.dataset.missionMockSkin = skin; if (skin === 'gauntlet_crown') { root.style.removeProperty('--mmh-score-bar'); root.style.setProperty('--mmh-score-label', "url('" + missionMockHudAssetUrl('score.png') + "')"); const crownHead = document.getElementById('play-cyber-crown-score-head'); const crownImg = document.getElementById('play-cyber-crown-score-img'); if (crownHead && crownImg) { const scoreSrc = missionMockHudAssetUrl('score.png'); crownHead.classList.remove('is-hidden'); crownHead.setAttribute('aria-hidden', 'false'); if (crownImg.getAttribute('src') !== scoreSrc) crownImg.setAttribute('src', scoreSrc); crownImg.alt = 'SCORE :'; crownImg.classList.add('mg2-score-label'); } } else { root.style.removeProperty('--mmh-score-label'); root.style.setProperty('--mmh-score-bar', "url('" + missionMockHudAssetUrl('score-bar.png') + "?v=3')"); } root.style.setProperty('--mmh-score-avatar', "url('" + missionMockHudAssetUrl('score-avartar.png') + "')"); root.style.setProperty('--mmh-mic-on', "url('" + missionMockHudAssetUrl('btn-mic_on.png') + "')"); root.style.setProperty('--mmh-btn-ready', "url('" + missionMockHudAssetUrl('btn-ready.png') + "')"); root.style.setProperty('--mmh-btn-start', "url('" + missionMockHudAssetUrl('btn-start.png') + "')"); const micImg = document.getElementById('play-cyber-mic-img'); if (micImg) { micImg.classList.remove('is-hidden'); micImg.setAttribute('aria-hidden', 'false'); const micSrc = missionMockHudAssetUrl('btn-mic_on.png'); if (micImg.getAttribute('src') !== micSrc) micImg.setAttribute('src', micSrc); } const frameOverlay = document.getElementById('play-cyber-profile-frame-overlay'); if (frameOverlay) { const frameSrc = missionMockHudAssetUrl('Avartar.png'); if (frameOverlay.getAttribute('src') !== frameSrc) frameOverlay.setAttribute('src', frameSrc); } } function isQuizQuestionMissionHudActivePlay() { return isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live'; } /** ภารกิจคำถาม mng8a80o ช่วง live: กล้องกลางแมป (world px) — ไม่ตาม me */ function getQuizQuestionMissionMapCenterWorldPxPlay() { if (!mapData || !isQuiz()) return null; const w = mapData.width || 20; const h = mapData.height || 15; const ts = tileSize; const mwPx = w * ts; const mhPx = h * ts; return { cx: mwPx * 0.5, cy: mhPx * 0.5, mwPx, mhPx }; } function applyQuizQuestionMissionPanelImages() { /* ติดคลาส scope ให้ overlay howto (popup HOW TO PLAY) ใช้สไตล์ mockup เฉพาะ mng8a80o */ const howtoOv = document.getElementById('gauntlet-crown-howto-overlay'); if (howtoOv) { const tfHowto = isQuizQuestionMissionUiMapPlay(); howtoOv.classList.toggle('gch-quiz-tf-mock', tfHowto); if (tfHowto) { ensureQuizTfScaleResizeListener(); updateGchTfMockScale(howtoOv); } else { howtoOv.style.removeProperty('--gch-tf-scale'); } } const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); if (howtoBg) { howtoBg.src = questionMissionAssetUrl('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 = questionMissionAssetUrl('popup-result.png'); resBg.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/popup-result.png'; }; } } function showQuizQuestionMissionHowtoOverlay() { if (!isQuizQuestionMissionUiMapPlay()) return; hideConflictingOverlaysForGauntletCrown(); applyQuizQuestionMissionPanelImages(); if (quizQuestionMissionCountdownTimer) { clearTimeout(quizQuestionMissionCountdownTimer); quizQuestionMissionCountdownTimer = null; } const cd = document.getElementById('gauntlet-crown-countdown'); if (cd) cd.classList.add('is-hidden'); quizQuestionMissionPhase = '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.classList.remove('gch-status--jumper'); const btn = document.getElementById('btn-gch-ready'); const humans = quizCarryPregameHumanIds(); const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); /** ผู้เล่นจริงในห้องคนเดียว และไม่มีบอท/peer อื่นในนับรวม — เท่านั้นที่ข้าม lobby */ const soleParticipant = totPlayers === 1; if (soleParticipant) { if (st) { st.textContent = ''; st.classList.add('is-hidden'); } if (btn) { const canGo = isMePlayHost() || humans.length === 1; 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'); } } else { if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); gauntletCrownSyncGuestReadyIfNeeded(); updateQuizQuestionMissionHowtoHud(); } } function beginQuizQuestionMissionCountdownThenRun() { if (!isQuizQuestionMissionUiMapPlay()) return; if (quizQuestionMissionCountdownTimer) { clearTimeout(quizQuestionMissionCountdownTimer); quizQuestionMissionCountdownTimer = null; } quizQuestionMissionPhase = '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 (quizQuestionMissionCountdownTimer) { clearTimeout(quizQuestionMissionCountdownTimer); quizQuestionMissionCountdownTimer = null; } quizQuestionMissionPhase = 'live'; playEmbedUserZoomMul = 1; const qov = document.getElementById('quiz-game-overlay'); if (qov) { if (isQuizQuestionMissionUiMapPlay()) qov.classList.add('is-hidden'); else if (previewMode && editorEmbedReturn) qov.classList.add('is-hidden'); else qov.classList.remove('is-hidden'); } const dp = quizQuestionMissionDeferredPhase; quizQuestionMissionDeferredPhase = null; if (dp) applyQuizPhaseFromServer(dp); if (previewMode && isQuiz()) loadPreviewQuizAndStart(); }; if (!cd || !numEl) { runFinish(); return; } cd.classList.remove('is-hidden'); syncGauntletCrownCountdownMg2ModePlay(cd); runAlignedCountdownPlay( function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, runFinish, function (id) { quizQuestionMissionCountdownTimer = id; } ); } function quizQuestionMissionBuildPayload() { const rows = []; function pushEnt(id, nickname, characterId, score) { const sid = String(id); rows.push({ id: id, nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id), characterId: characterId ?? null, baseScore: Math.max(0, Number(score) || 0), eliminated: false, hadQuizWrong: !!playQuizEverWrong[sid], rankBonus: 0, finalScore: Math.max(0, Number(score) || 0), }); } if (myId != null) { pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), playLiveQuizScores[myId]); } others.forEach(function (o, id) { if (!o) return; pushEnt(id, o.nickname, o.characterId, playLiveQuizScores[id]); }); 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)); }); 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: false, hadQuizWrong: !!row.hadQuizWrong, 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); let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; if (totalSum <= 0 && rows.length) grade = 'F'; const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); return { ranked: ranked, totalSum: totalSum, averageScore: averageScore, grade: grade, rewardCard: rewardCard, totalParts: totalParts, uiSkin: 'question_mission', survivorCount: rows.length, participantCount: rows.length, }; } function quizQuestionMissionMergePreviewBotsMission(mission) { if (!mission || mission.uiSkin !== 'question_mission' || !playBotsEnabled() || !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: false, hadQuizWrong: !!r.hadQuizWrong, rankBonus: 0, finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 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: Math.max(0, Number(playLiveQuizScores[id]) || 0), eliminated: false, hadQuizWrong: !!playQuizEverWrong[sid], rankBonus: 0, finalScore: Math.max(0, Number(playLiveQuizScores[id]) || 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)); }); 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: false, hadQuizWrong: !!row.hadQuizWrong, 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 n2 = ranked.length || 1; const averageScore = Math.floor(totalSum / n2); let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; if (totalSum <= 0 && baseRows.length) grade = 'F'; const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); return { ranked: ranked, totalSum: totalSum, averageScore: averageScore, grade: grade, rewardCard: rewardCard, totalParts: totalParts, uiSkin: 'question_mission', survivorCount: baseRows.length, participantCount: baseRows.length, }; } function isBalloonBoss() { return mapData && mapData.gameType === 'balloon_boss'; } function isQuizCarry() { return mapData && mapData.gameType === 'quiz_carry'; } /** พรีวิว editor + ภารกิจสืบสวน mnorwqx1 — flow เต็ม: HOWTO/READY, นับ 3-2-1, ครบ carrySessionLength, สรุปภารกิจ */ function quizCarryEmbedMissionFlowActive() { if (!isQuizCarry()) return false; if (previewMode && editorEmbedReturn) return true; return !!(isDetectiveMinigamePlay() && quizCarryUseMissionSummaryOverlay); } /** quiz_carry: จุดกลางแมป (world px) — กล้องไม่ตามตัวละคร; ใช้ร่วม draw + overlay DOM */ function getQuizCarryMapCameraWorldCenterPxPlay() { if (!mapData || mapData.gameType !== 'quiz_carry') return null; const ww = mapData.width || 20, hh = mapData.height || 15; const ts = tileSize; return { cx: ww * ts * 0.5, cy: hh * ts * 0.5 }; } 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 quizCarryWrapLinesFit(ctx, text, maxWidth) { const raw = String(text || '').trim(); if (!raw) return []; const lines = []; let cur = ''; for (const chr of raw) { if (chr === '\n') { lines.push(cur); cur = ''; continue; } const test = cur + chr; if (cur === '' || ctx.measureText(test).width <= maxWidth) cur = test; else { lines.push(cur); cur = chr; } } if (cur) 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; let fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps)); let maxW = Math.max(96, tileSpanX * ts * 1.5 - 14); ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; /* คำตอบไทยมักเป็น "คำเดียวยาว" (ไม่มีช่องว่าง → wrap ไม่ได้) → ย่อฟอนต์ให้พอดีกว้าง + ปล่อยป้ายกว้างได้ กันโดนตัด "…" */ while (fontPx > 8 && ctx.measureText(line).width > maxW) { fontPx -= 1; ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; } 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() { if (!quizCarryEmbedMissionFlowActive()) return false; const now = Date.now(); return (quizCarryEmbedCountdownEndAt > now) || (quizCarryEmbedPreOptionCountdownEndAt > now); } function isQuizCarryEmbedLobbyBlockingMovement() { return !!(quizCarryEmbedMissionFlowActive() && quizCarryPregameActive); } /* ผู้ถูกแบนรอบนี้ (Card 3 Ban) เข้ามาดูเฉยๆ ไม่ต้อง Ready → ไม่นับใน gate เริ่มเกม (ไม่งั้น host จะค้าง N/N+1 เพราะ banned ไม่เคย ready) */ function isQuizCarryPregameBannedId(id) { const o = others.get(id) || others.get(String(id)); return !!(o && o.bannedSpectator); } function quizCarryPregameHumanIds() { const set = new Set(); if (myId != null && !isPreviewBotId(myId) && !playBannedSpectator) set.add(String(myId)); /* นับจาก readyMap ของ server (authoritative + ตัด banned แล้ว) + ตัวเราเท่านั้น → host/client นับ "จำนวนผู้เล่น" ตรงกันเสมอ (เดิมวน others ซึ่ง membership ต่างกันชั่วคราว → ready ไม่ตรง) การรอให้ client เข้าครบใช้ expectedActive (gauntletCrownPregameExpectedHumans) แทน */ [quizCarryLobbyReadyMap, gauntletCrownLobbyReadyMap].forEach((rm) => { if (rm && typeof rm === 'object') { Object.keys(rm).forEach((id) => { if (id && !isPreviewBotId(id) && !isQuizCarryPregameBannedId(id)) set.add(String(id)); }); } }); return [...set]; } 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; /* fallback (load-bearing): host พึ่งตอน null — อย่าเปลี่ยน (เคยทำ MG3 ready ค้าง 4/5 + MG2 spawn ทับ) */ return String(playHostId) === String(myId); } /* บอทเติมช่อง (fill-bot) ใน 7 มินิเกมต้องเดิน/เล่น/คะแนนตรงกันทุก client: host เป็นผู้จำลอง bot ทั้งหมด (AI/ฟิสิกส์/คะแนน) แล้ว broadcast state — client อื่นปิด local sim แล้ว render ตาม (เดิม: ทุกเครื่องสุ่ม Math.random() แยกกัน → บอทตัวเดียวกันอยู่คนละที่/คนละคะแนนบนแต่ละจอ) */ function fbNum(v) { return (typeof v === 'number' && Number.isFinite(v)) ? Math.round(v * 100) / 100 : undefined; } function broadcastFillBotState() { if (!playBotsEnabled() || !isMePlayHost() || !mapData) return; const arr = []; others.forEach((o, id) => { if (!isPreviewBotId(id) || !o) return; const row = { id: id, x: fbNum(o.x), y: fbNum(o.y), direction: o.direction || 'down', walking: !!o.botIsWalking }; if (o.characterId != null) row.cid = o.characterId; /* ให้ client สร้างบอทที่ขาดได้ → ชุดบอท host-authoritative */ if (o.botTier != null) row.tier = o.botTier; /* tier (ฉลาด/กลาง/พลาดบ่อย) — host เป็นเจ้าของ ให้ label/พฤติกรรมตรงกันทุกเครื่อง (เดิมสุ่ม local → ป้ายไม่ตรง) */ if (o.gauntletScore != null) row.gScore = o.gauntletScore; if (o.gauntletEliminated != null) row.gElim = !!o.gauntletEliminated; if (o.gauntletJumpTicks != null) row.gJump = o.gauntletJumpTicks; if (o.gauntletJumpVis != null) row.gJumpVis = fbNum(o.gauntletJumpVis); if (o.jumpSurviveEliminated != null) row.jsElim = !!o.jumpSurviveEliminated; if (o.spaceShooterCx != null) row.ssCx = fbNum(o.spaceShooterCx); if (o.spaceShooterCy != null) row.ssCy = fbNum(o.spaceShooterCy); if (o.spaceShooterScore != null) row.ssScore = o.spaceShooterScore; if (o.spaceShooterEliminated != null) row.ssElim = !!o.spaceShooterEliminated; if (o.balloonBossCx != null) row.bbCx = fbNum(o.balloonBossCx); if (o.balloonBossCy != null) row.bbCy = fbNum(o.balloonBossCy); if (o.balloonBossScore != null) row.bbScore = o.balloonBossScore; if (o.balloonBossBossDmg != null) row.bbDmg = o.balloonBossBossDmg; if (o.balloonBossBalloons != null) row.bbBalloons = o.balloonBossBalloons; if (o.balloonBossEliminated != null) row.bbElim = !!o.balloonBossEliminated; if (o.quizCarryHeld !== undefined) row.qcHeld = o.quizCarryHeld; /* MG1 quiz: สถานะล็อก (ตอบผิด=ผี) + คะแนนบอท → host เป็นเจ้าของ ส่งให้ทุกเครื่องตรงกัน (เดิมไม่ส่งเลย → non-host เห็นบอทล็อก/respawn/คะแนนคนละแบบ) */ if (isQuiz()) { if (o.quizCannotTrue != null) row.qct = !!o.quizCannotTrue; if (o.quizCannotFalse != null) row.qcf = !!o.quizCannotFalse; const qsc = playLiveQuizScores[id]; if (qsc != null) row.qsc = Math.max(0, qsc | 0); } /* สีบอท (host เป็นเจ้าของ) — แนบไปด้วยเพื่อให้ทุก client แสดงสีตรงกัน (เดิมแต่ละเครื่องคำนวณสีเองจาก theme/sessionStorage → สีบอทไม่ตรงกันเมื่อ sync ธีมมาช้า) */ if (o.playTint && o.playTint.body) { row.tint = { head: o.playTint.head, hair: o.playTint.hair, body: o.playTint.body }; } arr.push(row); }); /* รายชื่อ "คนจริง" ที่ host เห็น (= myId(host) + others ที่ไม่ใช่บอท) — ให้ non-host force ลบผีที่ host ไม่มี (humans ไม่ถูก sync แบบบอท → ผี reconnect ค้างบน client; id-based ไม่ชนชื่อ default) */ const humanRoster = []; if (myId != null) humanRoster.push(String(myId)); others.forEach((o, id) => { if (!isPreviewBotId(id) && o) humanRoster.push(String(id)); }); if (arr.length) socket.emit('fill-bot-state', { bots: arr, humans: humanRoster }); } setInterval(broadcastFillBotState, 80); /* ~12.5Hz — self-gate ถ้าไม่ใช่ host หรือไม่มีบอท */ /* ===== Server-time sync + เวลาเริ่มเล่นจริงร่วมกัน (liveBeginsAt) ===== แก้ปัญหา "เวลาในเกมไม่เท่ากัน เริ่ม/จบไม่พร้อมกัน": เดิมแต่ละเครื่องนับ 3-2-1 เองด้วย setTimeout และจับเวลารอบด้วย performance.now() ของตัวเอง → GO และจุดจบไม่ตรงกัน แก้: sync นาฬิกากับ server (serverNow) แล้วทุกคนนับไปหา "liveBeginsAt" (server epoch) จุดเดียวกัน เมื่อ GO พร้อมกัน → นาฬิการอบ (performance.now ตั้งแต่ GO) ก็ sync กันเองอัตโนมัติ */ let playClockOffset = 0; /* serverTime - clientTime (ms) */ let playClockBestRtt = null; let playClockSyncInterval = null; function serverNow() { return Date.now() + playClockOffset; } function syncPlayClock(round) { round = round || 0; if (!socket || !socket.connected) return; /* เริ่ม burst ใหม่ทุกครั้ง → รีเซ็ต best RTT เพื่อ "re-establish" offset ใหม่ทั้งหมด ไม่งั้น best RTT ที่ค้างไว้จะทำให้ re-sync ครั้งหลังถูกข้าม แก้ clock drift ไม่ได้ (เวลาในเกม MG6/MG7 จะเพี้ยนสะสมเพราะ serverNow ของแต่ละเครื่องไม่ตรงกัน) */ if (round === 0) playClockBestRtt = null; const t0 = Date.now(); socket.emit('time-sync', (res) => { const t1 = Date.now(); if (res && Number.isFinite(res.t)) { const rtt = t1 - t0; if (playClockBestRtt == null || rtt <= playClockBestRtt) { playClockBestRtt = rtt; playClockOffset = res.t + rtt / 2 - t1; /* ชดเชยครึ่งหนึ่งของ round-trip latency */ } } if (round < 5) setTimeout(function () { syncPlayClock(round + 1); }, 300); }); } /* re-sync นาฬิกาเป็นระยะ → กัน clock drift ระหว่างเล่นยาว ๆ ทำให้ serverNow ทุกเครื่องตรงกันเสมอ (ฐานเวลาเดียวกัน → timer/boss/aim/spawn ของทุก minigame sync กัน) */ function startPeriodicClockSync() { if (playClockSyncInterval) return; playClockSyncInterval = setInterval(function () { try { if (socket && socket.connected) syncPlayClock(0); } catch (_e) { /* ignore */ } }, 12000); } let playMissionLiveBeginsAt = 0; /* server epoch ms ที่เกมเริ่มจริงพร้อมกันทุกคน (0 = ยังไม่รู้) */ function setPlayMissionLiveBeginsAt(v) { const n = Number(v); playMissionLiveBeginsAt = (Number.isFinite(n) && n > 0) ? n : 0; } /* นับถอยหลัง 3-2-1 ให้ "GO" ตรงกันทุกเครื่อง โดยนับไปหา liveBeginsAt (เวลา server) แทน setTimeout(1000) ในเครื่องตัวเอง drawNum(n): วาดเลขนับถอยหลัง · onGo(): เริ่มเล่นจริง · assignTimer(id): เก็บ timer id เดิมไว้ clear ได้ */ function runAlignedCountdownPlay(drawNum, onGo, assignTimer) { const begin = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : (serverNow() + 3000); let lastN = -1; const tick = function () { const remMs = begin - serverNow(); if (remMs <= 60) { assignTimer(null); onGo(); return; } const n = Math.min(3, Math.max(1, Math.ceil(remMs / 1000))); if (n !== lastN) { lastN = n; try { drawNum(n); } catch (_e) { /* ignore */ } } assignTimer(setTimeout(tick, 80)); }; tick(); } 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(); } /* เดิม guest auto-ready → ตอนนี้ทุกคน (client + host) ต้องกด Ready เองก่อน host จึง Start ได้ → no-op */ function quizCarrySyncGuestReadyIfNeeded() {} /** MG4: true เมื่อผู้เล่นมาครบตามที่คาด + กด Ready ครบทุกคน (host ถึงจะ START ได้) */ function quizCarryAllReadyPlay() { const humans = quizCarryPregameHumanIds(); if (humans.length === 0) return false; if (humans.length < gauntletCrownPregameExpectedHumans()) return false; return humans.every((id) => !!quizCarryLobbyReadyMap[id]); } 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()); const humans = quizCarryPregameHumanIds(); const humansReady = quizCarryAllReadyPlay(); const meReady = !!(myId && quizCarryLobbyReadyMap[String(myId)]); if (st) { /* ตาม design 01-howto / 01-howto-host: โฮสต์เห็น "กำลังรอ..ผู้เล่นกด Ready" / ผู้เล่นเห็น "Ready Status" */ st.textContent = isMePlayHost() ? (humansReady ? 'ผู้เล่นพร้อมแล้ว — กด START : ' + num + '/' + tot : 'กำลังรอ..ผู้เล่นกด Ready : ' + num + '/' + tot) : 'Ready Status : ' + num + '/' + tot; } 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) { /* READY phase: ทุกคน (รวม client) กดสลับ Ready ของตัวเองได้ | START phase: เฉพาะ host กดเริ่ม */ const canPress = humansReady ? isMePlayHost() : true; primary.classList.toggle('is-start-phase', humansReady); primary.classList.toggle('is-read-only', !canPress); primary.classList.toggle('is-pressed', meReady && !humansReady); primary.disabled = !canPress; primary.setAttribute('aria-pressed', humansReady ? 'false' : (meReady ? 'true' : 'false')); primary.title = humansReady ? (isMePlayHost() ? 'START' : 'รอโฮสต์กด START') : (meReady ? 'ยกเลิก READY' : 'READY'); } } function beginQuizCarryEmbedPregame() { if (!quizCarryEmbedMissionFlowActive()) return; quizCarryPregameActive = true; quizCarryLobbyReadyMap = {}; quizCarryPregameHumanIds().forEach((id) => { quizCarryLobbyReadyMap[id] = false; }); showQuizCarryPregameOverlay(); /* ขอสถานะ ready ปัจจุบันจาก server (authoritative) — ทุกคนรวม host ต้องกด READY เองตาม flow */ if (socket && socket.connected) socket.emit('quiz-carry-lobby-sync-request'); updateQuizCarryPregameHud(); } function endQuizCarryEmbedPregameAndStart() { if (!quizCarryPregameActive) return; quizCarryPregameActive = false; hideQuizCarryPregameOverlay(); quizCarryPickNextQuestion(); const nowEmb = Date.now(); if (!quizCarryEmbedMissionFlowActive()) { quizCarryRestoreEmbedPhaseLabel(); } else if (!(quizCarryEmbedCountdownEndAt > nowEmb) && !(quizCarryEmbedPreOptionCountdownEndAt > nowEmb)) { quizCarryRestoreEmbedPhaseLabel(); } } function quizCarryOptionsPickableNow() { if (!isQuizCarry() || !quizCarryCurrent) return true; /* embed: ระหว่าง 3–2–1 รอบสอง (คำถามขึ้นแล้ว) — ยังไม่วาด/ไม่ให้หยิบป้ายตัวเลือก */ if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > Date.now()) return false; 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; } } /** embed: หลัง 3–2–1 ก่อนหยิบ — เปิดป้ายตัวเลือกทันที + จับเวลาตอบ carryAnswerMs */ function quizCarryApplyEmbedAnswerWindowAfterPreOption321() { if (!quizCarryCurrent) return; const ansMs = Math.max(1000, Math.floor(Number(quizCarryCarryTimingMs.carryAnswerMs)) || 5000); const t0 = Date.now(); quizCarryOptionRevealAt = 0; quizCarryAnswerCloseAt = t0 + ansMs; } function updateQuizCarryCarryPhaseHud() { if (quizCarrySessionEnded) return; if (quizCarryPregameActive) return; if (!isQuizCarry() || !quizCarryCurrent) return; if (isDetectiveMinigamePlay() && !quizCarryEmbedMissionFlowActive()) return; const phaseEl = document.getElementById('quiz-game-phase-label'); if (!phaseEl) return; if (quizCarryAnswerTimeupAwaitNext && quizCarryAnswerRoundTimeupAdvanceAt > 0) { const sec = Math.max(0, Math.ceil((quizCarryAnswerRoundTimeupAdvanceAt - Date.now()) / 1000)); phaseEl.textContent = 'หมดเวลาตอบ — ไปข้อถัดไปใน ' + sec + ' วิ…'; return; } const now = Date.now(); if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > 0 && now < quizCarryEmbedPreOptionCountdownEndAt) { const sec = Math.max(0, Math.ceil((quizCarryEmbedPreOptionCountdownEndAt - now) / 1000)); phaseEl.textContent = 'เตรียมหยิบคำตอบ — นับ 3·2·1 (' + sec + ' วิ)'; return; } 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 (quizCarryEmbedPreOptionCountdownEndAt > Date.now()) return; if (quizCarryEmbedPendingQuestion) return; if (quizCarryAnswerTimeupAwaitNext) 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); if (isDetectiveMinigamePlay()) { quizCarryAfterRoundResolved(); return; } quizCarryAnswerTimeupAwaitNext = true; showQuizCarryAnswerRoundTimeupOverlay(); updateQuizCarryCarryPhaseHud(); } function resetQuizCarryEmbedCountdownOverlayInners() { 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.textContent = 'คำถาม · Question'; ck.style.display = 'none'; ck.classList.remove('quiz-carry-embed-countdown-kicker--mission'); } } /** นับ 3–2–1: ซ่อนคำถามและหัวข้อ — เหลือแค่รูปเลขจาก /Game/img/QUESTION */ function showQuizCarryEmbedCountdownDigitsOnlyChrome() { 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.textContent = ''; ck.style.display = 'none'; ck.classList.remove('quiz-carry-embed-countdown-kicker--mission'); } } function hideQuizCarryEmbedCountdownOverlay() { resetQuizCarryEmbedCountdownLayoutDom(); const ov = document.getElementById('quiz-carry-embed-countdown'); if (ov) { ov.classList.add('is-hidden'); ov.setAttribute('aria-hidden', 'true'); } resetQuizCarryEmbedCountdownOverlayInners(); } function tickQuizCarryEmbedCountdown() { if (!quizCarryEmbedMissionFlowActive()) return; const now = Date.now(); const ov = document.getElementById('quiz-carry-embed-countdown'); const numEl = document.getElementById('quiz-carry-embed-countdown-num'); if (quizCarryCurrent && quizCarryEmbedPreOptionCountdownEndAt > 0) { if (!numEl) return; if (now >= quizCarryEmbedPreOptionCountdownEndAt) { quizCarryEmbedPreOptionCountdownEndAt = 0; quizCarryEmbedPreOptionCountdownStartAt = 0; hideQuizCarryEmbedCountdownOverlay(); quizCarryApplyEmbedAnswerWindowAfterPreOption321(); quizCarryRestoreEmbedPhaseLabel(); syncQuizCarryEmbedQuestionStrip(); updateQuizCarryCarryPhaseHud(); return; } const elapsedPre = now - quizCarryEmbedPreOptionCountdownStartAt; const nPre = 3 - Math.min(2, Math.floor(elapsedPre / 1000)); setCountdown321QuestionAssetGraphic(numEl, Math.max(1, Math.min(3, nPre))); if (ov) { ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); } syncQuizCarryEmbedCountdownLayout(); showQuizCarryEmbedCountdownDigitsOnlyChrome(); syncQuizCarryEmbedQuestionStrip(); return; } if (!quizCarryEmbedPendingQuestion || quizCarryEmbedCountdownEndAt <= 0) return; 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(); quizCarryEmbedPreOptionCountdownStartAt = Date.now(); quizCarryEmbedPreOptionCountdownEndAt = quizCarryEmbedPreOptionCountdownStartAt + 3000; updateQuizCarryCarryPhaseHud(); return; } const elapsed = now - quizCarryEmbedCountdownStartAt; const n = 3 - Math.min(2, Math.floor(elapsed / 1000)); setCountdown321QuestionAssetGraphic(numEl, Math.max(1, Math.min(3, n))); if (ov) { ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); } syncQuizCarryEmbedCountdownLayout(); showQuizCarryEmbedCountdownDigitsOnlyChrome(); syncQuizCarryEmbedQuestionStrip(); } /* networked mission (quiz_carry detective): เฉพาะ host เลือกคำถาม + broadcast ให้คนอื่น กันแต่ละเครื่องสุ่มคนละข้อ (เดิม client สุ่ม Math.random() เอง → host/client เห็นคนละคำถาม) */ function quizCarryIsNetworkedMission() { return quizCarryEmbedMissionFlowActive() && socket && socket.connected; } /* ตั้งคำถามถัดไป (embed/detective flow) จาก q ที่ "เลือกแล้ว" — ใช้ได้ทั้ง host (หลังสุ่ม) และ client (รับ broadcast) */ function applyQuizCarryEmbedNextQuestion(q) { if (!q) return; quizCarryEmbedPendingQuestion = q; preloadQuizCarryChoiceImages(q); quizCarryEmbedCountdownStartAt = Date.now(); quizCarryEmbedCountdownEndAt = quizCarryEmbedCountdownStartAt + 3000; quizCarryEmbedPreOptionCountdownStartAt = 0; quizCarryEmbedPreOptionCountdownEndAt = 0; 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(); } function quizCarryPickNextQuestion() { if (quizCarrySessionEnded) return; if (quizCarryPregameActive) return; if (!quizCarryPool.length) { quizCarryCurrent = null; quizCarryEmbedPendingQuestion = null; quizCarryEmbedCountdownEndAt = 0; quizCarryEmbedCountdownStartAt = 0; quizCarryEmbedPreOptionCountdownStartAt = 0; quizCarryEmbedPreOptionCountdownEndAt = 0; quizCarryOptionRevealAt = 0; quizCarryAnswerCloseAt = 0; hideQuizCarryEmbedCountdownOverlay(); playQuizText = 'ไม่มีคำถาม — ใส่ quizQuestions ในแมป (choices + correctIndex) หรือตั้งใน Admin'; return; } /* client ที่ไม่ใช่ host ในห้องจริง: ไม่สุ่มเอง รอ event 'quiz-carry-question' จาก host */ if (quizCarryIsNetworkedMission() && !isMePlayHost()) return; let q; let qIdx = 0; let guard = 0; do { qIdx = Math.floor(Math.random() * quizCarryPool.length); q = quizCarryPool[qIdx]; guard++; } while (guard < 12 && quizCarryPool.length > 1 && quizCarryCurrent && q && q.text === quizCarryCurrent.text); if (quizCarryEmbedMissionFlowActive()) { /* host แจ้ง index คำถามให้ทุกคนใช้ข้อเดียวกัน */ if (socket && socket.connected) socket.emit('quiz-carry-question', { idx: qIdx }); applyQuizCarryEmbedNextQuestion(q); 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() && !quizCarryEmbedMissionFlowActive()) { finishDetectiveMinigameAndReturnLobby(); return; } /* ห้องจริง: เฉพาะ host คุมการเลื่อนรอบ/จบเซสชัน แล้ว broadcast — client อื่นรอ event ไม่เลื่อนเอง (กันแต่ละเครื่องเลื่อนรอบคนละจังหวะ/คนละคำถาม) */ if (quizCarryIsNetworkedMission() && !isMePlayHost()) return; if (!quizCarryEmbedMissionFlowActive() && !previewMode) { quizCarryPickNextQuestion(); return; } quizCarryRoundsCompleted++; const lim = quizCarrySessionLength; if (lim > 0 && quizCarryRoundsCompleted >= lim) { if (socket && socket.connected) socket.emit('quiz-carry-session-complete', {}); showQuizCarrySessionCompleteOverlay(); return; } quizCarryPickNextQuestion(); } function playQuizCarryAvatarUrlForMission(characterId) { if (!characterId || characterId === LEGACY_PLACEHOLDER_CHARACTER_ID) 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 = getPlayTintForMissionAvatar(peerId != null ? peerId : ((myId != null && myId !== '') ? myId : 'local')); const isMePeer = (peerId != null && myId != null && String(peerId) === String(myId)); const out = resolvePlayDrawCharSource(characterId, 'down', nowT, false, tint, isMePeer); 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); } /** payload สำหรับ overlay สรุปผลกลาง (gauntlet-crown-mission-overlay) — ให้ MG4 ใช้หน้าจบแบบเดียวกับเกมอื่น */ function quizCarryBuildGcmMissionPayload(opts) { opts = opts || {}; const ranks = quizCarryBuildMissionRankList(); const ranked = ranks.map(function (row, idx) { const pos = idx + 1; const sc = Math.max(0, Number(row.score) || 0); return { id: row.id, nickname: row.nickname, characterId: row.characterId, baseScore: sc, eliminated: false, rank: pos, rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), rankBonus: 0, finalScore: sc, }; }); 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.min(100, Math.max(0, Math.round(totalSum / n))); const grade = opts.forceGradeF ? 'F' : quizCarryGradeFromTeamAverage(averageScore); const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); return { ranked: ranked, totalSum: totalSum, averageScore: averageScore, grade: grade, rewardCard: rewardCard, totalParts: totalParts, uiSkin: 'quiz_carry', survivorCount: opts.forceGradeF ? 0 : ranked.length, participantCount: ranked.length, }; } /** ตั้งรูปพื้นหลังหน้าจบของ overlay กลางเป็นชุด quiz-carry (cut) */ function applyQuizCarryGcmMissionBg() { const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); if (resBg) { resBg.src = BASE + '/img/quiz-carry/popup-result.png'; resBg.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/popup-result.png'; }; } } function cancelEmbedPreviewLobbyReturnTimer() { if (embedPreviewLobbyReturnTimer != null) { clearTimeout(embedPreviewLobbyReturnTimer); embedPreviewLobbyReturnTimer = null; } } function cancelQuizCarryResultEndAfterTimeup() { if (quizCarryResultEndTimer != null) { clearTimeout(quizCarryResultEndTimer); quizCarryResultEndTimer = null; } cancelEmbedPreviewLobbyReturnTimer(); } function cancelQuizCarrySessionCompleteResultToSummary() { if (quizCarrySessionCompleteResultToSummaryT != null) { clearTimeout(quizCarrySessionCompleteResultToSummaryT); quizCarrySessionCompleteResultToSummaryT = null; } } function cancelQuizCarryAnswerRoundTimeupAuto() { if (quizCarryAnswerRoundTimeupAutoT != null) { clearTimeout(quizCarryAnswerRoundTimeupAutoT); quizCarryAnswerRoundTimeupAutoT = null; } quizCarryAnswerRoundTimeupAdvanceAt = 0; } function quizCarrySetAnswerRoundTimeupVisualActive(on) { try { if (on) document.documentElement.classList.add('quiz-carry-answer-timeup-active'); else document.documentElement.classList.remove('quiz-carry-answer-timeup-active'); } catch (e) { /* ignore */ } } /** หลังภาพ result-complete / gameover (editor embed + preview): รอ 5s แล้วไปล็อบบี้ห้องพรีวิว */ function scheduleEmbedPreviewReturnToLobbyAfterResultEnd() { cancelEmbedPreviewLobbyReturnTimer(); if (!(previewMode && editorEmbedReturn)) return; embedPreviewLobbyReturnTimer = setTimeout(function () { embedPreviewLobbyReturnTimer = null; window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); }, 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'; if (isSpaceShooterMissionUiMapPlay()) { img.onerror = function () { this.onerror = null; this.src = BASE + '/img/quiz-carry/' + fname + '?v=' + String(Date.now()); }; img.src = violentCrimeAssetUrl(fname) + '?v=' + String(Date.now()); } else { img.onerror = null; 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) { cancelQuizCarryAnswerRoundTimeupAuto(); 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() { cancelQuizCarryAnswerRoundTimeupAuto(); cancelQuizCarryResultEndAfterTimeup(); quizCarrySetAnswerRoundTimeupVisualActive(false); const el = document.getElementById('quiz-carry-timeup-desk-layer'); if (!el) { quizCarryAnswerTimeupAwaitNext = false; return; } el.classList.remove('qc-timeup-desk--interactive'); quizCarryAnswerTimeupAwaitNext = false; 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'); } /** หมดเวลาตอบ (carryAnswerMs) — โชว์ TIME'S UP บนโซนคำถาม ~3s แล้วไปข้อถัดไป (ไม่ใช่ flow 5s → result ของ embed จบภารกิจ) */ function showQuizCarryAnswerRoundTimeupOverlay() { if (!isQuizCarry()) return; cancelQuizCarryAnswerRoundTimeupAuto(); const el = document.getElementById('quiz-carry-timeup-desk-layer'); if (!el) return; const img = document.getElementById('quiz-carry-timeup-txt-img'); if (img) { img.onerror = null; img.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now()); } quizCarrySetAnswerRoundTimeupVisualActive(true); quizCarryAnswerRoundTimeupAdvanceAt = Date.now() + 3000; el.classList.add('qc-timeup-desk--interactive'); el.classList.remove('is-hidden'); el.setAttribute('aria-hidden', 'false'); try { syncQuizCarryTimeupDeskLayerPosition(); } catch (e) { /* ignore */ } quizCarryAnswerRoundTimeupAutoT = setTimeout(function () { quizCarryAnswerRoundTimeupAutoT = null; dismissQuizCarryAnswerRoundTimeupAndContinue(); }, 3000); } function dismissQuizCarryAnswerRoundTimeupAndContinue() { if (!quizCarryAnswerTimeupAwaitNext) return; cancelQuizCarryAnswerRoundTimeupAuto(); quizCarryAnswerTimeupAwaitNext = false; hideQuizCarryTimeupOnDeskLayer(); quizCarryAfterRoundResolved(); } /** หลังกดรับหลักฐาน (embed preview) — แสดง timeup กลางแคนวาส (Jumper mnptfts2 = img/Jumper/result-timeup.png) แยกจากป๊อปสรุปคะแนน * @param {function():boolean} [embedResultOkFn] — ถ้าไม่ส่ง = ใช้คะแนน quiz_carry (คนตอบถูก); ส่งเมื่อ crown เพื่อเลือก complete vs gameover */ function showQuizCarryTimeupOnDeskLayer(embedResultOkFn) { cancelQuizCarryAnswerRoundTimeupAuto(); quizCarrySetAnswerRoundTimeupVisualActive(false); const el = document.getElementById('quiz-carry-timeup-desk-layer'); if (!el) return; // Last Light (Gauntlet Crown): skip TIME'S UP desk graphic; keep embed 5s → result flow. if (isGauntletCrownHeistMapPlay()) { if (previewMode && editorEmbedReturn) { scheduleEmbedDeskResultAfterDelay( typeof embedResultOkFn === 'function' ? embedResultOkFn : quizCarryAnyPlayerHasCorrectAnswer ); } 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 if (isSpaceShooterMissionUiMapPlay()) { img.onerror = function () { this.onerror = null; this.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now()); }; img.src = violentCrimeAssetUrl('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); } /* การ์ดพิเศษจากชุดคำถาม (ได้มาจากการตอบคำถามพิเศษในฉาก) — แสดงเสมอถ้าได้รับ */ if (specialQuizAwardedCard) renderSpecialCardInBonus(cardTextEl, specialQuizAwardedCard); } overlay.classList.remove('is-hidden'); const btn = document.getElementById('btn-quiz-carry-mission-done'); if (btn) { btn.onclick = function () { cancelQuizCarrySessionCompleteResultToSummary(); overlay.classList.add('is-hidden'); if (isDetectiveMinigamePlay()) { finishDetectiveMinigameAndReturnLobby(); } else { window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); } }; } } function showQuizCarrySessionCompleteOverlay() { if (quizCarrySessionEnded) return; quizCarrySessionEnded = true; quizCarryPregameActive = false; hideQuizCarryPregameOverlay(); quizCarryCurrent = null; quizCarryEmbedPendingQuestion = null; quizCarryEmbedCountdownEndAt = 0; quizCarryEmbedCountdownStartAt = 0; quizCarryEmbedPreOptionCountdownStartAt = 0; quizCarryEmbedPreOptionCountdownEndAt = 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) { cancelQuizCarrySessionCompleteResultToSummary(); hideQuizCarryResultEndLayer(); const summaryOpts = { forceGradeF: !!(playQuizPlayerLocal && playQuizPlayerLocal.eliminated) }; const splashRanks = quizCarryBuildMissionRankList(); const splashParts = splashRanks.map((r) => Math.max(0, Number(r.score) || 0)); const splashTotal = splashParts.reduce((a, b) => a + b, 0); const splashN = splashRanks.length; const splashAvg = splashN ? Math.min(100, Math.max(0, Math.round(splashTotal / splashN))) : 0; const splashGrade = summaryOpts.forceGradeF ? 'F' : quizCarryGradeFromTeamAverage(splashAvg); const useCompleteSplash = splashGrade !== 'F'; showQuizCarryResultEndLayer(useCompleteSplash); quizCarrySessionCompleteResultToSummaryT = setTimeout(function () { quizCarrySessionCompleteResultToSummaryT = null; hideQuizCarryResultEndLayer(); /* ใช้หน้าจบ overlay กลาง (เหมือนเกมอื่น) skin quiz_carry แทน overlay เฉพาะเดิม */ showGauntletCrownMissionOverlay(quizCarryBuildGcmMissionPayload(summaryOpts)); }, QUIZ_CARRY_SESSION_END_SPLASH_MS); 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() { if (tryFinishDetectiveMinigameAndReturnLobby()) return; 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)'); } } } /** Gauntlet (รวมพรมแดง): กระโดด — ใช้ร่วมกับ Space/W/↑ และปุ่ม UI */ function tryRequestGauntletJumpPlay() { if (!mapData || !isGauntlet() || isChatFocused()) return false; if (isGauntletCrownPregameBlockingPlay()) return false; if (isGauntletCrownHeistMapPlay() && me.gauntletEliminated) return false; const now = Date.now(); if (now - lastGauntletJumpKey < 200) return false; lastGauntletJumpKey = now; socket.emit('gauntlet-jump'); return true; } function syncGauntletCrownJumpButton() { const btn = document.getElementById('gauntlet-crown-jump-btn'); if (!btn) return; /* MG5 jump_survive (jumper): โชว์ปุ่ม JUMP มุมขวาล่าง (design .mg5-jump 1648/818/231) — ให้ touch กระโดดได้ (เดิม keyboard-only) */ if (mapData && isJumpSurviveMissionHudActivePlay()) { btn.classList.remove('is-hidden'); btn.setAttribute('aria-hidden', 'false'); btn.style.pointerEvents = ''; btn.classList.add('mg2-jump'); const jimg = btn.querySelector('img'); if (jimg && jimg.src.indexOf('/Jumper/') < 0) jimg.src = jumperAssetUrl('btn-jump.png'); const hudJ = document.getElementById('play-cyber-hud'); if (hudJ && hudJ.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(hudJ); syncMg2MockJumpButtonBoundsPlay(); return; } /* MG7 mega_virus: ปุ่ม SHOOT มุมขวาล่าง (design .mg7-shoot 1648/818/231, Artboard 8.png) — แตะยิงตามลูกศรวงกลม */ if (mapData && isMegaVirusMissionShellMapPlay() && isMissionMockHudPlay() && !balloonBossGameEnded && !me.balloonBossEliminated) { btn.classList.remove('is-hidden'); btn.setAttribute('aria-hidden', 'false'); btn.style.pointerEvents = ''; btn.classList.add('mg2-jump'); const mimg = btn.querySelector('img'); if (mimg && mimg.src.indexOf('Artboard 8') < 0) mimg.src = megaVirusAssetUrl('Artboard 8.png'); const hudM = document.getElementById('play-cyber-hud'); if (hudM && hudM.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(hudM); syncMg2MockJumpButtonBoundsPlay(); return; } const pregameUi = gauntletCrownPregamePhase === 'howto' || gauntletCrownPregamePhase === 'countdown'; const liveUi = gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay(); const show = !!(mapData && isGauntletCrownHeistMapPlay() && !me.gauntletEliminated && (pregameUi || liveUi)); if (!show) { btn.classList.add('is-hidden'); btn.classList.remove('mg2-jump'); btn.setAttribute('aria-hidden', 'true'); btn.style.pointerEvents = 'none'; syncMg2MockJumpButtonBoundsPlay(null, 0); return; } btn.classList.remove('is-hidden'); btn.setAttribute('aria-hidden', 'false'); btn.style.pointerEvents = ''; const mg2Jump = isMissionMockHudPlay() && getMissionMockHudSkinPlay() === 'gauntlet_crown'; btn.classList.toggle('mg2-jump', !!mg2Jump); if (mg2Jump) { const hud = document.getElementById('play-cyber-hud'); if (hud && hud.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(hud); syncMg2MockJumpButtonBoundsPlay(); } else { btn.classList.remove('mg2-jump'); } } function syncStackTowerDropButtonPlay() { const btn = document.getElementById('stack-tower-drop-btn'); if (!btn) return; if (!mapData || !isStack() || !stackMini) { btn.classList.add('is-hidden'); btn.setAttribute('aria-hidden', 'true'); btn.style.pointerEvents = 'none'; btn.classList.remove('stack-tower-drop-btn--active'); return; } if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') { btn.classList.add('is-hidden'); btn.setAttribute('aria-hidden', 'true'); btn.style.pointerEvents = 'none'; btn.classList.remove('stack-tower-drop-btn--active'); return; } btn.classList.remove('is-hidden'); btn.setAttribute('aria-hidden', 'false'); const ok = stackHumanDropGateOkPlay(); btn.classList.toggle('stack-tower-drop-btn--active', ok); btn.style.pointerEvents = ok ? '' : 'none'; } /** MG3 (mnn93hpi): ผู้เล่นตาปัจจุบัน (active node) สำหรับ host portrait ใน DOM HUD */ function mg3CurrentSeatRowPlay() { const seat = getStackPreviewCurrentSeat(); const order = stackPreviewTurnOrder; let entry = null; if (order && order.length) entry = order.find((e) => e.seat === seat) || null; if (!entry || entry.kind === 'human') { return { id: myId, characterId: me.characterId || getPlayCharacterId(), isMe: true, playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me')), }; } const o = entry.botId ? others.get(entry.botId) : null; return { id: entry.botId, characterId: o ? o.characterId : null, isMe: false, playTint: o ? (o.playTint || resolvePlayTintForPeer(entry.botId)) : null, }; } /** * MG3 Stack Tower live HUD → DOM overlay (#mg3-tower-hud) — canvas object-fit:cover ครอป HUD ขอบจอ * จึงย้าย life bar (ขวาบน) + progress (ซ้ายล่าง) + code terminal + host frame มาเป็น DOM สเกลตาม stage */ function syncStackTowerMissionDomHudPlay() { const root = document.getElementById('mg3-tower-hud'); if (!root) return; const on = !!(mapData && isStack() && stackMini && isStackTowerMissionHudActivePlay()); root.classList.toggle('is-hidden', !on); root.setAttribute('aria-hidden', on ? 'false' : 'true'); if (!on) return; if (!root.dataset.mg3Assets) { const setSrc = (sel, file) => { const el = root.querySelector(sel); if (el) el.src = stackTowerAssetUrl(file); }; setSrc('.mg3-life-bar', 'life-bar.png'); setSrc('.mg3-host-frame', 'Avartar.png'); setSrc('.mg3-terminal-bg', 'code-bg.png'); setSrc('.mg3-progress-label', 'progress-txt.png'); setSrc('.mg3-progress-track', 'progress-bg.png'); setSrc('.mg3-progress-fill', 'progress.png'); root.dataset.mg3Assets = '1'; } /* วาง design plane 1920×1080 → scale แบบ contain + จัดกึ่งกลางใน stage (HUD เห็นครบ ไม่โดน object-fit:cover ครอป) */ const stack = document.getElementById('play-canvas-stack'); if (stack) { const r = stack.getBoundingClientRect(); const cw = Math.max(1, r.width || 0); const ch = Math.max(1, r.height || 0); const s = Math.min(cw / 1920, ch / 1080); if (s > 0 && isFinite(s)) { const left = Math.round(r.left + (cw - 1920 * s) / 2); const top = Math.round(r.top + (ch - 1080 * s) / 2); const key = left + ',' + top + ',' + s.toFixed(4); if (root.dataset.mg3Box !== key) { root.dataset.mg3Box = key; root.style.left = left + 'px'; root.style.top = top + 'px'; root.style.transform = 'scale(' + s + ')'; } } } const slots = Math.max(1, Math.min(20, Math.floor(Number(playStackTeamMissesMax) || 3))); const lives = Math.max(0, Math.min(slots, Math.floor(Number( stackMini.teamMissesLeft != null ? stackMini.teamMissesLeft : (stackMini.lives != null ? stackMini.lives : 3), ) || 0))); const heartsEl = document.getElementById('mg3-life-hearts'); if (heartsEl) { const hk = slots + '/' + lives; if (heartsEl.dataset.hk !== hk) { heartsEl.dataset.hk = hk; heartsEl.innerHTML = ''; for (let i = 0; i < slots; i++) { const im = document.createElement('img'); im.src = stackTowerAssetUrl(i < lives ? 'life-full.png' : 'life-hit.png'); im.alt = ''; heartsEl.appendChild(im); } } } const pct = Math.max(0, Math.min(100, Math.round(Number(stackTowerDecryptPctPlay()) || 0))); if (root.dataset.mg3Pct !== String(pct)) { root.dataset.mg3Pct = String(pct); /* fill เป็น px ในระนาบ design (track ภายในจาก left:30 กว้างสุด ~358px) */ const fillEl = document.getElementById('mg3-progress-fill'); if (fillEl) fillEl.style.width = Math.round(358 * pct / 100) + 'px'; const pctEl = document.getElementById('mg3-progress-pct'); if (pctEl) pctEl.textContent = '[ ' + pct + '% ]'; } const hostAv = document.getElementById('mg3-host-av'); if (hostAv) { const row = mg3CurrentSeatRowPlay(); const hk = (row.id != null ? row.id : 'me') + '|' + (row.characterId || ''); if (hostAv.dataset.hk !== hk) { hostAv.dataset.hk = hk; setCyberHudScoreAvatarImg(hostAv, row); } } const logEl = document.getElementById('mg3-terminal-log'); if (logEl) { const towerLiveNow = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live'; if (towerLiveNow && (!mg3TermLog || !mg3TermLog.length)) mg3TermBoot(); else if (!towerLiveNow && mg3TermLog && mg3TermLog.length) mg3TermReset(); let lines; if (mg3TermLog && mg3TermLog.length) lines = mg3TermLog.slice(-9); else if (stackPreviewHudLog && stackPreviewHudLog.length) lines = stackPreviewHudLog.slice(-7); else lines = ['>>> DECRYPT UPLINK READY', '>>> SPACE / CLICK = DROP (ตาคุณ)']; const txt = lines.join('\n'); if (logEl.textContent !== txt) logEl.textContent = txt; } } /** * MG6 Space Shooter life → overlay #mg6-life-hud (ระนาบ design 1920×1080 scale ตาม stage แบบเดียวกับ MG3) * life-bar.png + หัวใจ 3 ดวง (ViolentCrime) ตำแหน่ง/ขนาดเท่า MG3 — ไม่พึ่ง flex ของ cyber HUD (กันบีบ/ล้นขอบ) */ function syncSpaceShooterLifeDomHudPlay() { const root = document.getElementById('mg6-life-hud'); if (!root) return; const on = !!(mapData && isSpaceShooter() && isSpaceShooterMissionHudActivePlay()); root.classList.toggle('is-hidden', !on); root.setAttribute('aria-hidden', on ? 'false' : 'true'); if (!on) return; if (!root.dataset.assets) { const bar = root.querySelector('.mg6-life-bar'); if (bar) bar.src = violentCrimeAssetUrl('life-bar.png'); root.dataset.assets = '1'; } const stack = document.getElementById('play-canvas-stack'); if (stack) { const r = stack.getBoundingClientRect(); const cw = Math.max(1, r.width || 0); const ch = Math.max(1, r.height || 0); const s = Math.min(cw / 1920, ch / 1080); if (s > 0 && isFinite(s)) { const left = Math.round(r.left + (cw - 1920 * s) / 2); const top = Math.round(r.top + (ch - 1080 * s) / 2); const key = left + ',' + top + ',' + s.toFixed(4); if (root.dataset.box !== key) { root.dataset.box = key; root.style.left = left + 'px'; root.style.top = top + 'px'; root.style.transform = 'scale(' + s + ')'; } } } const slots = SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS; const hits = Math.max(0, Math.min(slots, Number(me.spaceShooterHits) || 0)); const rem = slots - hits; const heartsEl = document.getElementById('mg6-life-hearts'); if (heartsEl) { const hk = slots + '/' + rem; if (heartsEl.dataset.hk !== hk) { heartsEl.dataset.hk = hk; heartsEl.innerHTML = ''; for (let i = 0; i < slots; i++) { const im = document.createElement('img'); im.src = violentCrimeAssetUrl(i < rem ? 'life-full.png' : 'life-hit.png'); im.alt = ''; im.decoding = 'async'; heartsEl.appendChild(im); } } } } /** Quiz Battle (qbroom*) เลขห้องจาก mapId → tag/หมวด */ function quizBattleRoomNoPlay() { const m = String(currentPlayMapId() || '').match(/qbroom(\d+)/i); return m ? Math.max(1, Math.min(10, parseInt(m[1], 10))) : 0; } /** * Quiz Battle in-game HUD → overlay #qb-game-hud (design plane 1920×1080 scale ตาม stage) * header หมวด (tag-NN), PLAYERS x/50, SCORE (ตอบถูก), host avatar. EXIT/MY RESULT/RANKING wire แยก */ function syncQuizBattleHudPlay() { const root = document.getElementById('qb-game-hud'); if (!root) return; const on = !!(mapData && isQuizBattle()); root.classList.toggle('is-hidden', !on); root.setAttribute('aria-hidden', on ? 'false' : 'true'); if (!on) return; /* ซ่อนกล่อง dev instruction ('QUIZ BATTLE · โหลดคำถามแล้ว...') มุมซ้ายล่าง — ไม่มีใน design */ const qgOv = document.getElementById('quiz-game-overlay'); if (qgOv && !qgOv.classList.contains('is-hidden')) qgOv.classList.add('is-hidden'); const stack = document.getElementById('play-canvas-stack'); if (stack) { const r = stack.getBoundingClientRect(); const cw = Math.max(1, r.width || 0); const ch = Math.max(1, r.height || 0); const s = Math.min(cw / 1920, ch / 1080); if (s > 0 && isFinite(s)) { const left = Math.round(r.left + (cw - 1920 * s) / 2); const top = Math.round(r.top + (ch - 1080 * s) / 2); const key = left + ',' + top + ',' + s.toFixed(4); if (root.dataset.box !== key) { root.dataset.box = key; root.style.left = left + 'px'; root.style.top = top + 'px'; root.style.transform = 'scale(' + s + ')'; } } } const roomNo = quizBattleRoomNoPlay(); const tag = document.getElementById('qb-hud-tag'); if (tag && roomNo > 0) { const want = BASE + '/img/qb-ui/tag-' + (roomNo < 10 ? '0' + roomNo : String(roomNo)) + '.png'; if (tag.dataset.src !== want) { tag.dataset.src = want; tag.src = want; } } const pv = document.getElementById('qb-hud-players-val'); if (pv) { const n = countPlayHumans(); const max = (mapData && mapData.maxPlayers) || 50; const txt = n + '/' + max; if (pv.textContent !== txt) pv.textContent = txt; } const sv = document.getElementById('qb-hud-score-val'); if (sv) { const sc = quizBattleAnsweredComps ? quizBattleAnsweredComps.size : 0; if (sv.textContent !== String(sc)) sv.textContent = String(sc); } const av = document.getElementById('qb-hud-host-av'); if (av) { const cid = me.characterId || getPlayCharacterId(); if (cid && av.dataset.cid !== String(cid)) { av.dataset.cid = String(cid); /* full-body down avatar (เหมือนหน้า Quiz-Battle/.qb-room-profile-avatar) clip โชว์แค่ส่วนบน */ ensurePlayLobbyIdleAvatarImage(cid); const src = playLobbyIdleAvatarSrcForCharacter(cid); if (src) { av.src = src; } else { const im = getCharacterImg(cid, 'down'); if (im && im.src) av.src = im.src; } } } } function resetQuizCarryPlayState() { cancelQuizCarrySessionCompleteResultToSummary(); const qcmo = document.getElementById('quiz-carry-mission-overlay'); if (qcmo) qcmo.classList.add('is-hidden'); quizCarryPregameActive = false; hideQuizCarryPregameOverlay(); quizCarryPool = []; quizCarryCurrent = null; quizCarryEmbedPendingQuestion = null; quizCarryEmbedCountdownStartAt = 0; quizCarryEmbedCountdownEndAt = 0; quizCarryEmbedPreOptionCountdownStartAt = 0; quizCarryEmbedPreOptionCountdownEndAt = 0; quizCarryOptionRevealAt = 0; quizCarryAnswerCloseAt = 0; quizCarryAnswerTimeupAwaitNext = false; 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.quizMapPanelTheme && typeof disk.quizMapPanelTheme === 'object') { out.quizMapPanelTheme = disk.quizMapPanelTheme; } 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; if (disk.carryWalkSpeedMultForMapId != null) { out.carryWalkSpeedMultForMapId = String(disk.carryWalkSpeedMultForMapId).trim(); } if (disk.carryWalkSpeedMult != null) { const wm = Number(disk.carryWalkSpeedMult); if (Number.isFinite(wm)) out.carryWalkSpeedMult = wm; } 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() { cancelQuizCarrySessionCompleteResultToSummary(); quizCarryEmbedPreOptionCountdownStartAt = 0; quizCarryEmbedPreOptionCountdownEndAt = 0; 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; } if (snap) { if ((s.carryWalkSpeedMultForMapId == null || String(s.carryWalkSpeedMultForMapId).trim() === '') && snap.carryWalkSpeedMultForMapId != null) { s.carryWalkSpeedMultForMapId = snap.carryWalkSpeedMultForMapId; } if (s.carryWalkSpeedMult == null && snap.carryWalkSpeedMult != null) s.carryWalkSpeedMult = snap.carryWalkSpeedMult; } applyQuizCarryWalkSpeedFromSettingsObj(s); { 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 (quizCarryEmbedMissionFlowActive()) { beginQuizCarryEmbedPregame(); } else { quizCarryPickNextQuestion(); } const nowJoin = Date.now(); if (!quizCarryEmbedMissionFlowActive()) { quizCarryRestoreEmbedPhaseLabel(); } else if (!(quizCarryEmbedCountdownEndAt > nowJoin) && !(quizCarryEmbedPreOptionCountdownEndAt > nowJoin)) { 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 = isDetectiveMinigamePlay() ? '' : 'เข้าห้องแล้ว — คำถามจากแมป (เล่นจริงแนะนำซิงก์ผ่านโฮสต์ในอนาคต)'; 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; /* เดินแบบมีจุดหมาย (เหมือน lobby) — สุ่มจุดเป้า เดินเข้าหาแบบทแยงลื่น พักบ้าง · เลิกใช้สุ่มทิศแกนที่ดูกระตุก */ const arrived = (typeof o.botWanderTX === 'number' && typeof o.botWanderTY === 'number') && Math.hypot(o.botWanderTX - o.x, o.botWanderTY - o.y) < 0.5; const timedOut = typeof o.botWanderRetargetAt === 'number' && now >= o.botWanderRetargetAt; if (typeof o.botWanderTX !== 'number' || typeof o.botWanderTY !== 'number' || arrived || timedOut) { o.botWanderTX = 1 + Math.random() * Math.max(1, w - 2); o.botWanderTY = 1 + Math.random() * Math.max(1, h - 2); o.botWanderRetargetAt = now + 3000 + Math.floor(Math.random() * 4500); o.botWanderPauseUntil = (arrived && Math.random() < 0.45) ? now + 500 + Math.floor(Math.random() * 1600) : 0; } if (o.botWanderPauseUntil && now < o.botWanderPauseUntil) return; let dx = o.botWanderTX - o.x; let dy = o.botWanderTY - o.y; const dist = Math.hypot(dx, dy) || 1; dx /= dist; dy /= dist; if (Math.abs(dy) > Math.abs(dx)) o.direction = dy > 0 ? 'down' : 'up'; else o.direction = dx > 0 ? 'right' : 'left'; const step = MOVE_SPEED * quizCarryWalkSpeedMultActive; const nx = o.x + dx * step; const ny = o.y + dy * 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 { o.botWanderTX = undefined; o.botWanderTY = undefined; } } 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, }; } /** ปิดการตามความสูงหอจาก JSON เสมอ — เซิร์ฟเคยบังคับ enabled:true ใน policy ทำให้ boost เลื่อนโลก */ function getStackTowerCameraFollowPlayConfig() { return { enabled: false, pxPerLayer: 12, maxPx: 260 }; } /** ยกจุดยึดเชือกขึ้นเมื่อหอสูง (y ลดลง = สูงขึ้นในโลก) — เฉพาะ mnn93hpi + เปิดกล้องตามหอ */ function getStackTowerSwingLiftWorldPx(layerCount, layerWorldHPx) { if (!isStackTowerMissionUiMapPlay() || !getStackTowerCameraFollowPlayConfig().enabled) return 0; const n = Math.floor(Number(layerCount) || 0); if (n < STACK_TOWER_SWING_LIFT_FROM_LAYER) return 0; const lh = Math.max(10, Number(layerWorldHPx) || Math.max(14, (tileSize || 32) * 0.3)); const tiers = n - (STACK_TOWER_SWING_LIFT_FROM_LAYER - 1); return Math.min(lh * 20, tiers * lh * 0.98); } /** * progress ≥เกณฑ์ Tower + ชั้นพอ: ไม่วาดชั้นล่าง (ข้าม draw) — ตำแหน่ง Y ยังใช้ชั้นจริง (floorY-(i+1)*h) * เพื่อไม่ให้กองลอยหลุดจากฐานแมปเมื่อเทียบกริด */ function getStackTowerVisualHiddenLayerCountPlay() { if (!isStackTowerMissionHudActivePlay() || !stackMini) return 0; const p = Number(stackMini.progressPct) || 0; if (p < STACK_TOWER_POST50_PROGRESS_THRESH) return 0; const n = stackMini.layers ? stackMini.layers.length : 0; if (n < 4) return 0; return Math.floor(n / 2); } /** * world px ตามความสูงหอจริง (mnn93hpi) — คูณ z แล้วได้พิกเซลจอสำหรับเลื่อนแถบ BG * ใช้ n × layerWorldH (ความสูงชั้นตามบล็อก) คล้มด้วย maxPx จากแมป */ function getStackTowerBgScrollHeightBoostPx() { if (!isStackTowerMissionUiMapPlay() || !stackMini) return 0; const cfg = getStackTowerCameraFollowPlayConfig(); if (!cfg.enabled || cfg.pxPerLayer <= 0) return 0; const lh = Math.max(10, Number(stackMini.layerWorldH) || Math.max(14, (tileSize || 32) * 0.31)); const n = stackMini.layers ? stackMini.layers.length : 0; let layerCount = n; if (stackFall) { const dur = Math.max(1, stackFall.dur || 400); const u = Math.min(1, Math.max(0, (performance.now() - stackFall.t0) / dur)); layerCount = n + u * 0.9; } return Math.min(cfg.maxPx, layerCount * lh); } /** กล้อง stack: จุดกลางจาก land / release ของแมป (Tower ไม่เลื่อน cy ตามกอง) */ 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; markStackNextDropVisualDirty(); } function resetStackMinigameState() { stackMini = null; stackTowerPost50AnimStartMs = null; stackTowerHeartMinusFx = 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; stackRemoteDropQueue = []; const towerMission = isStackTowerMissionUiMapPlay(); const towerMul = towerMission ? STACK_TOWER_BLOCK_WORLD_SCALE : 1; const towerLayerStripH = Math.max(14, tileSize * 0.31) * towerMul; const missMax = Math.max(1, Math.min(20, Math.floor(Number(playStackTeamMissesMax) || 3))); stackMini = { topCenterX: towerCX, widthTiles: initW, initialWidthTiles: initW, craneWorldX: towerCX * tileSize, swingAmp, phase: Math.random() * Math.PI * 2, phaseSpeed: playStackSwingHz, floorWorldY, swingWorldY, layerWorldH: towerMission ? towerLayerStripH : Math.max(14, tileSize * 0.3), blockStripH: towerMission ? towerLayerStripH : Math.max(16, tileSize * 0.32), layers: [], lives: 3, teamMissesLeft: towerMission ? missMax : null, progressPct: 0, score: 0, combo: 0, lastDropAt: 0, stackTiltRad: 0, stackTiltVel: 0, nextDropVisualDirty: true, pendingDropSeat: 1, pendingDropHeavy: false, scorePopups: [], perfectStackX2Fx: null, }; if (playBotsEnabled()) { me.stackPreviewHumanPts = 0; stackPreviewHudLog = []; } ensureStackTowerSlingImagePlay(); if (towerMission || playBotsEnabled()) { ensureStackTowerLifeHudImagesPlay(); ensureStackTowerHeartMinusImgPlay(); } updateStackTowerSwingYForStripGapPlay(); } 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, layerVisualMeta) { if (!stackMini || !hit) return; const towerLive = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live'; if (hit.miss) { stackMini.combo = 0; stackMini.scorePopups = []; stackMini.perfectStackX2Fx = null; if (stackMini.layers && stackMini.layers.length) { for (let gi = 0; gi < stackMini.layers.length; gi++) { const lay = stackMini.layers[gi]; if (lay && lay.perfectGlowUntil != null) delete lay.perfectGlowUntil; } } if (towerLive && stackMini.teamMissesLeft != null) { stackMini.teamMissesLeft = Math.max(0, stackMini.teamMissesLeft - 1); } else { stackMini.lives = Math.max(0, stackMini.lives - 1); if (stackMini.lives <= 0) { stackMini.score = Math.max(0, stackMini.score - 2); stackMini.lives = 3; stackMini.layers = []; stackMini.scorePopups = []; stackMini.perfectStackX2Fx = null; 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; } } if (towerLive) { triggerStackTowerHeartMinusFxPlay(); const remain = stackMini.teamMissesLeft != null ? stackMini.teamMissesLeft : Math.max(0, (stackMini.lives || 0)); mg3TermOnMiss(Number(remain) || 0); } } else { const seatLay = layerVisualMeta && layerVisualMeta.seat != null ? layerVisualMeta.seat : getStackDropActorSeat(); const heavyLay = !!(layerVisualMeta && layerVisualMeta.heavy); const placedW = hit.placedW != null && Number.isFinite(hit.placedW) ? Math.max(0.85, Math.min(3.2, hit.placedW)) : stackMini.initialWidthTiles; stackMini.layers.push({ w: placedW, cx: hit.placedCx, seat: seatLay, heavy: heavyLay }); const nAfter = stackMini.layers.length; maybeQueueStackTowerBgScrollStepPlay(nAfter); const perfectFull = towerLive && hit.perfect && !hit.miss && nAfter >= 2 && hit.supportRatio != null && hit.supportRatio >= STACK_TOWER_PERFECT_SUPPORT_MIN; if (perfectFull) { const gUntil = performance.now() + STACK_TOWER_PERFECT_GLOW_MS; stackMini.layers[nAfter - 1].perfectGlowUntil = gUntil; stackMini.layers[nAfter - 2].perfectGlowUntil = gUntil; } if (towerLive && (perfectFull || hit.pts >= 20)) { ensureStackTowerPerfectFxImagesPlay(); stackMini.perfectStackX2Fx = { until: performance.now() + STACK_TOWER_PERFECT_X2_MS, topLayerIndex: nAfter - 1, }; } else if (towerLive) { stackMini.perfectStackX2Fx = null; } stackMini.score += hit.pts; if (towerLive && Number.isFinite(Number(hit.progressDelta)) && (hit.progressDelta || 0) > 0) { let np = Number(stackMini.progressPct) + Number(hit.progressDelta); np = Math.min(100, Math.max(0, np)); if (np > 99.999) np = 100; stackMini.progressPct = np; markStackNextDropVisualDirty(); } if (playBotsEnabled() && 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.topCenterX = hit.placedCx; if (towerLive && !hit.miss && hit.pts > 0) { const pNow = Number(stackMini.progressPct); const prog100 = Number.isFinite(pNow) && pNow >= 99.5; const kind20 = hit.pts >= 20 || prog100; if (!stackMini.scorePopups) stackMini.scorePopups = []; const until = performance.now() + STACK_TOWER_SCORE_POPUP_MS; stackMini.scorePopups.push({ until, layerIndex: nAfter - 1, kind: kind20 ? '20' : '10' }); if (kind20 && perfectFull && nAfter >= 2) { stackMini.scorePopups.push({ until, layerIndex: nAfter - 2, kind: '20' }); } while (stackMini.scorePopups.length > 12) stackMini.scorePopups.shift(); } if (towerLive && !hit.miss) mg3TermOnPlace(perfectFull || !!hit.perfect, stackMini.progressPct); } } /** * Editor embed เท่านั้น: ปรับ `stackMini.phase` ให้ sin·swingAmp ใกล้จุดรองรับก่อนปล่อย — บอทฉลาด/กลาง/พลาดบ่อย ต่างค่า aim error */ function previewBotAlignStackSwingPhasePlay(m, botPeer) { if (!playBotsEnabled() || !m || !mapData) return; const wMap = mapData.width || 20; const hMap = mapData.height || 15; const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap); const fallWRaw = m.widthTiles != null && Number.isFinite(m.widthTiles) ? m.widthTiles : m.initialWidthTiles; const fallW = Math.max(0.85, Math.min(3.2, fallWRaw)); 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; const swR = sup.w != null && Number.isFinite(sup.w) ? sup.w : m.initialWidthTiles; supportW = Math.max(0.85, Math.min(3.2, swR)); } const tier = botPeer && botPeer.botTier === 'sharp' ? 'sharp' : botPeer && botPeer.botTier === 'weak' ? 'weak' : 'avg'; const layerTight = 1 / Math.sqrt(1 + (m.layers.length || 0) * 1.05); const errMax = tier === 'sharp' ? 0.016 : tier === 'avg' ? 0.038 : 0.17; const tri = Math.random() + Math.random() - 1; let errTiles = tri * errMax * layerTight; if (m.layers.length >= 2) { const L = m.layers.length; const step = m.layers[L - 1].cx - m.layers[L - 2].cx; errTiles -= step * 0.28; } let targetCx = supportCx + errTiles; if (land && m.layers.length > 0) { const pull = (land.cx - supportCx) * 0.1; const cap = tier === 'sharp' ? 0.09 : tier === 'avg' ? 0.12 : 0.06; targetCx += Math.max(-cap, Math.min(cap, pull)); } const rawWant = targetCx - m.topCenterX; const amp = Math.max(1e-5, m.swingAmp); const s = Math.max(-1, Math.min(1, rawWant / amp)); const phi0 = Math.asin(s); const phi1 = Math.PI - phi0; const twoPi = Math.PI * 2; function nearestPhase(anchor, p) { let best = p; let bestD = Infinity; for (let k = -5; k <= 5; k++) { const cand = p + k * twoPi; const d = Math.abs(cand - anchor); if (d < bestD) { bestD = d; best = cand; } } return best; } const n0 = nearestPhase(m.phase, phi0); const n1 = nearestPhase(m.phase, phi1); m.phase = Math.abs(n0 - m.phase) <= Math.abs(n1 - m.phase) ? n0 : n1; } /** * ชั้นรองรับใช้ความกว้างจริงต่อชั้น (L.w) · บล็อกที่กำลังตกใช้ m.widthTiles (ปกติ vs heavy กว้างต่างกัน) * พลาดเมื่อทับแท่นไม่พอ หรือจุดกลางหลุดขอบฐาน (STACK_MAX_*) */ 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 fallWRaw = m.widthTiles != null && Number.isFinite(m.widthTiles) ? m.widthTiles : m.initialWidthTiles; const fallW = Math.max(0.85, Math.min(3.2, fallWRaw)); const wMul = getStackTowerBlockWorldScalePlay(); const fallWEff = fallW * wMul; 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; const swR = sup.w != null && Number.isFinite(sup.w) ? sup.w : m.initialWidthTiles; supportW = Math.max(0.85, Math.min(3.2, swR)); } const c1 = m.topCenterX + raw; const half1 = fallWEff * 0.5; const half0 = (m.layers.length === 0 ? supportW : supportW * wMul) * 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(STACK_OVERLAP_MISS_MIN_ON_LAND, fallWEff * STACK_OVERLAP_MISS_FRAC_ON_LAND) : Math.max(STACK_OVERLAP_MISS_MIN_ON_LAYER, fallWEff * STACK_OVERLAP_MISS_FRAC_ON_LAYER); const overlapMiss = overlap < missThreshold; const placedCx = c1; let driftMiss = false; if (!overlapMiss && m.layers.length > 0) { const anchorCx = land ? land.cx : wMap * 0.5; let maxDriftTiles; if (land) { const landSpan = Math.max(1, land.maxX - land.minX + 1); maxDriftTiles = landSpan * STACK_MAX_CENTER_DRIFT_FRAC_OF_LAND; } else { maxDriftTiles = Math.max(0.85, fallW * STACK_MAX_CENTER_DRIFT_NO_LAND_MULT); } if (Math.abs(placedCx - anchorCx) > maxDriftTiles) driftMiss = true; } const miss = overlapMiss || driftMiss; 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.055; const comboBefore = m.combo || 0; let pts; let progressDelta = 0; if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live') { const mult = !miss && perfect && comboBefore >= 1 ? 2 : 1; const progPerLayer = 100 / stackTowerProgressBlocksPlay(); pts = miss ? 0 : (10 * mult); progressDelta = miss ? 0 : (progPerLayer * mult); } else { const bonus = perfect ? Math.min(4, comboBefore) : 0; pts = perfect ? (2 + bonus) : (miss ? 0 : 1); } const supportRatio = fallWEff > 0 ? overlap / fallWEff : 0; return { miss, perfect, pts, progressDelta, 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; ensureStackNextDropVisual(); const m = stackMini; const actorSeat = m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat(); const actorHeavy = !!m.pendingDropHeavy; const tw = m.widthTiles * tileSize * getStackTowerBlockWorldScalePlay(); const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize; const lh = m.layerWorldH || Math.max(14, tileSize * 0.3); const swingLift = getStackTowerSwingLiftWorldPx(m.layers.length, lh); const swingAttachY = m.swingWorldY - swingLift; let y1 = m.floorWorldY - (m.layers.length + 1) * lh; if (y1 <= swingAttachY) y1 = swingAttachY + 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 - swingAttachY) * 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; const cableTopYRel = isStackTowerMissionUiMapPlay() ? (swingAttachY - tileSize * 6) : Math.max(0, swingAttachY - tileSize * 6); const craneXRel = m.craneWorldX != null ? m.craneWorldX : m.topCenterX * tileSize; const releaseRopeAng = Math.atan2(swingAttachY - cableTopYRel, swingXW - craneXRel); stackFall = { t0: performance.now(), dur, xLeft0, xLeft1, tw, y0: swingAttachY, y1, hit, tiltMax, sloppyN: offN, previewHumanDrop: isHumanPreview, previewBotId: botIdPreview, releaseRopeAng, releaseAttachWorldX: swingXW, releaseAttachWorldY: swingAttachY, actorSeat, actorHeavy, }; return true; } function stackHumanDropGateOkPlay() { if (!stackMini || !isStack() || stackFall || stackMini.settling) return false; if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return false; if (playBotsEnabled() && mapData && mapData.gameType === 'stack' && !stackTurnOrderReadyPlay()) { rebuildStackPreviewTurnOrder(); } if (playBotsEnabled() && !isMyStackTurn()) return false; /* กดได้เฉพาะตาตัวเอง (shared tower) */ if (Date.now() - stackMini.lastDropAt < 480) return false; return true; } /** @returns {boolean} เริ่มแอนิเมชันร่วงแล้วหรือไม่ */ function tryHumanStackDrop() { if (!stackHumanDropGateOkPlay()) return false; ensureStackNextDropVisual(); const hit = computeStackDropResult(stackMini); if (!startStackFallAnimation(hit, playBotsEnabled() ? { human: true } : null)) return false; /* broadcast ผลการวางของเรา → ทุกเครื่องวางบล็อกเดียวกัน + เลื่อนตาพร้อมกัน */ if (playBotsEnabled()) broadcastStackDropPlay(hit, getStackDropActorSeat(), !!stackMini.pendingDropHeavy, myId, false); if (playBotsEnabled()) { lastStackPreviewActorId = '__human__'; lastStackPreviewActorUntil = Date.now() + 900; } /* MG3 stack: เดินไปแตะไอคอนไม่ได้ → "หยอดบล็อก" (บล็อกร่วงผ่านไอคอน) = ทริกคำถามพิเศษเลย */ if (specialQuizIcon) sendSpecialQuizCollide(); return true; } /** บอท preview: ปล่อยแบบมีแอนิเมชันร่วงเหมือนผู้เล่น — apply หลังแอนิเมชันจบ */ function simulatePreviewBotStackDrop(botId, o) { if (!stackMini || stackFall || stackMini.settling) return; ensureStackNextDropVisual(); previewBotAlignStackSwingPhasePlay(stackMini, o); const hit = computeStackDropResult(stackMini); if (!startStackFallAnimation(hit, playBotsEnabled() ? { botId } : null)) return; /* host เป็นเจ้าของบอท → broadcast ผลการวางของบอทให้ทุกเครื่องวางตรงกัน */ broadcastStackDropPlay(hit, getStackDropActorSeat(), !!stackMini.pendingDropHeavy, botId, true); 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)); /* บอทหยอดบล็อกก็ทริกคำถามพิเศษ (กันไม่มีคนหยอดไปโดน) */ if (specialQuizIcon) sendSpecialQuizCollide(); } /** Stack preview: สลับตา — เฉพาะบอทที่ถึงตาถึงจะจำลองปล่อยหนึ่งครั้งแล้วเลื่อนตา */ function stepPreviewStackTurnBased(nowMs) { if (!playBotsEnabled() || !isStack() || !stackMini || stackFall || stackMini.settling) return; /* เล่นจริง: host เท่านั้นจำลองบอท (แล้ว broadcast) — non-host รอรับ stack-drop; editor preview (previewFillBots) จำลองเองได้ */ if (detectiveCaseFillBots && !isMePlayHost()) return; if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return; if (mapData && mapData.gameType === 'stack' && !stackTurnOrderReadyPlay()) { rebuildStackPreviewTurnOrder(); } const order = stackPreviewTurnOrder; if (!order || !order.length) return; const cur = order[stackPreviewTurnIndex % order.length]; if (!cur || cur.kind !== 'bot') return; if (!cur.botId) { advanceStackPreviewTurn(); return; } const o = others.get(cur.botId); if (!o) { advanceStackPreviewTurn(); return; } if (stackPreviewBotThinkUntil === 0) { const tier = o.botTier === 'sharp' ? 'sharp' : o.botTier === 'weak' ? 'weak' : 'avg'; const thinkLo = tier === 'sharp' ? 160 : tier === 'avg' ? 260 : 340; const thinkHi = tier === 'sharp' ? 340 : tier === 'avg' ? 520 : 620; stackPreviewBotThinkUntil = nowMs + thinkLo + Math.random() * (thinkHi - thinkLo); return; } if (nowMs < stackPreviewBotThinkUntil) return; stackPreviewBotThinkUntil = 0; simulatePreviewBotStackDrop(cur.botId, o); } /** apply ดรอปถัดไปในคิว (จาก host/คนอื่น) — เริ่มแอนิเมชันร่วงเหมือนเดิม → settle apply ชั้น + เลื่อนตา */ function applyNextRemoteStackDropPlay() { if (!stackMini || stackFall || stackMini.settling) return false; const item = stackRemoteDropQueue.shift(); if (!item || !item.hit) return false; ensureStackNextDropVisual(); stackMini.pendingDropSeat = item.seat || 1; stackMini.pendingDropHeavy = !!item.heavy; stackMini.widthTiles = stackBlockWidthTilesForPlay(stackMini.initialWidthTiles, !!item.heavy); return startStackFallAnimation(item.hit, item.actor || { human: true }); } function stackTickFrame() { stackTowerMissionMaybeEndPlay(); const now = performance.now(); const dt = Math.min(0.06, (now - lastStackTickMs) / 1000); lastStackTickMs = now; /* เล่นจริง: host ส่งลำดับตา+index เป็นระยะ → non-host adopt (ครอบ late-join: client โหลดช้า host จะรวมเข้า order ให้) */ if (detectiveCaseFillBots && isMePlayHost()) { if (!stackTurnOrderReadyPlay()) rebuildStackPreviewTurnOrder(); const ts = Date.now(); if (ts - stackLastStateEmit > 300) { stackLastStateEmit = ts; broadcastStackStatePlay(); } } 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(); markStackNextDropVisualDirty(); 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 = STACK_STABLE_SUPPORT_RATIO_MIN; if (hit.miss) { spawnStackMissDebrisPlay(stackFall); applyStackHitFromDrop(hit); if (playBotsEnabled() && (wasHuman || fallBotId)) { stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId }); } stackMini.lastDropAt = Date.now(); stackFall = null; markStackNextDropVisualDirty(); 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, actorSeat: stackFall.actorSeat, actorHeavy: stackFall.actorHeavy, }; stackFall = null; } else { let previewAttr; if (playBotsEnabled()) { if (wasHuman) previewAttr = { human: true }; else if (fallBotId) previewAttr = { botId: fallBotId }; } applyStackHitFromDrop(hit, previewAttr, { seat: stackFall.actorSeat, heavy: stackFall.actorHeavy }); if (playBotsEnabled() && (wasHuman || fallBotId)) { stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId }); } stackMini.lastDropAt = Date.now(); stackFall = null; markStackNextDropVisualDirty(); if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn(); } } } else if (stackMini && stackRemoteDropQueue.length && applyNextRemoteStackDropPlay()) { /* ว่าง (ไม่มีบล็อกร่วง/settle) + มีดรอปในคิวจาก host/คนอื่น → apply ทีละอัน (ไม่แกว่งเฟรมนี้) */ } else if (stackMini) { /* เล่นจริง: แกว่งเฉพาะตาคนที่คุม (เรา หรือ host คุมบอท) แล้ว broadcast เฟสให้คนอื่น mirror; editor preview / เล่นเดี่ยว: แกว่งเองตลอดเหมือนเดิม */ let advancePhase = true; if (detectiveCaseFillBots) { advancePhase = isMyStackTurn() || (isMePlayHost() && isStackCurrentBotTurnPlay()); } if (advancePhase) { stackMini.phase += stackMini.phaseSpeed * dt * Math.PI * 2; if (detectiveCaseFillBots && socket && myId != null) { const tnow = Date.now(); if (tnow - stackLastSwingEmit > 50) { stackLastSwingEmit = tnow; try { socket.emit('stack-swing', { phase: stackMini.phase }); } catch (e) { /* ignore */ } } } } } stepPreviewStackTurnBased(Date.now()); } /** * จุดบนจอที่เส้นก้าน→บล็อกตัดแนว y = -(ความสูงแคนวาส × STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC) * — ปลายเชือกอยู่เหนือขอบบน ~25% ของความสูงจอ (มองเห็นช่วงที่เข้ามาในจอ) */ function stackTowerRopeScreenTopAnchorPlay(slingBx, slingBy, tcx, tcy, canvasH) { const ch = Math.max(80, Number(canvasH) || 1080); const marginTop = -ch * STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC; const ddx = tcx - slingBx; const ddy = tcy - slingBy; if (Math.abs(ddy) < 1e-4) { return { x: tcx, y: Math.min(marginTop, Math.min(tcy, slingBy)) }; } const vTop = (marginTop - slingBy) / ddy; if (!Number.isFinite(vTop) || vTop < 0) { return { x: tcx, y: tcy }; } const xTop = slingBx + vTop * ddx; return { x: xTop, y: marginTop }; } 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 towerHudLive = isStackTowerMissionHudActivePlay(); const wMul = getStackTowerBlockWorldScalePlay(); ensureStackNextDropVisual(); 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 swingLiftDraw = getStackTowerSwingLiftWorldPx(m.layers.length, layerWorldH); const swingDrawY = m.swingWorldY - swingLiftDraw; const hidLayers = towerHudLive ? getStackTowerVisualHiddenLayerCountPlay() : 0; const nLay = m.layers.length; for (let i = hidLayers; i < nLay; i++) { const L = m.layers[i]; const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize * wMul; 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; const seatL = L.seat != null ? L.seat : 1; const heavyL = !!L.heavy; if (towerHudLive && L.perfectGlowUntil != null) { const ntGlow = performance.now(); if (ntGlow < L.perfectGlowUntil) { drawStackTowerPerfectYellowGlowBehindBlockPlay(ctx, sx, sy, drawW, drawH, ntGlow, L.perfectGlowUntil); } } drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seatL, heavyL, hue, {}); } if (towerHudLive && m.perfectStackX2Fx) { const fxX2 = m.perfectStackX2Fx; const ntX = performance.now(); if (ntX >= fxX2.until) { m.perfectStackX2Fx = null; } else { const ti = fxX2.topLayerIndex; if (ti >= hidLayers && ti >= 0 && ti < m.layers.length) { const Ltop = m.layers[ti]; const lwT = (Ltop.w != null ? Ltop.w : m.initialWidthTiles) * tileSize * wMul; const cxWT = Ltop.cx * tileSize; const ybT = floorY - (ti + 1) * layerWorldH; const xlT = cxWT - lwT / 2; const [sxT, syT] = worldToScreen(xlT, ybT); const drawWT = lwT * zoom; const drawHT = layerWorldH * zoom; const fxImX = ensureStackTowerPerfectFxImagesPlay(); if (fxImX.x2) { drawStackTowerPerfectX2ClusterPlay(ctx, fxImX.light, fxImX.x2, sxT, syT, drawWT, drawHT, ntX, fxX2.until); } } } } if (towerHudLive) { drawStackTowerScorePopupsPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY); } 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; const seatS = s.actorSeat != null ? s.actorSeat : 1; const heavyS = !!s.actorHeavy; ctx.save(); ctx.translate(cxs, cys); ctx.rotate(rotRad); drawStackBlockSpriteOrHue(ctx, -drawW / 2, -drawH / 2, drawW, drawH, seatS, heavyS, hue, {}); ctx.restore(); } else { const twWorld = m.widthTiles * tileSize * wMul; const drawStripPx = stripH * zoom; const cableTopY = isStackTowerMissionUiMapPlay() ? (swingDrawY - tileSize * 6) : Math.max(0, swingDrawY - tileSize * 6); const craneX = m.craneWorldX != null ? m.craneWorldX : m.topCenterX * tileSize; 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 = swingDrawY; } const cxMid = blockLeftWorld + twWorld / 2; const [tcx, tcy] = worldToScreen(craneX, cableTopY); const [bcx, bcy] = worldToScreen(cxMid, blockTopWorld); let slingBx = bcx; let slingBy = bcy; if (stackFall && stackFall.releaseAttachWorldX != null) { const attY = stackFall.releaseAttachWorldY; const p = worldToScreen(stackFall.releaseAttachWorldX, attY); slingBx = p[0]; slingBy = p[1]; } let ropeSx0 = tcx; let ropeSy0 = tcy; let ropeSx1 = slingBx; let ropeSy1 = slingBy; if (isStackTowerMissionUiMapPlay()) { const chCv = ctx.canvas && ctx.canvas.height ? ctx.canvas.height : STACK_TOWER_FIXED_RENDER_H; const topA = stackTowerRopeScreenTopAnchorPlay(slingBx, slingBy, tcx, tcy, chCv); ropeSx0 = topA.x; ropeSy0 = topA.y; ropeSx1 = slingBx; ropeSy1 = slingBy; const t1 = Math.max(0, Math.min(1, STACK_TOWER_ROPE_DRAW_T1)); if (t1 < 1) { ropeSx1 = ropeSx0 + (slingBx - ropeSx0) * t1; ropeSy1 = ropeSy0 + (slingBy - ropeSy0) * t1; } } drawStackSlingSegmentPlay(ctx, ropeSx0, ropeSy0, ropeSx1, ropeSy1, zoom); const ropeAng = stackFall && stackFall.releaseRopeAng != null ? stackFall.releaseRopeAng : Math.atan2(bcy - ropeSy0, bcx - ropeSx0); const swingTiltRad = ropeAng - Math.PI / 2; const hitMissTint = stackFall && stackFall.hit && stackFall.hit.miss; const seatDraw = stackFall && stackFall.actorSeat != null ? stackFall.actorSeat : (m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat()); const heavyDraw = stackFall ? !!stackFall.actorHeavy : !!m.pendingDropHeavy; const hueHint = (m.layers.length * 47) % 360; ctx.save(); ctx.translate(bcx, bcy); ctx.rotate(fallTiltRad + swingTiltRad); drawStackBlockSpriteOrHue(ctx, -twWorld * zoom / 2, 0, twWorld * zoom, drawStripPx, seatDraw, heavyDraw, hueHint, { missTint: !!hitMissTint, missOverlay: !!hitMissTint }); ctx.lineWidth = 1; ctx.restore(); } if (towerHudLive) { drawStackTowerHeartMinusFxPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY, nLay); } drawStackMissDebrisPlay(ctx, worldToScreen, zoom, floorY); } 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 && usesCrownLobbyShellPlay()) { gauntletCrownPregamePhase = 'live'; trySnapGauntletCrownRunwayScrollToSpawnsPlay(); } } 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, 'stackTowerMissionTimeSec')) { const st = Number(payload.stackTowerMissionTimeSec); if (Number.isFinite(st) && st > 0) { playStackTowerMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st))); } else { playStackTowerMissionTimeSec = 90; } } if (Object.prototype.hasOwnProperty.call(payload, 'stackTeamMissesMax')) { const sm = Number(payload.stackTeamMissesMax); if (Number.isFinite(sm)) { playStackTeamMissesMax = Math.max(1, Math.min(20, Math.floor(sm))); } else { playStackTeamMissesMax = 3; } } if (Object.prototype.hasOwnProperty.call(payload, 'stackTowerProgressBlocks')) { const pb = Number(payload.stackTowerProgressBlocks); if (Number.isFinite(pb) && pb >= 1) { playStackTowerProgressBlocks = Math.max(1, Math.min(500, Math.floor(pb))); } else { playStackTowerProgressBlocks = 50; } } if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockNormalImageUrls')) { playStackBlockNormalUrls = normalizePlayStackSixUrlsFromTiming(payload.stackBlockNormalImageUrls); } if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockHeavyImageUrls')) { playStackBlockHeavyUrls = normalizePlayStackSixUrlsFromTiming(payload.stackBlockHeavyImageUrls); } if (Object.prototype.hasOwnProperty.call(payload, 'stackHeavyBlockPercent')) { const hp = Number(payload.stackHeavyBlockPercent); if (Number.isFinite(hp)) { playStackHeavyBlockPercent = Math.max(0, Math.min(100, Math.floor(hp))); } else { playStackHeavyBlockPercent = 35; } } 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, 'jumpSurvivePlatformTiles') || Object.prototype.hasOwnProperty.call(payload, 'jumpSurvivePlatformTileUrl')) { const legacy = Object.prototype.hasOwnProperty.call(payload, 'jumpSurvivePlatformTileUrl') ? normalizeGauntletAssetUrlForPlay(typeof payload.jumpSurvivePlatformTileUrl === 'string' ? payload.jumpSurvivePlatformTileUrl : '') : ''; const arr = Array.isArray(payload.jumpSurvivePlatformTiles) ? payload.jumpSurvivePlatformTiles : []; const next = []; for (let pi = 0; pi < 3; pi++) { const o = arr[pi] && typeof arr[pi] === 'object' ? arr[pi] : {}; let url = normalizeGauntletAssetUrlForPlay(typeof o.url === 'string' ? o.url : ''); if (pi === 0 && !url && legacy) url = legacy; let ww = Number(o.w); let hh = Number(o.h); if (!Number.isFinite(ww) || ww <= 0) ww = 0; else ww = Math.max(8, Math.min(4096, Math.round(ww))); if (!Number.isFinite(hh) || hh <= 0) hh = 0; else hh = Math.max(8, Math.min(4096, Math.round(hh))); next.push({ url, w: ww, h: hh }); if (url) ensureGauntletAssetImage(url); } playJumpSurvivePlatformTiles = next; } 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 (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterShipImageUrls')) { const a = payload.spaceShooterShipImageUrls; const next = ['', '', '', '', '', '']; if (Array.isArray(a)) { for (let si = 0; si < 6; si++) { const u = normalizeGauntletAssetUrlForPlay(typeof a[si] === 'string' ? a[si] : ''); next[si] = u; if (u) ensureGauntletAssetImage(u); } } playSpaceShooterShipImageUrls = next; } if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidSpriteUrls')) { const raw = payload.spaceShooterAsteroidSpriteUrls; const next = []; if (Array.isArray(raw)) { for (let ai = 0; ai < raw.length && next.length < 32; ai++) { const u = normalizeGauntletAssetUrlForPlay(typeof raw[ai] === 'string' ? raw[ai] : ''); if (u) next.push(u); } } playSpaceShooterAsteroidSpriteUrls = next; next.forEach((u) => { if (u) ensureGauntletAssetImage(u); }); } if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidExplodeFrameMs')) { const ef = Number(payload.spaceShooterAsteroidExplodeFrameMs); if (Number.isFinite(ef)) { playSpaceShooterAsteroidExplodeFrameMs = Math.max(30, Math.min(500, Math.round(ef))); } } if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidIntervalMs')) { const iv = Number(payload.spaceShooterAsteroidIntervalMs); if (Number.isFinite(iv) && iv >= 200) { playSpaceShooterAsteroidIntervalMs = Math.min(10000, Math.floor(iv)); } else { playSpaceShooterAsteroidIntervalMs = 1040; } } if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterShipDamageOverlayUrls')) { const rawD = payload.spaceShooterShipDamageOverlayUrls; const nextD = ['', '', '']; if (Array.isArray(rawD)) { for (let di = 0; di < 3; di++) { const u = normalizeGauntletAssetUrlForPlay(typeof rawD[di] === 'string' ? rawD[di] : ''); nextD[di] = u; if (u) ensureGauntletAssetImage(u); } } playSpaceShooterShipDamageOverlayUrls = nextD; } if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossMissionTimeSec')) { const st = Number(payload.balloonBossMissionTimeSec); if (Number.isFinite(st) && st > 0) { playBalloonBossMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st))); } else { playBalloonBossMissionTimeSec = 0; } } if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossBossImageUrl')) { playBalloonBossBossImageUrl = normalizeGauntletAssetUrlForPlay( typeof payload.balloonBossBossImageUrl === 'string' ? payload.balloonBossBossImageUrl : '' ); if (playBalloonBossBossImageUrl) ensureGauntletAssetImage(playBalloonBossBossImageUrl); } if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossPlayerBalloonImageUrls')) { const a = payload.balloonBossPlayerBalloonImageUrls; const nextBb = ['', '', '', '', '', '']; if (Array.isArray(a)) { for (let si = 0; si < 6; si++) { const u = normalizeGauntletAssetUrlForPlay(typeof a[si] === 'string' ? a[si] : ''); nextBb[si] = u; if (u) ensureGauntletAssetImage(u); } } playBalloonBossPlayerBalloonImageUrls = nextBb; } if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossPlayerBalloonFallbackUrl')) { playBalloonBossPlayerBalloonFallbackUrl = normalizeGauntletAssetUrlForPlay( typeof payload.balloonBossPlayerBalloonFallbackUrl === 'string' ? payload.balloonBossPlayerBalloonFallbackUrl : '' ); if (playBalloonBossPlayerBalloonFallbackUrl) ensureGauntletAssetImage(playBalloonBossPlayerBalloonFallbackUrl); } 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; /* อาร์คกระโดดนุ่มแบบ design (rn-jump): sine โค้งสมมาตร ease-in-out peak กลางอากาศ เดิม: ขาขึ้น quadratic / ขาลง linear (อสมมาตร peak ~72% เวลา → ขาลงกระตุก) */ const p = Math.max(0, Math.min(1, 1 - t)); /* 0 = เริ่มกระโดด, 1 = แตะพื้น */ return Math.sin(Math.PI * p); } /* ===== Minigame-2: ชื่อ+คะแนนใต้ตัวละคร + เลข -10 เป็น DOM overlay (sync world→screen) แทนวาดบน canvas ===== เรียกทุกเฟรมจาก draw() (ส่ง worldToScreen/zDraw/tileSize เข้ามา) — container map buffer→display ด้วย transform:scale */ const gauntletDomLabelPool = new Map(); /* id -> .gnt-label (pool ไว้ reuse) */ const gauntletPenaltySeenById = new Map(); /* id -> ts penalty ล่าสุด (กัน spawn float ซ้ำ) */ function syncGauntletNameDom(worldToScreenFn, zDrawVal, tileSizePx) { const ov = document.getElementById('gauntlet-name-overlay'); if (!ov) return; const live = isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live'; if (!live || typeof worldToScreenFn !== 'function') { if (!ov.classList.contains('is-hidden')) { ov.classList.add('is-hidden'); gauntletDomLabelPool.forEach((el) => { if (el.parentNode) el.parentNode.removeChild(el); }); gauntletDomLabelPool.clear(); } return; } ov.classList.remove('is-hidden'); const canvas = document.getElementById('game-canvas'); const stage = document.getElementById('play-canvas-stage'); if (!canvas || !stage) return; const cr = canvas.getBoundingClientRect(); const srect = stage.getBoundingClientRect(); const dispScale = canvas.width > 0 ? cr.width / canvas.width : 1; ov.style.left = Math.round(cr.left - srect.left) + 'px'; ov.style.top = Math.round(cr.top - srect.top) + 'px'; ov.style.width = canvas.width + 'px'; ov.style.height = canvas.height + 'px'; ov.style.transform = 'scale(' + dispScale + ')'; const fp = (typeof getCharacterFootprintWH === 'function') ? getCharacterFootprintWH(mapData) : { cw: 1, ch: 1 }; const cwv = fp.cw || 1, chv = fp.ch || 1; function v(n) { const x = Number(n); return Number.isFinite(x) ? x : 0; } const seen = new Set(); function place(id, ent, isMe) { if (!ent) return; if (typeof shouldGauntletCrownHeistSkipAvatarDrawPlay === 'function' && shouldGauntletCrownHeistSkipAvatarDrawPlay(ent, isMe, 0, 0)) return; seen.add(id); const air = isMe ? (meGauntletJumpVis || 0) : ((ent.gauntletJumpVis != null ? ent.gauntletJumpVis : ent.gauntletJumpTicks) || 0); const liftWorldY = air > 0 ? gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSizePx * 0.52 : 0; const cxWorld = (v(ent.x) + cwv * 0.5) * tileSizePx; const cyBottomWorld = (v(ent.y) + chv) * tileSizePx - liftWorldY; const scr = worldToScreenFn(cxWorld, cyBottomWorld); const sx = scr[0], sy = scr[1]; /* ชื่อ/คะแนนใต้ตัวละคร: ตัดออกตาม mock — mock โชว์ชื่อ+คะแนนเฉพาะ "แถบคะแนนบน" เท่านั้น ไม่มีใต้ตัวละคร (กันดูเหมือน 2 layer ทับกัน) — คงไว้เฉพาะ -10 penalty float ที่ mock มี */ const pen = Number(ent.gauntletCrownPenaltyFxUntil) || 0; /* -10 float ต้องอยู่ "ใต้แถบคะแนนบน" เท่านั้น — กันผู้เล่นที่จุดเริ่ม (project ใกล้แถบคะแนน เช่น AFK) ทำ float โผล่ทับ SCORE label */ var sbEl = document.querySelector('.play-cyber-scoreboard'); var sbBottomBuf = sbEl ? ((sbEl.getBoundingClientRect().bottom - cr.top) / (dispScale || 1) + 6) : 0; if (pen > (gauntletPenaltySeenById.get(id) || 0) && (sy - 28) > sbBottomBuf) { gauntletPenaltySeenById.set(id, pen); /* -10 float = design asset score-.png (รูป "-10" สำเร็จรูป) ไม่ใช่ text สไตล์เอง */ const pf = document.createElement('img'); pf.className = 'gnt-pen'; pf.src = GAUNTLET_CROWN_SCORE_POP_URL; pf.alt = '-10'; pf.decoding = 'async'; pf.style.left = Math.round(sx) + 'px'; pf.style.top = Math.round(sy - 28) + 'px'; ov.appendChild(pf); setTimeout(function () { if (pf.parentNode) pf.parentNode.removeChild(pf); }, 900); } else if (pen > (gauntletPenaltySeenById.get(id) || 0)) { /* อยู่บริเวณแถบคะแนน — บันทึกว่าเห็นแล้ว ไม่ต้องสร้าง float (กัน spawn ซ้ำตอนเลื่อนลงมา) */ gauntletPenaltySeenById.set(id, pen); } } if (others && typeof others.forEach === 'function') others.forEach((o, id) => place(id, o, false)); place('__me', me, true); } 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); o.gauntletCrownPenaltyFxUntil = Date.now() + 1100; px = Math.max(0, px - 1); } } else { px = Math.max(0, px - 1); } } if (crown && mapData) { const mx = getGauntletCrownHeistMaxEntityXPlay(mapData); if (mx != null && Number.isFinite(mx)) { const maxPx = Math.floor(mx + 1e-6); px = Math.min(px, maxPx); } } if ((o.gauntletJumpTicks || 0) > 0) o.gauntletJumpTicks--; o.tx = px; o.ty = py; } function applyGauntletPreviewBotsAfterSync() { if (!playBotsEnabled() || !isMePlayHost() || !mapData || mapData.gameType !== 'gauntlet') return; if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return; if (isGauntletCrownHeistMapPlay() && gauntletCrownRunwayStripFrozenPlay()) 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 (!playBotsEnabled() || !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() { hideStackTowerResultFlashDomOnlyPlay(); 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'); hideStackTowerHowtoRulesDom(); const gchHowto = document.getElementById('gauntlet-crown-howto-overlay'); if (gchHowto) gchHowto.classList.remove(GCHOWTO_STACK_TOWER_CLASS); } 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'; } /** สรุปภารกิจแบบ mockup — ต่อมินิเกม mission map (Minigame 1/4–7 + Last Light mno9kb07) */ function isGcmMissionMockSummaryPlay(mission) { if (!mission || !mission.uiSkin) { return isGauntletCrownHeistMapPlay(); } if (mission.uiSkin === 'gauntlet_crown') return isGauntletCrownHeistMapPlay(); if (mission.uiSkin === 'question_mission') return isQuizQuestionMissionUiMapPlay(); if (mission.uiSkin === 'jumper') return isJumpSurviveMissionUiMapPlay(); if (mission.uiSkin === 'violent_crime') return isSpaceShooterMissionUiMapPlay(); if (mission.uiSkin === 'mega_virus') return isMegaVirusMissionShellMapPlay(); if (mission.uiSkin === 'stack_tower') return isStackTowerMissionUiMapPlay(); if (mission.uiSkin === 'quiz_carry') return quizCarryEmbedMissionFlowActive() || isQuizCarry(); return false; } function gcmMissionMockAssetUrl(mission, file) { if (!mission) { return isGauntletCrownHeistMapPlay() ? (BASE + '/img/gauntlet-assets/' + String(file || '').replace(/^\//, '')) : questionMissionAssetUrl(file); } const skin = mission.uiSkin; if (skin === 'gauntlet_crown') return BASE + '/img/gauntlet-assets/' + String(file || '').replace(/^\//, ''); if (skin === 'jumper') return jumperAssetUrl(file); if (skin === 'violent_crime') return violentCrimeAssetUrl(file); if (skin === 'mega_virus') return megaVirusAssetUrl(file); if (skin === 'stack_tower') return stackTowerAssetUrl(file); if (skin === 'quiz_carry') return BASE + '/img/quiz-carry/' + String(file || '').replace(/^\//, ''); return questionMissionAssetUrl(file); } function resolveGauntletCrownMissionUiSkinPlay(mission) { if (mission && mission.uiSkin) return mission.uiSkin; if (isGauntletCrownHeistMapPlay()) return 'gauntlet_crown'; return mission && mission.uiSkin; } function setGauntletCrownMg2CountdownGraphic(numEl, textEl, n) { const d = Math.max(1, Math.min(3, n)); if (textEl) { textEl.textContent = String(d); textEl.setAttribute('aria-label', String(d)); } if (numEl) { numEl.removeAttribute('src'); numEl.alt = ''; } } function syncGauntletCrownCountdownMg2ModePlay(cd) { if (!cd) return; const mg2 = isGauntletCrownHeistMapPlay(); cd.classList.toggle('gcc-mg2-mock', mg2); const numEl = document.getElementById('gauntlet-crown-countdown-num'); const textEl = document.getElementById('gauntlet-crown-countdown-text'); if (numEl) { numEl.classList.toggle('gcc-num--img', !mg2); numEl.classList.toggle('is-hidden', !!mg2); numEl.setAttribute('aria-hidden', mg2 ? 'true' : 'false'); } if (textEl) { textEl.classList.toggle('is-hidden', !mg2); textEl.setAttribute('aria-hidden', mg2 ? 'false' : 'true'); } } function gauntletCrownPregameReadyNumerator() { let n = quizCarryPregameBotCount(); quizCarryPregameHumanIds().forEach((id) => { if (gauntletCrownLobbyReadyMap[id]) n++; }); return n; } /* เดิม: guest auto-ready ทันทีที่เข้า howto → host เห็นพร้อมเลย กด start ได้ก่อน client ตั้งตัว ตอนนี้: ทุกคน (client + host) ต้องกด Ready เองด้วยมือ ก่อน host จึงกด Start ได้ → no-op */ function gauntletCrownSyncGuestReadyIfNeeded() {} /** จำนวนผู้เล่น (มนุษย์) ที่ต้องเข้าครบก่อนเริ่ม — ใช้ค่าจาก server (expectedActive) ถ้ามี กัน host เริ่มก่อน client โหลด/กด Ready ครบ → เกม sync + จบพร้อมกัน */ function gauntletCrownPregameExpectedHumans() { const here = quizCarryPregameHumanIds().length; const exp = (missionExpectedActiveCount > 0) ? missionExpectedActiveCount : here; return Math.max(exp, here); } /** true ก็ต่อเมื่อ ผู้เล่นมนุษย์มาครบตามที่คาด + ทุกคนกด Ready แล้ว (host ถึงจะ Start ได้) */ function gauntletCrownAllReadyPlay() { const humans = quizCarryPregameHumanIds(); if (humans.length === 0) return false; if (humans.length < gauntletCrownPregameExpectedHumans()) return false; return humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); } /** อัปเดต Ready Status + ปุ่ม READY/START ของ minigame ตระกูล crown-lobby (MG1,2,3,5,6,7) - ทุกคนกด READY เองได้ - แสดง n/expected (รอ client ที่ยังไม่เข้า) - START โผล่เฉพาะตอนมาครบ+พร้อมครบ และกดได้เฉพาะ host */ function applyGauntletCrownReadyButtonPlay(st, btn) { if (!btn) return false; const tot = Math.max(1, quizCarryPregameTotalPlayers(), gauntletCrownPregameExpectedHumans() + quizCarryPregameBotCount()); const num = gauntletCrownPregameReadyNumerator(); if (st) { st.classList.remove('is-hidden'); st.textContent = 'Ready Status : ' + num + '/' + tot; } const allReady = gauntletCrownAllReadyPlay(); const meReady = !!(myId && gauntletCrownLobbyReadyMap[String(myId)]); const locked = allReady && !isMePlayHost(); btn.classList.toggle('is-start-phase', allReady); if (gauntletCrownHowtoUsesMg2LayoutPlay()) { const btnImg = document.getElementById('btn-gch-ready-mg2-img'); if (btnImg) { btnImg.src = allReady ? (missionMockHudAssetUrl('btn-start.png') || (BASE + '/img/gauntlet-assets/btn-start.png')) : (missionMockHudAssetUrl('btn-ready.png') || (BASE + '/img/gauntlet-assets/btn-ready.png')); btnImg.alt = allReady ? 'START' : 'READY'; } } btn.classList.toggle('is-read-only', locked); btn.disabled = locked; btn.setAttribute('aria-pressed', (!allReady && meReady) ? 'true' : 'false'); btn.title = allReady ? (isMePlayHost() ? 'START' : 'รอโฮสต์เริ่ม') : (meReady ? 'ยกเลิก READY' : 'READY'); return allReady; } /** Minigame-2 mno9kb07 — HOW TO PLAY ใช้ layout mock 01-howto (canvas = mg-bg) */ function gauntletCrownHowtoUsesMg2LayoutPlay() { return !!isGauntletCrownHeistMapPlay(); } function getGauntletCrownHowtoStatusEl() { if (gauntletCrownHowtoUsesMg2LayoutPlay()) { return document.getElementById('gauntlet-crown-howto-status-mg2'); } return document.getElementById('gauntlet-crown-howto-status'); } function getGauntletCrownHowtoBtnEl() { if (gauntletCrownHowtoUsesMg2LayoutPlay()) { return document.getElementById('btn-gch-ready-mg2'); } return document.getElementById('btn-gch-ready'); } function setPlayMg2HowtoLivePlay(on) { document.documentElement.classList.toggle('play-mg2-howto-live', !!on); } function clearGchMg2OverlayBoundsPlay(ov) { ov = ov || document.getElementById('gauntlet-crown-howto-overlay'); if (!ov) return; ['position', 'left', 'top', 'width', 'height', 'right', 'bottom', 'transform'].forEach((p) => { ov.style.removeProperty(p); }); } function dismissGauntletCrownHowtoOverlayPlay() { const howto = document.getElementById('gauntlet-crown-howto-overlay'); if (howto) { howto.classList.add('is-hidden'); setPlayMg2HowtoLivePlay(false); clearGchMg2OverlayBoundsPlay(howto); } gauntletCrownHowtoVisible = false; } /** ภารกิจคำถาม mng8a80o — Ready Status / ปุ่ม แบบเดียวกับ Mega Virus (gauntlet-crown-lobby-sync) */ function updateQuizQuestionMissionHowtoHud() { if (!isQuizQuestionMissionUiMapPlay() || quizQuestionMissionPhase !== 'howto') return; const st = document.getElementById('gauntlet-crown-howto-status'); const btn = document.getElementById('btn-gch-ready'); if (!st || !btn) return; applyGauntletCrownReadyButtonPlay(st, btn); } function updateGauntletCrownHowtoHud() { const st = getGauntletCrownHowtoStatusEl(); const btn = getGauntletCrownHowtoBtnEl(); if (!btn || !usesCrownLobbyShellPlay() || gauntletCrownPregamePhase !== 'howto') return; applyGauntletCrownReadyButtonPlay(st, btn); } function beginGauntletCrownCountdownThenRun() { if (!usesCrownLobbyShellPlay()) return; if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } gauntletCrownPregamePhase = 'countdown'; if (playBotsEnabled() && mapData && mapData.gameType === 'gauntlet') { applyGauntletPreviewSpawnLayout(false); } trySnapGauntletCrownRunwayScrollToSpawnsPlay(); dismissGauntletCrownHowtoOverlayPlay(); const cd = document.getElementById('gauntlet-crown-countdown'); const numEl = document.getElementById('gauntlet-crown-countdown-num'); function applyMegaBalloonLiveStartPlay() { balloonBossSessionStartMs = performance.now(); balloonBossLastTickMs = performance.now(); /* anchor = liveBeginsAt server → boss fire + timer อ้างอิงจุดเดียวกันทุกเครื่อง */ balloonBossLiveAnchorServerMs = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : serverNow(); balloonBossBossShotCount = 0; balloonBossGameEnded = false; balloonBossPendingShots = []; balloonBossPlayerBullets = []; balloonBossBossBullets = []; balloonBossBossFireAcc = 0; balloonBossScorePopups = []; balloonBossHostBossDmg = null; balloonBossLastSyncEmit = 0; me.balloonBossScore = 0; me.balloonBossBossDmg = 0; me.balloonBossBalloons = balloonBossBalloonsStartPlay(); me.balloonBossEliminated = false; others.forEach((o) => { o.balloonBossScore = 0; o.balloonBossBossDmg = 0; o.balloonBossBalloons = balloonBossBalloonsStartPlay(); o.balloonBossEliminated = false; }); if (mapData) applyBalloonBossSpawnLayoutPlay(); } 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'; trySnapGauntletCrownRunwayScrollToSpawnsPlay(); if (isMegaVirusMissionShellMapPlay()) applyMegaBalloonLiveStartPlay(); } }); } else { gauntletCrownPregamePhase = 'live'; trySnapGauntletCrownRunwayScrollToSpawnsPlay(); if (isMegaVirusMissionShellMapPlay()) applyMegaBalloonLiveStartPlay(); } }; if (!cd || !numEl) { runFinish(); return; } syncGauntletCrownCountdownMg2ModePlay(cd); const textEl = document.getElementById('gauntlet-crown-countdown-text'); const mg2Cd = isGauntletCrownHeistMapPlay(); cd.classList.remove('is-hidden'); runAlignedCountdownPlay( function (n) { if (mg2Cd) setGauntletCrownMg2CountdownGraphic(numEl, textEl, n); else setCountdown321QuestionAssetGraphic(numEl, n); }, runFinish, function (id) { gauntletCrownCountdownTimer = id; } ); try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ } try { syncPlayCyberHud(); } catch (_sh) { /* ignore */ } } function showGauntletCrownHowtoOverlay() { if (!usesCrownLobbyShellPlay()) return; hideConflictingOverlaysForGauntletCrown(); if (isGauntletCrownHeistMapPlay()) applyGauntletCrownHeistMissionPanelImages(); else if (isMegaVirusMissionShellMapPlay()) applyMegaVirusMissionPanelImages(); const cd = document.getElementById('gauntlet-crown-countdown'); if (cd) { cd.classList.add('is-hidden'); syncGauntletCrownCountdownMg2ModePlay(cd); } if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } gauntletCrownPregamePhase = 'howto'; resetGauntletCrownRunwaySpawnScrollSnap(); const ov = document.getElementById('gauntlet-crown-howto-overlay'); if (!ov) return; gauntletCrownHowtoVisible = true; ov.classList.remove('is-hidden'); if (isGauntletCrownHeistMapPlay()) { setPlayMg2HowtoLivePlay(true); updateGchTfMockScale(ov); trySnapGauntletCrownRunwayScrollToSpawnsPlay(); } if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); gauntletCrownSyncGuestReadyIfNeeded(); updateGauntletCrownHowtoHud(); try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ } try { syncPlayCyberHud(); } catch (_sh) { /* ignore */ } } /** Editor embed: quiz_carry / crown ใช้เกรด F = gameover — Jumper: ภาพจบใช้เฉพาะเมื่อไม่มีผู้รอด (0 คน) */ function gauntletCrownEmbedMissionAnyOk(mission) { if (!mission) return false; if (mission.uiSkin === 'violent_crime') { const sc = Number(mission.survivorCount); if (Number.isFinite(sc) && sc <= 0) return false; const g = String(mission.grade || '').trim().toUpperCase().charAt(0); return g !== 'F'; } if (mission.uiSkin === 'jumper') { const sc = Number(mission.survivorCount); return Number.isFinite(sc) ? sc > 0 : (String(mission.grade || '').trim().toUpperCase().charAt(0) !== 'F'); } if (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower') { const g = String(mission.grade || '').trim().toUpperCase().charAt(0); return g !== 'F'; } if (mission.uiSkin === 'mega_virus') { const g = String(mission.grade || '').trim().toUpperCase().charAt(0); return g !== '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' }; } /** พรีวิว: สรุปภารกิจ Last Light หลังรันเวย์ครบ — ให้ gauntletCrownBuildDisplayMission รวมบอท */ function gauntletCrownHeistBuildLocalCrownMissionPlay() { const baseRows = []; if (myId != null) { baseRows.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', characterId: me.characterId ?? null, baseScore: me.gauntletEliminated ? 0 : Math.max(0, Number(me.gauntletScore) | 0), eliminated: !!me.gauntletEliminated, }); } others.forEach((o, id) => { if (!o || isPreviewBotId(id)) return; baseRows.push({ id, nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id), 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, 6); /* Last Light รองรับ 6 ผู้เล่น — summary ต้องครบ 6 */ const ranked = top.map((row, idx) => { const pos = idx + 1; const rankBonus = gauntletCrownRankBonusLocal(pos); 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, finalScore: bs + rankBonus, }; }); const totalSum = ranked.reduce((s, r) => s + r.finalScore, 0); const n = ranked.length || 1; const averageScore = Math.floor(totalSum / n); const survivorCount = baseRows.filter((r) => !r.eliminated).length; let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; if (survivorCount <= 0) grade = 'F'; const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); return { uiSkin: 'gauntlet_crown', ranked, totalSum, averageScore, grade, rewardCard, totalParts: ranked.map((r) => r.finalScore), participantCount: baseRows.length, survivorCount, }; } /** * 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' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus') return mission; if (!(playBotsEnabled() && 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, }); }); /* รวมตัวเรา (human) ถ้ายังไม่อยู่ — บาง mission (preview/จาก server) มีแต่บอท ทำให้ตัวเราตกหายจาก summary */ if (myId != null && !seen.has(String(myId))) { seen.add(String(myId)); baseRows.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', characterId: me.characterId ?? null, baseScore: me.gauntletEliminated ? 0 : Math.max(0, Number(me.gauntletScore) | 0), eliminated: !!me.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, 6); /* Last Light รองรับ 6 ผู้เล่น — summary ต้องครบ 6 รวมตัวเรา (เดิม 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; }), }; } /* ===== ชุดคำถามพิเศษ (รับการ์ดพิเศษ) — โมดูลกลาง ใช้ได้หลายเกม ===== * จุดต่อเข้าเกมจริง (trigger) จะ wire ภายหลังตามที่ผู้ใช้ระบุ * วิธีใช้: SpecialQuiz.fetchSettings() -> getSet(level,caseId) -> pickQuestions(set,n) * เล่นจบแล้ว awardCard(set, correct, total) -> set mission.specialCardAwarded = card */ let __specialQuizSettingsCache = null; let __specialQuizFetchPromise = null; function specialQuizFetchSettings() { if (__specialQuizSettingsCache) return Promise.resolve(__specialQuizSettingsCache); if (__specialQuizFetchPromise) return __specialQuizFetchPromise; __specialQuizFetchPromise = fetch(BASE + '/api/quiz-settings', { cache: 'no-store' }) .then(function (r) { return r.json(); }) .then(function (j) { __specialQuizSettingsCache = j || {}; return __specialQuizSettingsCache; }) .catch(function () { __specialQuizFetchPromise = null; return {}; }); return __specialQuizFetchPromise; } function specialQuizCurrentLevelCase() { const meta = window.__detectiveLobbyMeta || {}; let level = Number(meta.level); let caseId = Number(meta.caseId); if (!(level >= 1 && level <= 5)) { const lp = Number(params.get('lobbyLevel')); if (lp >= 1 && lp <= 5) level = lp; } if (!(caseId >= 1 && caseId <= 3)) { const cp = Number(params.get('case') || params.get('caseId')); if (cp >= 1 && cp <= 3) caseId = cp; } return { level: (level >= 1 && level <= 5) ? level : 1, caseId: (caseId >= 1 && caseId <= 3) ? caseId : 1, }; } function specialQuizGetSet(settings, level, caseId) { const sets = (settings && settings.specialQuizSets) || {}; const key = 'L' + level + '_C' + caseId; return sets[key] || sets.L1_C1 || { questions: [], specialCard: {} }; } function specialQuizPickQuestions(set, count) { const qs = (set && Array.isArray(set.questions)) ? set.questions.slice() : []; for (let i = qs.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const t = qs[i]; qs[i] = qs[j]; qs[j] = t; } let n = Number(count); if (!(n >= 1)) n = qs.length; return qs.slice(0, Math.min(n, qs.length)); } /** กติกาได้การ์ด: ตอบถูก >= ครึ่งหนึ่ง และชุดนั้นตั้งการ์ดไว้ — คืน card หรือ null */ function specialQuizAwardCard(set, correctCount, totalCount) { const card = (set && set.specialCard) || null; if (!card || !(card.th || card.en || card.imageUrl)) return null; const total = Number(totalCount) || 0; const correct = Number(correctCount) || 0; const passNeed = total > 0 ? Math.ceil(total / 2) : 1; if (correct < passNeed) return null; return card; } /** วาดการ์ดพิเศษลงในโซนโบนัสของสรุปผล (#gcm-bonus / .sm-bonus-right) */ function renderSpecialCardInBonus(bonusEl, card) { if (!bonusEl || !card || !(card.th || card.en || card.imageUrl || card.cardId)) return; const imgUrl = card.imageUrl || (card.cardId >= 1 && card.cardId <= 7 ? '/Game/img/special-cards/card-' + card.cardId + '.png' : ''); if (!imgUrl) return; const isPresetAsset = imgUrl.indexOf('/img/special-cards/') >= 0 || imgUrl.indexOf('/trycss/10-Game-1-result/') >= 0; if (bonusEl.classList && bonusEl.classList.contains('gcm-bonus-mg2-right')) { const img = document.createElement('img'); img.className = isPresetAsset ? 'bail-card gcm-special-card-img' : 'gcm-special-card-img'; img.src = imgUrl; img.alt = card.th || card.en || ''; img.decoding = 'async'; bonusEl.appendChild(img); return; } const wrap = document.createElement('div'); wrap.className = isPresetAsset ? 'sm-bonus-right gcm-special-card-preset' : 'gcm-special-card sm-bonus-special rarity-' + (card.rarity || 'rare'); const img = document.createElement('img'); img.className = isPresetAsset ? 'bail-card gcm-special-card-img' : 'gcm-special-card-img'; img.src = imgUrl; img.alt = card.th || card.en || ''; wrap.appendChild(img); if (!isPresetAsset) { const cap = document.createElement('div'); cap.className = 'gcm-special-card-cap'; const strong = document.createElement('strong'); strong.textContent = card.th || ''; const span = document.createElement('span'); span.textContent = card.en || ''; cap.appendChild(strong); cap.appendChild(span); wrap.appendChild(cap); } bonusEl.appendChild(wrap); } window.SpecialQuiz = { fetchSettings: specialQuizFetchSettings, currentLevelCase: specialQuizCurrentLevelCase, getSet: specialQuizGetSet, pickQuestions: specialQuizPickQuestions, awardCard: specialQuizAwardCard, renderCardInBonus: renderSpecialCardInBonus, }; /* ===== Special Quiz ในเกม — ไอคอนในฉาก (เดินชน) + overlay คำถาม multiplayer ===== * server เป็นผู้ตัดสิน: สุ่มไอคอน 1/3, เปิดคำถามให้ทุกคนพร้อมกัน, ตอบถูกทุกคนทุกข้อ -> ได้การ์ด */ const SPECIAL_QUIZ_CHOICE_LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; /** Test Mode (Admin) เก็บใน localStorage — เปิด shortcut ทดสอบ เช่น Ctrl+Q ตอบถูก */ function __isTestModeOnPlay() { try { return localStorage.getItem('justiceTestMode') === '1'; } catch (e) { return false; } } let specialQuizIcon = null; // { iconType, x, y } (พิกัด tile-center) let specialQuizArmSent = false; // ส่งสัญญาณ "เกม live แล้ว" ให้ server ปล่อยไอคอนหรือยัง (รอบนี้) /** เกมมินิ (mission shell) เข้าสู่สถานะ "เล่นจริง" แล้วหรือยัง — ใช้ตัดสินว่าจะให้ไอคอนคำถามพิเศษโผล่ได้ */ function detectiveMinigameLiveNowPlay() { if (!isMissionMockHudPlay()) return false; if (isGauntletCrownHeistMapPlay()) return gauntletCrownPregamePhase === 'live'; if (isQuizQuestionMissionUiMapPlay()) return quizQuestionMissionPhase === 'live'; if (isStackTowerMissionUiMapPlay()) return stackTowerMissionPhase === 'live'; if (isJumpSurviveMissionUiMapPlay()) return jumpSurviveMissionPhase === 'live'; if (isSpaceShooterMissionUiMapPlay()) return spaceShooterMissionPhase === 'live'; if (isMegaVirusMissionShellMapPlay()) return balloonBossSessionStartMs > 0 && !balloonBossGameEnded; if (isQuizCarry() && quizCarryEmbedMissionFlowActive()) return !quizCarryPregameActive; return false; } /** ส่งสัญญาณให้ server ปล่อยไอคอนคำถามพิเศษ (ครั้งเดียว) เมื่อเกม live จริง — กันไอคอนโผล่ตอน howto/นับถอยหลัง */ function maybeArmSpecialQuizPlay() { if (specialQuizArmSent) return; if (!detectiveMinigameLiveNowPlay()) return; specialQuizArmSent = true; try { if (socket && socket.connected) socket.emit('special-quiz-arm'); } catch (e) { /* ignore */ } } let specialQuizIconScreenRect = null; // { x, y, r } พิกัดบนจอ (ไว้คลิก/แตะไอคอน) let specialQuizCollideSent = false; let specialQuizCollideCooldownUntil = 0; let specialQuizAwardedCard = null; // การ์ดที่ได้รอบนี้ (จาก special-quiz-ended) let specialQuizActiveQuestion = null; // { index, total, choices, endsAt, iconType } let specialQuizAnswered = false; let specialQuizTimerRaf = 0; const specialQuizIconImgs = {}; /** หยุดเกมทุกอย่างระหว่างตอบคำถามพิเศษ แล้ว resume หลังจบ */ let specialQuizFreeze = false; let specialQuizFreezeStartMs = 0; let specialQuizRoster = []; // [{ id, nickname, isBot }] let specialQuizAnswerStatus = {}; // id -> true (ตอบแล้ว) let specialQuizFreezeLastTickMs = 0; function beginSpecialQuizFreeze() { if (specialQuizFreeze) return; specialQuizFreeze = true; specialQuizFreezeStartMs = Date.now(); specialQuizFreezeLastTickMs = Date.now(); try { for (const k in keys) keys[k] = false; } catch (e) { /* ignore */ } try { playPath = []; } catch (e) { /* ignore */ } if (me) me.isWalking = false; } /** เลื่อน deadline/anchor เวลาทุกตัวไปข้างหน้าเท่า delta — ใช้เรียกทุกเฟรมระหว่าง freeze เพื่อให้ "เวลาที่แสดง" หยุดนิ่ง (ไม่ใช่แค่ชดเชยตอนจบ) */ function shiftSpecialQuizFrozenTimers(deltaMs) { if (!(deltaMs > 0)) return; const shift = (v) => (typeof v === 'number' && v > 0) ? v + deltaMs : v; try { playQuizPhaseEndsAt = shift(playQuizPhaseEndsAt); } catch (e) {} try { quizCarryEmbedCountdownStartAt = shift(quizCarryEmbedCountdownStartAt); } catch (e) {} try { quizCarryEmbedCountdownEndAt = shift(quizCarryEmbedCountdownEndAt); } catch (e) {} try { quizCarryEmbedPreOptionCountdownStartAt = shift(quizCarryEmbedPreOptionCountdownStartAt); } catch (e) {} try { quizCarryEmbedPreOptionCountdownEndAt = shift(quizCarryEmbedPreOptionCountdownEndAt); } catch (e) {} try { quizCarryOptionRevealAt = shift(quizCarryOptionRevealAt); } catch (e) {} try { quizCarryAnswerCloseAt = shift(quizCarryAnswerCloseAt); } catch (e) {} try { if (typeof jumpSurviveSessionStartMs === 'number' && jumpSurviveSessionStartMs > 0) jumpSurviveSessionStartMs += deltaMs; } catch (e) {} try { if (typeof spaceShooterSessionStartMs === 'number' && spaceShooterSessionStartMs > 0) spaceShooterSessionStartMs += deltaMs; } catch (e) {} try { if (typeof stackTowerSessionStartAt === 'number' && stackTowerSessionStartAt > 0) stackTowerSessionStartAt += deltaMs; } catch (e) {} try { if (typeof balloonBossSessionStartMs === 'number' && balloonBossSessionStartMs > 0) balloonBossSessionStartMs += deltaMs; } catch (e) {} /* anchor server (serverNow-based) ของ MG5/6/7 ต้องเลื่อนด้วย — ไม่งั้นระหว่าง freeze บอส/ลูกศร/timer/spawn ยังเดินตาม serverNow และหลัง freeze จะคำนวณ backlog ยิงกระสุนรัวออกมา (กระสุนกระจาย) */ try { if (typeof jumpSurviveLiveAnchorServerMs === 'number' && jumpSurviveLiveAnchorServerMs > 0) jumpSurviveLiveAnchorServerMs += deltaMs; } catch (e) {} try { if (typeof spaceShooterLiveAnchorServerMs === 'number' && spaceShooterLiveAnchorServerMs > 0) spaceShooterLiveAnchorServerMs += deltaMs; } catch (e) {} try { if (typeof balloonBossLiveAnchorServerMs === 'number' && balloonBossLiveAnchorServerMs > 0) balloonBossLiveAnchorServerMs += deltaMs; } catch (e) {} } /** เรียกทุกเฟรมระหว่าง freeze — ดันเวลาไปข้างหน้าเท่าที่ผ่านไปจริง ทำให้ countdown หยุดนิ่ง */ function tickSpecialQuizFreezeShift() { if (!specialQuizFreeze) return; const now = Date.now(); shiftSpecialQuizFrozenTimers(now - specialQuizFreezeLastTickMs); specialQuizFreezeLastTickMs = now; } function endSpecialQuizFreeze() { if (!specialQuizFreeze) return; /* ชดเชย delta ที่เหลือตั้งแต่เฟรมสุดท้ายจนถึงตอนนี้ */ shiftSpecialQuizFrozenTimers(Date.now() - specialQuizFreezeLastTickMs); specialQuizFreeze = false; specialQuizFreezeStartMs = 0; specialQuizFreezeLastTickMs = 0; } function specialQuizIconUrl(type) { return '/Game/img/special-quiz/icon-' + (type === 'police' ? 'police' : 'lawyer') + '.png'; } function specialQuizGetIconImg(type) { const key = type === 'police' ? 'police' : 'lawyer'; if (specialQuizIconImgs[key]) return specialQuizIconImgs[key]; const img = new Image(); img.src = specialQuizIconUrl(key); specialQuizIconImgs[key] = img; return img; } const specialQuizGlowImgs = {}; /** แสง glow (08-event light-*) วางหลังไอคอน — ให้ดูเป็นของเก็บเรืองแสงบนเส้นทาง */ function specialQuizGetGlowImg(type) { const key = type === 'police' ? 'police' : 'lawyer'; if (specialQuizGlowImgs[key]) return specialQuizGlowImgs[key]; const img = new Image(); img.src = '/Game/img/special-quiz/light-' + key + '.png'; specialQuizGlowImgs[key] = img; return img; } function setSpecialQuizIconFromServer(payload) { if (!payload || payload.x == null || payload.y == null) { specialQuizIcon = null; return; } specialQuizIcon = { iconType: payload.iconType === 'police' ? 'police' : 'lawyer', x: Number(payload.x), y: Number(payload.y), expiresAt: Number(payload.expiresAt) || 0, }; specialQuizCollideSent = false; specialQuizCollideCooldownUntil = 0; specialQuizGetIconImg(specialQuizIcon.iconType); } function clearSpecialQuizIcon() { specialQuizIcon = null; specialQuizIconScreenRect = null; specialQuizCollideSent = false; specialQuizCollideCooldownUntil = 0; } /** ส่ง collide ไป server เพื่อเปิดคำถาม — ถ้า server ปฏิเสธ (เช่น race) ให้ลองใหม่ได้หลัง cooldown */ function sendSpecialQuizCollide() { if (specialQuizCollideSent || specialQuizActiveQuestion) return; if (Date.now() < specialQuizCollideCooldownUntil) return; specialQuizCollideSent = true; specialQuizCollideCooldownUntil = Date.now() + 800; try { socket.emit('special-quiz-collide', {}, function (res) { if (!(res && res.ok) && !specialQuizActiveQuestion) specialQuizCollideSent = false; }); } catch (e) { specialQuizCollideSent = false; } } /** กด/แตะไอคอนควิซเพื่อเปิดคำถาม (ใช้ได้ทุกมินิเกม รวม space shooter ที่เดินไปแตะไม่ได้) */ function trySpecialQuizClickAt(clientX, clientY) { if (!specialQuizIcon || specialQuizCollideSent || specialQuizActiveQuestion) return false; if (!specialQuizIconScreenRect || !canvas) return false; const r = canvas.getBoundingClientRect(); if (!r.width || !r.height) return false; const px = (clientX - r.left) * (canvas.width / r.width); const py = (clientY - r.top) * (canvas.height / r.height); const dx = px - specialQuizIconScreenRect.x; const dy = py - specialQuizIconScreenRect.y; /* เผื่อระยะกดให้กว้างขึ้น — แตะใกล้ไอคอนก็เปิดได้ */ const hitR = specialQuizIconScreenRect.r * 1.3; if (dx * dx + dy * dy <= hitR * hitR) { sendSpecialQuizCollide(); return true; } return false; } /** วาดไอคอนในฉาก — ใช้ worldToScreen + zDraw จาก draw() เพื่อให้ตรงทุกมินิเกม */ function drawSpecialQuizIconWorld(w2s, zDraw) { if (!specialQuizIcon || typeof w2s !== 'function') return; const img = specialQuizGetIconImg(specialQuizIcon.iconType); const sc = w2s(specialQuizIcon.x * tileSize, specialQuizIcon.y * tileSize); const sx = sc[0]; const sy = sc[1]; if (!Number.isFinite(sx) || !Number.isFinite(sy)) return; const size = Math.max(30, tileSize * zDraw * 1.75); const bob = Math.sin(Date.now() / 320) * size * 0.06; const cy = sy + bob; ctx.save(); /* glow 08-event (light-*) หมุน+เต้นหลังไอคอน — collectible เรืองแสง */ const glowImg = specialQuizGetGlowImg(specialQuizIcon.iconType); if (glowImg && glowImg.complete && glowImg.naturalWidth > 0) { const pulse = 0.82 + Math.sin(Date.now() / 300) * 0.18; const gsz = size * 2.5 * pulse; ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = 0.9; ctx.translate(sx, cy); ctx.rotate(Date.now() / 2600); ctx.drawImage(glowImg, -gsz / 2, -gsz / 2, gsz, gsz); ctx.restore(); } const r = size * 0.62; const grad = ctx.createRadialGradient(sx, cy, r * 0.18, sx, cy, r); grad.addColorStop(0, 'rgba(122, 162, 247, 0.5)'); grad.addColorStop(1, 'rgba(122, 162, 247, 0)'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(sx, cy, r, 0, Math.PI * 2); ctx.fill(); /* เก็บตำแหน่งบนจอไว้ให้กด/แตะไอคอนได้ (สำหรับมินิเกมที่เดินไปแตะไม่ได้ เช่น space shooter) */ specialQuizIconScreenRect = { x: sx, y: cy, r: Math.max(size * 0.7, 36) }; if (img && img.complete && img.naturalWidth > 0) { ctx.drawImage(img, sx - size / 2, cy - size / 2, size, size); } else { ctx.fillStyle = '#7aa2f7'; ctx.fillRect(sx - size / 2, cy - size / 2, size, size); } ctx.fillStyle = '#ffd166'; ctx.font = '700 ' + Math.round(size * 0.42) + 'px system-ui, "Kanit", sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('?', sx + size * 0.4, cy - size * 0.38); /* countdown บนไอคอน — เหลือกี่วินาทีก่อนหาย (badge เหนือไอคอน) */ if (specialQuizIcon.expiresAt > 0) { const remain = Math.max(0, Math.ceil((specialQuizIcon.expiresAt - Date.now()) / 1000)); const bx = sx; const by = cy - size * 0.6; const br = size * 0.27; const low = remain <= 3; ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fillStyle = low ? 'rgba(247,118,142,0.95)' : 'rgba(10,16,30,0.85)'; ctx.fill(); ctx.lineWidth = Math.max(2, size * 0.045); ctx.strokeStyle = low ? '#ffd6e0' : '#7aa2f7'; ctx.stroke(); ctx.fillStyle = '#ffffff'; ctx.font = '800 ' + Math.round(size * 0.36) + 'px system-ui, "Kanit", sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(String(remain), bx, by + size * 0.012); } ctx.restore(); } /** เช็คผู้เล่นเดินชนไอคอน — ใครชนก่อนเป็นคนเปิดคำถามให้ทั้งห้อง (server กันซ้ำ) */ function checkSpecialQuizCollision() { if (!specialQuizIcon || specialQuizCollideSent || specialQuizActiveQuestion) return; const ix = specialQuizIcon.x; const iy = specialQuizIcon.y; /* รัศมีกว้างขึ้น (1.25 tile) — ไอคอนวาดใหญ่ 1.75 tile เดินเข้าใกล้ก็ทริกได้ ไม่ต้องทับ tile กลางพอดี */ const within = (ex, ey) => { if (!Number.isFinite(ex) || !Number.isFinite(ey)) return false; const dx = ex - ix; const dy = ey - iy; return dx * dx + dy * dy <= 1.25 * 1.25; }; if (within(me.x, me.y)) { sendSpecialQuizCollide(); return; } /* บอท (preview / เติมคดี) เดินโดนไอคอนก็เปิดคำถามพิเศษให้ทั้งห้อง (ผู้เล่นจริงเป็นคนตอบ) — กันกรณีไม่มีคนเดินไปโดน */ if (playBotsEnabled()) { for (const [id, o] of others) { if (!isPreviewBotId(id) || !o) continue; if (within(o.x, o.y)) { sendSpecialQuizCollide(); return; } } } } function specialQuizOverlayEl() { return document.getElementById('special-quiz-overlay'); } function specialQuizSelfId() { return myId != null ? String(myId) : null; } function specialQuizMemberLabel(m) { if (m && m.id && m.id === specialQuizSelfId()) return (m.nickname || 'คุณ') + ' (คุณ)'; return (m && m.nickname) ? m.nickname : '—'; } function renderSpecialQuizMembers() { const box = document.getElementById('sq-ov-members'); if (!box) return; box.innerHTML = ''; specialQuizRoster.forEach(function (m, i) { const row = document.createElement('div'); row.className = 'sq-ov-member' + (m.id === specialQuizSelfId() ? ' sq-ov-member--me' : ''); row.dataset.id = m.id; const dot = document.createElement('span'); dot.className = 'sq-ov-member-dot'; dot.textContent = String(i + 1); const name = document.createElement('span'); name.className = 'sq-ov-member-name'; name.textContent = specialQuizMemberLabel(m); const tag = document.createElement('span'); tag.className = 'sq-ov-member-tag'; tag.textContent = m.isBot ? 'บอท' : ''; const ans = document.createElement('span'); ans.className = 'sq-ov-member-ans'; ans.textContent = specialQuizAnswerStatus[m.id] ? '…' : ''; if (specialQuizAnswerStatus[m.id]) row.classList.add('sq-ov-member--answered'); row.appendChild(dot); row.appendChild(name); row.appendChild(tag); row.appendChild(ans); box.appendChild(row); }); } function markSpecialQuizMemberAnswered(id) { if (!id) return; specialQuizAnswerStatus[id] = true; const box = document.getElementById('sq-ov-members'); if (!box) return; const row = box.querySelector('.sq-ov-member[data-id="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]'); if (!row) { renderSpecialQuizMembers(); return; } row.classList.add('sq-ov-member--answered'); const ans = row.querySelector('.sq-ov-member-ans'); if (ans && !row.classList.contains('sq-ov-member--correct') && !row.classList.contains('sq-ov-member--wrong')) ans.textContent = '✓'; } /* ===== mock 09-Quiz: avatar ผู้เล่นใต้คำตอบที่เลือก (แทนชิปสมาชิก) ===== */ var SPECIAL_QUIZ_AV_COLORS = ['#5eeaff', '#ff7ab2', '#9b8cff', '#7cff6b', '#ffd166', '#ff8c5a', '#6ad4ff', '#c792ff']; function specialQuizMemberIndexById(id) { for (var i = 0; i < specialQuizRoster.length; i++) { if (String(specialQuizRoster[i].id) === String(id)) return i; } return -1; } function addSpecialQuizChoiceAvatar(choiceIdx, id) { if (id == null || !(choiceIdx >= 0)) return; const choicesEl = document.getElementById('sq-ov-choices'); if (!choicesEl) return; const btn = choicesEl.querySelector('.sq-ov-choice[data-idx="' + choiceIdx + '"]'); if (!btn) return; const boxAv = btn.querySelector('.sq-ov-choice-avatars'); if (!boxAv) return; const safe = (window.CSS && CSS.escape) ? CSS.escape(String(id)) : String(id); if (boxAv.querySelector('[data-mid="' + safe + '"]')) return; /* กันซ้ำ */ const idx = specialQuizMemberIndexById(id); const isMe = String(id) === String(specialQuizSelfId()); const el = document.createElement('div'); el.className = 'sq-ov-av' + (isMe ? ' sq-ov-av--me' : ''); el.dataset.mid = String(id); el.style.background = SPECIAL_QUIZ_AV_COLORS[(idx >= 0 ? idx : 0) % SPECIAL_QUIZ_AV_COLORS.length]; el.textContent = idx >= 0 ? String(idx + 1) : '?'; boxAv.appendChild(el); } function openSpecialQuizQuestion(q) { if (!q) return; clearSpecialQuizIcon(); beginSpecialQuizFreeze(); specialQuizActiveQuestion = { index: q.index | 0, total: q.total | 0, choices: Array.isArray(q.choices) ? q.choices : [], endsAt: Number(q.endsAt) || 0, iconType: q.iconType, }; if (Array.isArray(q.roster) && q.roster.length) specialQuizRoster = q.roster.slice(); specialQuizAnswerStatus = {}; specialQuizAnswered = false; const ov = specialQuizOverlayEl(); if (!ov) return; const iconEl = document.getElementById('sq-ov-icon'); if (iconEl) iconEl.src = specialQuizIconUrl(q.iconType); const prog = document.getElementById('sq-ov-progress'); if (prog) prog.textContent = 'ข้อ ' + ((q.index | 0) + 1) + ' / ' + (q.total | 0); const qEl = document.getElementById('sq-ov-question'); if (qEl) qEl.textContent = q.text || ''; const result = document.getElementById('sq-ov-result'); if (result) { result.classList.add('is-hidden'); result.textContent = ''; result.className = 'sq-ov-result is-hidden'; } /* แสดงเฉพาะ layer คำถาม/คำตอบ — ซ่อน layer ผลรางวัล */ const quizBody = document.getElementById('sq-ov-quiz-body'); if (quizBody) quizBody.classList.remove('is-hidden'); const awardLayer = document.getElementById('sq-ov-award'); if (awardLayer) awardLayer.classList.add('is-hidden'); if (ov) ov.classList.remove('is-award'); const status = document.getElementById('sq-ov-status'); if (status) status.textContent = 'เลือกคำตอบ — ทุกคนต้องตอบถูกทุกข้อจึงได้การ์ดพิเศษ'; const choicesEl = document.getElementById('sq-ov-choices'); if (choicesEl) { choicesEl.innerHTML = ''; specialQuizActiveQuestion.choices.forEach(function (c, i) { const b = document.createElement('button'); b.type = 'button'; b.className = 'sq-ov-choice'; b.dataset.idx = String(i); const k = document.createElement('span'); k.className = 'sq-ov-choice-key'; k.textContent = (SPECIAL_QUIZ_CHOICE_LABELS[i] || String(i + 1)) + ' :'; const t = document.createElement('span'); t.className = 'sq-ov-choice-text'; t.textContent = c; const av = document.createElement('div'); av.className = 'sq-ov-choice-avatars'; av.dataset.idx = String(i); b.appendChild(k); b.appendChild(t); b.appendChild(av); b.onclick = function () { submitSpecialQuizAnswer(i); }; choicesEl.appendChild(b); }); } /* mock 09-Quiz: ซ่อนปุ่มถัดไป + รีเซ็ตสถานะตอบ (avatar ใต้คำตอบ) */ ov.classList.remove('sq-answered'); const nextBtn0 = document.getElementById('sq-ov-next'); if (nextBtn0) nextBtn0.classList.add('is-hidden'); renderSpecialQuizMembers(); ov.classList.remove('is-hidden'); startSpecialQuizTimerLoop(); } function submitSpecialQuizAnswer(i) { if (specialQuizAnswered || !specialQuizActiveQuestion) return; specialQuizAnswered = true; const choicesEl = document.getElementById('sq-ov-choices'); if (choicesEl) { Array.prototype.forEach.call(choicesEl.children, function (b) { b.classList.toggle('sq-ov-choice--picked', Number(b.dataset.idx) === i); b.disabled = true; }); } const status = document.getElementById('sq-ov-status'); if (status) status.textContent = 'ส่งคำตอบแล้ว — รอผู้เล่นคนอื่น...'; if (specialQuizSelfId()) { markSpecialQuizMemberAnswered(specialQuizSelfId()); addSpecialQuizChoiceAvatar(i, specialQuizSelfId()); /* mock: avatar ตัวเองใต้คำตอบที่เลือก */ } try { socket.emit('special-quiz-answer', { choiceIndex: i }, function () {}); } catch (e) { /* ignore */ } } function startSpecialQuizTimerLoop() { cancelAnimationFrame(specialQuizTimerRaf); function tick() { if (!specialQuizActiveQuestion) return; const timerEl = document.getElementById('sq-ov-timer'); const ms = specialQuizActiveQuestion.endsAt - Date.now(); const s = Math.max(0, Math.ceil(ms / 1000)); if (timerEl) { timerEl.textContent = String(s); timerEl.classList.toggle('sq-ov-timer--low', s <= 5); } if (ms > 0) specialQuizTimerRaf = requestAnimationFrame(tick); } tick(); } function handleSpecialQuizResult(r) { if (!r || !specialQuizActiveQuestion) return; const choicesEl = document.getElementById('sq-ov-choices'); if (choicesEl) { Array.prototype.forEach.call(choicesEl.children, function (b) { const idx = Number(b.dataset.idx); b.disabled = true; if (idx === r.correctIndex) b.classList.add('sq-ov-choice--correct'); else if (b.classList.contains('sq-ov-choice--picked')) b.classList.add('sq-ov-choice--wrong'); }); } /* เฉลย — เปิดเผยว่าแต่ละคนตอบอะไร ถูก/ผิด */ const answers = (r.answers && typeof r.answers === 'object') ? r.answers : {}; /* mock 09-Quiz: วาง avatar ของทุกคนใต้คำตอบที่เขาเลือก + ซ่อนวงเวลา */ const sqOvAns = specialQuizOverlayEl(); if (sqOvAns) sqOvAns.classList.add('sq-answered'); Object.keys(answers).forEach(function (mid) { const ai = answers[mid]; if (Number.isInteger(ai)) addSpecialQuizChoiceAvatar(ai, mid); }); const labels = SPECIAL_QUIZ_CHOICE_LABELS; const box = document.getElementById('sq-ov-members'); if (box) { Array.prototype.forEach.call(box.children, function (row) { const id = row.dataset.id; const a = answers[id]; const ansEl = row.querySelector('.sq-ov-member-ans'); row.classList.remove('sq-ov-member--answered'); if (Number.isInteger(a)) { const ok = a === r.correctIndex; row.classList.add(ok ? 'sq-ov-member--correct' : 'sq-ov-member--wrong'); if (ansEl) ansEl.textContent = (ok ? '✓ ' : '✗ ') + (labels[a] || String(a + 1)); } else { row.classList.add('sq-ov-member--wrong'); if (ansEl) ansEl.textContent = '—'; } }); } const result = document.getElementById('sq-ov-result'); if (result) { result.classList.remove('is-hidden'); result.textContent = r.allCorrect ? 'ทุกคนตอบถูก!' : (r.timeout ? 'หมดเวลา — ตอบไม่ครบทุกคน' : 'มีคนตอบผิด — ไม่ได้การ์ดพิเศษ'); result.className = 'sq-ov-result ' + (r.allCorrect ? 'sq-ov-result--ok' : 'sq-ov-result--fail'); } cancelAnimationFrame(specialQuizTimerRaf); } /** การ์ดพิเศษถูก "ใช้" — โชว์ animation + ขยายเวลามินิเกมฝั่ง client (ถ้าเป็น Time Dilation) */ function applySpecialCardClient(data) { const card = data.card || {}; const addSec = Number(data.addSec) || 0; const coins = Number(data.coins) || 0; if (addSec > 0 && card.effectKey === 'time_dilation') { extendCurrentMinigameTimeClient(addSec); } if (coins > 0 && card.effectKey === 'fund') { /* server แจกเหรียญแล้ว (game-award.php) — แค่ดึงยอดล่าสุดมาแสดง; ถ้าไม่ใช่ (เผื่อ fallback) ค่อยบวกเอง */ if (data.coinsServerAwarded) resyncOwnCoins(); else addOwnCoinsViaApi(coins); } /* ถ้า overlay ควิซยังเปิด (การ์ดเพิ่งได้จากควิซ) — แผง «เล่นต่อ» โชว์การ์ดอยู่แล้ว ไม่ต้องเด้งซ้ำ */ const sqOv = specialQuizOverlayEl(); if (sqOv && !sqOv.classList.contains('is-hidden')) return; showSpecialCardUseAnimation(card, { addSec: addSec, coins: coins }); } /** Card 6 Fund — แต่ละ client บวกเหรียญของตัวเอง (อ่าน jdPlayerKey จาก localStorage) */ function addOwnCoinsViaApi(amount) { let key = ''; try { key = (localStorage.getItem('jdPlayerKey') || '').trim(); } catch (e) { key = ''; } if (!key || key.length < 8) return; const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins-add.php') : '/Admin/api/player-coins-add.php'); try { fetch(url, { method: 'POST', credentials: 'omit', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playerKey: key, amount: amount }), }).then(function (r) { return r.json(); }).then(function (d) { if (d && d.ok && typeof d.coins === 'number') { try { localStorage.setItem('jdCoins', String(d.coins)); } catch (e) { /* ignore */ } } }).catch(function () { /* ignore */ }); } catch (e) { /* ignore */ } } /** ดึงยอดเหรียญล่าสุดจาก server มาอัปเดต cache (ใช้หลัง server แจกเหรียญเอง เช่น Fund) */ function resyncOwnCoins() { let key = ''; try { key = (localStorage.getItem('jdPlayerKey') || '').trim(); } catch (e) { key = ''; } if (!key || key.length < 8) return; const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins.php') : '/Admin/api/player-coins.php') + '?playerKey=' + encodeURIComponent(key); try { fetch(url, { credentials: 'omit' }).then(function (r) { return r.json(); }).then(function (d) { if (d && d.ok && typeof d.coins === 'number') { try { localStorage.setItem('jdCoins', String(d.coins)); } catch (e) { /* ignore */ } } }).catch(function () { /* ignore */ }); } catch (e) { /* ignore */ } } /** +วินาที ให้มินิเกมที่กำลังเล่น (jump/shooter/quiz carry) — mng8a80o จัดการฝั่ง server */ function extendCurrentMinigameTimeClient(sec) { const ms = Math.max(0, sec * 1000); if (!ms) return; if (isJumpSurvive() && jumpSurviveSessionStartMs) jumpSurviveSessionStartMs += ms; if (isSpaceShooter() && spaceShooterSessionStartMs) spaceShooterSessionStartMs += ms; if (isStack() && stackTowerSessionStartAt) stackTowerSessionStartAt += ms; if (isBalloonBoss() && balloonBossSessionStartMs) balloonBossSessionStartMs += ms; if (isQuizCarry()) { if (quizCarryAnswerCloseAt > 0) quizCarryAnswerCloseAt += ms; if (quizCarryOptionRevealAt > 0) quizCarryOptionRevealAt += ms; } } function showSpecialCardUseAnimation(card, opts) { opts = opts || {}; let ov = document.getElementById('special-card-use-overlay'); if (!ov) { ov = document.createElement('div'); ov.id = 'special-card-use-overlay'; ov.className = 'is-hidden'; ov.innerHTML = '
' + '
'; document.body.appendChild(ov); } const img = ov.querySelector('.scu-img'); const nameEl = ov.querySelector('.scu-name'); const fxEl = ov.querySelector('.scu-effect'); if (img) img.src = card.imageUrl || (card.cardId ? '/Game/img/special-cards/card-' + card.cardId + '.png' : ''); if (nameEl) nameEl.textContent = 'ใช้การ์ด: ' + (card.en || card.th || 'Special Card'); if (fxEl) { let fx = card.desc || card.th || ''; if (opts.addSec > 0) fx = '+' + opts.addSec + ' วินาที'; else if (opts.coins > 0) fx = 'ทุกคน +' + opts.coins + ' COINS'; fxEl.textContent = fx; } ov.classList.remove('is-hidden'); ov.classList.remove('scu-play'); void ov.offsetWidth; ov.classList.add('scu-play'); clearTimeout(showSpecialCardUseAnimation._t); showSpecialCardUseAnimation._t = setTimeout(function () { ov.classList.add('is-hidden'); ov.classList.remove('scu-play'); }, 2600); } /** กด «เล่นต่อ» — ส่ง ack ไป server แล้ว "รอทุกคนกดครบ" (ไม่ปลด freeze เอง) server จะรวบ ack ครบทุกผู้เล่นจริง แล้ว broadcast `special-quiz-resume-all` → ทุกคนเล่นต่อพร้อมกัน */ function specialQuizClickContinue() { const btn = document.getElementById('sq-ov-award-continue'); if (btn) { if (btn.dataset.sqWaiting === '1') return; /* กันกดซ้ำ */ btn.dataset.sqWaiting = '1'; btn.disabled = true; btn.classList.add('is-waiting'); btn.textContent = 'รอผู้เล่นอื่น…'; } /* แจ้ง server ว่าเรากด «เล่นต่อ» แล้ว (ack) */ try { socket.emit('special-quiz-continue', {}, function () {}); } catch (e) { /* ignore */ } /* fallback กันค้าง: ถ้าไม่ได้รับ resume-all จาก server นาน → ปลดเอง */ clearTimeout(specialQuizClickContinue._fb); specialQuizClickContinue._fb = setTimeout(specialQuizDoResume, 40000); } /** ปลด freeze จริง + ปิด overlay (เรียกเมื่อ server แจ้ง `special-quiz-resume-all` หรือ fallback/safety) */ function specialQuizDoResume() { clearTimeout(endSpecialQuiz._safety); endSpecialQuiz._safety = 0; clearTimeout(specialQuizClickContinue._fb); specialQuizClickContinue._fb = 0; const ov = specialQuizOverlayEl(); const award = document.getElementById('sq-ov-award'); if (award) award.classList.add('is-hidden'); if (ov) { ov.classList.add('is-hidden'); ov.classList.remove('is-award'); } const btn = document.getElementById('sq-ov-award-continue'); if (btn) { btn.disabled = false; btn.classList.remove('is-waiting'); delete btn.dataset.sqWaiting; btn.textContent = 'เล่นเกมต่อ'; } endSpecialQuizFreeze(); } function endSpecialQuiz(data) { cancelAnimationFrame(specialQuizTimerRaf); specialQuizActiveQuestion = null; specialQuizAnswered = false; const success = !!(data && data.success && data.card); const card = success ? data.card : null; if (success) specialQuizAwardedCard = card; const status = document.getElementById('sq-ov-status'); if (status) status.textContent = ''; const result = document.getElementById('sq-ov-result'); if (result) { result.classList.remove('is-hidden'); result.textContent = success ? 'ได้รับการ์ดพิเศษ!' : 'รอบนี้ไม่ได้การ์ดพิเศษ'; result.className = 'sq-ov-result ' + (success ? 'sq-ov-result--ok' : 'sq-ov-result--fail'); } /* แผงโชว์การ์ด (mock 10-Game-1-result) + ปุ่ม «เล่นเกมต่อ» — เกมหยุดค้างจนกว่าจะกด */ const award = document.getElementById('sq-ov-award'); const imgEl = document.getElementById('sq-ov-award-img'); const txtEl = document.getElementById('sq-ov-award-txt'); const effEl = document.getElementById('sq-ov-award-effect-img'); const msgEl = document.getElementById('sq-ov-award-msg'); const btn = document.getElementById('sq-ov-award-continue'); const ovEl = specialQuizOverlayEl(); if (award) { if (success) { const cid = card.cardId; const REWARD = '/Game/img/special-quiz/reward/'; if (imgEl) { imgEl.removeAttribute('hidden'); imgEl.src = cid ? (REWARD + 'card-' + cid + '.png') : (card.imageUrl || ''); } if (effEl) effEl.style.display = ''; if (txtEl) { /* การ์ด 7 ใช้ชื่อไฟล์ card--7txt.png (ผิดแพทเทิร์น) นอกนั้น card-N-txt.png */ txtEl.removeAttribute('hidden'); txtEl.src = REWARD + (Number(cid) === 7 ? 'card--7txt.png' : ('card-' + cid + '-txt.png')); } if (msgEl) msgEl.textContent = ''; } else { if (imgEl) { imgEl.setAttribute('hidden', ''); imgEl.removeAttribute('src'); } if (effEl) effEl.style.display = 'none'; if (txtEl) { txtEl.setAttribute('hidden', ''); txtEl.removeAttribute('src'); } if (msgEl) msgEl.textContent = 'รอบนี้ไม่ได้รับการ์ดพิเศษ — ตอบถูกครบทุกคนเพื่อรับในครั้งหน้า'; } if (btn) { /* รีเซ็ตสถานะปุ่ม (เผื่อ overlay ถูกใช้ซ้ำรอบก่อน) */ btn.disabled = false; btn.classList.remove('is-waiting'); delete btn.dataset.sqWaiting; btn.textContent = 'เล่นเกมต่อ'; btn.onclick = specialQuizClickContinue; } /* สลับ layer: ซ่อนคำถาม/คำตอบ แล้วโชว์ผลรางวัลเต็มจอ (ตัดกรอบควิซออก) */ const quizBody = document.getElementById('sq-ov-quiz-body'); if (quizBody) quizBody.classList.add('is-hidden'); if (ovEl) ovEl.classList.add('is-award'); award.classList.remove('is-hidden'); } else { /* ไม่มี element สำรอง — ส่ง ack แล้วเล่นต่อทันที */ try { socket.emit('special-quiz-continue', {}, function () {}); } catch (e) { /* ignore */ } specialQuizDoResume(); return; } /* กันค้าง (fallback ฝั่ง client): ปกติ server จะ broadcast resume-all (มี safety 32s ฝั่ง server) ตั้งไว้นานกว่า server เล็กน้อย เผื่อ resume-all หาย */ clearTimeout(endSpecialQuiz._safety); endSpecialQuiz._safety = setTimeout(specialQuizDoResume, 40000); } 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; if (!mission.uiSkin && isGauntletCrownHeistMapPlay()) mission.uiSkin = 'gauntlet_crown'; const missionSkin = resolveGauntletCrownMissionUiSkinPlay(mission); if (!mission.specialCardAwarded && specialQuizAwardedCard) mission.specialCardAwarded = specialQuizAwardedCard; let disp = gauntletCrownBuildDisplayMission(mission); if (mission.uiSkin === 'jumper') { disp = jumpSurviveMergePreviewBotsMission(disp) || disp; } else if (mission.uiSkin === 'violent_crime') { disp = spaceShooterMergePreviewBotsMission(disp) || disp; } else if (mission.uiSkin === 'question_mission') { disp = quizQuestionMissionMergePreviewBotsMission(disp) || disp; } else if (mission.uiSkin === 'stack_tower') { disp = stackTowerMissionMergePreviewBotsMission(disp) || disp; } else if (mission.uiSkin === 'mega_virus') { disp = balloonBossMergePreviewBotsMission(disp) || disp; } if (!disp || !Array.isArray(disp.ranked)) return; /* mission map — สรุปผลภารกิจตาม mockup (Minigame 1/4–7 + Last Light 07-summary) */ const tfMockSummary = isGcmMissionMockSummaryPlay(mission); /* สรุปผลแบบ MG2 (โบนัสพิเศษ 2 คอลัมน์ + popup-result panel) ให้ "ทุกเกม" ใช้ — CSS/asset ต่อ skin มีครบ */ const mg2Summary = tfMockSummary; ov.classList.toggle('gcm-quiz-tf-mock', tfMockSummary); ov.classList.toggle('gcm-mg2-mock', mg2Summary); const gcmVrule = document.getElementById('gcm-vrule'); if (gcmVrule) { gcmVrule.classList.toggle('is-hidden', !mg2Summary); gcmVrule.setAttribute('aria-hidden', mg2Summary ? 'true' : 'true'); } const gcmBgImg = ov.querySelector('.gcm-bg'); if (gcmBgImg) { const gcmBgSrc = tfMockSummary ? gcmMissionMockAssetUrl(mission, 'popup-result.png') : (BASE + '/img/gauntlet-assets/popup-result.png'); if (gcmBgImg.getAttribute('src') !== gcmBgSrc) gcmBgImg.setAttribute('src', gcmBgSrc); } if (tfMockSummary) { ensureMePlayTintFromLobbyStorage(); ensureQuizTfScaleResizeListener(); updateGcmTfMockScale(ov); /* preload tint ทุกคนก่อนวาด avatar — กันรูปว่าง/สีเดียว */ disp.ranked.forEach(function (r) { const cid = resolveRankedCharacterIdPlay(r); if (!cid) return; preloadPlayTintForAvatar(cid, resolveCyberHudAvatarTintPlay({ id: r.id, characterId: cid, isMe: myId != null && r.id != null && String(r.id) === String(myId), })); }); } else { ov.style.removeProperty('--gcm-tf-scale'); } 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 (tfMockSummary) { /* mockup: ทุกอันดับเป็นภาพ (result-1st/2nd/3rd, txt-1=4, txt-2=5+) */ const badge = rk === 1 ? 'result-1st.png' : rk === 2 ? 'result-2nd.png' : rk === 3 ? 'result-3rd.png' : rk === 4 ? 'result-txt-1.png' : 'result-txt-2.png'; const tagImg = document.createElement('img'); tagImg.src = gcmMissionMockAssetUrl(mission, badge); tagImg.alt = '[' + String(r.rankLabel || rk) + ']'; tagImg.style.maxWidth = '100%'; tagImg.style.height = 'auto'; tag.appendChild(tagImg); } else 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 = resolveRankedCharacterIdPlay(r); const avRow = { id: r.id, characterId: cid, isMe: myId != null && r.id != null && String(r.id) === String(myId), playTint: resolveCyberHudAvatarTintPlay({ id: r.id, characterId: cid, isMe: myId != null && r.id != null && String(r.id) === String(myId), }), }; if (cid) { applyCyberHudScoreAvatarImgWithRetry(img, avRow, 0); } else { img.src = defaultAvatarImg.src; } img.onerror = function () { this.onerror = null; applyCyberHudScoreAvatarImgWithRetry(this, avRow, 3); }; if (tfMockSummary) { /* mission mock: หน้าใน gcm-av-clip z3 ทับ player-frame z2 (กลางกรอบทึบ) */ const avClip = document.createElement('div'); avClip.className = 'gcm-av-clip'; avClip.appendChild(img); frame.appendChild(avClip); const frameImg = document.createElement('img'); frameImg.className = 'gcm-frame-img'; frameImg.src = gcmMissionMockAssetUrl(mission, 'player-frame.png'); frameImg.alt = ''; frameImg.decoding = 'async'; frame.appendChild(frameImg); } else { frame.appendChild(img); } const qMissionLose = mission && mission.uiSkin === 'question_mission' && r.hadQuizWrong; if (tfMockSummary && (r.eliminated || qMissionLose)) cell.classList.add('gcm-cell--out'); if (r.eliminated || qMissionLose) { const st = document.createElement('img'); st.className = 'gcm-stamp'; const jSkin = mission && mission.uiSkin === 'jumper'; let loseUrl = BASE + '/img/gauntlet-assets/result-Lose_stamp.png'; let altLose = 'เสียชีวิต'; if (jSkin && r.eliminated) { loseUrl = jumperAssetUrl('stamp-sacrifice.png'); altLose = 'ผู้เสียสละ'; } else if (qMissionLose) { loseUrl = BASE + '/img/QUESTION/result-Lose_stamp.png'; altLose = 'ตอบผิด'; } st.src = loseUrl; st.alt = altLose; 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' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus'); const botScorePlain = (playBotsEnabled() && isGauntletCrownHeistMapPlay() && isPreviewBotId(r.id) && !mg2Summary) || (playBotsEnabled() && mission && mission.uiSkin === 'question_mission' && isPreviewBotId(r.id)) || (playBotsEnabled() && mission && mission.uiSkin === 'stack_tower' && isPreviewBotId(r.id)) || (playBotsEnabled() && mission && mission.uiSkin === 'mega_virus' && isPreviewBotId(r.id)); if (mg2Summary) { const bs = Math.max(0, Number(r.baseScore) || 0); const rb = Math.max(0, Number(r.rankBonus) || 0); sc.textContent = ''; sc.appendChild(document.createTextNode(String(bs))); if (rb > 0) { const bon = document.createElement('span'); bon.className = 'gcm-sc-bonus'; bon.textContent = '(+' + String(rb) + ')'; sc.appendChild(bon); } } else { 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'; const violentSkin = mission && mission.uiSkin === 'violent_crime'; const questionMissionSkin = mission && mission.uiSkin === 'question_mission'; if (tfMockSummary) { totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') =' + sum + ''; } else 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 if (violentSkin) { 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 if (questionMissionSkin) { const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0)); totalEl.innerHTML = 'คะแนนตอบคำถาม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด'; } else if (mission && mission.uiSkin === 'stack_tower') { const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0)); totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด'; } else if (mission && mission.uiSkin === 'mega_virus') { const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0)); totalEl.innerHTML = 'ความเสียหายรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + 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'; const hasSpecialCard = !!(mission && mission.specialCardAwarded); let bonusCardHost = bonusEl; /* mg2 summary: โชว์ block "โบนัสพิเศษ" เสมอ — ได้การ์ด=โชว์การ์ด, ไม่ได้=การ์ดเปล่า + ข้อความ "ไม่ได้รับการ์ด" (การ์ดพิเศษได้จากคำถามพิเศษระหว่างเล่น ไม่ขึ้นกับเกรด → แม้เกรด F ก็โชว์สถานะการ์ดได้) */ if (mg2Summary) { const bonusWrap = document.createElement('div'); bonusWrap.className = 'gcm-bonus-mg2 sm-bonus'; const bonusLeft = document.createElement('div'); bonusLeft.className = 'gcm-bonus-mg2-left sm-bonus-left'; const bonusHead = document.createElement('h3'); bonusHead.className = 'gcm-h gcm-bonus-mg2-head head'; bonusHead.textContent = 'โบนัสพิเศษ'; bonusLeft.appendChild(bonusHead); { /* โชว์รูปสถานะการ์ดเสมอ (ไม่ขึ้นกับเกรด) — ได้การ์ด=mission-complete-select.png, ไม่ได้=mission-complete.png */ const completeImg = document.createElement('img'); completeImg.className = 'gcm-bonus-mg2-complete complete'; completeImg.src = gcmMissionMockAssetUrl(mission, hasSpecialCard ? 'mission-complete-select.png' : 'mission-complete.png'); completeImg.alt = hasSpecialCard ? 'ได้รับการ์ด' : 'ไม่ได้รับการ์ด'; completeImg.decoding = 'async'; completeImg.onerror = function () { this.onerror = null; this.src = BASE + '/img/Jumper/' + (hasSpecialCard ? 'mission-complete-select.png' : 'mission-complete.png'); }; bonusLeft.appendChild(completeImg); } const bonusRight = document.createElement('div'); bonusRight.className = 'gcm-bonus-mg2-right sm-bonus-right'; bonusWrap.appendChild(bonusLeft); bonusWrap.appendChild(bonusRight); bonusEl.appendChild(bonusWrap); bonusCardHost = bonusRight; if (!hasSpecialCard) { /* ไม่ได้การ์ดพิเศษ → การ์ดพื้นหลังเปล่า (popup-result-card-bg.png) + ข้อความ "ไม่ได้รับการ์ด" */ const bgCard = document.createElement('img'); bgCard.className = 'bail-card gcm-special-card-img'; bgCard.src = BASE + '/img/Jumper/popup-result-card-bg.png'; bgCard.alt = ''; bgCard.decoding = 'async'; bonusRight.appendChild(bgCard); } } else if (missionOk && !mg2Summary) { const row = document.createElement('div'); row.className = 'gcm-bonus-okrow'; row.style.display = 'flex'; row.style.alignItems = 'center'; row.style.gap = '8px'; const chk = document.createElement('img'); const stackTS = mission && mission.uiSkin === 'stack_tower'; const megaVs = mission && mission.uiSkin === 'mega_virus'; chk.src = stackTS ? stackTowerAssetUrl('result-check.png') : (megaVs ? megaVirusAssetUrl('result-check.png') : (BASE + '/img/gauntlet-assets/result-check.png')); chk.alt = ''; if (stackTS || megaVs) { chk.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/result-check.png'; }; } if (tfMockSummary) { chk.src = gcmMissionMockAssetUrl(mission, 'mission-complete-select.png'); chk.alt = 'ภารกิจสำเร็จ'; chk.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/result-check.png'; }; row.appendChild(chk); } else { 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 (!mg2Summary && 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 (!mg2Summary && !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); } if (mission && mission.specialCardAwarded) { renderSpecialCardInBonus(bonusCardHost, mission.specialCardAwarded); } } const gcmHead = document.getElementById('gcm-heading'); if (gcmHead) { if (mission && (mission.uiSkin === 'gauntlet_crown' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus' || mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime')) gcmHead.classList.add('sr-only'); else gcmHead.classList.remove('sr-only'); } if (btn && mg2Summary) { btn.style.backgroundImage = "url('" + gcmMissionMockAssetUrl(mission, 'btn-done.png') + "')"; } else if (btn) { btn.style.removeProperty('background-image'); } ov.classList.remove('is-hidden'); if (tfMockSummary) { requestAnimationFrame(function () { refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked); }); setTimeout(function () { refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked); }, 200); setTimeout(function () { refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked); }, 550); setTimeout(function () { refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked); }, 1200); } function goLobby() { ov.classList.add('is-hidden'); if (gcmHead) gcmHead.classList.remove('sr-only'); if (tryFinishDetectiveMinigameAndReturnLobby()) return; window.location.href = buildRoomLobbyReturnHref(); } if (btn) { btn.onclick = function () { if (previewMode && editorEmbedReturn) { ov.classList.add('is-hidden'); if (gcmHead) gcmHead.classList.remove('sr-only'); if (mission && (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus' || mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'quiz_carry')) { cancelQuizCarryResultEndAfterTimeup(); hideQuizCarryTimeupOnDeskLayer(); hideQuizCarryResultEndLayer(); scheduleEmbedPreviewReturnToLobbyAfterResultEnd(); } else { 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; /* 1) flood-fill หากลุ่มโดม (raw component = 1 สถานีคำถาม) */ const raw = Array(h).fill(0).map(() => Array(w).fill(0)); const compsCells = []; // compsCells[i] = [[x,y],...] ของกลุ่มที่ i (rawId = i+1) let rawId = 0; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { if (grid[y][x] !== 1 || raw[y][x]) continue; rawId++; const cells = []; const stack = [[x, y]]; raw[y][x] = rawId; while (stack.length) { const c = stack.pop(); const cx = c[0], cy = c[1]; cells.push([cx, cy]); 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 || raw[ny][nx]) continue; raw[ny][nx] = rawId; stack.push([nx, ny]); } } compsCells.push(cells); } } /* 2) BFS ระยะทางจากจุดเกิด (เดินบนเส้นทาง+โดม) เพื่อจัดเลขโดมตามลำดับ "เดินถึงก่อน = เลขน้อยกว่า" */ const path = md.quizBattlePathArea; const dist = Array(h).fill(0).map(() => Array(w).fill(-1)); const bq = []; const sp = md.spawn || { x: 1, y: 1 }; const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0)); const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0)); const walkable = (tx, ty) => { const onPath = path && path[ty] && path[ty][tx] === 1; const onDome = grid[ty] && grid[ty][tx] === 1; return onPath || onDome; }; const enq = (tx, ty, d) => { if (tx < 0 || ty < 0 || tx >= w || ty >= h) return; if (dist[ty][tx] >= 0 || !walkable(tx, ty)) return; dist[ty][tx] = d; bq.push([tx, ty, d]); }; enq(stx, sty, 0); if (dist[sty][stx] < 0) { for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) enq(stx + dx, sty + dy, 0); } const sa = md.spawnArea; if (sa && Array.isArray(sa)) { for (let ty = 0; ty < h; ty++) { const row = sa[ty]; if (!row) continue; for (let tx = 0; tx < w; tx++) if (row[tx] === 1) enq(tx, ty, 0); } } let head = 0; while (head < bq.length) { const cur = bq[head++]; const cx = cur[0], cy = cur[1], cd = cur[2]; enq(cx + 1, cy, cd + 1); enq(cx - 1, cy, cd + 1); enq(cx, cy + 1, cd + 1); enq(cx, cy - 1, cd + 1); } /* 3) ระยะทางต่ำสุดของแต่ละกลุ่ม → เรียง → ให้เลข 1..N (กลุ่มที่ไปไม่ถึงไว้ท้าย) */ const order = compsCells.map((cells, i) => { let md0 = Infinity; for (const [cx, cy] of cells) { const dd = dist[cy][cx]; if (dd >= 0 && dd < md0) md0 = dd; } return { idx: i, minDist: md0, firstCell: cells[0] }; }); order.sort((a, b) => (a.minDist - b.minDist) || (a.firstCell[1] - b.firstCell[1]) || (a.firstCell[0] - b.firstCell[0])); const comp = Array(h).fill(0).map(() => Array(w).fill(0)); order.forEach((o, rank) => { for (const [cx, cy] of compsCells[o.idx]) comp[cy][cx] = rank + 1; }); md.quizBattleDomeComp = comp; } function hasQuizBattleManualPathGatesPlay(md) { const mg = md && md.quizBattlePathGateArea; if (!mg || !Array.isArray(mg)) return false; for (let ty = 0; ty < mg.length; ty++) { const row = mg[ty]; if (!row) continue; for (let tx = 0; tx < row.length; tx++) { if ((row[tx] | 0) > 0) return true; } } return false; } function buildQuizBattlePathGateFromManualPlay(md) { const w = md.width || 20, h = md.height || 15; const path = md.quizBattlePathArea; const dome = md.quizBattleDomeArea; const manual = md.quizBattlePathGateArea; const gate = Array(h).fill(0).map(() => Array(w).fill(0)); const bestReq = Array(h).fill(0).map(() => Array(w).fill(-1)); const queue = []; function enqueue(tx, ty, req) { if (tx < 0 || ty < 0 || tx >= w || ty >= h) return; const onPath = path[ty] && path[ty][tx] === 1; const onDome = dome && dome[ty] && dome[ty][tx] === 1; if (!onPath && !onDome) return; if (bestReq[ty][tx] >= 0 && req >= bestReq[ty][tx]) return; bestReq[ty][tx] = req; queue.push({ tx, ty, req }); } const sp = md.spawn || { x: 1, y: 1 }; const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0)); const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0)); enqueue(stx, sty, 0); if (bestReq[sty][stx] < 0) { for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) enqueue(stx + dx, sty + dy, 0); } const sa = md.spawnArea; if (sa && Array.isArray(sa)) { for (let ty = 0; ty < h; ty++) { const row = sa[ty]; if (!row) continue; for (let tx = 0; tx < w; tx++) { if (row[tx] === 1) enqueue(tx, ty, 0); } } } while (queue.length) { const cur = queue.shift(); const { tx, ty, req } = cur; if (bestReq[ty][tx] !== req) continue; const onDome = dome && dome[ty] && dome[ty][tx] === 1; let enterReq = req; const mg = manual && manual[ty] && manual[ty][tx] ? (manual[ty][tx] | 0) : 0; if (onDome) enterReq = 0; else if (mg > 0) enterReq = Math.max(enterReq, mg); if (path[ty] && path[ty][tx] === 1) gate[ty][tx] = enterReq; let outReq = enterReq; if (mg > 0) outReq = Math.max(outReq, mg); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { enqueue(tx + dx, ty + dy, outReq); } } md.quizBattlePathGateRequired = gate; } /** ประตูบนเลน — เลนหลังโดม C ต้องตอบถูกโดม 1..C ก่อน (ยืนบนโดมได้โดยไม่ต้องตอบโดมนั้น) */ function buildQuizBattlePathGateRequiredPlay(md) { if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActive(md)) { if (md) md.quizBattlePathGateRequired = null; return; } if (hasQuizBattleManualPathGatesPlay(md)) { buildQuizBattlePathGateFromManualPlay(md); return; } normalizeQuizBattleDomeInPlay(md); const w = md.width || 20, h = md.height || 15; const path = md.quizBattlePathArea; const dome = md.quizBattleDomeArea; const domeComp = md.quizBattleDomeComp; const gate = Array(h).fill(0).map(() => Array(w).fill(0)); const dist = Array(h).fill(0).map(() => Array(w).fill(-1)); const queue = []; const sp = md.spawn || { x: 1, y: 1 }; const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0)); const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0)); function tryEnqueue(tx, ty, d) { if (tx < 0 || ty < 0 || tx >= w || ty >= h) return; if (dist[ty][tx] >= 0) return; const onPath = path[ty] && path[ty][tx] === 1; const onDome = dome && dome[ty] && dome[ty][tx] === 1; if (!onPath && !onDome) return; dist[ty][tx] = d; queue.push({ tx, ty, d }); } tryEnqueue(stx, sty, 0); if (dist[sty][stx] < 0) { for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) tryEnqueue(stx + dx, sty + dy, 0); } const sa = md.spawnArea; if (sa && Array.isArray(sa)) { for (let ty = 0; ty < h; ty++) { const row = sa[ty]; if (!row) continue; for (let tx = 0; tx < w; tx++) { if (row[tx] === 1) tryEnqueue(tx, ty, 0); } } } const domeMinDist = {}; let maxComp = 0; while (queue.length) { const cur = queue.shift(); const { tx, ty, d } = cur; const dc = domeComp[ty] && domeComp[ty][tx] ? domeComp[ty][tx] : 0; if (dc > 0) { maxComp = Math.max(maxComp, dc); if (domeMinDist[dc] == null || d < domeMinDist[dc]) domeMinDist[dc] = d; } for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const nx = tx + dx, ny = ty + dy; if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; if (dist[ny][nx] >= 0) continue; const onPath = path[ny] && path[ny][nx] === 1; const onDome = dome && dome[ny] && dome[ny][nx] === 1; if (onPath || onDome) tryEnqueue(nx, ny, d + 1); } } for (let ty = 0; ty < h; ty++) { for (let tx = 0; tx < w; tx++) { if (!path[ty] || path[ty][tx] !== 1) continue; const d = dist[ty][tx]; if (d < 0) { gate[ty][tx] = maxComp; continue; } let req = 0; for (let c = 1; c <= maxComp; c++) { if (domeMinDist[c] != null && domeMinDist[c] < d) req = Math.max(req, c); } gate[ty][tx] = req; } } md.quizBattlePathGateRequired = gate; } function quizBattlePathGateRequiredAtPlay(md, px, py) { const gate = md && md.quizBattlePathGateRequired; if (!gate || !quizBattlePathModeActive(md)) return 0; const path = md.quizBattlePathArea; const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py); let maxReq = 0; for (const k of tiles) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (!path[ty] || path[ty][tx] !== 1) continue; maxReq = Math.max(maxReq, gate[ty][tx] || 0); } return maxReq; } function quizBattleFirstMissingGateCompPlay(md, px, py, solvedSet) { const maxReq = quizBattlePathGateRequiredAtPlay(md, px, py); if (maxReq <= 0) return 0; for (let c = 1; c <= maxReq; c++) { if (!solvedSet.has(c)) return c; } return 0; } function quizBattlePathGateAllowsPlay(md, px, py, solvedSet) { if (quizBattleFootprintOnDomePlay(md, px, py)) return true; return quizBattleFirstMissingGateCompPlay(md, px, py, solvedSet) === 0; } /** * คำถามของโดม compId → index ใน pool — เคารพ "คำถามที่กำหนดเองต่อโดม" (quizBattleDomeQuestionArea) จาก Editor * ถ้าไม่กำหนด (0) ใช้ตามลำดับเดิน (compId-1)%len เหมือนเดิม */ function quizBattleQuestionIndexForCompPlay(compId, poolLen) { if (!(poolLen > 0)) return 0; let override = 0; const md = mapData; if (md && Array.isArray(md.quizBattleDomeComp) && Array.isArray(md.quizBattleDomeQuestionArea)) { const comp = md.quizBattleDomeComp, qa = md.quizBattleDomeQuestionArea; for (let y = 0; y < comp.length && !override; y++) { const cr = comp[y], qr = qa[y]; if (!cr || !qr) continue; for (let x = 0; x < cr.length; x++) { if (cr[x] === compId && (qr[x] | 0) > 0) { override = qr[x] | 0; break; } } } } const idx = (override > 0 ? override - 1 : compId - 1); return ((idx % poolLen) + poolLen) % poolLen; } let quizBattleGateFlashAt = 0; function quizBattleGateQuestionSnippetPlay(compId) { if (!(compId > 0) || !quizBattleMcqPool.length) return ''; const q = quizBattleMcqPool[quizBattleQuestionIndexForCompPlay(compId, quizBattleMcqPool.length)]; if (!q || !q.text) return ''; const t = String(q.text).trim(); return t.length > 42 ? t.slice(0, 42) + '…' : t; } function quizBattleFlashGateBlocked(md, px, py, solvedSet) { const need = quizBattleFirstMissingGateCompPlay(md, px, py, solvedSet); if (!need) return; const now = performance.now(); if (now - quizBattleGateFlashAt < 1400) return; quizBattleGateFlashAt = now; const snip = quizBattleGateQuestionSnippetPlay(need); flashQuizBattleFeedback( snip ? ('ตอบข้อ ' + need + ' ก่อนผ่าน: 「' + snip + '」') : ('ตอบคำถามข้อ ' + need + ' ก่อนผ่านทางนี้'), false ); } /** เส้นทาง 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; } /** ทุกช่องที่สไปรต์กินพื้นที่ต้องอยู่บนเลน — กันหลุดไปพื้นเปิด (path=0) หรือทับกำแพง (objects=1) */ 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 g = md.quizBattlePathArea; const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py); if (tiles.size === 0) return false; 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; } /** ยืน/เดินได้บนเลนม่วง หรือทุกช่องสไปรต์อยู่ใน spawnArea (ฟ้า) — โซนรอก่อนลงเลน */ /** footprint แตะโซนจุดจบ (quizBattleFinishArea) ไหม — ใช้อนุญาตให้ยืน/เด้งไปจุดจบได้โดยไม่ถูกสแน็ปกลับเลน */ function quizBattleFootprintTouchesFinishPlay(md, px, py) { const fa = md && md.quizBattleFinishArea; if (!Array.isArray(fa)) return false; const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py); for (const k of tiles) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (fa[ty] && fa[ty][tx] === 1) return true; } const cx = Math.floor(px), cy = Math.floor(py); return !!(fa[cy] && fa[cy][cx] === 1); } function quizBattlePositionAllowedPlay(md, px, py) { if (!md || !quizBattlePathModeActive(md)) return true; if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false; if (quizBattleFootprintFullyOnPath(md, px, py)) return true; if (quizBattleFootprintTouchesFinishPlay(md, px, py)) return true; /* โซนจุดจบ — ยืนได้ */ const sa = md.spawnArea; if (!sa || !Array.isArray(sa)) return false; const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py); if (tiles.size === 0) return false; for (const k of tiles) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (!sa[ty] || sa[ty][tx] !== 1) return false; } return true; } /** สปอว์นในพื้นที่ฟ้า — เช็คแค่ไม่ทับกำแพง ไม่บังคับอยู่บนเลน */ function quizBattleSpawnWallClearPlay(md, nx, ny) { if (!md || !md.objects) return false; if (typeof nx !== 'number' || typeof ny !== 'number' || !Number.isFinite(nx) || !Number.isFinite(ny)) return false; const w = md.width || 20, h = md.height || 15; const prevMap = mapData; mapData = md; try { for (const k of quizTilesWallCollisionFootprintPlay(nx, ny)) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; const row = md.objects[ty]; if (!row || row[tx] === 1) return false; } return true; } finally { mapData = prevMap; } } /** เดินทีละย่อย — เลนบาง 1 ช่อง + กล่องสไปรต์ใหญ่กว่าเท้า ยังเดินได้โดยไม่หลุดเลน */ function quizBattleTryStepMovePlay(fx, fy, tx, ty, canWalkFn) { const SUB = 8; let x = fx; let y = fy; for (let i = 1; i <= SUB; i++) { const t = i / SUB; const nx = fx + (tx - fx) * t; const ny = fy + (ty - fy) * t; if (canWalkFn(nx, ny, x, y)) { x = nx; y = ny; } } return { x, y }; } /** สแน็ปบนเลน — เช็คแค่กำแพง + อยู่บน 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) && !quizBattlePositionAllowedPlay(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 ใหญ่กว่าเลน: ลองจุดกลางช่อง — ต้องผ่านกล่องสไปรต์+path เหมือนก้อนบน (เดิมเช็คแค่ไม่ทับกำแพง → snap หลุดเลน) */ 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.5; const ny = ty + 0.5; 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 }; const sa = mapData.spawnArea; if (sa && Array.isArray(sa)) { for (let ty = 0; ty < h; ty++) { const row = sa[ty]; if (!row) continue; for (let tx = 0; tx < w; tx++) { if (row[tx] !== 1) continue; for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, 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 }; 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; } /** สแน็ป peer / บอทหลัง lerp — กันตัวละครล้ำกำแพงหรือหลุดเลนระหว่างเฟรม */ function enforceQuizBattleLaneOnPeersPlay() { if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return; others.forEach((o) => { if (!o) return; if (quizBattleSnapTargetValidPlay(o.x, o.y)) return; const sn = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y); o.x = sn.x; o.y = sn.y; o.tx = sn.x; o.ty = sn.y; }); } function hideQuizBattleMcqModal() { const ov = document.getElementById('quiz-battle-mcq-overlay'); if (ov) { ov.classList.add('is-hidden'); ov.setAttribute('aria-hidden', 'true'); } quizBattleChoiceLocked = false; } 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 quizBattleFootprintOnDomePlay(md, px, py) { const dome = md && md.quizBattleDomeArea; if (!dome) return false; for (const k of quizTilesFootprintPlay(px, py)) { const p = k.split(','); const tx = +p[0], ty = +p[1]; if (dome[ty] && dome[ty][tx] === 1) return true; } return false; } /** โดมใต้เท้า หรือช่องติดกัน (8 ทิศ) — ให้กด E / เด้งคำถามได้แม้ยืนข้างสถานี */ function getNearestQuizBattleDomeCompAt(px, py) { const direct = getPrimaryQuizBattleDomeCompAt(px, py); if (direct != null) return direct; if (!mapData || !isQuizBattle()) return null; const dome = mapData.quizBattleDomeArea; const comp = mapData.quizBattleDomeComp; if (!dome) return null; const w = mapData.width || 20, h = mapData.height || 15; let best = null; for (const k of quizTilesFootprintPlay(px, py)) { const p = k.split(','); const tx = +p[0], ty = +p[1]; for (const [dx, dy] of [[0, 1], [0, -1], [1, 0], [-1, 0], [1, 1], [-1, 1], [1, -1], [-1, -1]]) { const nx = tx + dx, ny = ty + dy; if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; if (!dome[ny] || dome[ny][nx] !== 1) continue; const cid = comp && comp[ny] && comp[ny][nx] ? comp[ny][nx] : 1; if (best == null || cid < best) best = cid; } } return best; } function showQuizBattleMcqModal(q) { const ov = document.getElementById('quiz-battle-mcq-overlay'); const textEl = document.getElementById('quiz-battle-mcq-text'); if (!ov || !textEl || !q) return; const titleEl = document.getElementById('quiz-battle-mcq-title'); if (titleEl) titleEl.textContent = 'QUESTION #' + (quizBattleModalCompId || ''); 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) continue; const ch = (q.choices && q.choices[i]) ? String(q.choices[i]) : ''; const letterEl = btn.querySelector('.qbc-letter'); const txtEl = btn.querySelector('.qbc-text'); const markEl = btn.querySelector('.qbc-mark'); if (letterEl) letterEl.textContent = labels[i]; if (txtEl) txtEl.textContent = ch; if (markEl) { markEl.removeAttribute('src'); markEl.alt = ''; } btn.classList.remove('is-correct', 'is-wrong', 'is-locked'); btn.disabled = false; } quizBattleChoiceLocked = false; ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); } function trySubmitQuizBattleChoice(idx) { if (quizBattleChoiceLocked) return; 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 qIdx = quizBattleQuestionIndexForCompPlay(compId, pool.length); const q = pool[qIdx]; if (!q) return; const ok = Number(q.correctIndex) === Number(idx); /* ตอบครั้งเดียวจบ — ผิดก็ "ผ่าน" และจำว่าผิด (ไม่ให้ตอบซ้ำ) · log ใช้ทำ MY RESULT (O/X) + accuracy */ quizBattleAnswerLog.set(compId, { qIndex: qIdx, firstTry: ok, correct: ok, submittedIdx: idx, q: q, }); quizBattlePassedComps.add(compId); /* ผ่านโดมนี้แล้ว (ถูกหรือผิดก็ผ่าน — ปลดล็อกเส้นทาง) */ /* โชว์ผลถูก/ผิดบนตัวเลือกทันที (q-correct.png / q-wrong.png) แล้วล็อกกันกดซ้ำ */ quizBattleChoiceLocked = true; const ov = document.getElementById('quiz-battle-mcq-overlay'); if (ov) { ov.querySelectorAll('.quiz-battle-choice').forEach((b) => { b.classList.add('is-locked'); b.disabled = true; }); const setMark = (i, kind) => { const b = ov.querySelector('.quiz-battle-choice[data-idx="' + i + '"]'); if (!b) return; b.classList.add(kind === 'correct' ? 'is-correct' : 'is-wrong'); const m = b.querySelector('.qbc-mark'); if (m) m.src = BASE + '/img/qb-ui/' + (kind === 'correct' ? 'q-correct.png' : 'q-wrong.png'); }; if (ok) { setMark(idx, 'correct'); } else { setMark(idx, 'wrong'); setMark(Number(q.correctIndex), 'correct'); /* เฉลยคำตอบที่ถูก */ } } if (ok) { quizBattleAnsweredComps.add(compId); if (typeof socket !== 'undefined' && socket && socket.connected) { try { socket.emit('quizbattle-solve', { compId: compId }, function () {}); } catch (e) { /* ignore */ } } if (myId != null) { if (!playLiveQuizScores) playLiveQuizScores = {}; playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + 1; renderPlayQuizScoreboard(playLiveQuizScores); } submitQuizBattleLeaderboardScore(); flashQuizBattleFeedback('ถูกต้อง · +1 · ทางไปข้างหน้าเปิดแล้ว', true); } else { flashQuizBattleFeedback('ตอบผิด — ผ่านได้เลย (บันทึกว่าผิด) ไม่ต้องตอบซ้ำ', false); } saveQuizBattleProgress(); /* ปิด popup แล้วเช็คจบ (ทั้งถูกและผิด) — ผิดหน่วงนานขึ้นให้เห็นเฉลยก่อน */ setTimeout(() => { quizBattleModalCompId = null; hideQuizBattleMcqModal(); const totalComps = quizBattleTotalCompsPlay(); if (!quizBattleCompletedShown && totalComps > 0 && quizBattlePassedComps.size >= totalComps) { quizBattleCompletedShown = true; setTimeout(openQuizBattleCompleted, 280); } }, ok ? 950 : 1300); } /** * ชื่อผู้เล่นสำหรับ leaderboard/ranking — ผูกกับชื่อที่ตั้งใน Main-Lobby * (localStorage 'roomLobbyDisplayName' → 'playerName' ผ่าน readStoredDisplayName / jdReadDisplayName) * ก่อน fallback ไป me.nickname / ?nick= เพื่อให้ตรงกับโปรไฟล์เดียวกันทุกหน้า */ function quizBattlePlayerDisplayName() { let stored = ''; try { if (typeof window.jdReadDisplayName === 'function') { const v = String(window.jdReadDisplayName() || '').trim(); if (v && v !== 'MONE') stored = v; /* 'MONE' = ค่า default เมื่อยังไม่ตั้งชื่อ → ไม่ถือว่าตั้งแล้ว */ } } catch (e) { /* ignore */ } if (!stored) stored = readStoredDisplayName(); const name = stored || (me && me.nickname) || nick || 'ผู้เล่น'; return String(name || 'ผู้เล่น').trim().slice(0, 24) || 'ผู้เล่น'; } /** ส่งคะแนน (จำนวนตอบถูก) ขึ้น global leaderboard ของห้อง/หมวด */ function submitQuizBattleLeaderboardScore() { if (!isQuizBattle() || !socket || !socket.connected) return; const roomNo = quizBattleRoomNoPlay(); if (roomNo <= 0) return; const score = quizBattleAnsweredComps ? quizBattleAnsweredComps.size : 0; try { socket.emit('qb-leaderboard-submit', { roomId: 'qbroom' + roomNo, name: quizBattlePlayerDisplayName(), score: score, characterId: me.characterId || getPlayCharacterId() || '', }); } catch (e) { /* ignore */ } quizBattleScoreSubmittedAt = Date.now(); } /* ===== Quiz Battle: progress persistence (resume ต่อจากเดิม) ===== */ function quizBattleProgressKey() { const roomNo = quizBattleRoomNoPlay(); const roomId = 'qbroom' + (roomNo > 0 ? roomNo : 1); return 'qbProgress:' + roomId + ':' + quizBattlePlayerDisplayName(); } function saveQuizBattleProgress() { if (!isQuizBattle()) return; try { const passed = []; quizBattleAnswerLog.forEach((v, c) => { passed.push({ c: c, qi: v.qIndex, ft: v.firstTry ? 1 : 0, ok: v.correct ? 1 : 0, idx: v.submittedIdx }); }); const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); const elapsed = quizBattleSessionStartMs ? (nowMs - quizBattleSessionStartMs) : 0; const data = { passed: passed, pos: (me && Number.isFinite(me.x) && Number.isFinite(me.y)) ? { x: me.x, y: me.y } : null, elapsed: Math.max(0, Math.round(elapsed)), ts: Date.now(), }; localStorage.setItem(quizBattleProgressKey(), JSON.stringify(data)); } catch (e) { /* ignore */ } } function loadQuizBattleProgress() { try { const raw = localStorage.getItem(quizBattleProgressKey()); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } /** คืนสถานะที่เคยเล่น (โดมที่ผ่าน/คำตอบ/เวลา) + ตำแหน่งล่าสุด — เรียกใน setupPlayQuizBattleUi */ function restoreQuizBattleProgress() { const data = loadQuizBattleProgress(); if (!data || !Array.isArray(data.passed)) return; data.passed.forEach((p) => { if (!p || !(p.c > 0)) return; quizBattleAnswerLog.set(p.c, { qIndex: (p.qi != null ? p.qi : 0), firstTry: !!p.ft, correct: !!p.ok, submittedIdx: p.idx, q: null, /* q ผูกใหม่จาก pool ตอนเปิด MY RESULT */ }); quizBattlePassedComps.add(p.c); if (p.ok) quizBattleAnsweredComps.add(p.c); }); /* เวลาเดินต่อ (offset session start ด้วยเวลาที่เคยใช้) */ if (data.elapsed > 0) { const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); quizBattleSessionStartMs = nowMs - data.elapsed; } /* sync score HUD + leaderboard ให้ตรงกับที่เคยทำ */ if (myId != null) { if (!playLiveQuizScores) playLiveQuizScores = {}; playLiveQuizScores[myId] = quizBattleAnsweredComps.size; renderPlayQuizScoreboard(playLiveQuizScores); } /* ย้ายตัวไปจุดเดิม (ถ้ายังเดินได้) แล้วบอกเซิร์ฟเวอร์ */ if (data.pos && Number.isFinite(data.pos.x) && Number.isFinite(data.pos.y)) { const px = data.pos.x, py = data.pos.y; setTimeout(() => { try { if (!isQuizBattle() || !me) return; if (quizBattleFinishLock) return; /* ถึงจุดจบแล้ว — อย่าเด้งกลับจุดเดิม */ if (typeof canWalkLikeLobby === 'function' && !canWalkLikeLobby(px, py)) return; me.x = px; me.y = py; if (socket && socket.connected) socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); draw(); } catch (e) { /* ignore */ } }, 350); } /* เข้ามาแล้วตอบครบทุกข้ออยู่แล้ว (resume) → ไปจุดจบเลย ไม่ต้องตอบใหม่ */ setTimeout(() => { try { if (!isQuizBattle()) return; const total = quizBattleTotalCompsPlay(); if (total > 0 && quizBattlePassedComps.size >= total) { quizBattleCompletedShown = true; moveToQuizBattleFinishPlay(); } } catch (e) { /* ignore */ } }, 600); } /* ===== Quiz Battle: MY RESULT + RANKING popups (Stage 2) ===== */ function qbEscHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function qbFmtDuration(ms) { const s = Math.max(0, Math.round((ms || 0) / 1000)); const m = Math.floor(s / 60), ss = s % 60; if (m >= 60) { const h = Math.floor(m / 60); return h + 'h ' + (m % 60) + 'm'; } if (m > 0) return m + 'm ' + ss + 's'; return ss + ' s'; } /** จำนวนโดม (สถานีคำถาม) ทั้งหมดของแมป = ค่า comp สูงสุดใน quizBattleDomeComp */ function quizBattleTotalCompsPlay() { const md = mapData; if (md && md.quizBattleDomeComp && Array.isArray(md.quizBattleDomeComp)) { let mx = 0; const dc = md.quizBattleDomeComp; for (let y = 0; y < dc.length; y++) { const row = dc[y]; if (!row) continue; for (let x = 0; x < row.length; x++) { const v = row[x] | 0; if (v > mx) mx = v; } } if (mx > 0) return mx; } return quizBattleMcqPool.length || 0; } function qbSetText(id, txt) { const el = document.getElementById(id); if (el) el.textContent = txt; } function qbAvatarSrcForCharacter(cid) { if (!cid) return ''; try { ensurePlayLobbyIdleAvatarImage(cid); } catch (e) { /* ignore */ } const src = playLobbyIdleAvatarSrcForCharacter(cid); if (src) return src; const im = getCharacterImg(cid, 'down'); return (im && im.src) ? im.src : ''; } function openQuizBattleMyResult() { if (!isQuizBattle()) return; const pop = document.getElementById('qb-myresult-popup'); if (!pop) return; const total = quizBattleTotalCompsPlay(); const passed = quizBattlePassedComps ? quizBattlePassedComps.size : 0; let firstCorrect = 0, attempted = 0; quizBattleAnswerLog.forEach((v) => { attempted++; if (v.firstTry) firstCorrect++; }); const accPct = attempted ? Math.round((firstCorrect / attempted) * 100) : 0; const progPct = total ? Math.round((passed / total) * 100) : 0; const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); const elapsed = quizBattleSessionStartMs ? (nowMs - quizBattleSessionStartMs) : 0; qbSetText('qb-mr-accuracy', accPct + '%'); qbSetText('qb-mr-accuracy-sub', '(' + firstCorrect + '/' + attempted + ')'); qbSetText('qb-mr-progress', progPct + '%'); qbSetText('qb-mr-progress-sub', '(' + passed + '/' + total + ')'); qbSetText('qb-mr-time', qbFmtDuration(elapsed)); const grid = document.getElementById('qb-mr-grid'); if (grid) { const cells = Math.max(total, attempted, 0); let html = ''; for (let c = 1; c <= cells; c++) { const log = quizBattleAnswerLog.get(c); let cls = 'qb-mr-cell', mark = ''; if (!log) { cls += ' is-unanswered'; } else if (log.correct) { cls += ' is-correct'; mark = 'O'; } else { cls += ' is-wrong'; mark = 'X'; } html += '
' + c + '' + mark + '
'; } grid.innerHTML = html; } const det = document.getElementById('qb-mr-detail'); if (det) { const entries = Array.from(quizBattleAnswerLog.entries()).sort((a, b) => a[0] - b[0]); if (!entries.length) { det.innerHTML = '
ยังไม่ได้ตอบคำถาม — เดินเข้าโดมแล้วกด E เพื่อเริ่ม
'; } else { const letters = ['A', 'B', 'C']; let html = ''; entries.forEach(function (pair) { const cid = pair[0], log = pair[1]; const q = log.q || quizBattleMcqPool[log.qIndex] || {}; const choices = Array.isArray(q.choices) ? q.choices : []; const markImg = log.correct ? 'q-correct.png' : 'q-wrong.png'; html += '
'; html += '
ข้อ ' + cid + '
'; html += '
' + qbEscHtml(q.text || '') + '
'; choices.forEach(function (ch, i) { let ocls = 'qb-mr-q-opt'; if (i === Number(q.correctIndex)) ocls += ' is-correct'; else if (i === Number(log.submittedIdx) && !log.correct) ocls += ' is-chosen-wrong'; html += '
' + letters[i] + '. ' + qbEscHtml(ch) + '
'; }); html += '
'; }); det.innerHTML = html; } det.scrollTop = 0; } pop.classList.remove('is-hidden'); pop.setAttribute('aria-hidden', 'false'); } function renderQuizBattleRanking(data) { const top = (data && Array.isArray(data.top)) ? data.top : []; const meRow = data && data.me ? data.me : null; const podium = document.getElementById('qb-rank-podium'); if (podium) { const order = [1, 0, 2]; /* แสดง 2-1-3 */ let html = ''; order.forEach(function (idx) { const e = top[idx]; if (!e) return; const rank = idx + 1; const av = qbAvatarSrcForCharacter(e.characterId); html += '
'; html += '
' + (av ? '' : '') + '
'; html += '
' + rank + '
'; html += '
' + qbEscHtml(e.name || '-') + '
'; html += '
' + (e.score | 0) + '
'; html += '
'; }); podium.innerHTML = html || '
ยังไม่มีคะแนน
'; } const list = document.getElementById('qb-rank-list'); if (list) { let html = ''; for (let i = 3; i < top.length && i < 50; i++) { const e = top[i]; html += '
' + (e.rank || (i + 1)) + '' + '' + qbEscHtml(e.name || '-') + '' + '' + (e.score | 0) + '
'; } list.innerHTML = html; } const meBox = document.getElementById('qb-rank-me'); if (meBox) { if (meRow && (meRow.rank || 0) > 0) { meBox.innerHTML = '
' + (meRow.rank || '-') + '' + '' + qbEscHtml(meRow.name || 'คุณ') + ' (คุณ)' + '' + (meRow.score | 0) + '
'; } else { meBox.innerHTML = ''; } } } function openQuizBattleRanking() { if (!isQuizBattle()) return; const pop = document.getElementById('qb-ranking-popup'); if (!pop) return; /* ส่งคะแนนล่าสุดก่อน เพื่อให้อันดับตัวเองเป็นปัจจุบัน */ try { submitQuizBattleLeaderboardScore(); } catch (e) { /* ignore */ } const roomNo = quizBattleRoomNoPlay(); const roomId = 'qbroom' + (roomNo > 0 ? roomNo : 1); const myName = quizBattlePlayerDisplayName(); const list = document.getElementById('qb-rank-list'); if (list) list.innerHTML = '
กำลังโหลดอันดับ…
'; const podium = document.getElementById('qb-rank-podium'); if (podium) podium.innerHTML = ''; const meBox = document.getElementById('qb-rank-me'); if (meBox) meBox.innerHTML = ''; pop.classList.remove('is-hidden'); pop.setAttribute('aria-hidden', 'false'); if (socket && socket.connected) { try { socket.emit('qb-leaderboard-get', { roomId: roomId, name: myName }, function (resp) { renderQuizBattleRanking(resp || {}); }); } catch (e) { if (list) list.innerHTML = '
โหลดอันดับไม่สำเร็จ
'; } } else if (list) { list.innerHTML = '
ยังไม่ได้เชื่อมต่อเซิร์ฟเวอร์
'; } } function closeQuizBattlePopup(which) { let id = 'qb-myresult-popup'; if (which === 'ranking') id = 'qb-ranking-popup'; else if (which === 'completed') id = 'qb-completed-popup'; const pop = document.getElementById(id); if (!pop) return; pop.classList.add('is-hidden'); pop.setAttribute('aria-hidden', 'true'); } /** popup "Completed" (popup-result.png) — สรุป Correct / Time Taken / My Rank เมื่อตอบครบทุกโดม */ /** สุ่มช่องในโซนจุดจบ (quizBattleFinishArea) เป็นพิกัด tile — ไม่มี = null (ให้ผู้เล่นกระจายตัว ไม่กองจุดเดียว) */ function quizBattleFinishRandomCellPlay() { const md = mapData; const fa = md && md.quizBattleFinishArea; if (!Array.isArray(fa)) return null; const cells = []; for (let y = 0; y < fa.length; y++) { const row = fa[y]; if (!row) continue; for (let x = 0; x < row.length; x++) { if (row[x] === 1) cells.push([x, y]); } } if (!cells.length) return null; const pick = cells[Math.floor(Math.random() * cells.length)]; /* สุ่มตำแหน่งย่อยในช่องด้วย เพื่อไม่ให้ซ้อนกันเป๊ะแม้สุ่มได้ช่องเดียวกัน */ const jitter = () => 0.25 + Math.random() * 0.5; return { x: pick[0] + jitter(), y: pick[1] + jitter() }; } /** ตอบครบทุกข้อ → ย้ายผู้เล่นไปจุดจบที่ตั้งไว้ใน Editor (สุ่มช่อง) */ function moveToQuizBattleFinishPlay() { const c = quizBattleFinishRandomCellPlay(); if (!c || !me) return; quizBattleFinishLock = true; /* ล็อก: หลังจากนี้ห้าม echo/restore เด้งกลับ */ me.x = c.x; me.y = c.y; playPath = []; try { if (socket && socket.connected) socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } catch (e) { /* ignore */ } try { draw(); } catch (e) { /* ignore */ } } function openQuizBattleCompleted() { if (!isQuizBattle()) return; moveToQuizBattleFinishPlay(); /* ย้ายไปจุดจบที่ตั้งไว้ (ถ้ามี) */ const pop = document.getElementById('qb-completed-popup'); if (!pop) return; const total = quizBattleTotalCompsPlay(); let firstCorrect = 0; quizBattleAnswerLog.forEach((v) => { if (v.firstTry) firstCorrect++; }); const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); const elapsed = quizBattleSessionStartMs ? (nowMs - quizBattleSessionStartMs) : 0; qbSetText('qb-cp-correct', firstCorrect + ' / ' + total); qbSetText('qb-cp-time', qbFmtDuration(elapsed)); qbSetText('qb-cp-rank', '—'); /* ส่งคะแนนล่าสุด แล้วดึงอันดับมาแสดง My Rank */ try { submitQuizBattleLeaderboardScore(); } catch (e) { /* ignore */ } const roomNo = quizBattleRoomNoPlay(); const roomId = 'qbroom' + (roomNo > 0 ? roomNo : 1); const myName = quizBattlePlayerDisplayName(); if (socket && socket.connected) { try { socket.emit('qb-leaderboard-get', { roomId: roomId, name: myName }, function (resp) { const rank = resp && resp.me && resp.me.rank ? ('#' + resp.me.rank) : '—'; qbSetText('qb-cp-rank', rank); }); } catch (e) { /* ignore */ } } pop.classList.remove('is-hidden'); pop.setAttribute('aria-hidden', 'false'); } /** หา comp โดมที่ตัวละครเหยียบอยู่ — เช็คหลายจุด + ช่องติดโดม ให้เด้งง่ายเมื่อถึงสถานี */ function quizBattleDomeAtMe() { if (!isQuizBattle()) return null; const pts = [[me.x, me.y], [me.x, me.y + 0.45], [me.x, me.y - 0.25], [me.x + 0.35, me.y], [me.x - 0.35, me.y]]; for (let i = 0; i < pts.length; i++) { const c = getNearestQuizBattleDomeCompAt(pts[i][0], pts[i][1]); if (c != null) return c; } return getNearestQuizBattleDomeCompAt(me.x, me.y); } /** ZEP-style: เหยียบสถานี (โดม) ที่ยังไม่ตอบ → เปิดคำถามอัตโนมัติ (ครั้งเดียวต่อการเข้า · เดินออกแล้วกลับมาใหม่ถึงเด้งอีก) */ function maybeAutoOpenQuizBattle() { if (!isQuizBattle() || myId == null) return; const compNow = quizBattleDomeAtMe(); if (compNow == null) { qbAutoLastComp = null; return; } const ov = document.getElementById('quiz-battle-mcq-overlay'); const modalOpen = quizBattleModalCompId != null || (ov && !ov.classList.contains('is-hidden')); if (modalOpen) return; if (compNow === qbAutoLastComp) return; qbAutoLastComp = compNow; if (quizBattlePassedComps.has(compNow)) return; const pool = quizBattleMcqPool; if (!pool.length) { flashQuizBattleFeedback('ยังไม่มีคำถาม — บันทึกใน Admin → Quiz Battle แล้วรีเฟรช', false); return; } const q = pool[quizBattleQuestionIndexForCompPlay(compNow, pool.length)]; if (!q) return; quizBattleModalCompId = compNow; showQuizBattleMcqModal(q); } function qbRoundRect(ctx, x, y, w, h, r) { r = Math.min(r, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } /** หีบบนสถานีคำถาม — ตอบถูก=เขียว+ติ๊ก / ตอบผิด(ผ่านแล้ว)=แดง+กากบาท / ยังไม่ตอบ=ล็อก(น้ำตาล+กุญแจ) + เลขสถานี */ function drawQuizBattleChest(ctx, cx, cy, size, done, num, wrong) { const w = size * 0.84, h = size * 0.64; const x0 = cx - w / 2, y0 = cy - h / 2; ctx.save(); ctx.shadowColor = done ? (wrong ? 'rgba(248,113,128,0.95)' : 'rgba(52,211,153,0.95)') : 'rgba(255,200,70,0.95)'; ctx.shadowBlur = Math.max(4, size * 0.3); ctx.fillStyle = done ? (wrong ? '#8a3340' : '#3f7d57') : '#8a5a2b'; qbRoundRect(ctx, x0, y0 + h * 0.34, w, h * 0.66, size * 0.08); ctx.fill(); ctx.fillStyle = done ? (wrong ? '#b3455a' : '#57a875') : '#a86f33'; qbRoundRect(ctx, x0, y0, w, h * 0.44, size * 0.1); ctx.fill(); ctx.shadowBlur = 0; ctx.fillStyle = done ? (wrong ? '#ffd6dc' : '#d6f5e3') : '#f1c66a'; ctx.fillRect(x0, y0 + h * 0.30, w, h * 0.10); ctx.fillRect(cx - size * 0.045, y0, size * 0.09, h); if (done && wrong) { ctx.strokeStyle = '#fff0f2'; ctx.lineWidth = Math.max(2, size * 0.08); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(cx - size * 0.12, cy - size * 0.10); ctx.lineTo(cx + size * 0.12, cy + size * 0.12); ctx.moveTo(cx + size * 0.12, cy - size * 0.10); ctx.lineTo(cx - size * 0.12, cy + size * 0.12); ctx.stroke(); } else if (done) { ctx.strokeStyle = '#eafff3'; ctx.lineWidth = Math.max(2, size * 0.08); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(cx - size * 0.13, cy + size * 0.04); ctx.lineTo(cx - size * 0.02, cy + size * 0.15); ctx.lineTo(cx + size * 0.17, cy - size * 0.12); ctx.stroke(); } else { ctx.strokeStyle = '#f1c66a'; ctx.lineWidth = Math.max(2, size * 0.05); ctx.beginPath(); ctx.arc(cx, cy + h * 0.04, size * 0.085, Math.PI, 0); ctx.stroke(); ctx.fillStyle = '#f1c66a'; qbRoundRect(ctx, cx - size * 0.085, cy + h * 0.04, size * 0.17, size * 0.13, size * 0.03); ctx.fill(); } if (num) { ctx.fillStyle = 'rgba(8,12,28,0.82)'; ctx.beginPath(); ctx.arc(cx, y0 - size * 0.14, size * 0.17, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#fff'; ctx.font = 'bold ' + Math.max(9, size * 0.23) + 'px Kanit, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(String(num), cx, y0 - size * 0.14); ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; } ctx.restore(); } function tryOpenQuizBattleFromKey() { if (!isQuizBattle() || !mapData || myId == null) return; const compId = quizBattleDomeAtMe(); if (compId == null) { flashQuizBattleFeedback('เข้าใกล้สถานีคำถาม (หีบ) แล้วกด E', false); return; } if (quizBattlePassedComps.has(compId)) { flashQuizBattleFeedback('โดมนี้ผ่านแล้ว', false); return; } const pool = quizBattleMcqPool; if (!pool.length) { flashQuizBattleFeedback('ยังไม่มีคำถามในระบบ (Admin → Quiz Battle)', false); return; } const q = pool[quizBattleQuestionIndexForCompPlay(compId, pool.length)]; if (!q) return; quizBattleModalCompId = compId; showQuizBattleMcqModal(q); } function pushSanitizedBattleMcqFromPayload(s) { if (!s || !Array.isArray(s.battleQuizMcq)) return 0; /* กรองเฉพาะคลังคำถามของหัวข้อห้องนี้ (mapData.quizBattleCategoryId เช่น topic1..topic10) — ไม่ระบุ = ใช้ทั้งหมด */ const roomCat = (mapData && mapData.quizBattleCategoryId) ? String(mapData.quizBattleCategoryId).trim() : ''; let n = 0; for (let i = 0; i < s.battleQuizMcq.length; i++) { const raw = s.battleQuizMcq[i]; if (!raw || !String(raw.text || '').trim()) continue; if (roomCat && String(raw.categoryId || '').trim() !== roomCat) 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(); quizBattlePassedComps = 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(); quizBattleAnswerLog = new Map(); quizBattlePassedComps = new Set(); quizBattleUserZoomMul = 1; quizBattleSessionStartMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); quizBattleModalCompId = null; quizBattleMcqPool = []; quizBattleCompletedShown = false; quizBattleFinishLock = false; hideQuizBattleMcqModal(); playLiveQuizScores = {}; if (myId != null) playLiveQuizScores[myId] = 0; others.forEach((_, id) => { playLiveQuizScores[id] = 0; }); renderPlayQuizScoreboard(playLiveQuizScores); /* คืนสถานะที่เคยเล่น (โดมที่ผ่าน/ตำแหน่งล่าสุด) — เล่นต่อจากเดิมไม่ต้องตอบซ้ำ */ restoreQuizBattleProgress(); 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 normalizeJumpSurvivePlatformVariantAreaInPlay(md) { if (!md || md.gameType !== 'jump_survive') return; const w = md.width || 20, h = md.height || 15; const pa = md.jumpSurvivePlatformArea || []; const src = md.jumpSurvivePlatformVariantArea || []; const rows = []; for (let y = 0; y < h; y++) { const row = []; for (let x = 0; x < w; x++) { const has = pa[y] && pa[y][x] === 1; let v = has ? Math.floor(Number(src[y] && src[y][x])) : 0; if (has && (!Number.isFinite(v) || v < 1)) v = 1; if (v > 3) v = 3; if (!has) v = 0; row.push(v); } rows.push(row); } md.jumpSurvivePlatformVariantArea = rows; } function jumpSurvivePlatformVariantIndexAtPlay(md, tx, ty) { const pa = md && md.jumpSurvivePlatformArea; if (!pa || !pa[ty] || pa[ty][tx] !== 1) return 0; const va = md.jumpSurvivePlatformVariantArea; let v = va && va[ty] ? Math.floor(Number(va[ty][tx])) : 1; if (!Number.isFinite(v) || v < 1) v = 1; if (v > 3) v = 3; return v; } function normalizeJumpSurviveHazardAreaInPlay(md) { if (!md || md.gameType !== 'jump_survive') return; const w = md.width || 20, h = md.height || 15; const src = md.jumpSurviveHazardArea || []; 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.jumpSurviveHazardArea = rows; } /** โซนตาย jump_survive — กริดคงที่ในโลก (ไม่เลื่อนกับแพลตฟอร์ม) · ช่องที่มีแพลตฟอร์มอยู่ด้วย = ไม่นับ hazard (ยืนบนแพลตฟอร์มชนะ) */ function jumpSurviveOverlapsHazardArea(md, left, top, right, bottom) { const ha = md && md.jumpSurviveHazardArea; if (!ha) return false; const pa = md && md.jumpSurvivePlatformArea; const ts = tileSize; const w = md.width || 20, h = md.height || 15; const x0 = Math.max(0, Math.floor(left / ts)); const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts)); const y0 = Math.max(0, Math.floor(top / ts)); const y1 = Math.min(h - 1, Math.floor((bottom - 1e-6) / ts)); for (let ty = y0; ty <= y1; ty++) { for (let tx = x0; tx <= x1; tx++) { if (!ha[ty] || ha[ty][tx] !== 1) continue; if (pa && pa[ty] && pa[ty][tx] === 1) continue; return true; } } return false; } function normalizeShooterSpawnSlotsInPlay(md) { if (!md || (md.gameType !== 'space_shooter' && md.gameType !== 'jump_survive')) 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; } /** ลำดับผู้เล่นที่ "เหมือนกันทุกเครื่อง" สำหรับเกมยาน (MG6 space_shooter / MG7 balloon_boss) สำคัญ: ห้ามเอา me ขึ้นก่อน — เดิมทุกเครื่องดันตัวเองเป็น slot แรก ทำให้ - สี/รูปยาน (slot) ของผู้เล่นคนเดียวกันไม่ตรงกันข้ามจอ - spawn ถูก map คนละ slot → ตำแหน่งเริ่มไม่ตรงกัน/กองทับกัน แก้: เรียงตาม id เหมือนกันทุกเครื่อง (มนุษย์รวมตัวเองก่อน เรียง id แล้วบอท) */ function buildStableShipParticipantRefsPlay() { const refById = new Map(); if (me && myId != null) refById.set(String(myId), me); others.forEach((o, id) => { if (o) refById.set(String(id), o); }); const humanIds = [...refById.keys()].filter((id) => !isPreviewBotId(id)).sort(); const botIds = [...refById.keys()].filter((id) => isPreviewBotId(id)).sort(); const refs = []; humanIds.forEach((id) => refs.push({ id, ref: refById.get(id) })); botIds.forEach((id) => refs.push({ id, ref: refById.get(id) })); return refs.filter((r) => r.ref); } function buildSpaceShooterParticipantRefsPlay() { return buildStableShipParticipantRefsPlay(); } 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; ent.spaceShooterSlot = slot; 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 60; /* สเปก MG6: เล่นภายใน 60 วินาที (default เมื่อ map/game-timing ไม่ได้ตั้งค่า) */ } function spaceShooterRemainingSecPlay() { if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return null; const lim = spaceShooterTimeLimitSecPlay(); if (lim <= 0) return null; /* นับเวลาจาก anchor server → จบพร้อมกันทุกเครื่อง; fallback local ถ้ายังไม่มี anchor (preview) */ const elapsed = spaceShooterLiveAnchorServerMs > 0 ? (serverNow() - spaceShooterLiveAnchorServerMs) / 1000 : (performance.now() - spaceShooterSessionStartMs) / 1000; return Math.max(0, Math.ceil(lim - elapsed)); } function endSpaceShooterTimeUp() { if (spaceShooterGameEnded || !mapData || mapData.gameType !== 'space_shooter') return; if (isSpaceShooterMissionUiMapPlay()) { endSpaceShooterMissionRound('time_up'); return; } spaceShooterGameEnded = true; spaceShooterBullets = []; spaceShooterAsteroids = []; spaceShooterAsteroidExplosions = []; 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 = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); } 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)); } const SPACE_SHOOTER_ASTEROID_INTERVAL_MIN = 200; /** ms ระหว่างเกิดอุกาบาต — แมปทับถ้าตั้ง ≥200 */ function spaceShooterAsteroidSpawnIntervalMsPlay() { const mapI = Number(mapData && mapData.spaceShooterAsteroidIntervalMs); if (Number.isFinite(mapI) && mapI >= SPACE_SHOOTER_ASTEROID_INTERVAL_MIN) { return Math.min(10000, Math.floor(mapI)); } const g = Number(playSpaceShooterAsteroidIntervalMs); if (Number.isFinite(g) && g >= SPACE_SHOOTER_ASTEROID_INTERVAL_MIN) return Math.min(10000, Math.floor(g)); return 1040; } function stepSpaceShooterPreviewBots(dt) { if (spaceShooterGameEnded || !playBotsEnabled() || !isMePlayHost() || !mapData || mapData.gameType !== 'space_shooter') return; if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') 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 || o.spaceShooterEliminated) 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); const padX = spaceShooterShipEdgePadWorldPxPlay(); o.spaceShooterCx = Math.max(padX, Math.min(mw - padX, 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; const bX = o.spaceShooterCx; const bY = o.spaceShooterCy - 16; spaceShooterBullets.push({ x: bX, y: bY, vy: -500, ownerId: bid }); /* host จำลองบอท → broadcast กระสุนบอทให้ client อื่นเห็น */ try { if (socket && myId != null) socket.emit('space-shooter-shot', { x: bX, y: bY, vy: -500, ownerId: bid, t0: serverNow() }); } catch (eB) { /* ignore */ } } }); } function spaceShooterTickFrame() { if (!mapData || mapData.gameType !== 'space_shooter') return; if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') 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; tickPlayScrollBg(dt); spaceShooterTickAsteroidExplosions(dt); 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) { /* จบแบบ host-authoritative (mission): host emit คะแนนสุดท้าย, non-host รอรับ; fallback ถ้า host หลุด */ if (isSpaceShooterMissionUiMapPlay()) { if (isMePlayHost()) { triggerSpaceShooterOverPlay('time_up'); return; } if (remSec <= -2) { endSpaceShooterMissionRound('time_up'); return; } } else { endSpaceShooterTimeUp(); return; } } const interval = spaceShooterAsteroidSpawnIntervalMsPlay(); if (spaceShooterLiveAnchorServerMs > 0) { /* deterministic spawn: ก้อนลำดับ k เกิดที่เวลา anchor + k*interval ด้วยค่าสุ่มจาก seed(anchor,k) → ทุกเครื่องเกิดก้อนชุดเดียวกัน ตำแหน่ง/HP/ความเร็วเท่ากัน ที่เวลา server เดียวกัน */ const targetCount = Math.floor((serverNow() - spaceShooterLiveAnchorServerMs) / interval) + 1; let guard = 0; while (spaceShooterSpawnCount < targetCount && guard < 200) { guard++; const k = spaceShooterSpawnCount++; const rng = makeSeededRng(envSpawnSeed(spaceShooterLiveAnchorServerMs, k)); const maxHp = 1 + Math.floor(rng() * SPACE_SHOOTER_ASTEROID_MAX_HP); const ax = 36 + rng() * (mw - 72); const baseY = -45 - rng() * 90; const avy = 58 + rng() * 62; const spawnTimeMs = spaceShooterLiveAnchorServerMs + k * interval; const elapsedSec = Math.max(0, (serverNow() - spawnTimeMs) / 1000); spaceShooterAsteroids.push({ id: k, x: ax, y: baseY + avy * elapsedSec, r: spaceShooterAsteroidRadiusFromHpPlay(maxHp), vy: avy, maxHp: maxHp, hp: maxHp, }); } } else { spaceShooterSpawnAccMs += dt * 1000; while (spaceShooterSpawnAccMs >= interval) { spaceShooterSpawnAccMs -= interval; const maxHp = 1 + Math.floor(Math.random() * SPACE_SHOOTER_ASTEROID_MAX_HP); const hp0 = maxHp; spaceShooterAsteroids.push({ x: 36 + Math.random() * (mw - 72), y: -45 - Math.random() * 90, r: spaceShooterAsteroidRadiusFromHpPlay(hp0), vy: 58 + Math.random() * 62, maxHp: maxHp, hp: hp0, }); } } others.forEach((o, id) => { if (isPreviewBotId(id)) return; /* lerp แรงขึ้น (0.45) → ยานผู้เล่นคนอื่นตามตำแหน่งจริงทันขึ้น ลดอาการ "ไม่ตรง/ยืด" */ if (o.tx != null) o.x += (o.tx - o.x) * 0.45; if (o.ty != null) o.y += (o.ty - o.y) * 0.45; 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; const canPilotMe = !(isSpaceShooterMissionUiMapPlay() && me.spaceShooterEliminated); if (canPilotMe) { if (me.spaceShooterCx != null) { const padX = spaceShooterShipEdgePadWorldPxPlay(); me.spaceShooterCx = Math.max(padX, Math.min(mw - padX, me.spaceShooterCx + vx * moveSpd * dt)); } if (me.spaceShooterCy != null) { me.spaceShooterCy = clampSpaceShooterWorldCy(me.spaceShooterCy + vy * moveSpd * dt, mh); } syncSpaceShooterFootFromShipCenter(me); } spaceShooterFireCd -= dt; const autoFireOk = canPilotMe && !isChatFocused(); if (autoFireOk && spaceShooterFireCd <= 0 && me.spaceShooterCy != null) { spaceShooterFireCd = 0.21; const shotX = me.spaceShooterCx; const shotY = me.spaceShooterCy - 20; spaceShooterBullets.push({ x: shotX, y: shotY, vy: -580, ownerId: myId }); /* broadcast กระสุนให้ทุกคนเห็นตรงกัน */ try { if (socket && myId != null) socket.emit('space-shooter-shot', { x: shotX, y: shotY, vy: -580, ownerId: myId, t0: serverNow() }); } catch (eS) { /* ignore */ } } 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); } spaceShooterMissionResolveShipAsteroidHitsPlay(); 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]; const mh = Math.max(1, Math.floor(Number(a.maxHp)) || SPACE_SHOOTER_ASTEROID_MAX_HP); a.hp = Math.max(0, Math.floor(Number(a.hp) || mh) - 1); spaceShooterBullets.splice(bi, 1); const add = 5; const ownerEnt = (b.ownerId === myId) ? me : others.get(b.ownerId); /* popup เด้ง +5 ให้เห็นทุกเครื่อง (visual) */ if (ownerEnt && ownerEnt.spaceShooterCx != null) { spaceShooterPopups.push({ x: ownerEnt.spaceShooterCx, y: ownerEnt.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 }); } /* score: เจ้าของเท่านั้นเป็นคนบวก (มนุษย์=ตัวเอง broadcast ผ่าน move, บอท=host broadcast ผ่าน fill-bot-state) เครื่องอื่นห้ามบวกเอง — เดิม non-owner บวกจาก asteroid ที่ต่างกันต่อเครื่อง → คะแนนสุดท้ายไม่ตรง */ if (b.ownerId === myId) { me.spaceShooterScore = Math.max(0, (me.spaceShooterScore || 0) + add); } else if (isPreviewBotId(b.ownerId) && isMePlayHost() && ownerEnt) { ownerEnt.spaceShooterScore = Math.max(0, (ownerEnt.spaceShooterScore || 0) + add); } if (a.hp <= 0) { spaceShooterSpawnAsteroidExplosion(a.x, a.y, a.r); spaceShooterAsteroids.splice(hit, 1); } /** ขนาดคงที่จนแตก — ไม่ย่อ r ตาม HP ที่เหลือ (สลับสปรายต์ผ่าน spaceShooterAsteroidLiveSpriteIndexPlay) */ } } 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 > 60 && socket && myId != null) { spaceShooterLastMoveEmit = tEmit; socket.emit('move', { x: me.x, y: me.y, direction: me.direction, spaceShooterScore: Math.max(0, me.spaceShooterScore | 0), spaceShooterHits: Math.max(0, Number(me.spaceShooterHits) || 0), spaceShooterEliminated: !!me.spaceShooterEliminated, }); } } function drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs) { if (!mapData || !isSpaceShooter()) return; const refs = buildSpaceShooterParticipantRefsPlay(); const wallNow = typeof timeMs === 'number' ? timeMs : Date.now(); const astUrls = playSpaceShooterAsteroidSpriteUrls; 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 si = spaceShooterAsteroidLiveSpriteIndexPlay(a); let liveUrl = (astUrls && astUrls[si]) ? String(astUrls[si]).trim() : ''; if (!liveUrl && si > 0 && astUrls && astUrls[0]) liveUrl = String(astUrls[0]).trim(); const liveRec = liveUrl ? ensureGauntletAssetImage(liveUrl) : null; const liveAstImg = liveRec && liveRec.ready && liveRec.img && liveRec.img.naturalWidth > 0 ? liveRec.img : null; if (liveAstImg) { const iw = liveAstImg.naturalWidth; const ih = liveAstImg.naturalHeight; const diam = sr * 2.15; const sc = Math.min(diam / iw, diam / ih); const dw = iw * sc; const dh = ih * sc; ctx.drawImage(liveAstImg, sx - dw * 0.5, sy - dh * 0.5, dw, dh); } else { 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; } } if (astUrls && astUrls.length >= 2) { for (let ei = 0; ei < spaceShooterAsteroidExplosions.length; ei++) { const ex = spaceShooterAsteroidExplosions[ei]; const u = astUrls[ex.fi + 1]; if (!u) continue; const rec = ensureGauntletAssetImage(u); const img = rec && rec.ready && rec.img && rec.img.naturalWidth > 0 ? rec.img : null; if (!img) continue; const [sx, sy] = worldToScreen(ex.x, ex.y); const sr = Math.max(6, ex.r * zDraw); const iw = img.naturalWidth; const ih = img.naturalHeight; const diam = sr * 2.4; const sc = Math.min(diam / iw, diam / ih); const dw = iw * sc; const dh = ih * sc; ctx.drawImage(img, sx - dw * 0.5, sy - dh * 0.5, dw, dh); } } 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(); } } const shipBody = spaceShooterShipBodyScreenPxPlay(zDraw); const bodyW = shipBody.bodyW; const bodyH = shipBody.bodyH; 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 elimShip = !!(isSpaceShooterMissionUiMapPlay() && ent.spaceShooterEliminated); if (elimShip) return; /* slot จาก stable idx (refs = buildStableShipParticipantRefsPlay เรียงตาม id เหมือนกันทุกเครื่อง) → สี/รูปยานตรงกันทุกเครื่องและ auto-correct เมื่อ set ผู้เล่น/บอทครบ (เดิมใช้ ent.spaceShooterSlot ที่ freeze ไว้ → เพี้ยนถ้า others ยังไม่ครบตอน layout) */ const slot = (idx % 6) + 1; const col = SPACE_SHOOTER_SHIP_COLORS[(slot - 1) % SPACE_SHOOTER_SHIP_COLORS.length]; const shipUrl = (playSpaceShooterShipImageUrls[slot - 1] || '').trim(); const shipRec = shipUrl ? ensureGauntletAssetImage(shipUrl) : null; const shipImg = shipRec && shipRec.ready && shipRec.img && shipRec.img.naturalWidth > 0 ? shipRec.img : null; let shipDrawW = bodyW * 2.2; let shipDrawH = bodyH * 2.2; if (shipImg) { ctx.save(); ctx.translate(sx, sy); if (elimShip) ctx.globalAlpha = 0.38; const iw = shipImg.naturalWidth; const ih = shipImg.naturalHeight; const maxW = bodyW * 2.4; const maxH = bodyH * 2.4; const sc = Math.min(maxW / iw, maxH / ih); const dw = iw * sc; const dh = ih * sc; shipDrawW = dw; shipDrawH = dh; ctx.drawImage(shipImg, -dw * 0.5, -dh * 0.5, dw, dh); ctx.restore(); } else { ctx.save(); ctx.translate(sx, sy); if (elimShip) ctx.globalAlpha = 0.38; 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(); } if (isSpaceShooterMissionUiMapPlay()) { const hits = Math.max(0, Math.min(3, Number(ent.spaceShooterHits) || 0)); if (hits > 0 && playSpaceShooterShipDamageOverlayUrls && playSpaceShooterShipDamageOverlayUrls.length) { const oUrl = (playSpaceShooterShipDamageOverlayUrls[hits - 1] || '').trim(); if (oUrl) { const oRec = ensureGauntletAssetImage(oUrl); const oImg = oRec && oRec.ready && oRec.img && oRec.img.naturalWidth > 0 ? oRec.img : null; if (oImg) { ctx.save(); if (elimShip) ctx.globalAlpha = 0.38; const ow = oImg.naturalWidth; const oh = oImg.naturalHeight; const scO = Math.min(shipDrawW / ow, shipDrawH / oh); const odw = ow * scO; const odh = oh * scO; ctx.drawImage(oImg, sx - odw * 0.5, sy - odh * 0.5, odw, odh); 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; } /** MG7 — ครึ่งหน้าต่าง (tiles) ที่ต้องเห็นรอบกลางแมป = ระยะไกลสุดของ slot ผู้เล่น+บอส จากกลางแมป + margin ใช้ซูมให้เต็มจอกว่า fit ทั้งแมป (แมปแคบ aspect 1.33 → ขอบดำเยอะ) โดยไม่ crop ตัวผู้เล่น */ let _balloonBossVisHalfCache = null; function getBalloonBossVisibleHalfTilesPlay(md) { if (!md || md.gameType !== 'balloon_boss') return null; if (_balloonBossVisHalfCache && _balloonBossVisHalfCache._md === md) return _balloonBossVisHalfCache; const w = md.width || 20, h = md.height || 15; const ccx = w / 2, ccy = h / 2; let maxDX = 0, maxDY = 0; const g = md.balloonBossPlayerSlots; if (g) { for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { if (g[y] && g[y][x]) { maxDX = Math.max(maxDX, Math.abs((x + 0.5) - ccx)); maxDY = Math.max(maxDY, Math.abs((y + 0.5) - ccy)); } } } } const bx = md.balloonBossBossSpawn; if (bx && Number.isFinite(Number(bx.x))) { maxDX = Math.max(maxDX, Math.abs((Number(bx.x) + 0.5) - ccx)); maxDY = Math.max(maxDY, Math.abs((Number(bx.y) + 0.5) - ccy) + 2.5 /* บอสลอยขึ้นได้ */); } if (maxDX <= 0 && maxDY <= 0) { maxDX = w / 2; maxDY = h / 2; } const margin = 1.6; /* เผื่อขนาดตัวละคร + ลูกศร/ลูกโป่ง */ const res = { _md: md, halfW: Math.min(w / 2, maxDX + margin), halfH: Math.min(h / 2, maxDY + margin), }; _balloonBossVisHalfCache = res; return res; } 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 }; } /** * จุดกลางบอสระหว่างเล่น — ลอยแบบลูกโป่งจากผลรวม sine (deterministic ต่อเวลาในรอบ) * เพื่อให้ทุก client ได้ตำแหน่งใกล้เคียงกัน (ไม่ใช้ state สุ่มต่อเครื่อง) */ /* เฟสเริ่มต้นของลูกศรวงกลมต่อ id — hash จาก id (เหมือนกันทุกเครื่อง) */ function balloonBossAimPhaseForIdPlay(id) { const s = String(id == null ? '' : id); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return (h % 10000) / 10000 * Math.PI * 2; } /* มุมลูกศรหมุนแบบ deterministic จากเวลา server + เฟสต่อ id → "วงๆ" หมุนตรงกันทุกเครื่อง (เดิมสุ่ม init + บวก dt ต่อเครื่อง → คนละมุม) ความเร็ว 1.02 rad/s */ function balloonBossAutoAimRadPlay(id) { const tMs = balloonBossLiveAnchorServerMs > 0 ? (serverNow() - balloonBossLiveAnchorServerMs) : serverNow(); const a = (tMs * 0.00102) + balloonBossAimPhaseForIdPlay(id); return a % (Math.PI * 2); } function getBalloonBossBossLiveCenterPlay(md, ts) { if (!md || md.gameType !== 'balloon_boss') return getBalloonBossBossWorldCenterPlay(md, ts); const base = getBalloonBossBossWorldCenterPlay(md, ts); const w = md.width || 20, h = md.height || 15; const mw = w * ts, mh = h * ts; /* ผูกเวลาบอสกับ anchor server (เหมือน timer/กระสุนบอส) → ตำแหน่งบอสตรงกันทุกเครื่อง (เดิมใช้ balloonBossSessionStartMs = performance.now() local → คนละ baseline = บอสคนละที่) */ const tSec = balloonBossLiveAnchorServerMs > 0 ? (serverNow() - balloonBossLiveAnchorServerMs) / 1000 : (balloonBossSessionStartMs > 0 ? (performance.now() - balloonBossSessionStartMs) / 1000 : performance.now() / 1000); const ax = Math.min(mw * 0.19, 240); const ay = Math.min(mh * 0.17, 200); const cx = base.cx + Math.sin(tSec * 0.33 + 0.75) * ax + Math.sin(tSec * 0.69 + 0.15) * (ax * 0.4); const cy = base.cy + Math.cos(tSec * 0.29 + 1.05) * ay + Math.cos(tSec * 0.64 + 0.45) * (ay * 0.37); return clampBalloonBossWorld(cx, cy, mw, mh); } /** จุดโลกประมาณกลางกระจุกลูกโป่ง (ใช้ hit จากกระสุนบอส) — ไม่ใช่จุดเท้า */ function balloonBossBalloonClusterWorldPlay(ent, bossCx, bossCy) { if (!ent || ent.balloonBossCx == null) return { cx: 0, cy: 0 }; const dx = ent.balloonBossCx - bossCx; const dy = ent.balloonBossCy - bossCy; const L = Math.hypot(dx, dy) || 1; const ox = (dx / L) * 22; const oy = (dy / L) * 12; return { cx: ent.balloonBossCx + ox, cy: ent.balloonBossCy - 52 + oy * 0.35, }; } /** ผู้เล่นหลักเด้งกับวงชนบอส (บอสเป็นวงนิ่งใน world) */ function balloonBossPlayerBossElasticPlay(meEnt, bossCx, bossCy, bossCollideR, mw, mh) { if (!meEnt || meEnt.balloonBossEliminated || meEnt.balloonBossCx == null) return; const rMe = 44; const rB = Math.max(30, bossCollideR * 0.9); const dx = meEnt.balloonBossCx - bossCx; const dy = meEnt.balloonBossCy - bossCy; const dist = Math.hypot(dx, dy) || 1e-6; const minD = rMe + rB; if (dist >= minD) return; const nx = dx / dist; const ny = dy / dist; const push = (minD - dist) * 0.58; meEnt.balloonBossCx += nx * push; meEnt.balloonBossCy += ny * push; const vn = meEnt.balloonBossVelX * nx + meEnt.balloonBossVelY * ny; if (vn < 0) { meEnt.balloonBossVelX -= 1.85 * vn * nx; meEnt.balloonBossVelY -= 1.85 * vn * ny; } const cl = clampBalloonBossWorld(meEnt.balloonBossCx, meEnt.balloonBossCy, mw, mh); meEnt.balloonBossCx = cl.cx; meEnt.balloonBossCy = cl.cy; } /** ผู้เล่นหลักเด้งกับคนอื่น (บอทขยับรับแรงปะทะเล็กน้อย) */ function balloonBossPlayerElasticCollisionsPlay(meEnt, aliveRefs, mw, mh) { if (!meEnt || meEnt.balloonBossEliminated || meEnt.balloonBossCx == null) return; const rMe = 44; const rO = 44; const minD = rMe + rO - 2; for (let ii = 0; ii < aliveRefs.length; ii++) { const entry = aliveRefs[ii]; if (!entry || entry.ref === meEnt) continue; const o = entry.ref; if (!o || o.balloonBossEliminated || o.balloonBossCx == null) continue; const dx = meEnt.balloonBossCx - o.balloonBossCx; const dy = meEnt.balloonBossCy - o.balloonBossCy; const dist = Math.hypot(dx, dy); if (!dist || dist >= minD) continue; const nx = dx / dist; const ny = dy / dist; const push = (minD - dist) * 0.52; meEnt.balloonBossCx += nx * push; meEnt.balloonBossCy += ny * push; const vn = meEnt.balloonBossVelX * nx + meEnt.balloonBossVelY * ny; if (vn < 0) { meEnt.balloonBossVelX -= 1.85 * vn * nx; meEnt.balloonBossVelY -= 1.85 * vn * ny; } if (isPreviewBotId(entry.id)) { const pushO = (minD - dist) * 0.48; o.balloonBossCx -= nx * pushO; o.balloonBossCy -= ny * pushO; } } const cl2 = clampBalloonBossWorld(meEnt.balloonBossCx, meEnt.balloonBossCy, mw, mh); meEnt.balloonBossCx = cl2.cx; meEnt.balloonBossCy = cl2.cy; } /** หมายเลขสล็อต 1–6 ที่มีบนแมป (เรียง) — ถ้ามีแค่ 3 ช่อง ผู้เล่นจะวนที่นั่ง 3 จุดรอบบอส · Occupied P-slots on map for spawn cycling */ function getBalloonBossOccupiedSlotNumbersPlay(md) { if (!md || !md.balloonBossPlayerSlots) return []; const g = md.balloonBossPlayerSlots; const w = md.width || 20, h = md.height || 15; const seen = new Set(); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const v = g[y] && g[y][x]; const n = typeof v === 'number' ? v : parseInt(String(v), 10); if (Number.isFinite(n) && n >= 1 && n <= 6) seen.add(Math.floor(n)); } } return Array.from(seen).sort((a, b) => a - b); } function buildBalloonBossParticipantRefsPlay() { return buildStableShipParticipantRefsPlay(); } function balloonBossBalloonsStartPlay() { const n = Number(mapData && mapData.balloonBossBalloonsPerPlayer); /** ไม่มีค่าในแมป = 3 ลูก (Mega Virus mock) — ไม่ใช้ 5 เพราะบอท/preview ติด 5 */ return Math.max(1, Math.min(12, Number.isFinite(n) ? Math.floor(n) : 3)); } 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); const g = Number(playBalloonBossMissionTimeSec); if (Number.isFinite(g) && g > 0 && g < 7200) return Math.floor(g); return 120; } function balloonBossRemainingSecPlay() { const lim = balloonBossTimeLimitSecPlay(); if (lim <= 0) return null; if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) return lim; if (!balloonBossSessionStartMs && !(balloonBossLiveAnchorServerMs > 0)) return lim; /* นับเวลาจาก anchor server → จบพร้อมกันทุกเครื่อง; fallback local ถ้ายังไม่มี anchor */ const elapsed = balloonBossLiveAnchorServerMs > 0 ? (serverNow() - balloonBossLiveAnchorServerMs) / 1000 : (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; } /** รวมดาเมจที่ทำกับบอส (ไม่เท่ากับคะแนน — คะแนน +10 ต่อครั้ง, HP บอส -2 ต่อครั้ง) non-host: ใช้ค่าที่ host เป็นเจ้าของ (single source of truth) ถ้าได้รับแล้ว — HP บอสตรงกันทุกเครื่อง */ function balloonBossTeamBossDamagePlay() { if (!isMePlayHost() && balloonBossHostBossDmg != null) { return Math.max(0, balloonBossHostBossDmg | 0); } let s = Math.max(0, me.balloonBossBossDmg | 0); others.forEach((o) => { s += Math.max(0, o.balloonBossBossDmg | 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(); const slotCycle = getBalloonBossOccupiedSlotNumbersPlay(mapData); refs.forEach((entry, idx) => { const ent = entry.ref; const slot = slotCycle.length ? slotCycle[idx % slotCycle.length] : ((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.balloonBossSkinSlot = slot; ent.balloonBossCx = cl.cx; ent.balloonBossCy = cl.cy; if (typeof ent.balloonBossScore !== 'number' || !Number.isFinite(ent.balloonBossScore)) ent.balloonBossScore = 0; if (typeof ent.balloonBossBossDmg !== 'number' || !Number.isFinite(ent.balloonBossBossDmg)) ent.balloonBossBossDmg = 0; /** จำนวนลูกโป่งตามแมปเสมอเมื่อจัดตำแหน่ง — ไม่ค้าง 5 จาก preview bot · Always sync start count */ 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 = 'down'; ent.balloonBossAimRad = balloonBossAutoAimRadPlay(entry.id); }); } function endBalloonBossGame(reason) { if (balloonBossGameEnded || !mapData || mapData.gameType !== 'balloon_boss') return; balloonBossGameEnded = true; balloonBossPendingShots = []; balloonBossPlayerBullets = []; balloonBossBossBullets = []; balloonBossScorePopups = []; if (isMegaVirusMissionShellMapPlay()) { applyMegaVirusMissionPanelImages(); beginBalloonBossMegaVirusResultFlashSequenceThenGcm(balloonBossBuildMissionPayloadPlay(reason), reason); 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 = 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) || '—'} — ${Math.max(0, Number(r && r.score) || 0)} pts`; if (isMe) li.className = 'gauntlet-ended-me'; listEl.appendChild(li); }); ov.classList.remove('is-hidden'); function goLobby() { window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); } if (btn) { btn.onclick = () => { if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden'); else goLobby(); }; } } /* host เป็นผู้ตัดสินจบเกม MG7 แล้ว broadcast → ทุกเครื่องจบพร้อมกัน (เดิมต่างคนต่างตัดสินจาก state ที่สะสมไม่พร้อมกัน → game over ไม่เท่ากัน) */ function triggerBalloonBossOverPlay(reason) { if (balloonBossGameEnded) return; try { if (socket && myId != null && isMePlayHost()) { /* แนบตารางคะแนน + สถานะตาย/รอดสุดท้าย (host authoritative) → ทุกเครื่องโชว์อันดับ/คะแนน/โบนัสผู้รอดตรงกัน (เดิมส่งแค่ score ไม่มี eliminated → client ที่บอท/คนตายไม่ sync ได้โบนัสผู้รอด +10 เกิน = คะแนนจบไม่ตรง) */ const scores = buildStableShipParticipantRefsPlay().map((e) => ({ id: e.id, score: Math.max(0, (e.ref && (e.ref.balloonBossScore | 0)) || 0), eliminated: !!(e.ref && e.ref.balloonBossEliminated), })); socket.emit('balloon-boss-over', { reason: reason, scores: scores }); } } catch (e) { /* ignore */ } endBalloonBossGame(reason); } function stepBalloonBossPreviewBots(dt, mw, mh, bossCx, bossCy, ts) { if (!playBotsEnabled() || !isMePlayHost() || !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.direction = 'down'; 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 bspd = 500 + Math.random() * 90; /** บอทยิงตามมุม "ลูกศรวงกลม" ของตัวเอง (ทันที) เหมือนผู้เล่น — ลูกศรหมุนตลอดทั้งทดสอบ/เล่นจริง ไม่ auto-aim ใส่บอส */ const aim = (typeof o.balloonBossAimRad === 'number' && Number.isFinite(o.balloonBossAimRad)) ? o.balloonBossAimRad : Math.atan2(bossCy - o.balloonBossCy, bossCx - o.balloonBossCx); const bvx = Math.cos(aim) * bspd; const bvy = Math.sin(aim) * bspd; /** ยิงจากปลายลูกศรเหมือนผู้เล่น */ const botSx = o.balloonBossCx + Math.cos(aim) * 26; const botSy = o.balloonBossCy - 10.5 + Math.sin(aim) * 26; balloonBossPendingShots.push({ releaseAt: performance.now(), sx: botSx, sy: botSy, vx: bvx, vy: bvy, ownerId: bid, }); /* host เป็นผู้จำลองบอท → broadcast กระสุนบอทให้ client อื่นเห็นด้วย */ try { if (socket && myId != null) { socket.emit('balloon-boss-shot', { sx: botSx, sy: botSy, vx: bvx, vy: bvy, ownerId: bid, t0: serverNow() }); } } catch (eB) { /* ignore */ } /* สเปก MG7: บอทยิง 1 ครั้ง = −5 คะแนน เหมือนผู้เล่น (ต่ำสุด 0) */ o.balloonBossScore = Math.max(0, (o.balloonBossScore | 0) - 5); } }); } function balloonBossTickFrame() { if (!mapData || mapData.gameType !== 'balloon_boss') return; if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) 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 = getBalloonBossBossLiveCenterPlay(mapData, ts); const bossR = Math.max(36, ts * 1.15); const maxHp = balloonBossMaxHpPlay(); const teamBossDmg = balloonBossTeamBossDamagePlay(); 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); } for (let si = balloonBossScorePopups.length - 1; si >= 0; si--) { const sp = balloonBossScorePopups[si]; sp.t -= dt; if (sp.t <= 0) balloonBossScorePopups.splice(si, 1); } return; } const remSec = balloonBossRemainingSecPlay(); const allRefsEarly = buildBalloonBossParticipantRefsPlay(); /* จบเกมแบบ host-authoritative: host ตัดสิน + broadcast, ทุกเครื่องจบพร้อมกัน (เดิมแต่ละเครื่องตัดสินเองจาก teamBossDmg/elimination ที่สะสมไม่พร้อมกัน → game over ไม่เท่ากัน) */ if (isMePlayHost()) { if (remSec != null && remSec <= 0) { if (teamBossDmg >= maxHp) { triggerBalloonBossOverPlay('victory'); return; } /* หมดเวลาแต่บอสยังไม่ตาย → ลูกโป่งของคนที่ยังเหลือ "แตกหมด" ตายทั้งหมด (แพ้) */ allRefsEarly.forEach((e) => { if (e.ref && !e.ref.balloonBossEliminated) { e.ref.balloonBossBalloons = 0; e.ref.balloonBossEliminated = true; } }); try { if (socket && myId != null) socket.emit('move', { x: me.x, y: me.y, direction: me.direction, balloonBossBalloons: 0, balloonBossEliminated: true }); } catch (eT) { /* ignore */ } triggerBalloonBossOverPlay('all_dead'); return; } if (teamBossDmg >= maxHp) { triggerBalloonBossOverPlay('victory'); return; } if (allRefsEarly.length > 0 && allRefsEarly.every((e) => e.ref && e.ref.balloonBossEliminated)) { triggerBalloonBossOverPlay('all_dead'); return; } } else if (remSec != null && remSec <= -2) { /* non-host fallback: เผื่อ host หลุด — เลยเวลามา 2s แล้วยังไม่ได้รับสัญญาณจบ ให้ปิดเกมเอง */ endBalloonBossGame(teamBossDmg >= maxHp ? 'victory' : 'all_dead'); return; } for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) { balloonBossHitFx[hi].t -= dt; if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1); } for (let si = balloonBossScorePopups.length - 1; si >= 0; si--) { const sp = balloonBossScorePopups[si]; sp.t -= dt; if (sp.t <= 0) balloonBossScorePopups.splice(si, 1); } const aliveRefs = allRefsEarly.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 = 1.42; if (balloonBossLiveAnchorServerMs > 0) { /* deterministic boss fire: กระสุนลำดับ k ยิงที่ anchor + k*bossFireEvery ด้วยค่าสุ่มจาก seed(anchor,k) → ทุกเครื่องเห็นแพตเทิร์นกระสุนบอสเดียวกัน (เดิม Math.random แยกกัน = คนละลาย) */ const fireIntervalMs = bossFireEvery * 1000; const targetShots = Math.floor((serverNow() - balloonBossLiveAnchorServerMs) / fireIntervalMs) + 1; let bguard = 0; while (balloonBossBossShotCount < targetShots && bguard < 200) { bguard++; const k = balloonBossBossShotCount++; if (!aliveRefs.length) continue; const rng = makeSeededRng(envSpawnSeed(balloonBossLiveAnchorServerMs, k)); const ang = rng() * Math.PI * 2; const spd = 88 + rng() * 72; const gy = 48 + rng() * 56; balloonBossBossBullets.push({ x: bossC.cx, y: bossC.cy, vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd, gy: gy, }); } } else { balloonBossBossFireAcc += dt; while (balloonBossBossFireAcc >= bossFireEvery) { balloonBossBossFireAcc -= bossFireEvery; if (aliveRefs.length) { const ang = Math.random() * Math.PI * 2; const spd = 88 + Math.random() * 72; balloonBossBossBullets.push({ x: bossC.cx, y: bossC.cy, vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd, gy: 48 + Math.random() * 56, }); } } } 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), balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0), }); } } for (let bi = balloonBossBossBullets.length - 1; bi >= 0; bi--) { const b = balloonBossBossBullets[bi]; const gy = typeof b.gy === 'number' && Number.isFinite(b.gy) ? b.gy : 62; b.vy += gy * dt; 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; /* การชนกระสุนบอส: เจ้าของตัดสินยานตัวเองเท่านั้น (+ บอทพรีวิว = host) — เหมือน MG6 คนอื่นรอสถานะลูกโป่ง/ตายจาก move ของเจ้าตัว + balloon-boss-sync ของ host (เดิมทุกเครื่องคิดชนให้ทุกยานด้วยตำแหน่ง interpolate ที่ต่างกัน → ลูกโป่งเพี้ยน/ตายไม่ตรงกัน) */ if (me && !me.balloonBossEliminated && me.balloonBossCx != null) { const cc = balloonBossBalloonClusterWorldPlay(me, bossC.cx, bossC.cy); const dx = b.x - cc.cx, dy = b.y - cc.cy; if (dx * dx + dy * dy <= (36 * 36)) { hit = true; applyBalloonBossHitToEntity(me); } } if (!hit && isMePlayHost()) { aliveRefs.forEach((entry) => { if (hit || !isPreviewBotId(entry.id)) return; const ent = entry.ref; if (!ent || ent.balloonBossCx == null) return; const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy); const dx = b.x - cc.cx, dy = b.y - cc.cy; if (dx * dx + dy * dy <= (36 * 36)) { 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; } let pHit = false; for (let ji = 0; ji < aliveRefs.length; ji++) { if (pHit) break; const entry = aliveRefs[ji]; const ent = entry.ref; if (!ent || ent.balloonBossCx == null) continue; if (String(entry.id) === String(b.ownerId)) continue; /* ลูกโป่งของตัวเอง = เจ้าตัวตัดสิน · บอท = host ตัดสินคนเดียว (non-host เอาตาย/ลูกโป่งจาก fill-bot-state) เดิม non-host คิด damage บอทเองจากกระสุนที่ relay มา → บอทตายคนละจังหวะ 2 จอ */ const canBalloonHit = (ent === me) || (isPreviewBotId(entry.id) && isMePlayHost()); if (!canBalloonHit) continue; const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy); const pdx = b.x - cc.cx, pdy = b.y - cc.cy; if (pdx * pdx + pdy * pdy <= (34 * 34)) { applyBalloonBossHitToEntity(ent); balloonBossPlayerBullets.splice(i, 1); pHit = true; } } if (pHit) continue; /* MG7: ยิงโดนไอคอนคำถามพิเศษ = ทริกได้เลย (ไม่ต้องเดินไปแตะ) */ if (specialQuizIcon && !specialQuizCollideSent && !specialQuizActiveQuestion) { const iwx = specialQuizIcon.x * tileSize; const iwy = specialQuizIcon.y * tileSize; const idx = b.x - iwx, idy = b.y - iwy; const ir = tileSize * 0.95; if (idx * idx + idy * idy <= ir * ir) { sendSpecialQuizCollide(); 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 }); balloonBossScorePopups.push({ ownerId: b.ownerId, x: b.x, y: b.y, t: 0.85, tMax: 0.85, }); const addScore = 10; const addBossDmg = 2; /* score/dmg: เจ้าของเท่านั้นเป็นคนบวก (มนุษย์=ตัวเอง broadcast ผ่าน move, บอท=host broadcast ผ่าน fill-bot-state) เครื่องอื่นห้ามบวกเอง — เดิม non-owner บวกจากกระสุนที่ถึงคนละจังหวะ → คะแนน/HP สุดท้ายไม่ตรง */ if (b.ownerId === myId) { me.balloonBossScore = Math.max(0, (me.balloonBossScore | 0) + addScore); me.balloonBossBossDmg = Math.max(0, (me.balloonBossBossDmg | 0) + addBossDmg); if (socket && myId != null) { balloonBossLastMoveEmit = Date.now(); socket.emit('move', { x: me.x, y: me.y, direction: me.direction, balloonBossScore: Math.max(0, me.balloonBossScore | 0), balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0), balloonBossBalloons: Math.max(0, me.balloonBossBalloons | 0), balloonBossEliminated: !!me.balloonBossEliminated, }); } } else if (isPreviewBotId(b.ownerId) && isMePlayHost()) { const o = others.get(b.ownerId); if (o) { o.balloonBossScore = Math.max(0, (o.balloonBossScore | 0) + addScore); o.balloonBossBossDmg = Math.max(0, (o.balloonBossBossDmg | 0) + addBossDmg); } } balloonBossPlayerBullets.splice(i, 1); } } const tWind = performance.now() * 0.001; const gustAx = Math.sin(tWind * 0.36 + 1.15) * 62 + Math.sin(tWind * 1.05) * 24; const gustAy = Math.cos(tWind * 0.39 + 0.28) * 58 + Math.cos(tWind * 0.93) * 22; const maxSpd = Math.min(228, balloonBossShipMaxSpeedPlay() * 1.32); const dragPerSec = balloonBossShipDragPlay() * 0.4; 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; nvx += gustAx * dt; nvy += gustAy * 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.88; if (Math.abs(cl.cy - ncy) > 0.5) me.balloonBossVelY *= -0.88; me.balloonBossCx = cl.cx; me.balloonBossCy = cl.cy; } syncBalloonBossFootFromCenter(me); balloonBossPlayerElasticCollisionsPlay(me, aliveRefs, mw, mh); balloonBossPlayerBossElasticPlay(me, bossC.cx, bossC.cy, bossR, mw, mh); syncBalloonBossFootFromCenter(me); /** ฉากนี้แสดงตัวหันหน้าเข้ากล้อง — sync กับ combat draw */ me.direction = 'down'; me.isWalking = Math.abs(me.balloonBossVelX) > 12 || Math.abs(me.balloonBossVelY) > 12; /** วงลูกศรหมุนเองเรื่อย ๆ (คุมไม่ได้) — Mega Virus ยิงตามมุมลูกศร ณ ตอนกด Space */ aliveRefs.forEach((e) => { const r = e.ref; if (!r || r.balloonBossEliminated) return; r.balloonBossAimRad = balloonBossAutoAimRadPlay(e.id); }); balloonBossPlayerFireCd -= dt; const btnFire = balloonBossFireBtnQueued; balloonBossFireBtnQueued = false; const wantFire = !isChatFocused() && (!!keys['Space'] || btnFire) && !me.balloonBossEliminated; if (wantFire && balloonBossPlayerFireCd <= 0 && me.balloonBossCy != null) { balloonBossPlayerFireCd = 0.35; /** ยิงตามมุม "ลูกศรวงกลม" เสมอ (me.balloonBossAimRad อัปเดตทุกเฟรม) — fallback เล็งบอสเฉพาะตอนมุมยังไม่พร้อม */ /** ลูกศรหมุนตลอดสำหรับ balloon_boss → ยิงทันที (delay 0) ทุกกรณี ทั้งทดสอบ/เล่นจริง ไม่งั้นกระสุนโผล่ไม่ตรงลูกศร */ const delayMs = 0; const bspd = 480; const aim = (typeof me.balloonBossAimRad === 'number' && Number.isFinite(me.balloonBossAimRad)) ? me.balloonBossAimRad : Math.atan2(bossC.cy - me.balloonBossCy, bossC.cx - me.balloonBossCx); const vx = Math.cos(aim) * bspd; const vy = Math.sin(aim) * bspd; /** ยิงออกจาก "ปลายลูกศร" ไม่ใช่กลางตัว — วงอยู่เหนือตัว ~10.5px (world) รัศมีปลายหาง ~26px */ const shotSx = me.balloonBossCx + Math.cos(aim) * 26; const shotSy = me.balloonBossCy - 10.5 + Math.sin(aim) * 26; balloonBossPendingShots.push({ releaseAt: now + delayMs, sx: shotSx, sy: shotSy, vx, vy, ownerId: myId, }); /* broadcast กระสุนให้ทุกคนเห็นตรงกัน (host เห็นยังไง ทุกคนเห็นแบบนั้น) */ try { if (socket && myId != null) { socket.emit('balloon-boss-shot', { sx: shotSx, sy: shotSy, vx, vy, ownerId: myId, t0: serverNow() }); } } catch (eS) { /* ignore */ } /* สเปก MG7: ยิง 1 ครั้ง = −5 คะแนน (ต่ำสุด 0 ไม่ติดลบ) */ me.balloonBossScore = Math.max(0, (me.balloonBossScore | 0) - 5); } 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), balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0), balloonBossBalloons: Math.max(0, me.balloonBossBalloons | 0), balloonBossEliminated: !!me.balloonBossEliminated, }); } /* MG7 host-authoritative boss HP: host broadcast ดาเมจรวม + สถานะผู้เล่นคนอื่น ~7Hz → ทุกเครื่องแสดง HP บอส/ลูกโป่ง/การตาย ตรงกับ host (single source of truth) */ if (isMePlayHost() && socket && myId != null && tEmit - balloonBossLastSyncEmit > 140) { balloonBossLastSyncEmit = tEmit; const syncRefs = buildBalloonBossParticipantRefsPlay(); socket.emit('balloon-boss-sync', { bossDmg: Math.max(0, balloonBossTeamBossDamagePlay() | 0), /* anchor เวลาเริ่มเล่น (server ms) — clients adopt เพื่อให้มุม "ลูกศรวงกลม" หมุนตรงกันทุกเครื่อง (เดิม anchor fallback = serverNow() ตอนเข้าเล่น ต่างกันต่อเครื่อง → วงๆ ไม่ sync) */ anchor: balloonBossLiveAnchorServerMs > 0 ? balloonBossLiveAnchorServerMs : 0, players: syncRefs.map((e) => ({ id: e.id, d: Math.max(0, (e.ref && (e.ref.balloonBossBossDmg | 0)) || 0), b: Math.max(0, (e.ref && (e.ref.balloonBossBalloons | 0)) || 0), e: !!(e.ref && e.ref.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 = getBalloonBossBossLiveCenterPlay(mapData, ts); const maxHp = balloonBossMaxHpPlay(); const dmg = balloonBossTeamBossDamagePlay(); const hpLeft = Math.max(0, maxHp - dmg); const bossR = Math.max(36, ts * 1.15); const refs = buildBalloonBossParticipantRefsPlay(); /* slot รูปลูกโป่งคำนวณสดจาก stable idx (เหมือนสูตรใน spawn layout) → ตรงกันทุกเครื่อง ไม่อิง ent.balloonBossSkinSlot ที่ freeze ไว้ (เพี้ยนถ้า set ยังไม่ครบตอน layout) */ const bbSlotCycle = getBalloonBossOccupiedSlotNumbersPlay(mapData); const leaderId = (() => { let best = null, bs = -1; refs.forEach((e) => { if (!e.ref || e.ref.balloonBossEliminated) return; const sc = Math.max(0, e.ref.balloonBossScore | 0); if (sc > bs) { bs = sc; best = e.id; } }); return best; })(); const bossImgUrl = normalizeGauntletAssetUrlForPlay(String(playBalloonBossBossImageUrl || '')); const bossImgRec = bossImgUrl ? ensureGauntletAssetImage(bossImgUrl) : null; 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 score10PopUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/score+10.png'); const score10PopRec = score10PopUrl ? ensureGauntletAssetImage(score10PopUrl) : null; for (let si = 0; si < balloonBossScorePopups.length; si++) { const sp = balloonBossScorePopups[si]; let wx = sp.x; let wy = sp.y; if (sp.ownerId != null) { const oid = sp.ownerId; const ent = (myId != null && String(oid) === String(myId)) ? me : others.get(oid); if (ent && ent.balloonBossCx != null && !ent.balloonBossEliminated) { const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy); const tMax = typeof sp.tMax === 'number' && sp.tMax > 0 ? sp.tMax : 0.95; const rise = ((tMax - Math.max(0, sp.t || 0)) / tMax) * 58; wx = cc.cx; wy = cc.cy - 46 - rise; } } const [sx, sy] = worldToScreen(wx, wy); ctx.save(); ctx.globalAlpha = Math.min(1, Math.max(0, sp.t || 0) * 1.12); if (sp.dmgTxt) { const fpx = Math.max(11, 14 * zDraw); ctx.font = `bold ${fpx}px ui-sans-serif, system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.strokeStyle = 'rgba(0, 0, 0, 0.62)'; ctx.lineWidth = Math.max(2, 2.4 * zDraw); ctx.strokeText(String(sp.dmgTxt), sx, sy); ctx.fillStyle = '#f8fafc'; ctx.fillText(String(sp.dmgTxt), sx, sy); } else if (score10PopRec && score10PopRec.ready && score10PopRec.img && score10PopRec.img.naturalWidth > 0) { const iw = score10PopRec.img.naturalWidth; const ih = score10PopRec.img.naturalHeight; const mh = Math.max(14, 20 * zDraw); const sc = Math.min(1.35, mh / ih); const dw = iw * sc; const dh = ih * sc; ctx.drawImage(score10PopRec.img, sx - dw / 2, sy - dh / 2, dw, dh); } ctx.restore(); } const bossAlive = hpLeft > 0; if (bossAlive) { const [bsx, bsy] = worldToScreen(bossC.cx, bossC.cy); ctx.save(); ctx.translate(bsx, bsy); const limR = bossR * zDraw; if (bossImgRec && bossImgRec.ready && bossImgRec.img && bossImgRec.img.naturalWidth > 0) { const iw = bossImgRec.img.naturalWidth; const ih = bossImgRec.img.naturalHeight; const diam = limR * 2.1; const scale = Math.min(diam / iw, diam / ih); const dw = iw * scale; const dh = ih * scale; ctx.save(); ctx.beginPath(); ctx.arc(0, 0, limR * 1.05, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(bossImgRec.img, -dw / 2, -dh / 2, dw, dh); ctx.restore(); } else { 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.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; } /** HUD บอส: แถบชิดเหนือหัวบอส; โลโก้ Artboard10 + [hp/max] เหนือแถบ — bbHudS ลดสเกลทั้งแถว (ขอให้เล็กลง ~ครึ่ง) */ const bbHudS = 0.5; const fontPx = Math.max(8, 12.5 * zDraw * bbHudS); ctx.font = `bold ${fontPx}px ui-monospace, monospace`; ctx.textBaseline = 'middle'; const hpStr = '[ ' + hpLeft + ' / ' + maxHp + ' ]'; const hpW = ctx.measureText(hpStr).width; const gapHp = 5 * zDraw * bbHudS; const powBgUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/boss-power-bg.png'); const powFillUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/boss-power.png'); const powBgRec = powBgUrl ? ensureGauntletAssetImage(powBgUrl) : null; const powFillRec = powFillUrl ? ensureGauntletAssetImage(powFillUrl) : null; /** ความสูงหลอด: ยึดความกว้างก่อน — คูณ bbHudS ให้สัดส่วนเทียบหัวบอสเล็กลง */ const barHMin = Math.max(9, 10.5 * zDraw) * bbHudS; const barHMax = Math.max(17, 21 * zDraw) * bbHudS; const titleImgUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/Artboard%2010.png'); const titleImgRec = titleImgUrl ? ensureGauntletAssetImage(titleImgUrl) : null; let imgW = 0; let imgH = 0; if (titleImgRec && titleImgRec.ready && titleImgRec.img && titleImgRec.img.naturalWidth > 0) { const iw = titleImgRec.img.naturalWidth; const ih = titleImgRec.img.naturalHeight; let barHForTitle = barHMin; if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) { const biw = powBgRec.img.naturalWidth; const bijh = powBgRec.img.naturalHeight; const wEst = Math.max(limR * 2.12 * bbHudS, hpW + gapHp + iw * 0.35 * bbHudS); barHForTitle = Math.min(barHMax, Math.max(barHMin, bijh * (wEst / biw))); } const capTitle = Math.min(17 * zDraw * bbHudS, Math.max(fontPx * 1.15, barHForTitle * 0.88)); const maxH = Math.max(fontPx * 1.12, 14 * zDraw * bbHudS, capTitle); const sc = Math.min(1.0, maxH / ih); imgW = iw * sc; imgH = ih * sc; } let rowW = (imgW > 0 ? imgW + gapHp : 0) + hpW; if (imgW <= 0) { const legacyPre = 'MEGA VIRUS : '; rowW = ctx.measureText(legacyPre).width + hpW; } const barPadRow = 8 * zDraw * bbHudS; const barWTarget = Math.max(limR * 2.08 * bbHudS, rowW + barPadRow); let barDw = barWTarget; let barDh = barHMin; if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) { const iw = powBgRec.img.naturalWidth; const ih = powBgRec.img.naturalHeight; const naturalH = ih * (barWTarget / iw); barDh = Math.min(barHMax, Math.max(barHMin, naturalH)); } const gapBarBoss = 4 * zDraw * bbHudS; const gapTitleBar = 5 * zDraw * bbHudS; const barBottomEdge = -limR * 1.0 - gapBarBoss; const barTopY = barBottomEdge - barDh; const bx = -barDw / 2; const by = barTopY; const ratHp = Math.max(0, Math.min(1, hpLeft / maxHp)); const titleRowH = Math.max(imgH, fontPx * 1.12); const labelY = barTopY - gapTitleBar - titleRowH * 0.5; let xLeft = -(imgW + (imgW > 0 ? gapHp : 0) + hpW) / 2; if (imgW > 0) { ctx.drawImage(titleImgRec.img, xLeft, labelY - imgH / 2, imgW, imgH); xLeft += imgW + gapHp; } else { ctx.textAlign = 'left'; ctx.fillStyle = 'rgba(255, 100, 200, 0.9)'; const legacyPre = 'MEGA VIRUS : '; const pw = ctx.measureText(legacyPre).width; xLeft = -(pw + hpW) / 2; ctx.fillText(legacyPre, xLeft, labelY); xLeft += pw; } ctx.textAlign = 'left'; ctx.fillStyle = '#ffffff'; ctx.strokeStyle = 'rgba(0, 0, 0, 0.22)'; ctx.lineWidth = Math.max(0.5, 1 * zDraw * bbHudS); ctx.strokeText(hpStr, xLeft, labelY); ctx.fillText(hpStr, xLeft, labelY); if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) { ctx.drawImage(powBgRec.img, bx, by, barDw, barDh); if (ratHp > 0 && powFillRec && powFillRec.ready && powFillRec.img && powFillRec.img.naturalWidth > 0) { const fiw = powFillRec.img.naturalWidth; const fih = powFillRec.img.naturalHeight; const padIn = barDw * 0.04; const innerW = barDw - padIn * 2; const innerH = barDh - padIn * 1.2; const fsc2 = Math.min(innerW / fiw, innerH / fih); const fdw2 = fiw * fsc2; const fdh2 = fih * fsc2; const fx0 = bx + (barDw - fdw2) * 0.5; const fy0 = by + (barDh - fdh2) * 0.5; const srcW = fiw * ratHp; ctx.drawImage(powFillRec.img, 0, 0, srcW, fih, fx0, fy0, fdw2 * ratHp, fdh2); } } else { ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(bx, by, barDw, barDh); ctx.fillStyle = 'rgba(255, 80, 200, 0.85)'; ctx.fillRect(bx, by, barDw * ratHp, barDh); ctx.strokeStyle = 'rgba(180, 255, 255, 0.5)'; ctx.strokeRect(bx, by, barDw, barDh); } ctx.restore(); } /** จุดก้นลูกโป่งหลังหมุน tilt (รัศมีจากกลางสู่ก้นใน local) */ function balloonBossBalloonKnotScreen(bx, by, dw, dh, tiltRad) { const lx = 0; const ly = dh * 0.5; const c = Math.cos(tiltRad); const s = Math.sin(tiltRad); return { x: bx + lx * c - ly * s, y: by + lx * s + ly * c }; } /** ลูกโป่งด้านบน +50% — กระจุกเอียงซ้าย/กลางตรง/ขวา (tilt) + เชือกบางไปจุดผูบนวง · Bouquet tilt like mock */ function drawBalloonsCluster(sx, sy, count, col, z, slot1to6, playerRingR) { const slotIdx = Math.max(0, Math.min(5, (Math.floor(Number(slot1to6)) || 1) - 1)); const perSeat = String((playBalloonBossPlayerBalloonImageUrls[slotIdx] || '')).trim(); const fallback = String(playBalloonBossPlayerBalloonFallbackUrl || '').trim(); const bRec = resolveBalloonBossBalloonSpriteRec(perSeat, fallback); const n = Math.max(0, Math.min(8, count | 0)); const ringRpx = playerRingR != null && playerRingR > 0 ? playerRingR : 22 * z; const sz = 1.5; const tw = 22 * sz * z; const th = 27 * sz * z; const stackAnchor = ringRpx + 10 * z; const tieX = sx; const tieY = sy - ringRpx * 0.98; const tiltStep = 0.5; const spreadX = 5.4 * z; const baseY = sy - stackAnchor - 1.5 * z; const items = []; for (let b = 0; b < n; b++) { const rel = b - (n - 1) / 2; const tilt = rel * tiltStep; const bx = sx + rel * spreadX; const by = baseY + Math.abs(rel) * 2.1 * z; const bImg = bRec && bRec.img; let dw = 0; let dh = 0; if (bImg && bImg.complete && bImg.naturalWidth > 0) { const iw = bImg.naturalWidth; const ih = bImg.naturalHeight; const sc = Math.min(tw / iw, th / ih); dw = iw * sc; dh = ih * sc; } else { const ry = 12 * sz * z; dw = 10 * sz * z * 2; dh = ry * 2; } const knot = balloonBossBalloonKnotScreen(bx, by, dw, dh, tilt); items.push({ bx, by, dw, dh, bImg, tilt, knotX: knot.x, knotY: knot.y }); } if (n > 0) { ctx.strokeStyle = 'rgba(55, 48, 42, 0.55)'; ctx.lineWidth = Math.max(0.65, 0.78 * z); ctx.lineCap = 'butt'; ctx.lineJoin = 'bevel'; for (let i = 0; i < items.length; i++) { const it = items[i]; ctx.beginPath(); ctx.moveTo(tieX, tieY); ctx.lineTo(it.knotX, it.knotY); ctx.stroke(); } } for (let i = 0; i < items.length; i++) { const it = items[i]; const bImg = it.bImg; if (bImg && bImg.complete && bImg.naturalWidth > 0) { const iw = bImg.naturalWidth; const ih = bImg.naturalHeight; const sc = Math.min(tw / iw, th / ih); const dw = iw * sc; const dh = ih * sc; ctx.save(); ctx.translate(it.bx, it.by); ctx.rotate(it.tilt); ctx.drawImage(bImg, -dw / 2, -dh / 2, dw, dh); ctx.restore(); } else { ctx.save(); ctx.translate(it.bx, it.by); ctx.rotate(it.tilt); ctx.fillStyle = col; ctx.beginPath(); ctx.ellipse(0, 0, 10 * sz * z, 12 * sz * z, 0, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke(); ctx.restore(); } } } refs.forEach((entry, idx) => { const ent = entry.ref; if (ent.balloonBossCx == null || ent.balloonBossCy == null) return; if (ent.balloonBossEliminated) return; const [sx, sy] = worldToScreen(ent.balloonBossCx, ent.balloonBossCy); const col = BALLOON_BOSS_PLAYER_COLORS[idx % BALLOON_BOSS_PLAYER_COLORS.length]; const z = zDraw; const skinSlot = bbSlotCycle.length ? bbSlotCycle[idx % bbSlotCycle.length] : ((idx % 6) + 1); const ringR = 25 * z; /** จุดกลางฟองบนจอ — ยกจากจุด world เล็กน้อยให้ตัวอยู่กลางวง ลูกโป่งชิดขอบบน (ตาม mock) */ const bubbleCy = sy - ringR * 0.42; /** มุมลูกศร Artboard 9 — หมุนอัตโนมัติ; Mega Virus ยิงตามมุมนี้ ณ ตอนกด Space */ const angRing = balloonBossAutoAimRadPlay(entry.id); /** หันหน้าเข้ากล้อง (mock) — ไม่ใช้ทิศออกจากบอสในเลเยอร์นี้ */ const faceDir = 'down'; const slotIdxRing = Math.max(0, Math.min(5, skinSlot - 1)); const perRing = String((playBalloonBossPlayerBalloonImageUrls[slotIdxRing] || '')).trim(); const fbRing = String(playBalloonBossPlayerBalloonFallbackUrl || '').trim(); /** กรอบฟองรอบตัว = fallback (Artboard 9) เท่านั้น — ลูกโป่งต่อที่นั่งใช้แค่กองด้านบน · Ring frame is never per-seat balloon art */ const ringRec = fbRing ? resolveBalloonBossBalloonSpriteRec('', fbRing) : resolveBalloonBossBalloonSpriteRec(perRing, ''); ctx.save(); const ringImg = ringRec && ringRec.img; const ringPainted = !!(ringImg && ringImg.complete && ringImg.naturalWidth > 0); if (ringPainted) { const iw = ringImg.naturalWidth; const ih = ringImg.naturalHeight; const maxBox = 2 * ringR * 1.14; const sc = Math.min(maxBox / iw, maxBox / ih); const dw = iw * sc; const dh = ih * sc; /** หางฟอง (Artboard 9: หางอยู่มุมขวาล่าง ~32.7°/0.5714rad จาก +X ไม่ใช่ +Y) หมุนให้ "หางชี้ตรงทิศยิง" = angRing */ ctx.save(); ctx.translate(sx, bubbleCy); ctx.rotate(angRing - 0.5714); ctx.drawImage(ringImg, -dw / 2, -dh / 2, dw, dh); ctx.restore(); } if (!ringPainted) { ctx.strokeStyle = 'rgba(255, 255, 255, 0.72)'; ctx.lineWidth = Math.max(1.2, 1.6 * z); ctx.beginPath(); ctx.arc(sx, bubbleCy, ringR, 0, Math.PI * 2); ctx.stroke(); } else { ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)'; ctx.lineWidth = Math.max(1, 1.1 * z); ctx.beginPath(); ctx.arc(sx, bubbleCy, ringR, 0, Math.PI * 2); ctx.stroke(); } drawBalloonsCluster(sx, bubbleCy, ent.balloonBossBalloons | 0, col, z, skinSlot, ringR); 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; const labelY = bubbleCy + ringR + 8 * z; ctx.strokeText(label, sx, labelY); ctx.fillText(label, sx, labelY); const charImg = resolvePlayDrawCharSource( ent.characterId, faceDir, timeMs, !!ent.isWalking, ent.playTint || resolvePlayTintForPeer(entry.id), myId != null && entry.id != null && String(entry.id) === String(myId), ); 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; const charTop = bubbleCy - dh * 0.48; ctx.drawImage(charImg, 0, 0, iw, ih, sx - dw / 2, charTop, dw, dh); } else { ctx.fillStyle = col; ctx.beginPath(); ctx.arc(sx, bubbleCy, 9 * z, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(0, 10, 30, 0.45)'; ctx.lineWidth = Math.max(1, 1.2 * z); ctx.stroke(); } ctx.restore(); }); const bulletCodeUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/bullet-code.png'); const bulletCodeRec = bulletCodeUrl ? ensureGauntletAssetImage(bulletCodeUrl) : null; 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); /** bullet-code.png หัวลูกศรชี้ขวา (+x) — หมุนตามทิศความเร็วโดยไม่บวก π/2 (ก่อนหน้านี้ดูเหมือนยิงเฉียง) */ ctx.rotate(Math.atan2(b.vy, b.vx)); if (bulletCodeRec && bulletCodeRec.ready && bulletCodeRec.img && bulletCodeRec.img.naturalWidth > 0) { const iw = bulletCodeRec.img.naturalWidth; const ih = bulletCodeRec.img.naturalHeight; const mh = Math.max(10, 15 * zDraw); const sc = Math.min(1.4, mh / ih); const dw = iw * sc; const dh = ih * sc; ctx.drawImage(bulletCodeRec.img, -dw / 2, -dh / 2, dw, dh); } else { 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); } function jumpSurviveTileHasPlatformCellPlay(md, tx, ty) { const pa = md && md.jumpSurvivePlatformArea; const h = md.height || (pa && pa.length) || 15; const w = md.width || 20; if (!pa || ty < 0 || ty >= h) return false; const row = pa[ty]; if (!row || tx < 0 || tx >= w) return false; return row[tx] === 1; } /** หาแพลตฟอร์มในคอลัมน์ tx ใกล้ ty — กันจุดเกิดช่องว่างแล้วลอย */ function jumpSurviveFindPlatformColumnPlay(md, tx, tyHint) { const w = md.width || 20, h = md.height || 15; const tcx = Math.max(0, Math.min(w - 1, Math.floor(tx))); let ty = Math.max(0, Math.min(h - 1, Math.floor(tyHint))); if (jumpSurviveTileHasPlatformCellPlay(md, tcx, ty)) return { x: tcx, y: ty }; for (let d = 1; d < h; d++) { const yb = ty + d; if (yb < h && jumpSurviveTileHasPlatformCellPlay(md, tcx, yb)) return { x: tcx, y: yb }; const ya = ty - d; if (ya >= 0 && jumpSurviveTileHasPlatformCellPlay(md, tcx, ya)) return { x: tcx, y: ya }; } return { x: tcx, y: ty }; } /** จุดเกิดตามลำดับ join / P1–P6 → world ยืนบนแพลตฟอร์ม (ไม่ทับสุ่ม pool) */ function jumpSurviveSpawnWorldFromJoinOrderPlay(md, joinOrder) { const sp = pickSpawnForJoinPlay(md, joinOrder | 0); let tx = Math.floor(Number(sp.x)); let ty = Math.floor(Number(sp.y)); if (!Number.isFinite(tx) || !Number.isFinite(ty)) { const fb = md.spawn || { x: 1, y: 1 }; tx = Math.floor(Number(fb.x)) || 1; ty = Math.floor(Number(fb.y)) || 1; } const cell = jumpSurviveFindPlatformColumnPlay(md, tx, ty); const sc = jumpSurvivePlatformScrollPx; const p = jumpSurviveGridPosStandingOnPlatform(md, cell.x, cell.y, sc); return { x: p.x + (Math.random() - 0.5) * 0.05, y: p.y + (Math.random() - 0.5) * 0.05, }; } /** ทดสอบจากเอดิเตอร์: วางคน+บอทบนแพลตฟอร์ม (ไม่ให้เกิดบน spawnArea บนฟ้าแล้วลอย) */ /** Quiz Battle พรีวิว: บอท spawn ตามโหมดแมป — ผู้เล่นจริงใช้ตำแหน่งจาก server แล้ว snap เท่าที่จำเป็น */ function applyQuizBattlePreviewSpawnLayout(onlyBots) { if (!mapData || mapData.gameType !== 'quiz_battle' || !quizBattlePathModeActive(mapData)) return; function stampQuizEnt(ent, joinOrd) { if (!ent) return; const jo = Number.isFinite(Number(joinOrd)) ? Math.max(0, Math.floor(Number(joinOrd))) : 0; const pos = quizBattleSpawnWorldFromJoinOrderPlay(mapData, jo); ent.x = pos.x; ent.y = pos.y; ent.tx = pos.x; ent.ty = pos.y; } if (!onlyBots) { const sn = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y); me.x = sn.x; me.y = sn.y; me.tx = sn.x; me.ty = sn.y; } const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); const botIds = [...others.keys()].filter(isPreviewBotId).sort(); if (!onlyBots) { realIds.forEach((rid) => { const o = others.get(rid); if (!o) return; const jo = Number.isFinite(Number(o.spawnJoinOrder)) ? o.spawnJoinOrder : realIds.indexOf(rid); stampQuizEnt(o, jo); }); } botIds.forEach((bid, bi) => { const o = others.get(bid); if (!o) return; const jo = Number.isFinite(Number(o.spawnJoinOrder)) ? o.spawnJoinOrder : countPlayHumans() + bi; stampQuizEnt(o, jo); }); } function applyJumpSurvivePreviewSpawnLayout(onlyBots) { if (!mapData || mapData.gameType !== 'jump_survive') return; function stampJumpBotState(o) { if (!o) return; o.jumpSurviveVy = 0; o.jumpSurviveOnGround = true; o.jumpSurviveEliminated = false; o.tx = o.x; o.ty = o.y; } if (isJumpSurviveMissionUiMapPlay()) { function snapEnt(ent) { if (!ent) return; const ordRaw = Number(ent.spawnJoinOrder); const jo = Number.isFinite(ordRaw) ? Math.max(0, Math.floor(ordRaw)) : 0; const pos = jumpSurviveSpawnWorldFromJoinOrderPlay(mapData, jo); ent.x = pos.x; ent.y = pos.y; stampJumpBotState(ent); } if (!onlyBots) { snapEnt(me); const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); realIds.forEach((rid) => snapEnt(others.get(rid))); } const botIds = [...others.keys()].filter(isPreviewBotId).sort(); botIds.forEach((bid) => snapEnt(others.get(bid))); jumpSurviveInitRuntime(); 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(); 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) * ตายจาก: hazard ที่วาง, บีบระหว่าง solid+object wall, หรือตกออกนอกขอบโลกแมป (ไม่ใช้ขอบกล้อง) * @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, } = 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; } else if (wasOnGround) { /* ยืนอยู่ → ติดไปกับแท่นที่เลื่อนลง (กันลอยเหนือแท่นตอน fps สูง: การจม ∝dt² < ระยะ scroll ∝dt → onGround หลุด กระโดดไม่ได้/วิ่งค้าง) */ const platformRise = (Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42) * dt; feetY += platformRise; } 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 (vy > 0 && jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) { /* ตกลง → ลงยืนบนแพลตฟอร์ม/ขอบ (แพลตฟอร์มเป็น one-way: ชนเฉพาะตอนกำลังตก) */ const step = ts * 0.035; const penetrationEst = Math.abs(vy) * dt + ts * 0.55; const maxSteps = Math.min(160, Math.max(28, Math.ceil(penetrationEst / step) + 6)); let cleared = false; for (let s = 0; s < maxSteps; s++) { feetY -= step; headTop = feetY - bh; if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) { cleared = true; break; } } if (cleared) { /* snap แม่นยำที่ผิวบนแพลตฟอร์ม — กันตัวละคร “ดิ้น” ตอนยืน (แท่นเลื่อนลงทุกเฟรม จึงต้อง re-land แต่ตำแหน่งต้องนิ่ง) */ let lo = feetY, hi = feetY + step; for (let i = 0; i < 20; i++) { const mid = (lo + hi) * 0.5; if (jumpSurviveOverlapsSolid(md, pxc - bw, mid - bh, pxc + bw, mid)) hi = mid; else lo = mid; } feetY = lo; headTop = feetY - bh; } vy = 0; onGround = true; } else if (vy < 0 && jumpSurviveOverlapsObjectSolids(md, pxc - bw, headTop, pxc + bw, feetY)) { /* กระโดดขึ้น: แพลตฟอร์มทะลุได้ (one-way) — หยุดเฉพาะกำแพง/ขอบ/เพดานจริง (objects) ไม่ให้หัวติดแท่นบน */ const stepUp = ts * 0.035; const penetrationEstUp = Math.abs(vy) * dt + ts * 0.55; const maxStepsUp = Math.min(160, Math.max(28, Math.ceil(penetrationEstUp / stepUp) + 6)); for (let s = 0; s < maxStepsUp; s++) { feetY += stepUp; headTop = feetY - bh; if (!jumpSurviveOverlapsObjectSolids(md, pxc - bw, headTop, pxc + bw, feetY)) { vy = 0; break; } } } else { onGround = false; } /* ชนด้านบน/เพดาน (object wall ที่แถบหัว) — เดิม "ตาย" (head crush) ตามคำขอ: ชนด้านบนไม่ตาย → แค่หยุดการพุ่งขึ้น กันทะลุเพดาน (ไม่คืน died) หมายเหตุ: การชนเพดานตอนกระโดดถูกบล็อก vy ที่ลูป vy<0 ด้านบนแล้ว — อันนี้กันกรณีถูก scroll ดันชนเพดาน */ headTop = feetY - bh; const headBandBottom = headTop + bh * 0.42; if (vy < 0 && jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY) && jumpSurviveOverlapsObjectWall(md, pxc - bw, headTop, pxc + bw, headBandBottom)) { vy = 0; } /* กั้นใต้เพดานบนสุด — แมปมีเพดานทึบ (objects แถว 0 สูง 1 ช่อง) แต่กระโดดแรงทะลุได้ (tunneling) แล้วหลุดนอกฉาก clamp หัวให้อยู่ "ใต้เพดาน" (headTop>=ts) — ห้าม clamp ที่ y=0 (=ในเพดาน) เพราะ landing branch จะดันหัวขึ้นพ้นเพดาน แล้ว clamp ดันลง ตีกันค้างถาวร (เพดานเป็น object ตรึง ไม่เลื่อนลงตาม). ที่ใต้เพดาน standing branch จะพาเลื่อนลงเอง → ชนเพดานแล้วหยุด+ตกลง ไม่ตาย ไม่ติดค้าง ไม่หลุดนอกฉาก */ headTop = feetY - bh; if (headTop < ts) { feetY = ts + bh; if (vy < 0) vy = 0; headTop = feetY - bh; } headTop = feetY - bh; if (jumpSurviveOverlapsHazardArea(md, pxc - bw, headTop, pxc + bw, feetY)) { return { pxc, feetY, vy, onGround, died: true }; } headTop = feetY - bh; /* ตายเฉพาะ hazard ที่วาง + ตกหลุดออก "ด้านล่าง" แมป (ขอบบน clamp ไว้แล้ว ไม่ตาย) */ const mapHpx = h * ts; if (feetY > mapHpx + ts * 8) { 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) return true; if (ty < 0) continue; /* แพลตฟอร์มวนแนวตั้ง (period = h*ts) — worldY เกิน h*ts เป็นปกติ ห้ามถือ ty>=h เป็น “ซีลพื้นแมป” ไม่งั้น solid+กำแพงข้าง = ตายคาแอร์ */ if (ty >= h) 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; let kMin = Math.ceil((top - rawTop - ts - vm) / period); let kMax = Math.floor((bottom - rawTop + vm) / period); /* ceil/floor กับช่วงสั้น ๆ อาจได้ kMin > kMax → ไม่ลอง k เลย แล้วตกทะลุแพลตฟอร์ม */ if (kMin > kMax) { const kGuess = Math.round((bottom - rawTop - ts * 0.5) / period); kMin = kGuess; kMax = kGuess; } 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 jumpSurvivePlatformEdgeKillRows(md) { const pa = md && md.jumpSurvivePlatformArea; if (!pa) return null; const h = Math.min(pa.length, md.height || pa.length || 15); const wM = md.width || 20; let minTy = h; let maxTy = -1; for (let ty = 0; ty < h; ty++) { const row = pa[ty]; if (!row) continue; const wlim = Math.min(row.length || 0, wM); let rowHas = false; for (let tx = 0; tx < wlim; tx++) { if (row[tx] === 1) { rowHas = true; break; } } if (!rowHas) continue; if (ty < minTy) minTy = ty; if (ty > maxTy) maxTy = ty; } if (minTy > maxTy) return null; if (minTy === maxTy) return null; return { minTy, maxTy }; } /** ชนแพลตฟอร์มแถวเพดานเท่านั้น (รวมเลื่อนแบบคาบ) = ตายทันที — ไม่ฆ่าที่แถวพื้นล่าง */ function jumpSurviveOverlapsEdgeKillPlatforms(md, left, top, right, bottom, scrollPx) { const edges = jumpSurvivePlatformEdgeKillRows(md); if (!edges) return false; 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; const overlapsRowTy = function (ty) { if (ty < 0 || ty >= h || !pa[ty]) return false; for (let tx = x0; tx <= x1; tx++) { if (pa[ty][tx] !== 1) continue; const rawTop = ty * ts + scrollPx; let kMin = Math.ceil((top - rawTop - ts - vm) / period); let kMax = Math.floor((bottom - rawTop + vm) / period); if (kMin > kMax) { const kGuess = Math.round((bottom - rawTop - ts * 0.5) / period); kMin = kGuess; kMax = kGuess; } const zSlack = ts * 0.08; 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 - zSlack && platTop < bottom + zSlack && right > tileLeft && left < tileRight) return true; } } return false; }; if (overlapsRowTy(edges.minTy)) 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 (!playBotsEnabled() || !isMePlayHost() || !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, }); 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 = (!r.onGround && 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('time_up'); 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; /* ผู้เล่นจริงระยะไกล (ไม่ใช่บอท): เลอร์ปตำแหน่งตาม move ที่รับมา (o.tx/o.ty) เดิม jump_survive ไม่ได้เลอร์ป others เลย → host/เพื่อนค้างที่จุดเกิดขณะกล้องเลื่อนขึ้น = ตัวละครหายจากจอ (เกมอื่นทำที่ tick กลาง 23235 แต่ jump_survive return ก่อนถึงตรงนั้น จึงต้องทำในนี้เอง) */ others.forEach((o, id) => { if (isPreviewBotId(id) || !o) 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; }); let zJumpSurviveWorld = zoom; if (previewMode && editorEmbedReturn && mapData) zJumpSurviveWorld *= playEmbedUserZoomMul; const halfViewH = canvas.height / (2 * zJumpSurviveWorld); const risePerSec = Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42; if (jumpSurviveLiveAnchorServerMs > 0) { /* scroll = ฟังก์ชันของเวลา server (ไม่สะสม dt local) → ทุกเครื่องเลื่อนเท่ากันที่เวลาเดียวกัน */ const scrollSec = Math.max(0, (serverNow() - jumpSurviveLiveAnchorServerMs) / 1000); jumpSurvivePlatformScrollPx = scrollSec * risePerSec; } else { jumpSurvivePlatformScrollPx += risePerSec * dt; } 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; /* virtual joystick (touch) — แกน x ป้อนซ้าย/ขวาเหมือนคีย์ A/D (deadzone จัดการในตัว update แล้ว) */ const joyX = isJumpSurviveMissionHudActivePlay() ? (Number(quizQuestionMissionJoyVecX) || 0) : 0; const moveLeftIn = !!(keys['ArrowLeft'] || keys['KeyA']) || joyX < -0.001; const moveRightIn = !!(keys['ArrowRight'] || keys['KeyD']) || joyX > 0.001; const r = jumpSurvivePhysicsIntegrate(md, { dt, cw, ch, pxc0: pxc, feetY0: feetY, vy0: jumpSurviveVy, wasOnGround: jumpSurviveOnGround, jumpQueued: jq, moveLeft: moveLeftIn, moveRight: moveRightIn, }); 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; }); /* broadcast การตายทันที — เดิมพอตายแล้วบล็อก emit move (เงื่อนไข !eliminated) → คนอื่นไม่เคยรู้ว่าเราตาย = "ไม่ยอมตาย" */ try { if (socket && myId != null) socket.emit('move', { x: me.x, y: me.y, direction: me.direction, jumpSurviveEliminated: true }); } catch (eJ) { /* ignore */ } } else { jumpSurviveVy = r.vy; jumpSurviveOnGround = r.onGround; jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY); } stepJumpSurvivePreviewBots(dt, halfViewH); if (!jumpSurviveEliminated) { me.isWalking = (!jumpSurviveOnGround && Math.abs(jumpSurviveVy) > 40) || moveLeftIn || moveRightIn; if (moveRightIn) me.direction = 'right'; else if (moveLeftIn) 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; playEmbedUserZoomMul = 1; if (mapData.gameType !== 'quiz_carry') { quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT; } { const qMap = (playMapIdFromQuery || '').trim(); const dataId = mapData && mapData.id != null && String(mapData.id).trim() !== '' ? String(mapData.id).trim() : ''; const resMap = res && res.mapId != null && String(res.mapId).trim() !== '' ? String(res.mapId).trim() : ''; if (qMap) playSessionMapId = qMap; else if (dataId) playSessionMapId = dataId; else if (resMap) playSessionMapId = resMap; } applyPlayEmbedZoomForCurrentMapPlay(); if (res && res.hostId != null) playHostId = res.hostId; if (!isQuizQuestionMissionUiMapPlay()) { quizQuestionMissionPhase = null; quizQuestionMissionDeferredPhase = null; if (quizQuestionMissionCountdownTimer) { clearTimeout(quizQuestionMissionCountdownTimer); quizQuestionMissionCountdownTimer = null; } } if (mapData.gameType !== 'space_shooter') { spaceShooterGameEnded = false; if (prevWasSpaceShooter) { const gend = document.getElementById('gauntlet-ended-overlay'); if (gend) gend.classList.add('is-hidden'); spaceShooterMissionPhase = null; spaceShooterAsteroidExplosions = []; if (spaceShooterMissionCountdownTimer) { clearTimeout(spaceShooterMissionCountdownTimer); spaceShooterMissionCountdownTimer = null; } const gccLeave = document.getElementById('gauntlet-crown-countdown'); if (gccLeave) gccLeave.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); applyQuizCarryWalkSpeedFromSettingsObj(quizCarryJoinSettingsSnap || {}); } if (mapData.gameType === 'quiz_battle') { if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = []; if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = []; normalizeQuizBattlePathInPlay(mapData); normalizeQuizBattleDomeInPlay(mapData); buildQuizBattlePathGateRequiredPlay(mapData); } tileSize = mapData.tileSize || 32; mapBackgroundImg = null; if (mapData.backgroundImage) { mapBackgroundImg = new Image(); mapBackgroundImg.src = mapData.backgroundImage; } reloadPlayScrollBgFromMap(); reloadStackTowerScrollBgFromMap(); reloadGauntletCrownRunwayBgFromMap(); 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'; if (myPeer && myPeer.nickname) { me.nickname = String(myPeer.nickname).trim() || me.nickname; nick = me.nickname; } 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 bd0 = Number(myPeer && myPeer.balloonBossBossDmg); me.balloonBossBossDmg = Number.isFinite(bd0) ? Math.max(0, bd0) : 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; if (Array.isArray(res.lobbyBotThemes)) playLobbyBotThemes = res.lobbyBotThemes; const myPeerJoin = (Array.isArray(plist) ? plist : []).find((q) => q && q.id === myId); /* ตัวเราเอง: ใช้สีที่เลือกไว้ใน Main-Lobby (localStorage) ก่อน แล้วค่อย fallback ไป theme-index ของ server (กันสีกระพริบ) */ me.playTint = loadPlayTintFromLobbyPlayerStorage() || playTintFromJoinPeer(myPeerJoin) || resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')), myPeerJoin); if (myPeerJoin && myPeerJoin.lobbyColorThemeIndex) { try { const ti = parsePlayLobbyThemeIndex(myPeerJoin.lobbyColorThemeIndex); const si = parsePlayLobbySkinIndex(myPeerJoin.lobbySkinToneIndex) || 1; if (ti) { localStorage.setItem(LOBBY_PLAYER_TINT_KEY, JSON.stringify({ themeRgb: PLAY_LOBBY_THEME_HEX[ti - 1], skinRgb: PLAY_LOBBY_SKIN_HEX[si - 1], })); } } catch (eTintStore) { /* ignore */ } } // อุ่น cache เลเยอร์สีตัวละครก่อนเข้าเกม (ตัวเรา + เพื่อนร่วมห้อง) กันกระตุกตอนโหลดสีกลางเกม try { preloadPlayCharacterLayers(me.characterId || getPlayCharacterId()); (Array.isArray(plist) ? plist : []).forEach(function (p) { if (p && p.characterId) preloadPlayCharacterLayers(p.characterId); }); preloadPlayOccupantsTints(); preloadPlayLobbyIdleAvatarsForOccupants(); } catch (e) { /* ignore */ } { const jo = Number(myPeer && myPeer.spawnJoinOrder); me.spawnJoinOrder = Number.isFinite(jo) ? Math.max(0, Math.floor(jo)) : Math.max(0, plist.findIndex((q) => q && q.id === myId)); } others.clear(); plist.forEach(p => { if (p.id !== myId && p.id !== socket.id) { 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; } const joP = Number(p && p.spawnJoinOrder); const spawnOrd = Number.isFinite(joP) ? Math.max(0, Math.floor(joP)) : Math.max(0, plist.findIndex((q) => q && q.id === p.id)); others.set(p.id, { x: px, y: py, tx: px, ty: py, direction: p.direction, nickname: p.nickname, characterId: p.characterId, spawnJoinOrder: spawnOrd, playTint: playTintFromJoinPeer(p) || playTintForPreviewBotId(p.id) || resolvePlayTintForPeer(p.id, p), 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; })(), balloonBossBossDmg: (() => { const s = Number(p.balloonBossBossDmg); 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, bannedSpectator: !!p.bannedSpectator, }); } }); 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 === 'quiz_battle' && quizBattlePathModeActive(mapData)) { applyQuizBattlePreviewSpawnLayout(false); try { socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } catch (eSync) { /* ignore */ } } 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; }); if (isStackTowerMissionUiMapPlay()) { stackTowerMissionEndedOnce = false; stackTowerMissionDeferredPhase = null; if (stackTowerMissionCountdownTimer) { clearTimeout(stackTowerMissionCountdownTimer); stackTowerMissionCountdownTimer = null; } stackTowerMissionPhase = 'howto'; stackTowerSessionStartAt = 0; hideConflictingOverlaysForGauntletCrown(); applyStackTowerMissionPanelImages(); 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 () { showStackTowerMissionHowtoOverlay(); }, 50); } else { teardownStackTowerMissionUiPlay(); } } else { stackMini = null; teardownStackTowerMissionUiPlay(); } playPath = []; me.isWalking = false; if (mapData.gameType === 'jump_survive') { if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = []; if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = []; if (!mapData.jumpSurviveHazardArea) mapData.jumpSurviveHazardArea = []; if (!mapData.jumpSurvivePlatformVariantArea) mapData.jumpSurvivePlatformVariantArea = []; normalizeJumpSurvivePlatformAreaInPlay(mapData); normalizeJumpSurvivePlatformVariantAreaInPlay(mapData); normalizeJumpSurviveHazardAreaInPlay(mapData); normalizeShooterSpawnSlotsInPlay(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') { normalizeShooterSpawnSlotsInPlay(mapData); if (spaceShooterMissionCountdownTimer) { clearTimeout(spaceShooterMissionCountdownTimer); spaceShooterMissionCountdownTimer = null; } 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 () {}); if (isSpaceShooterMissionUiMapPlay()) { spaceShooterMissionPhase = 'howto'; hideConflictingOverlaysForGauntletCrown(); applySpaceShooterMissionPanelImages(); setTimeout(function () { showSpaceShooterMissionHowtoOverlay(); }, 50); } else { spaceShooterMissionPhase = null; spaceShooterGameEnded = false; spaceShooterBullets = []; spaceShooterAsteroids = []; spaceShooterAsteroidExplosions = []; 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 = []; balloonBossScorePopups = []; balloonBossLastTickMs = performance.now(); balloonBossPlayerFireCd = 0; balloonBossSessionStartMs = performance.now(); balloonBossLastMoveEmit = 0; balloonBossBossFireAcc = 0; 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 () {}); } if (isQuiz()) { setupPlayQuizUi(); if (isQuizQuestionMissionUiMapPlay()) { quizQuestionMissionDeferredPhase = null; if (quizQuestionMissionCountdownTimer) { clearTimeout(quizQuestionMissionCountdownTimer); quizQuestionMissionCountdownTimer = null; } quizQuestionMissionPhase = 'howto'; hideConflictingOverlaysForGauntletCrown(); applyQuizQuestionMissionPanelImages(); 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 () { showQuizQuestionMissionHowtoOverlay(); }, 50); } } 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 === 'balloon_boss' && isMegaVirusMissionShellMapPlay()) { hideConflictingOverlaysForGauntletCrown(); gauntletCrownPregamePhase = null; gauntletCrownLobbyReadyMap = {}; if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } const gccRm = document.getElementById('gauntlet-crown-countdown'); if (gccRm) gccRm.classList.add('is-hidden'); if (res && Object.prototype.hasOwnProperty.call(res, 'gauntletCrownRunHeld')) { applyGauntletTimingFromServer({ gauntletCrownRunHeld: res.gauntletCrownRunHeld }); } 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 if (mapData.gameType === 'space_shooter' && isSpaceShooterMissionUiMapPlay()) { if (spaceShooterMissionCountdownTimer) { clearTimeout(spaceShooterMissionCountdownTimer); spaceShooterMissionCountdownTimer = null; } const gccRs = document.getElementById('gauntlet-crown-countdown'); if (gccRs) gccRs.classList.add('is-hidden'); gauntletCrownPregamePhase = null; gauntletCrownLobbyReadyMap = {}; } else if (isQuiz() && isQuizQuestionMissionUiMapPlay()) { if (quizQuestionMissionCountdownTimer) { clearTimeout(quizQuestionMissionCountdownTimer); quizQuestionMissionCountdownTimer = null; } const gccRq = document.getElementById('gauntlet-crown-countdown'); if (gccRq) gccRq.classList.add('is-hidden'); gauntletCrownPregamePhase = null; gauntletCrownLobbyReadyMap = {}; } else if (isStack() && isStackTowerMissionUiMapPlay()) { if (stackTowerMissionCountdownTimer) { clearTimeout(stackTowerMissionCountdownTimer); stackTowerMissionCountdownTimer = null; } const gccRst = document.getElementById('gauntlet-crown-countdown'); if (gccRst) gccRst.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'); teardownStackTowerMissionUiPlay(); } resizeCanvas(); draw(); tick(); } socket.on('connect', () => { firstCharacterDefaultPromise.then(() => { const joinPlayMapId = (params.get('map') || '').trim(); const joinNick = (typeof window.jdReadDisplayName === 'function' ? window.jdReadDisplayName() : null) || readStoredDisplayName() || nick; const __chosenColors = getMyChosenLobbyColorIndicesPlay(); socket.emit('join-space', { spaceId, nickname: joinNick, characterId: getPlayCharacterId(), playMapId: joinPlayMapId || undefined, playerKey: getJdPlayerKey(), bannedSpectator: playBannedSpectator || undefined, /* ส่งสีที่เลือก → server กันบอทไม่ให้ใช้ธีมเดียวกับเรา (กันสีตัวละครซ้ำ) */ desiredLobbyColorThemeIndex: __chosenColors.themeIndex || undefined, desiredLobbySkinToneIndex: __chosenColors.skinIndex || undefined, }, (res) => { if (!res || !res.ok) { const errMsg = (res && res.error) || 'เข้าร่วมไม่ได้'; const caseLocked = /เริ่มคดี|ไม่รับผู้เล่น/.test(errMsg); showPlayAlert(errMsg, { type: 'error', requireButton: true, onClose: function () { if (caseLocked) { window.location.replace(buildRoomLobbyReturnHref()); } else { window.location.replace(playDesignMainMenuHref()); } }, }); return; } myId = socket.id; try { syncPlayClock(); } catch (_sc) { /* ignore */ } try { startPeriodicClockSync(); } catch (_sci) { /* ignore */ } if (res && res.missionLiveBeginsAt != null) setPlayMissionLiveBeginsAt(res.missionLiveBeginsAt); if (playBannedSpectator) setupBannedSpectatorUi(); if (specialQuizForceDebug) { try { socket.emit('special-quiz-force', { on: true }); } catch (eF) { /* ignore */ } } if (__isTestModeOnPlay()) { try { socket.emit('special-quiz-testmode', { on: true }); } catch (eT) { /* ignore */ } } 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.quizSettingsSnap && typeof res.quizSettingsSnap === 'object') { if (res.quizSettingsSnap.quizMapPanelTheme && typeof res.quizSettingsSnap.quizMapPanelTheme === 'object') { setQuizMapPanelThemeFromApi({ quizMapPanelTheme: res.quizSettingsSnap.quizMapPanelTheme }); } } 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 (res.specialQuizIcon) setSpecialQuizIconFromServer(res.specialQuizIcon); 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; function applyPlayDisplayNameUpdate(data) { if (!data || !data.id) return; const next = String(data.nickname || '').trim(); if (!next) return; if (data.id === myId) { me.nickname = next; nick = next; try { localStorage.setItem('roomLobbyDisplayName', next); localStorage.setItem('playerName', next); } catch (e) { /* ignore */ } if (window.jdDisplayName && typeof window.jdDisplayName.syncDom === 'function') { window.jdDisplayName.syncDom(next); } } else { const o = others.get(data.id); if (o) o.nickname = next; } if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud(); if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud(); if (typeof syncPlayCyberHud === 'function') syncPlayCyberHud(); } socket.on('peer-display-name', applyPlayDisplayNameUpdate); socket.on('lobby-tint-sync', applyPlayLobbyTintSync); window.addEventListener('jd-display-name-changed', function (ev) { const next = ev && ev.detail && ev.detail.displayName ? String(ev.detail.displayName).trim() : ''; if (!next || !myId) return; applyPlayDisplayNameUpdate({ id: myId, nickname: next }); }); socket.on('user-joined', (data) => { if (!data || isPreviewBotId(data.id)) return; if (myId != null && String(data.id) === String(myId)) return; if (socket && socket.id && String(data.id) === String(socket.id)) return; /* กันผี reconnect ("เงาตัวละคร"): คนเดิมกลับเข้ามาด้วย socket.id ใหม่ (เปลี่ยนหน้า/หลุดต่อใหม่) — server ลบ peer เก่าด้วย playerKey เท่านั้น ถ้าไม่ match จะค้างเป็นตัวซ้ำ. ลบ peer เดิม (คนจริง) ที่ "ชื่อ+ตัวละครเดียวกัน" แต่ id ต่าง ก่อนเพิ่มตัวใหม่ (สัญญาณว่าเป็นคนเดิม) */ const joinNickDedup = (data.nickname != null ? String(data.nickname).trim() : ''); const joinCidDedup = (data.characterId != null ? String(data.characterId) : ''); /* dedup เฉพาะชื่อ "เฉพาะตัว" (custom) — ข้ามชื่อ default ที่ซ้ำกันได้ ('ผู้เล่น', 'บอท', ว่าง) ไม่งั้นผู้เล่นจริง 2 คนที่ไม่ตั้งชื่อ (default 'ผู้เล่น') จะถูกลบผิด → จำนวนคนหาย/ready ค้าง 4/5 */ const isGenericNick = !joinNickDedup || /^ผู้เล่น/.test(joinNickDedup) || joinNickDedup === 'บอท'; if (!isGenericNick) { [...others.keys()].forEach((oid) => { if (isPreviewBotId(oid) || String(oid) === String(data.id)) return; const ex = others.get(oid); if (!ex) return; const exNick = (ex.nickname != null ? String(ex.nickname).trim() : ''); const exCid = (ex.characterId != null ? String(ex.characterId) : ''); if (exNick === joinNickDedup && exCid === joinCidDedup) others.delete(oid); }); } const x = Number(data.x); const y = Number(data.y); const px = Number.isFinite(x) ? x : 1; const py = Number.isFinite(y) ? y : 1; const joJ = Number(data && data.spawnJoinOrder); const spawnOrdJ = Number.isFinite(joJ) ? Math.max(0, Math.floor(joJ)) : 0; others.set(data.id, { x: px, y: py, tx: px, ty: py, direction: data.direction || 'down', nickname: data.nickname, characterId: data.characterId ?? null, spawnJoinOrder: spawnOrdJ, playTint: playTintFromJoinPeer(data) || playTintForPreviewBotId(data.id) || resolvePlayTintForPeer(data.id, data), 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; })(), balloonBossBossDmg: (() => { const s = Number(data.balloonBossBossDmg); 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, bannedSpectator: !!data.bannedSpectator, }); if (playBotsEnabled()) rebalancePreviewBots(); /* MG3: มีคนเข้าใหม่ → host รวมเข้าลำดับตา (preserve คนที่กำลังถึงตา) แล้ว broadcast ใหม่ — กัน late-join ตกหล่น */ if (isStack() && detectiveCaseFillBots && isMePlayHost()) rebuildStackPreviewTurnOrder(); if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud(); if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud(); if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud(); if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud(); if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud(); }); socket.on('host-changed', (d) => { if (d && d.hostId != null) playHostId = d.hostId; if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud(); if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud(); if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud(); if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud(); if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud(); }); /* upsert others ที่ขาดหายจาก peers snapshot ที่ server แนบมากับ lobby-sync กัน host/client ไม่เห็นตัวละคร (และนับจำนวนผู้เล่นไม่เท่ากัน) เมื่อ plist/user-joined มาไม่ครบ ช่วงเปลี่ยนหน้า lobbyB → play (ใช้ทั้ง quiz_carry และ crown family: MG1/2/3/5/6/7) */ function upsertOthersFromPeersSnapPlay(peers) { if (!Array.isArray(peers)) return false; let changed = false; peers.forEach((p) => { if (!p || p.id == null || isPreviewBotId(p.id)) return; if (myId != null && String(p.id) === String(myId)) return; const existing = others.get(p.id) || others.get(String(p.id)); if (existing) { /* entry มีอยู่แล้ว — อัปเดต flag สำคัญ (โดยเฉพาะ bannedSpectator) ให้ตรง server กัน host นับผู้ถูกแบนเป็นผู้เล่นที่ต้องรอ Ready → humansReady ไม่ครบ → เริ่มเกมไม่ได้ */ const nb = !!p.bannedSpectator; if (existing.bannedSpectator !== nb) { existing.bannedSpectator = nb; changed = true; } return; } const px = Number(p.x); const py = Number(p.y); const x = Number.isFinite(px) ? px : 1; const y = Number.isFinite(py) ? py : 1; const joP = Number(p.spawnJoinOrder); others.set(p.id, { x, y, tx: x, ty: y, direction: p.direction || 'down', nickname: p.nickname, characterId: p.characterId ?? null, spawnJoinOrder: Number.isFinite(joP) ? Math.max(0, Math.floor(joP)) : 0, playTint: playTintFromJoinPeer(p) || playTintForPreviewBotId(p.id) || resolvePlayTintForPeer(p.id, p), gauntletJumpTicks: 0, gauntletJumpVis: 0, gauntletScore: 0, jumpSurviveEliminated: false, spaceShooterScore: 0, balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossBalloons: balloonBossBalloonsStartPlay(), balloonBossEliminated: false, quizCarryHeld: null, bannedSpectator: !!p.bannedSpectator, }); if (p.characterId) { /* pre-warm เลเยอร์ + สีย้อม (tinted source) ของ peer ที่เพิ่งรู้จัก → avatar ในช่อง SCORE ขึ้นสีตรงกันทุกเครื่อง (กันฝั่ง client เห็น avatar เป็นสีเทา/ไม่มีสี) */ const tintP = playTintFromJoinPeer(p) || playTintForPreviewBotId(p.id) || resolvePlayTintForPeer(p.id, p); preloadPlayTintForAvatar(p.characterId, tintP); } changed = true; }); return changed; } socket.on('quiz-carry-lobby-sync', (d) => { if (!d || typeof d.readyMap !== 'object') return; quizCarryLobbyReadyMap = { ...d.readyMap }; if (Number.isFinite(d.expectedActive)) missionExpectedActiveCount = d.expectedActive; upsertOthersFromPeersSnapPlay(d.peers); if (quizCarryPregameActive && isQuizCarry()) { updateQuizCarryPregameHud(); } }); socket.on('quiz-carry-lobby-started', (payload) => { setPlayMissionLiveBeginsAt(payload && payload.liveBeginsAt); if (!quizCarryEmbedMissionFlowActive()) return; endQuizCarryEmbedPregameAndStart(); }); /* host เลือกคำถาม → client อื่นใช้ข้อเดียวกัน (กันสุ่มไม่ตรงกัน) */ socket.on('quiz-carry-question', (d) => { if (!d || isMePlayHost() || !isQuizCarry() || !quizCarryEmbedMissionFlowActive()) return; const idx = Number(d.idx); if (!Number.isInteger(idx) || idx < 0 || idx >= quizCarryPool.length) return; applyQuizCarryEmbedNextQuestion(quizCarryPool[idx]); }); /* host จบเซสชัน → client อื่นโชว์สรุปพร้อมกัน */ socket.on('quiz-carry-session-complete', () => { if (isMePlayHost() || !isQuizCarry() || !quizCarryEmbedMissionFlowActive()) return; showQuizCarrySessionCompleteOverlay(); }); socket.on('gauntlet-crown-lobby-sync', (d) => { if (!d || typeof d.readyMap !== 'object') return; gauntletCrownLobbyReadyMap = { ...d.readyMap }; if (Number.isFinite(d.expectedActive)) missionExpectedActiveCount = d.expectedActive; /* เติม others ที่ขาด → host/client เห็นจำนวนผู้เล่นเท่ากัน + ยาน spawn ตรง slot เดียวกัน re-layout เฉพาะตอนยังไม่เริ่มเล่น (pre-live) ไม่งั้นยานคนที่กำลังเล่นจะถูกดีดกลับจุดเกิด */ if (upsertOthersFromPeersSnapPlay(d.peers)) { if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') applySpaceShooterSpawnLayoutPlay(); if (mapData && mapData.gameType === 'balloon_boss' && !(balloonBossLiveAnchorServerMs > 0)) applyBalloonBossSpawnLayoutPlay(); } if (gauntletCrownPregamePhase === 'howto') { gauntletCrownSyncGuestReadyIfNeeded(); updateGauntletCrownHowtoHud(); } else if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') { gauntletCrownSyncGuestReadyIfNeeded(); updateQuizQuestionMissionHowtoHud(); } else if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') { gauntletCrownSyncGuestReadyIfNeeded(); updateStackTowerMissionHowtoHud(); } else if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') { gauntletCrownSyncGuestReadyIfNeeded(); updateJumpSurviveMissionHowtoHud(); } else if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') { gauntletCrownSyncGuestReadyIfNeeded(); updateSpaceShooterMissionHowtoHud(); } }); socket.on('gauntlet-crown-lobby-started', (payload) => { setPlayMissionLiveBeginsAt(payload && payload.liveBeginsAt); if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') { beginQuizQuestionMissionCountdownThenRun(); return; } if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') { beginStackTowerMissionCountdownThenRun(); return; } if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') { beginJumpSurviveMissionCountdownThenRun(); return; } if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') { beginSpaceShooterMissionCountdownThenRun(); return; } if (!usesCrownLobbyShellPlay()) return; beginGauntletCrownCountdownThenRun(); }); /* รับ state ของ fill-bot จาก host (เฉพาะ client ที่ไม่ใช่ host) → render ตาม ไม่จำลองเอง */ socket.on('fill-bot-state', (data) => { /* ได้รับ fill-bot-state = server relay จาก "host จริง" เท่านั้น (server gate + socket.to ไม่ส่งกลับผู้ส่ง) → เรา "ไม่ใช่ host" แน่นอน. ใช้ data.hostId แก้ playHostId ที่ผิด (race LobbyB→play ทำให้เข้าใจผิดว่าเป็น host → ignore fill-bot-state + sim บอทเอง = บอท/จำนวนคนละชุดบน 2 จอ) */ if (data && data.hostId != null && myId != null && String(data.hostId) !== String(myId)) { if (String(playHostId) !== String(data.hostId)) playHostId = data.hostId; } if (isMePlayHost()) return; if (!data || !Array.isArray(data.bots)) return; /* force รายชื่อ "คนจริง" ให้ตรง host — ลบผี (peer ที่ host ไม่มี แต่ค้างบน client จาก reconnect/snapshot) id-based (ไม่ชนชื่อ default). debounce 3 ครั้งติด กัน peer ที่ host ยังไม่เห็นชั่วคราวถูกลบผิด (self-heal) */ if (Array.isArray(data.humans)) { const hostHumans = new Set(data.humans.map(String)); [...others.keys()].forEach((oid) => { if (isPreviewBotId(oid)) return; const o = others.get(oid); if (!o) return; if (hostHumans.has(String(oid))) { o._notInHostRoster = 0; return; } o._notInHostRoster = (o._notInHostRoster || 0) + 1; if (o._notInHostRoster >= 3) others.delete(oid); }); } /* เกมที่ render อ่านตำแหน่งจาก x/y หรือ Cx/Cy ตรงๆ (ไม่ผ่าน lerp ของ main tick) → สแนปตำแหน่งเลย */ const snapPos = isSpaceShooter() || isBalloonBoss() || isJumpSurvive(); /* ชุดบอทต้องตรงกับ host เป๊ะ — กัน client คำนวณ wantBots ต่างจาก host (เช่นนับ human เกิน) แล้วได้บอทคนละจำนวน → position/การตายไม่ตรงกัน (host ส่งบอททั้งหมดทุก 80ms → ตัวที่ไม่อยู่ในลิสต์ = host ไม่มี → ลบทิ้ง; ตัวที่ขาด = สร้างเพิ่ม) */ const hostBotIds = new Set(); data.bots.forEach((rb) => { if (rb && isPreviewBotId(rb.id)) hostBotIds.add(String(rb.id)); }); [...others.keys()].filter(isPreviewBotId).forEach((bid) => { if (!hostBotIds.has(String(bid))) others.delete(bid); }); let quizBotScoreChanged = false; let tierChangedFb = false; data.bots.forEach((rb) => { if (!rb || !isPreviewBotId(rb.id)) return; let o = others.get(rb.id); if (!o) { /* บอทที่ host มีแต่ client ยังไม่มี → สร้าง entry ใหม่ให้ render ได้ (ตำแหน่ง/สีตามที่ host ส่ง) */ const nx = Number.isFinite(rb.x) ? rb.x : 1; const ny = Number.isFinite(rb.y) ? rb.y : 1; o = { x: nx, y: ny, tx: nx, ty: ny, direction: rb.direction || 'down', nickname: 'บอท', characterId: rb.cid != null ? rb.cid : null, playTint: (rb.tint && rb.tint.body) ? { head: rb.tint.head, hair: rb.tint.hair, body: rb.tint.body } : (playTintForPreviewBotId(rb.id) || resolvePlayTintForPeer(rb.id)), spaceShooterScore: 0, balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossBalloons: balloonBossBalloonsStartPlay(), balloonBossEliminated: false, spaceShooterEliminated: false, jumpSurviveEliminated: false, quizCarryHeld: null, botTier: (rb.tier === 'sharp' || rb.tier === 'weak' || rb.tier === 'avg') ? rb.tier : 'avg', }; others.set(rb.id, o); if (o.characterId) { try { preloadPlayTintForAvatar(o.characterId, o.playTint); } catch (_e) { /* ignore */ } } } if (rb.cid != null && o.characterId == null) o.characterId = rb.cid; /* delta ใหญ่ = teleport/respawn (เช่น บอทควิซตอบผิดถูกดีดกลับจุดเกิด) → สแนปเลย ไม่งั้น lerp ไถลทะลุกำแพง */ if (Number.isFinite(rb.x)) { const bigX = Number.isFinite(o.x) && Math.abs(rb.x - o.x) > 3; o.tx = rb.x; if (snapPos || bigX || !Number.isFinite(o.x)) o.x = rb.x; } if (Number.isFinite(rb.y)) { const bigY = Number.isFinite(o.y) && Math.abs(rb.y - o.y) > 3; o.ty = rb.y; if (snapPos || bigY || !Number.isFinite(o.y)) o.y = rb.y; } if (rb.direction) o.direction = rb.direction; /* สีบอทจาก host → ใช้ตามให้ตรงกันทุกเครื่อง (อัปเดตเฉพาะเมื่อเปลี่ยน กัน churn ทุกเฟรม) */ if (rb.tint && rb.tint.body) { const tk = String(rb.tint.head) + '|' + String(rb.tint.hair) + '|' + String(rb.tint.body); if (o._fbTintKey !== tk) { o._fbTintKey = tk; o.playTint = { head: rb.tint.head, hair: rb.tint.hair, body: rb.tint.body }; } } o.botIsWalking = !!rb.walking; if ((rb.tier === 'sharp' || rb.tier === 'weak' || rb.tier === 'avg') && o.botTier !== rb.tier) { o.botTier = rb.tier; tierChangedFb = true; } if (rb.gScore != null) o.gauntletScore = rb.gScore; if (rb.gElim != null) o.gauntletEliminated = !!rb.gElim; if (rb.gJump != null) o.gauntletJumpTicks = rb.gJump; if (rb.gJumpVis != null) o.gauntletJumpVis = rb.gJumpVis; if (rb.jsElim != null) o.jumpSurviveEliminated = !!rb.jsElim; if (rb.ssCx != null) o.spaceShooterCx = rb.ssCx; if (rb.ssCy != null) o.spaceShooterCy = rb.ssCy; if (rb.ssScore != null) o.spaceShooterScore = rb.ssScore; if (rb.ssElim != null) o.spaceShooterEliminated = !!rb.ssElim; if (rb.bbCx != null) o.balloonBossCx = rb.bbCx; if (rb.bbCy != null) o.balloonBossCy = rb.bbCy; if (rb.bbScore != null) o.balloonBossScore = rb.bbScore; if (rb.bbDmg != null) o.balloonBossBossDmg = rb.bbDmg; if (rb.bbBalloons != null) o.balloonBossBalloons = rb.bbBalloons; if (rb.bbElim != null) o.balloonBossEliminated = !!rb.bbElim; if (rb.qcHeld !== undefined) o.quizCarryHeld = rb.qcHeld; /* MG1 quiz: รับสถานะล็อก + คะแนนบอทจาก host (single source of truth) */ if (rb.qct != null) o.quizCannotTrue = !!rb.qct; if (rb.qcf != null) o.quizCannotFalse = !!rb.qcf; /* ghost overlay (วาดผี) อ่านจาก playQuizEverWrong — non-host ไม่ได้รันบล็อกตัดสินบอท (host-only) จึงต้อง bridge จากสถานะล็อกที่ host ส่งมา ไม่งั้น client ไม่เห็นบอทเป็นผีตอนตอบผิด */ if (o.quizCannotTrue && o.quizCannotFalse) playQuizEverWrong[String(rb.id)] = true; if (rb.qsc != null) { const k = String(rb.id); const nv = Math.max(0, rb.qsc | 0); if (playLiveQuizScores[k] !== nv) { playLiveQuizScores[k] = nv; quizBotScoreChanged = true; } } }); if (tierChangedFb) relabelPreviewBotsPlay(); /* tier จาก host เปลี่ยน → อัปเดตป้าย "บอท N (tier)" ให้ตรง host */ if (quizBotScoreChanged && isQuiz()) renderPlayQuizScoreboard(playLiveQuizScores); }); /* MG3 stack (shared tower): รับผลการวางจากเจ้าของตา → เข้าคิว แล้วทยอย apply ทีละอัน (กันทิ้งดรอปตอนบล็อกกำลังร่วง = ตึก/% ต่างกัน) */ socket.on('stack-drop', (d) => { if (!d || !mapData || !isStack() || !stackMini || !detectiveCaseFillBots) return; const hit = { miss: !!d.miss, perfect: !!d.perfect, pts: Number(d.pts) || 0, progressDelta: Number(d.progressDelta) || 0, landOffsetTiles: Number(d.landOffsetTiles) || 0, placedW: Number(d.placedW), placedCx: Number(d.placedCx), overlap: Number(d.overlap), supportRatio: (d.supportRatio == null ? null : Number(d.supportRatio)), delta: Number(d.delta) || 0, }; stackRemoteDropQueue.push({ hit: hit, seat: Number(d.seat) || 1, heavy: !!d.heavy, actor: d.isBot ? { botId: String(d.actorId || '') } : { human: true }, }); }); /* MG3 stack (shared tower): รับเฟสแกว่งจากเจ้าของตา → mirror บล็อกที่กำลังแกว่ง (เห็นเหมือนกันจนกว่าจะถึงตาตัวเอง) */ socket.on('stack-swing', (d) => { if (!d || !mapData || !isStack() || !stackMini || !detectiveCaseFillBots) return; if (isMyStackTurn()) return; /* ตาเรา → คุมเฟสเอง */ if (isMePlayHost() && isStackCurrentBotTurnPlay()) return; /* host คุมบอทเอง */ const ph = Number(d.phase); if (Number.isFinite(ph)) stackMini.phase = ph; }); /* MG3 stack — non-host รับลำดับตา+index จาก host (single source of truth) → เลิกแยกตึก/ตาเหลื่อม */ socket.on('stack-state', (d) => { if (!d || !mapData || !isStack() || !detectiveCaseFillBots || isMePlayHost()) return; if (Array.isArray(d.order)) { stackPreviewTurnOrder = d.order.map((e) => ({ kind: e.kind, seat: e.seat, id: e.id != null ? String(e.id) : null, botId: e.botId != null ? String(e.botId) : null, })); } if (stackPreviewTurnOrder && stackPreviewTurnOrder.length && Number.isFinite(Number(d.idx))) { const n = stackPreviewTurnOrder.length; stackPreviewTurnIndex = ((Math.floor(Number(d.idx)) % n) + n) % n; } }); /* MG3 stack — host ตัดสินจบรอบ → non-host จบพร้อมกันด้วย outcome เดียวกัน (กัน "จบไม่พร้อมกัน") */ socket.on('stack-over', (d) => { if (!mapData || !isStack() || !detectiveCaseFillBots) return; const outcome = (d && typeof d.outcome === 'string') ? d.outcome : 'time_up'; finishStackTowerMissionLocalPlay(outcome); }); /* MG7 balloon_boss — รับกระสุนของผู้เล่น/บอทคนอื่น แล้วเพิ่มเข้า array ให้เห็นตรงกันทุกเครื่อง fast-forward ตำแหน่งตามเวลาที่ผ่านไปจาก t0 (กระสุนผู้เล่นไม่มีแรงโน้มถ่วง → ตำแหน่ง = spawn + v*elapsed เป๊ะทุกเครื่อง) */ socket.on('balloon-boss-shot', (d) => { if (!d || !mapData || mapData.gameType !== 'balloon_boss') return; if (d.ownerId != null && myId != null && String(d.ownerId) === String(myId)) return; /* ของตัวเองเพิ่มไปแล้ว */ const sx = Number(d.sx), sy = Number(d.sy), vx = Number(d.vx), vy = Number(d.vy); if (![sx, sy, vx, vy].every(Number.isFinite)) return; const t0 = Number(d.t0); const fwd = Number.isFinite(t0) ? Math.max(0, Math.min(2.5, (serverNow() - t0) / 1000)) : 0; balloonBossPlayerBullets.push({ x: sx + vx * fwd, y: sy + vy * fwd, vx, vy, ownerId: d.ownerId }); }); /* MG7 — รับสัญญาณจบเกมจาก host → จบพร้อมกันทุกเครื่อง + ใช้คะแนนสุดท้ายของ host (อันดับตรงกัน) */ socket.on('balloon-boss-over', (d) => { if (!mapData || mapData.gameType !== 'balloon_boss' || balloonBossGameEnded) return; const reason = (d && d.reason === 'victory') ? 'victory' : 'all_dead'; if (d && Array.isArray(d.scores)) { d.scores.forEach((s) => { if (!s || s.id == null) return; const sc = Math.max(0, Number(s.score) || 0); const elim = !!s.eliminated; /* sync สถานะตาย/รอดจาก host → โบนัสผู้รอด/อันดับตรงกัน (กันได้ +10 เกิน) */ if (myId != null && String(s.id) === String(myId)) { me.balloonBossScore = sc; me.balloonBossEliminated = elim; } else { const o = others.get(String(s.id)) || others.get(s.id); if (o) { o.balloonBossScore = sc; o.balloonBossEliminated = elim; } } }); } endBalloonBossGame(reason); }); /* MG7 — host เป็นเจ้าของ HP บอส: non-host รับดาเมจรวม + สถานะผู้เล่นคนอื่น (ลูกโป่ง/ตาย) มาตรง ๆ host เพิกเฉย (ตัวเองคำนวณเอง) · ตัวเราเองไม่ทับ (เป็นเจ้าของ balloon/dmg ของตัวเอง broadcast ผ่าน move) */ socket.on('balloon-boss-sync', (d) => { if (!d || !mapData || mapData.gameType !== 'balloon_boss' || balloonBossGameEnded) return; if (isMePlayHost()) return; const bd = Number(d.bossDmg); if (Number.isFinite(bd)) balloonBossHostBossDmg = Math.max(0, bd); /* adopt anchor ของ host → มุมลูกศรวงกลม (balloonBossAutoAimRadPlay) คำนวณตรงกับ host ทุกเครื่อง */ const anc = Number(d.anchor); if (Number.isFinite(anc) && anc > 0 && Math.abs((balloonBossLiveAnchorServerMs || 0) - anc) > 1) { balloonBossLiveAnchorServerMs = anc; } if (Array.isArray(d.players)) { d.players.forEach((p) => { if (!p || p.id == null) return; if (myId != null && String(p.id) === String(myId)) return; /* ตัวเองเป็นเจ้าของ ไม่ทับ */ const o = others.get(String(p.id)) || others.get(p.id); if (!o) return; const dd = Number(p.d); if (Number.isFinite(dd)) o.balloonBossBossDmg = Math.max(0, dd); const bb = Number(p.b); if (Number.isFinite(bb)) o.balloonBossBalloons = Math.max(0, bb); if (typeof p.e === 'boolean') o.balloonBossEliminated = p.e; }); } }); /* MG6 space_shooter — รับกระสุนผู้เล่น/บอทคนอื่น (ตรงดิ่งขึ้น vy คงที่) → ตำแหน่ง = y + vy*elapsed เป๊ะทุกเครื่อง */ socket.on('space-shooter-shot', (d) => { if (!d || !mapData || mapData.gameType !== 'space_shooter') return; if (d.ownerId != null && myId != null && String(d.ownerId) === String(myId)) return; const x = Number(d.x), y = Number(d.y), vy = Number(d.vy); if (![x, y, vy].every(Number.isFinite)) return; const t0 = Number(d.t0); const fwd = Number.isFinite(t0) ? Math.max(0, Math.min(2, (serverNow() - t0) / 1000)) : 0; spaceShooterBullets.push({ x, y: y + vy * fwd, vy, ownerId: d.ownerId }); }); /* MG6 — รับสัญญาณจบเกมจาก host → ใช้คะแนนสุดท้ายของ host แล้วจบพร้อมกัน (อันดับตรงกัน) */ socket.on('space-shooter-over', (d) => { if (!mapData || mapData.gameType !== 'space_shooter' || spaceShooterGameEnded) return; if (!isSpaceShooterMissionUiMapPlay()) return; const kind = (d && d.kind === 'all_dead') ? 'all_dead' : 'time_up'; if (d) applySpaceShooterOverScoresPlay(d.scores); endSpaceShooterMissionRound(kind); }); 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.balloonBossBossDmg != null) { const d = Number(data.balloonBossBossDmg); if (Number.isFinite(d)) me.balloonBossBossDmg = Math.max(0, d); } 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 (quizBattleFinishLock) return; /* ถึงจุดจบแล้ว — เพิกเฉย echo ตำแหน่งของตัวเอง (กันเด้งกลับ) */ 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; me.tx = me.x; } } if (data.y != null) { const y = Number(data.y); if (Number.isFinite(y)) { me.y = y; me.ty = me.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; /* quiz: ปล่อยให้ lerp (24025) เลื่อน o.x→o.tx เอง → มี delta = "ตัวละครคนอื่นเดินมี animation" (เดิมสแนปทันที = o.x==o.tx = ไม่มี delta = ยืนนิ่งไม่เดิน) ; สแนปเฉพาะ delta ใหญ่ (เทเลพอร์ต/คลิกไกล) กันลากช้า/ค้าง */ if (mapData && isQuiz() && !isQuizCarry()) { const px = Number(data.x); const py = Number(data.y); const bigJump = (Number.isFinite(px) && Math.abs(px - o.x) > 2.5) || (Number.isFinite(py) && Math.abs(py - o.y) > 2.5); if (bigJump) { if (Number.isFinite(px)) o.x = px; if (Number.isFinite(py)) o.y = py; } } } o.direction = data.direction || o.direction; if (data.characterId != null) o.characterId = data.characterId; if (mapData && isQuizCarry() && data.quizCarryHeld !== undefined) { o.quizCarryHeld = (data.quizCarryHeld == null ? null : data.quizCarryHeld); } if (mapData && mapData.gameType === 'space_shooter') { if (data.spaceShooterScore != null) { const s = Number(data.spaceShooterScore); if (Number.isFinite(s)) o.spaceShooterScore = Math.max(0, s); } if (data.spaceShooterHits != null) { const hh = Number(data.spaceShooterHits); if (Number.isFinite(hh)) o.spaceShooterHits = Math.max(0, hh); } /* ตาย/รอด มาจากเจ้าของยานเท่านั้น → ทุกเครื่องเห็นตรงกัน */ if (data.spaceShooterEliminated != null) o.spaceShooterEliminated = !!data.spaceShooterEliminated; } /* MG5: การตายของผู้เล่นมนุษย์มาจากเจ้าตัว (broadcast ผ่าน move) → ทุกเครื่องเห็นตรงกัน (เดิมไม่ส่ง = เพื่อน "ไม่ยอมตาย") */ if (mapData && mapData.gameType === 'jump_survive' && data.jumpSurviveEliminated != null) { o.jumpSurviveEliminated = !!data.jumpSurviveEliminated; } 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.balloonBossBossDmg != null) { const d = Number(data.balloonBossBossDmg); if (Number.isFinite(d)) o.balloonBossBossDmg = Math.max(0, d); } 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; const leftWasCurrentStack = !!(isStack() && detectiveCaseFillBots && isMePlayHost() && stackCurrentTurnEntryPlay() && String(stackCurrentTurnEntryPlay().id) === String(data.id)); others.delete(data.id); if (playBotsEnabled()) rebalancePreviewBots(); /* MG3: คนออก → host รื้อลำดับตาใหม่; ถ้าคนที่ออกกำลังถึงตา → เลื่อนตา (กันค้างเพราะไม่มีใครวางแทน) */ if (isStack() && detectiveCaseFillBots && isMePlayHost()) { rebuildStackPreviewTurnOrder(); if (leftWasCurrentStack && !(stackFall || (stackMini && stackMini.settling))) advanceStackPreviewTurn(); } if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud(); if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud(); if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud(); if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud(); }); 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; }); /* ============================================================ ไมค์ในเกม (MG1-7) — WebRTC mesh เหมือนหน้า lobby - แยกเป็น IIFE อิสระ: ไม่แตะ logic เกมเดิม (เพิ่ม socket listener เสริมเท่านั้น — socket.io รองรับหลาย listener ต่อ event), ไม่มี hook ใน draw/update ของเกม - server รองรับ voice events ทุก space อยู่แล้ว (voice-state / voice-activity / webrtc-signal / peer-voice-state / peer-speaking) - ใช้ตัวแปรเกม: socket, others(Map peers), BASE, isPreviewBotId, previewMode - ฟัง offer จากเพื่อนได้ยินเสียงแบบ passive แม้ยังไม่เปิดไมค์เอง ============================================================ */ (function setupPlayVoiceChat() { var btnVoice = document.getElementById('btn-voice'); if (previewMode) { if (btnVoice) btnVoice.style.display = 'none'; return; } var localStream = null; var voiceActivityInterval = null; var voiceAnalyserContext = null; var voiceAnalyser = null; var peerConnections = {}; var remoteAudios = {}; var iceQueue = {}; var audioUnlocked = false; var cachedIceServers = null; var defaultIceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; /* เพื่อนที่ต่อสายเสียงได้ = ไม่ใช่ตัวเอง และไม่ใช่บอท */ function isVoicePeerId(id) { return id != null && String(id) !== String(socket.id) && !isPreviewBotId(id); } function unlockAudio() { if (audioUnlocked) return; try { var a = new Audio(); a.volume = 0; a.play().then(function () { audioUnlocked = true; playAllRemoteAudios(); }).catch(function () {}); } catch (e) { /* ignore */ } } function playAllRemoteAudios() { Object.keys(remoteAudios).forEach(function (pid) { tryPlayPeer(pid); }); } function tryPlayPeer(pid) { var el = remoteAudios[pid]; if (el && el.srcObject) el.play().catch(function () {}); } function addRemoteAudio(pid, stream) { if (!stream || !stream.getTracks().length) return; unlockAudio(); if (remoteAudios[pid]) { remoteAudios[pid].srcObject = stream; tryPlayPeer(pid); return; } var el = document.createElement('audio'); el.autoplay = true; el.setAttribute('playsinline', ''); el.volume = 1; el.srcObject = stream; el.style.cssText = 'position:absolute;width:0;height:0;opacity:0;pointer-events:none;'; remoteAudios[pid] = el; document.body.appendChild(el); el.onloadedmetadata = function () { tryPlayPeer(pid); }; el.oncanplay = function () { tryPlayPeer(pid); }; tryPlayPeer(pid); } function getIceServers() { if (cachedIceServers) return Promise.resolve(cachedIceServers); return fetch(BASE + '/api/ice-servers') .then(function (r) { return r.json(); }) .then(function (d) { cachedIceServers = (d && d.iceServers && d.iceServers.length) ? d.iceServers : defaultIceServers; return cachedIceServers; }) .catch(function () { cachedIceServers = defaultIceServers; return cachedIceServers; }); } function createPeerConnection(pid, fromOffer) { if (peerConnections[pid]) return Promise.resolve(peerConnections[pid]); return getIceServers().then(function (iceServers) { if (peerConnections[pid]) return peerConnections[pid]; var pc = new RTCPeerConnection({ iceServers: iceServers }); pc._isInitiator = !fromOffer; if (localStream) localStream.getTracks().forEach(function (t) { pc.addTrack(t, localStream); }); pc.ontrack = function (e) { if (e.track.kind !== 'audio') return; e.track.enabled = true; var stream = (e.streams && e.streams[0]) ? e.streams[0] : new MediaStream([e.track]); addRemoteAudio(pid, stream); }; pc.onicecandidate = function (e) { if (e.candidate) socket.emit('webrtc-signal', { to: pid, type: 'ice', candidate: e.candidate }); }; pc.oniceconnectionstatechange = function () { if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') tryPlayPeer(pid); }; pc.onnegotiationneeded = function () { if (!pc._isInitiator) return; pc.createOffer().then(function (offer) { return pc.setLocalDescription(offer).then(function () { socket.emit('webrtc-signal', { to: pid, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); }); }).catch(function (err) { console.warn('voice offer', err); }); }; peerConnections[pid] = pc; iceQueue[pid] = []; return pc; }); } function drainIceQueue(pid) { var pc = peerConnections[pid]; var q = iceQueue[pid]; if (!pc || !q || !q.length) return Promise.resolve(); var chain = Promise.resolve(); while (q.length) { (function (c) { chain = chain.then(function () { return pc.addIceCandidate(new RTCIceCandidate(c)).catch(function (e) { console.warn('addIce', e); }); }); })(q.shift()); } return chain; } function closePeer(pid) { var pc = peerConnections[pid]; if (pc) { try { pc.close(); } catch (e) {} delete peerConnections[pid]; } var el = remoteAudios[pid]; if (el) { el.srcObject = null; el.remove(); delete remoteAudios[pid]; } delete iceQueue[pid]; } socket.on('webrtc-signal', function (data) { var from = data && data.from, type = data && data.type, sdp = data && data.sdp, candidate = data && data.candidate; if (!from || from === socket.id) return; var sdpObj = (sdp && sdp.sdp !== undefined) ? sdp : (sdp ? { type: sdp.type, sdp: sdp.sdp || '' } : null); Promise.resolve().then(function () { if (type === 'offer' && sdpObj) { return createPeerConnection(from, true).then(function (pc) { return pc.setRemoteDescription(new RTCSessionDescription(sdpObj)) .then(function () { return pc.createAnswer(); }) .then(function (answer) { return pc.setLocalDescription(answer).then(function () { socket.emit('webrtc-signal', { to: from, type: 'answer', sdp: { type: answer.type, sdp: answer.sdp } }); }); }) .then(function () { return drainIceQueue(from); }); }); } else if (type === 'answer' && sdpObj) { var pcA = peerConnections[from]; if (pcA) return pcA.setRemoteDescription(new RTCSessionDescription(sdpObj)).then(function () { return drainIceQueue(from); }); } else if (type === 'ice' && candidate) { var pcI = peerConnections[from]; if (pcI) { if (pcI.remoteDescription) { return pcI.addIceCandidate(new RTCIceCandidate(candidate)).catch(function () { (iceQueue[from] = iceQueue[from] || []).push(candidate); }); } (iceQueue[from] = iceQueue[from] || []).push(candidate); } } }).catch(function (err) { console.warn('voice signal', err); }); }); socket.on('peer-voice-state', function (d) { if (!d) return; var p = others.get(d.id); if (p) p.voiceMicOn = d.micOn !== false; }); socket.on('peer-speaking', function (d) { if (!d) return; var p = others.get(d.id); if (p) { p.speakingUntil = d.until; p.speakingLevel = d.level; } }); /* peer ใหม่เข้าระหว่างเราเปิดไมค์ → ต่อสายเสียงให้ (listener เสริม ไม่แตะ handler เกม) */ socket.on('user-joined', function (data) { if (!data || !isVoicePeerId(data.id)) return; if (localStream) createPeerConnection(data.id); }); socket.on('user-left', function (data) { if (data && data.id != null) closePeer(data.id); }); function startVoiceActivityDetection(stream) { if (!stream || !stream.getTracks().length) return; try { var ctx = new (window.AudioContext || window.webkitAudioContext)(); if (ctx.state === 'suspended') ctx.resume().catch(function () {}); var src = ctx.createMediaStreamSource(stream); var analyser = ctx.createAnalyser(); analyser.fftSize = 256; analyser.smoothingTimeConstant = 0.5; src.connect(analyser); voiceAnalyserContext = ctx; voiceAnalyser = analyser; var dataArray = new Uint8Array(analyser.frequencyBinCount); var lastEmit = 0; voiceActivityInterval = setInterval(function () { if (!voiceAnalyser || !stream.active) return; voiceAnalyser.getByteFrequencyData(dataArray); var sum = 0; for (var i = 0; i < dataArray.length; i++) sum += dataArray[i]; var level = Math.min(1, (sum / dataArray.length) / 60); if (level > 0.08 && Date.now() - lastEmit > 80) { lastEmit = Date.now(); socket.emit('voice-activity', { level: level }); } }, 100); } catch (e) { /* ignore */ } } function stopVoiceActivityDetection() { if (voiceActivityInterval) { clearInterval(voiceActivityInterval); voiceActivityInterval = null; } if (voiceAnalyserContext) { try { voiceAnalyserContext.close(); } catch (e) {} voiceAnalyserContext = null; } voiceAnalyser = null; } function setMicIcon(on) { if (!btnVoice) return; var img = btnVoice.querySelector('#btn-voice-icon-img') || btnVoice.querySelector('img'); if (img) { img.src = BASE + (on ? '/img/btn-mic-on.png' : '/img/btn-mic-mute.png'); img.alt = on ? 'ปิดไมค์' : 'เปิดไมค์'; } btnVoice.classList.toggle('is-mic-on', !!on); btnVoice.title = on ? 'ปิดไมค์' : 'เปิดไมค์'; } function enableMic() { var audioConstraints = { audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }; return navigator.mediaDevices.getUserMedia(audioConstraints).then(function (stream) { localStream = stream; startVoiceActivityDetection(localStream); setMicIcon(true); socket.emit('voice-state', { micOn: true }); var ids = []; others.forEach(function (_, id) { if (isVoicePeerId(id)) ids.push(id); }); return ids.reduce(function (chain, pid) { return chain.then(function () { return createPeerConnection(pid).then(function (pc) { if (!localStream || !pc) return; var senders = pc.getSenders(); var added = false; localStream.getTracks().forEach(function (track) { if (!senders.find(function (s) { return s.track === track; })) { pc.addTrack(track, localStream); added = true; } }); if (added) { return pc.createOffer().then(function (offer) { return pc.setLocalDescription(offer).then(function () { socket.emit('webrtc-signal', { to: pid, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); }); }).catch(function (e) { console.warn('voice renegotiate', e); }); } }); }); }, Promise.resolve()); }); } function disableMic() { stopVoiceActivityDetection(); Object.keys(peerConnections).forEach(function (pid) { var pc = peerConnections[pid]; if (pc && pc.getSenders) pc.getSenders().forEach(function (s) { try { pc.removeTrack(s); } catch (e) {} }); }); if (localStream) { localStream.getTracks().forEach(function (t) { t.stop(); }); localStream = null; } socket.emit('voice-state', { micOn: false }); setMicIcon(false); } if (btnVoice) { btnVoice.style.display = ''; setMicIcon(false); btnVoice.addEventListener('click', function () { unlockAudio(); if (localStream) { disableMic(); return; } btnVoice.disabled = true; enableMic().catch(function (err) { showPlayAlert('ไม่สามารถเปิดไมค์ได้: ' + (err && (err.message || err)), { type: 'error' }); try { disableMic(); } catch (e) {} }).then(function () { btnVoice.disabled = false; }); }); } /* Quiz Battle (qbroom*, สูงสุด 50 คน) — voice mesh แบบ P2P ไม่สเกล → ซ่อนปุ่มไมค์ + ปิดไมค์ถ้าเปิดอยู่ ตรวจจาก #qb-game-hud (โชว์เฉพาะ quiz_battle); ไม่แตะ logic เกม */ var qbHud = document.getElementById('qb-game-hud'); if (qbHud && btnVoice) { var syncQbVoiceHide = function () { var qbActive = !qbHud.classList.contains('is-hidden'); if (qbActive) { if (localStream) { try { disableMic(); } catch (e) {} } btnVoice.style.display = 'none'; } else { btnVoice.style.display = ''; } }; try { var qbMo = new MutationObserver(syncQbVoiceHide); qbMo.observe(qbHud, { attributes: true, attributeFilter: ['class'] }); } catch (e) { /* ignore */ } syncQbVoiceHide(); } /* ปลดล็อก autoplay เสียงเพื่อนตั้งแต่คลิก/แตะแรก (ได้ยินคนอื่นแม้ไม่เปิดไมค์เอง) */ var unlockOnce = function () { unlockAudio(); document.body.removeEventListener('click', unlockOnce); document.body.removeEventListener('touchstart', unlockOnce); }; document.body.addEventListener('click', unlockOnce, { once: true }); document.body.addEventListener('touchstart', unlockOnce, { once: true }); })(); socket.on('quiz-phase', (p) => { if (previewMode) return; if (!p || !mapData || !isQuiz()) return; if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase !== 'live' && quizQuestionMissionPhase !== 'ended') { quizQuestionMissionDeferredPhase = p; return; } applyQuizPhaseFromServer(p); }); socket.on('quiz-result', (r) => { if (previewMode || !r) return; if (r.scores) { playLiveQuizScores = { ...r.scores }; renderPlayQuizScoreboard(playLiveQuizScores); } if (isQuiz() && Array.isArray(r.results)) { r.results.forEach(function (row) { if (!row || row.id == null) return; if (!row.right) { playQuizEverWrong[String(row.id)] = true; const o = others.get(row.id); if (o) { o.quizCannotTrue = true; o.quizCannotFalse = true; } } }); } /** เกมจริง: bot ไม่อยู่ในผล server → HOST ตัดสินบอทคนเดียว (เช็คโซน/respawn/คะแนน) แล้ว broadcast ผ่าน fill-bot-state (เดิมทุก client ตัดสินเอง + respawn ด้วย Math.random แยกกัน → ตำแหน่ง/ล็อก/คะแนนบอทคนละแบบทุกจอ) */ if (isQuiz() && Array.isArray(r.results) && playBotsEnabled() && isMePlayHost()) { const correctTrueBot = !!r.correctTrue; others.forEach(function (o, id) { if (!isPreviewBotId(id) || !o) return; const br = quizResolveChoiceFromStanding(o.x, o.y, correctTrueBot); o.botPath = []; o.botAnswerWander = false; if (br.right) { playLiveQuizScores[id] = (playLiveQuizScores[id] || 0) + QUIZ_TF_POINTS_PER_CORRECT; } else { playQuizEverWrong[String(id)] = true; o.quizCannotTrue = true; o.quizCannotFalse = true; const ordB = quizPreviewJoinOrderForRespawn(id); const sp = pickSpawnForJoinPlay(mapData, ordB); 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; o.tx = o.x; o.ty = o.y; } }); } if (isQuiz() && Array.isArray(r.results) && r.results.some(function (row) { return row && row.right; })) { spawnQuizTrueFalseScorePopupPlay(!!r.correctTrue); } showPlayQuizFeedback(r); checkMg1AllDeadAndEnd(); }); 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), }; if (quizGhostConfineActivePlay()) warpMeIntoQuizGhostZonePlay(); checkMg1AllDeadAndEnd(); }); socket.on('quiz-ended', () => { if (previewMode && isQuiz()) return; let questionMissionEnd = null; if (isQuizQuestionMissionUiMapPlay()) { questionMissionEnd = quizQuestionMissionBuildPayload(); } 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(); if (questionMissionEnd) { quizQuestionMissionPhase = 'ended'; quizQuestionMissionDeferredPhase = null; applyQuizQuestionMissionPanelImages(); showGauntletCrownMissionOverlay(questionMissionEnd); } }); /* ===== Special Quiz ในเกม — socket events ===== */ socket.on('special-quiz-icon', (payload) => { if (previewMode) return; setSpecialQuizIconFromServer(payload); }); socket.on('special-quiz-icon-clear', () => { clearSpecialQuizIcon(); }); socket.on('special-quiz-question', (q) => { if (previewMode) return; openSpecialQuizQuestion(q); }); socket.on('special-quiz-answer-update', (d) => { if (previewMode || !d || !specialQuizActiveQuestion) return; markSpecialQuizMemberAnswered(d.id); }); socket.on('special-quiz-result', (r) => { if (previewMode) return; handleSpecialQuizResult(r); }); socket.on('special-quiz-ended', (data) => { if (previewMode) return; endSpecialQuiz(data); }); /* server แจ้งว่าทุกคนกด «เล่นต่อ» ครบแล้ว (หรือ safety timeout) → ทุกคนเล่นต่อพร้อมกัน */ socket.on('special-quiz-resume-all', () => { if (previewMode) return; specialQuizDoResume(); }); /* อัปเดตจำนวนคนที่กด «เล่นต่อ» แล้ว (X/Y) บนปุ่มระหว่างรอ */ socket.on('special-quiz-continue-progress', (d) => { if (previewMode) return; const btn = document.getElementById('sq-ov-award-continue'); if (!btn || btn.dataset.sqWaiting !== '1') return; const ready = (d && Number(d.ready)) || 0; const total = (d && Number(d.total)) || 0; if (total > 0) btn.textContent = 'รอผู้เล่นอื่น… (' + ready + '/' + total + ')'; }); socket.on('special-card-applied', (data) => { if (previewMode || !data || !data.card) return; applySpecialCardClient(data); }); socket.on('gauntlet-sync', (data) => { if (!data || typeof data !== 'object') return; if (isMegaVirusMissionShellMapPlay()) { applyGauntletTimingFromServer(data); return; } if (!mapData || mapData.gameType !== 'gauntlet') 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 prevSc = Math.max(0, Number(me.gauntletScore) || 0); 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); const newSc = Number.isFinite(sc) ? Math.max(0, sc) : 0; me.gauntletScore = newSc; if (isGauntletCrownHeistMapPlay() && prevSc - newSc >= 10) { me.gauntletCrownPenaltyFxUntil = Date.now() + 1100; } me.gauntletEliminated = !!p.gauntletEliminated; clampGauntletCrownHeistEntityXInPlacePlay(me); } else { const o = others.get(p.id) ?? others.get(pid); if (o) { const prevSc = Math.max(0, Number(o.gauntletScore) || 0); 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); const newSc = Number.isFinite(sc) ? Math.max(0, sc) : 0; o.gauntletScore = newSc; if (isGauntletCrownHeistMapPlay() && prevSc - newSc >= 10) { o.gauntletCrownPenaltyFxUntil = Date.now() + 1100; } o.gauntletEliminated = !!p.gauntletEliminated; clampGauntletCrownHeistEntityXInPlacePlay(o); } } }); if (playBotsEnabled()) { applyGauntletPreviewBotsAfterSync(); emitGauntletPreviewRowsToServer(); } }); function showMinigameCoinsToast(mine) { let el = document.getElementById('minigame-coins-toast'); if (!el) { el = document.createElement('div'); el.id = 'minigame-coins-toast'; el.style.cssText = 'position:fixed;left:50%;top:13%;transform:translateX(-50%);z-index:99999;padding:13px 26px;border-radius:14px;font-weight:700;text-align:center;pointer-events:none;box-shadow:0 6px 24px rgba(0,0,0,.45);transition:opacity .35s;opacity:0;font-size:20px;line-height:1.3;'; document.body.appendChild(el); } const survived = mine.survived !== false; const coins = Math.max(0, Number(mine.coins) || 0); if (survived && coins > 0) { el.style.background = 'linear-gradient(135deg,#1b9e4b,#13c06a)'; el.style.color = '#fff'; el.innerHTML = '🪙 +' + coins + ' เหรียญ
คะแนนสะสม +' + coins + ''; } else if (survived) { el.style.background = 'linear-gradient(135deg,#555,#777)'; el.style.color = '#fff'; el.textContent = 'รอบนี้ไม่ติดอันดับรางวัล'; } else { el.style.background = 'linear-gradient(135deg,#a11,#d33)'; el.style.color = '#fff'; el.textContent = '💀 ตายแล้ว — ไม่ได้รับเหรียญ'; } el.style.opacity = '1'; if (window.__miniCoinsToastT) clearTimeout(window.__miniCoinsToastT); window.__miniCoinsToastT = setTimeout(() => { el.style.opacity = '0'; }, 4200); } socket.on('minigame-coins', (data) => { try { if (!data || !Array.isArray(data.results)) return; const mine = data.results.find((r) => r.id === myId); if (mine) showMinigameCoinsToast(mine); } catch (e) { /* ignore */ } }); socket.on('gauntlet-ended', (data) => { cancelQuizCarryResultEndAfterTimeup(); gauntletEndsAtMs = null; gauntletCrownRunwayBgFinishLatched = false; gauntletFinishPhaseSignaled = false; gauntletCrownRunwayBgStripFreezeSinceMs = 0; gauntletCrownRunwayFinishAlignLatchedWorldX = null; gauntletCrownRunwayClientMissionShown = false; gauntletCrownRunHeldRemote = false; gauntletCrownPregamePhase = null; gauntletCrownLobbyReadyMap = {}; resetGauntletCrownRunwaySpawnScrollSnap(); 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 = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); } if (btn) { btn.onclick = () => { if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden'); else goLobby(); }; } }); socket.on('game-start', (ev) => { if (!ev || !ev.mapId) return; const urlMap = (params.get('map') || '').trim(); if (urlMap && ev.stayInRoomLobby && String(ev.mapId).trim() !== urlMap) { return; } const applySnap = (md) => { mapData = md; playEmbedUserZoomMul = 1; specialQuizArmSent = false; /* รอบมินิเกมใหม่ — รีเซ็ตให้ส่งสัญญาณ arm ไอคอนคำถามพิเศษได้อีกครั้ง */ if (ev.mapId != null && String(ev.mapId).trim() !== '') { playSessionMapId = String(ev.mapId).trim(); } applyPlayEmbedZoomForCurrentMapPlay(); if (!isQuizQuestionMissionUiMapPlay()) { quizQuestionMissionPhase = null; quizQuestionMissionDeferredPhase = null; if (quizQuestionMissionCountdownTimer) { clearTimeout(quizQuestionMissionCountdownTimer); quizQuestionMissionCountdownTimer = null; } } if (!isStackTowerMissionUiMapPlay()) { teardownStackTowerMissionUiPlay(); } if (mapData.gameType !== 'space_shooter') { spaceShooterGameEnded = false; spaceShooterMissionPhase = null; if (spaceShooterMissionCountdownTimer) { clearTimeout(spaceShooterMissionCountdownTimer); spaceShooterMissionCountdownTimer = null; } } 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; } reloadPlayScrollBgFromMap(); reloadStackTowerScrollBgFromMap(); reloadGauntletCrownRunwayBgFromMap(); 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; }); } /* quiz_carry: sync ตำแหน่งจุดเกิดของผู้เล่นจริงระยะไกลจาก peersSnap ตอนเข้าเกม เดิม client apply peersSnap เฉพาะ gauntlet → quiz_carry ผู้เล่นยืนนิ่งตอบคำถาม ไม่มี move ส่ง → others ค้างที่ตำแหน่งเก่า (lobby) นอกแมป quiz_carry = host หายจากจอ client */ if (mapData.gameType === 'quiz_carry' && ev.peersSnap && Array.isArray(ev.peersSnap)) { ev.peersSnap.forEach((p) => { if (p.id == null) return; const px = Number(p.x); const py = Number(p.y); if (myId != null && String(p.id) === String(myId)) { if (Number.isFinite(px)) { me.x = px; me.tx = px; } if (Number.isFinite(py)) { me.y = py; me.ty = py; } if (p.direction) me.direction = p.direction; } else { const o = others.get(p.id) ?? others.get(String(p.id)); if (o) { if (Number.isFinite(px)) { o.x = px; o.tx = px; } if (Number.isFinite(py)) { o.y = py; o.ty = py; } if (p.direction) o.direction = p.direction; } } }); } 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 megaShellGs = mapData.gameType === 'balloon_boss' && String(ev.mapId || '').trim() === BALLOON_BOSS_MISSION_MAP_ID; const skipHideCrownShell = (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay()) || (mapData.gameType === 'space_shooter' && isSpaceShooterMissionUiMapPlay()) || (mapData.gameType === 'quiz' && isQuizQuestionMissionUiMapPlay()) || (mapData.gameType === 'stack' && isStackTowerMissionUiMapPlay()) || megaShellGs; 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; }); if (isStackTowerMissionUiMapPlay()) { stackTowerMissionEndedOnce = false; stackTowerMissionDeferredPhase = null; if (stackTowerMissionCountdownTimer) { clearTimeout(stackTowerMissionCountdownTimer); stackTowerMissionCountdownTimer = null; } stackTowerMissionPhase = 'howto'; stackTowerSessionStartAt = 0; hideConflictingOverlaysForGauntletCrown(); applyStackTowerMissionPanelImages(); 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 () { showStackTowerMissionHowtoOverlay(); }, 60); } else { teardownStackTowerMissionUiPlay(); } } else { stackMini = null; teardownStackTowerMissionUiPlay(); } if (mapData.gameType === 'jump_survive') { if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = []; if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = []; if (!mapData.jumpSurviveHazardArea) mapData.jumpSurviveHazardArea = []; if (!mapData.jumpSurvivePlatformVariantArea) mapData.jumpSurvivePlatformVariantArea = []; normalizeJumpSurvivePlatformAreaInPlay(mapData); normalizeJumpSurvivePlatformVariantAreaInPlay(mapData); normalizeJumpSurviveHazardAreaInPlay(mapData); normalizeShooterSpawnSlotsInPlay(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') { normalizeShooterSpawnSlotsInPlay(mapData); if (spaceShooterMissionCountdownTimer) { clearTimeout(spaceShooterMissionCountdownTimer); spaceShooterMissionCountdownTimer = null; } if (isSpaceShooterMissionUiMapPlay()) { spaceShooterMissionPhase = 'howto'; hideConflictingOverlaysForGauntletCrown(); applySpaceShooterMissionPanelImages(); 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 () { showSpaceShooterMissionHowtoOverlay(); }, 60); } else { spaceShooterMissionPhase = null; spaceShooterGameEnded = false; spaceShooterBullets = []; spaceShooterAsteroids = []; spaceShooterAsteroidExplosions = []; spaceShooterPopups = []; spaceShooterLastTickMs = performance.now(); spaceShooterSpawnAccMs = 0; spaceShooterFireCd = 0; spaceShooterSessionStartMs = performance.now(); spaceShooterLastMoveEmit = 0; } } if (mapData.gameType === 'quiz') { if (!mapData.quizTrueArea) mapData.quizTrueArea = []; if (!mapData.quizFalseArea) mapData.quizFalseArea = []; if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; } if (mapData.gameType === 'balloon_boss') { balloonBossGameEnded = false; normalizeBalloonBossPlayerSlotsInPlay(mapData); balloonBossPendingShots = []; balloonBossPlayerBullets = []; balloonBossBossBullets = []; balloonBossHitFx = []; balloonBossScorePopups = []; balloonBossLastTickMs = performance.now(); balloonBossPlayerFireCd = 0; balloonBossLastMoveEmit = 0; balloonBossBossFireAcc = 0; balloonBossHostBossDmg = null; balloonBossLastSyncEmit = 0; me.balloonBossScore = 0; me.balloonBossBossDmg = 0; me.balloonBossBalloons = balloonBossBalloonsStartPlay(); me.balloonBossEliminated = false; others.forEach((o) => { o.balloonBossScore = 0; o.balloonBossBossDmg = 0; o.balloonBossBalloons = balloonBossBalloonsStartPlay(); o.balloonBossEliminated = false; }); if (String(ev.mapId || '').trim() === BALLOON_BOSS_MISSION_MAP_ID) { balloonBossSessionStartMs = 0; if (Object.prototype.hasOwnProperty.call(ev, 'gauntletCrownRunHeld')) { applyGauntletTimingFromServer({ gauntletCrownRunHeld: ev.gauntletCrownRunHeld }); } fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) .then((r) => (r.ok ? r.json() : null)) .then((t) => { if (t) applyGauntletTimingFromServer(t); if (Object.prototype.hasOwnProperty.call(ev, 'gauntletCrownRunHeld')) { applyGauntletTimingFromServer({ gauntletCrownRunHeld: ev.gauntletCrownRunHeld }); } }) .catch(() => {}); gauntletCrownPregamePhase = null; gauntletCrownLobbyReadyMap = {}; if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; } const gccMv = document.getElementById('gauntlet-crown-countdown'); if (gccMv) gccMv.classList.add('is-hidden'); setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 60); } else { balloonBossSessionStartMs = performance.now(); fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) .then((r) => (r.ok ? r.json() : null)) .then((t) => { if (t) applyGauntletTimingFromServer(t); }) .catch(() => {}); } } if (mapData.gameType === 'quiz_battle') { if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = []; if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = []; normalizeQuizBattlePathInPlay(mapData); normalizeQuizBattleDomeInPlay(mapData); buildQuizBattlePathGateRequiredPlay(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(); if (isQuizQuestionMissionUiMapPlay()) { quizQuestionMissionDeferredPhase = null; if (quizQuestionMissionCountdownTimer) { clearTimeout(quizQuestionMissionCountdownTimer); quizQuestionMissionCountdownTimer = null; } quizQuestionMissionPhase = 'howto'; hideConflictingOverlaysForGauntletCrown(); applyQuizQuestionMissionPanelImages(); 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 () { showQuizQuestionMissionHowtoOverlay(); }, 60); } } else if (isQuizCarry()) setupPlayQuizCarryUi(); else if (isQuizBattle()) setupPlayQuizBattleUi(); else teardownPlayQuizUi(); resizeCanvas(); if (mapData.gameType === 'balloon_boss') { applyBalloonBossSpawnLayoutPlay(); } if (playBotsEnabled() && mapData.gameType === 'gauntlet') { applyGauntletPreviewSpawnLayout(true); emitGauntletPreviewRowsToServer(); } if (playBotsEnabled() && mapData.gameType === 'jump_survive') { applyJumpSurvivePreviewSpawnLayout(true); } if (playBotsEnabled() && mapData.gameType === 'space_shooter') { applySpaceShooterSpawnLayoutPlay(); } if (playBotsEnabled() && 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() { if (mapData && isStackTowerMissionUiMapPlay() && canvas) { canvas.width = Math.max(320, STACK_TOWER_FIXED_RENDER_W); canvas.height = Math.max(240, STACK_TOWER_FIXED_RENDER_H); /* รีคำนวน BG scroll หลังขนาดบัฟเฟอร์คงที่ — กันรูปโหลดเร็วตอนแคนวาสยังไม่ล็อกแล้วไม่ซิงก์ใหม่ */ stackTowerScrollBgSrawWorldBaselinePlay = null; stackTowerScrollBgSyncInitialScrollToBottom(); syncQuizCarryEmbedCountdownLayout(); return; } const vw = window.innerWidth || document.documentElement.clientWidth || 800; const vh = window.innerHeight || document.documentElement.clientHeight || 600; const header = document.querySelector('.game-header'); const headerH = playHideTopChrome() ? 0 : (header && header.offsetHeight ? header.offsetHeight : 48); const stage = document.getElementById('play-canvas-stage'); const stackEl = document.getElementById('play-canvas-stack'); const parent = stage || (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); /* จอใหญ่ / iframe embed: client บางทียัง 0 ช้ากว่า layout — ใช้ offset เป็นทางเลือก */ if (parent) { const ow = parent.offsetWidth || 0; const oh = parent.offsetHeight || 0; if (ow > 80) cw = Math.max(cw, ow); if (oh > 80) ch = Math.max(ch, oh); } /* embed: stage อาจยัง 0 แต่ stack วัดได้แล้ว — อย่าให้ fallback เป็น vw/vh เต็มจอแล้ว CSS ย่อเข้า stage = ดูชิดมุม */ if (previewMode && editorEmbedReturn && stackEl) { const sw = Math.max(stackEl.clientWidth || 0, stackEl.offsetWidth || 0); const sh = Math.max(stackEl.clientHeight || 0, stackEl.offsetHeight || 0); if (sw > 80) cw = Math.max(cw, sw); if (sh > 80) ch = Math.max(ch, sh); } if (ch < 120) ch = Math.max(240, vh - headerH - 6); if (cw < 120) { if (previewMode && editorEmbedReturn && stackEl) { const sw = Math.max(stackEl.clientWidth || 0, stackEl.offsetWidth || 0); cw = sw > 80 ? Math.max(320, sw) : Math.max(320, vw); } else cw = Math.max(320, vw); } canvas.width = Math.max(320, cw); canvas.height = Math.max(240, ch); syncQuizCarryEmbedCountdownLayout(); const hud = document.getElementById('play-cyber-hud'); if (hud && hud.classList.contains('play-cyber-hud--quiz-tf-mock')) { _quizTfScaleLast = 0; updateQuizTfMockScale(hud); } } canvas.addEventListener('click', (e) => { if (!mapData || !isStack() || isChatFocused()) return; tryHumanStackDrop(); }); (function setupPlayEditorEmbedViewZoom() { const stack = document.getElementById('play-canvas-stack'); if (!stack) return; const wheelZoomHandler = (e) => { if (!mapData) return; const embedZoom = previewMode && editorEmbedReturn; const qbZoom = isQuizBattle(); if (!embedZoom && !qbZoom) return; if (isStackTowerEmbedZoomLockedPlay()) return; if (isQuizQuestionMissionHudActivePlay()) return; const t = e.target; if (t && typeof t.closest === 'function') { if (t.closest('button, input, textarea, select, a[href]')) return; } /* ล้อธรรม / trackpad pinch มักไม่มี ctrl — ให้ซูมได้; Ctrl/⌘+ล้อยังใช้ได้ */ e.preventDefault(); e.stopPropagation(); const raw = e.deltaMode === 1 ? e.deltaY * 14 : e.deltaMode === 2 ? e.deltaY * 320 : e.deltaY; const k = Math.exp(-raw * 0.002); if (qbZoom) { quizBattleUserZoomMul = Math.max( PLAY_EMBED_USER_ZOOM_MIN, Math.min(PLAY_EMBED_USER_ZOOM_MAX, quizBattleUserZoomMul * k), ); } else { playEmbedUserZoomMul = Math.max( PLAY_EMBED_USER_ZOOM_MIN, Math.min(PLAY_EMBED_USER_ZOOM_MAX, playEmbedUserZoomMul * k), ); } draw(); }; stack.addEventListener('wheel', wheelZoomHandler, { passive: false, capture: true }); })(); /* กด/แตะไอคอนควิซพิเศษ — ทำงานทุกมินิเกม (กันเคสเดินไปแตะไม่ได้ เช่น space shooter) */ canvas.addEventListener('pointerdown', (e) => { if (trySpecialQuizClickAt(e.clientX, e.clientY)) { e.preventDefault(); e.stopPropagation(); } }, true); 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 qmC = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null; const carryCam = !qmC && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null; const camX = qmC ? qmC.cx : (carryCam ? carryCam.cx : me.x * tileSize); const camY = qmC ? qmC.cy : (carryCam ? carryCam.cy : me.y * tileSize); const zHit = lastPlayZDrawForInput > 0 ? lastPlayZDrawForInput : zoom; const gx = (sx - canvas.width / 2) / zHit + camX; const gy = (sy - canvas.height / 2) / zHit + 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 }; } /** Quiz Battle: กล่องชน/เส้นทางตามสไปรต์ที่วาดจริง (drawAvatar ใช้ cw×1.15, ch×1.35 จากเท้า) */ const QUIZ_BATTLE_SPRITE_COL_SCALE_W = 1.15; const QUIZ_BATTLE_SPRITE_COL_SCALE_H = 1.35; function quizBattleSpriteBoundsTilesPlay(md, px, py) { const s = new Set(); if (!md || md.gameType !== 'quiz_battle') return s; if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s; const w = md.width || 20, h = md.height || 15; const { cw, ch } = getCharacterFootprintWH(md); const cx = px + cw * 0.5; const feetY = py + ch; const left = cx - (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2; const right = cx + (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2; const top = feetY - ch * QUIZ_BATTLE_SPRITE_COL_SCALE_H; const bottom = feetY; if (left < 0 || right > w || top < 0 || bottom > h) return s; const minTx = Math.floor(left); const maxTx = Math.min(w - 1, Math.floor(right - 1e-6)); const minTy = Math.floor(top); const maxTy = Math.min(h - 1, Math.floor(bottom - 1e-6)); for (let ty = minTy; ty <= maxTy; ty++) { for (let tx = minTx; tx <= maxTx; tx++) s.add(tx + ',' + ty); } return s; } /** * ขนาดช่องที่ใช้ชนกำแพง (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 }; } function getGauntletCrownHeistMaxEntityXPlay(md) { if (!md || !isGauntletCrownHeistMapPlay()) return null; const w = md.width || 20; const { cw } = getCharacterFootprintWH(md); const capRight = GAUNTLET_CROWN_HEIST_MAX_X_WORLD_FRAC * w - cw; const fullMapMax = Math.max(0, w - cw + 0.999); return Math.max(0, Math.min(fullMapMax, capRight)); } function clampGauntletCrownHeistEntityXInPlacePlay(ent) { if (!ent || !mapData || !isGauntletCrownHeistMapPlay()) return; const mx = getGauntletCrownHeistMaxEntityXPlay(mapData); if (mx == null || !Number.isFinite(mx)) return; if (Number.isFinite(ent.x)) ent.x = Math.max(0, Math.min(mx, ent.x)); if (ent.tx != null && Number.isFinite(ent.tx)) ent.tx = Math.max(0, Math.min(mx, ent.tx)); } /** กันตัวละคร 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); let maxX = Math.max(0, w - cw + 0.999); const crownMx = getGauntletCrownHeistMaxEntityXPlay(md); if (crownMx != null) maxX = Math.min(maxX, crownMx); 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 (!playBotsEnabled() || !isMePlayHost() || !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); } } /* แยกบอทออกจาก "ผู้เล่นจริง" ด้วย — เดิมแยกแค่บอท↔บอท → บอท spawn/เดินทับคนจริง แล้วติดกันใน blockPlayer zone (entity บล็อกกัน) เดินไม่ได้ทั้งคู่. host ดันเฉพาะบอท (คนจริงเป็นเจ้าของตำแหน่งตัวเอง broadcast ผ่าน move) */ /* แยกบอท↔คน เฉพาะแมปที่มี blockPlayer (entity บล็อกกันได้ = ติดกันเดินไม่ได้) — แมปอื่นซ้อนได้ไม่ต้องแยก */ if (mapData.blockPlayer) { const minDHuman = 0.95; /* ระยะ ~1 ช่อง — ให้ footprint แยกชัด คนไม่โดนบล็อก */ const humansSep = []; if (me && Number.isFinite(me.x) && Number.isFinite(me.y)) humansSep.push(me); others.forEach((p, pid) => { if (!isPreviewBotId(pid) && p && Number.isFinite(p.x) && Number.isFinite(p.y)) humansSep.push(p); }); for (let i = 0; i < ids.length; i++) { const a = others.get(ids[i]); if (!a) continue; for (let k = 0; k < humansSep.length; k++) { const hu = humansSep[k]; let ddx = a.x - hu.x, ddy = a.y - hu.y; let d = Math.sqrt(ddx * ddx + ddy * ddy); if (d >= minDHuman) continue; if (d < 1e-5) { ddx = (i % 2 === 0) ? 1 : -1; ddy = (i % 3) - 1; d = Math.sqrt(ddx * ddx + ddy * ddy) || 1; } const ux = ddx / d, uy = ddy / d; /* ดันบอทออกจากคน */ /* ดันให้พ้น minDHuman ในเฟรมเดียว + เช็คแค่กำแพง (ignorePeers) → หนีออกจาก blockPlayer zone ได้ (ถ้าเช็ค peer ด้วย ทุกช่องรอบตัวคนถูกบล็อก → บอทหนีไม่ออก ติดตลอด) */ const need = (minDHuman - d) + 0.06; let placed = false; const try1x = a.x + ux * need, try1y = a.y + uy * need; if (canWalkLikeLobbyForBot(try1x, try1y, a.x, a.y, a, true)) { a.x = try1x; a.y = try1y; placed = true; } if (!placed) { /* ทิศตรงชนกำแพง → ลองทิศตั้งฉาก 2 ด้าน */ const perp = [[-uy, ux], [uy, -ux]]; for (let pI = 0; pI < perp.length && !placed; pI++) { const tx2 = a.x + perp[pI][0] * need, ty2 = a.y + perp[pI][1] * need; if (canWalkLikeLobbyForBot(tx2, ty2, a.x, a.y, a, true)) { a.x = tx2; a.y = ty2; placed = true; } } } if (placed) clampPlayEntityFootprintToMap(a, 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) { if (mapData && mapData.gameType === 'quiz_battle') { return quizBattleSpriteBoundsTilesPlay(mapData, 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); if (wallTiles.size === 0) return false; 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 (!isQuizBattle()) { const { colW, colH } = getCharacterCollisionFootprintWH(mapData); if (wallTiles.size < colW * colH) 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 (quizGhostConfineActivePlay() && !quizGhostFootprintInsidePlay(x, y)) return false; if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false; if (isQuizBattle() && quizBattlePathModeActive(mapData)) { if (!quizBattlePositionAllowedPlay(mapData, x, y)) return false; if (!quizBattlePathGateAllowsPlay(mapData, x, y, quizBattlePassedComps)) { quizBattleFlashGateBlocked(mapData, x, y, quizBattlePassedComps); return false; } } return true; } function canWalkLikeLobbyForBot(x, y, fromX, fromY, o, ignorePeers) { 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; if (!isQuizBattle()) { const { colW, colH } = getCharacterCollisionFootprintWH(mapData); if (wallTilesB.size < colW * colH) return false; } const bp = mapData.blockPlayer; if (bp && !ignorePeers) { 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 (!quizBattlePositionAllowedPlay(mapData, x, y)) return false; if (o && isPreviewBotId(o.id || o.peerId)) { /* preview bots ไม่ล็อกประตู */ } else if (!quizBattlePathGateAllowsPlay(mapData, x, y, quizBattlePassedComps)) 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 carryWalk = isQuizCarry() ? quizCarryWalkSpeedMultActive : 1; const baseEmbedCarryPathMul = 0.74; const pathStepMul = (previewFillBots && editorEmbedReturn) ? (isQuizCarry() ? baseEmbedCarryPathMul * (quizCarryWalkSpeedMultActive / QUIZ_CARRY_WALK_SPEED_MULT) : 0.56) : 1; const step = Math.min(MOVE_SPEED * 1.28 * pathStepMul * carryWalk, 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 (pathStrict) { const moved = quizBattleTryStepMovePlay(o.x, o.y, nx, ny, (px, py, fx, fy) => canWalkLikeLobbyForBot(px, py, fx, fy, o)); o.x = moved.x; o.y = moved.y; } else 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 { /* เส้นตรงไป waypoint อาจตัดมุม hub / block — ลองก้าวแนวแกนตามทิศหลักอย่างใดอย่างหนึ่ง */ const cstep = Math.min(MOVE_SPEED * 1.2 * pathStepMul * carryWalk, 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() && playBotsEnabled()) 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() || !isMePlayHost() || !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; /* บอทที่ตอบผิดไปแล้วในแมป mng8a80o → เป็นผีเดินมั่ว ไม่ต้องไปยืนรอที่โซนตอบ */ const ghostWanderBot = isQuizQuestionMissionUiMapPlay() && isQuizWrongGhostActiveForEnt(id); if (ghostWanderBot) { /* ข้าม path-following → ตกไป wander logic ด้านล่าง */ o.botPath = []; o.botAnswerWander = true; /* คุมบอทผีให้อยู่ในโซนผี — ถ้าหลุดออกนอกโซน ดึงกลับเข้าช่องในโซน (เหมือน me ที่โดนวาร์ป) */ if (quizGhostAreaGridPlay() && !quizGhostFootprintInsidePlay(o.x, o.y)) { const gz = randomGhostZoneCellPlay(); if (gz) { o.x = gz.x; o.y = gz.y; o.tx = gz.x; o.ty = gz.y; o.botWanderTX = undefined; o.botWanderTY = undefined; } } } 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; } /* ก่อนตอบ / พัก / บอทสับสน / ผี: เดินแบบมีจุดหมาย (สุ่มจุดเป้า เดินเข้าหาแบบทแยงลื่น พักบ้าง) — เนียนกว่าสุ่มทิศแกน */ const arrivedQ = (typeof o.botWanderTX === 'number' && typeof o.botWanderTY === 'number') && Math.hypot(o.botWanderTX - o.x, o.botWanderTY - o.y) < 0.5; const timedOutQ = typeof o.botWanderRetargetAt === 'number' && now >= o.botWanderRetargetAt; if (typeof o.botWanderTX !== 'number' || typeof o.botWanderTY !== 'number' || arrivedQ || timedOutQ) { if (ghostWanderBot) { /* บอทผี: สุ่มเป้าหมายเฉพาะ "ในโซนผี" → ไม่เดินออกนอกเขต */ const gz = randomGhostZoneCellPlay(); o.botWanderTX = gz ? gz.x : o.x; o.botWanderTY = gz ? gz.y : o.y; } else { o.botWanderTX = 1 + Math.random() * Math.max(1, w - 2); o.botWanderTY = 1 + Math.random() * Math.max(1, h - 2); } o.botWanderRetargetAt = now + 3000 + Math.floor(Math.random() * 4500); o.botWanderPauseUntil = (arrivedQ && Math.random() < 0.4) ? now + 500 + Math.floor(Math.random() * 1500) : 0; } const ox = o.x, oy = o.y; if (!(o.botWanderPauseUntil && now < o.botWanderPauseUntil)) { let dxq = o.botWanderTX - o.x; let dyq = o.botWanderTY - o.y; const distq = Math.hypot(dxq, dyq) || 1; dxq /= distq; dyq /= distq; if (Math.abs(dyq) > Math.abs(dxq)) o.direction = dyq > 0 ? 'down' : 'up'; else o.direction = dxq > 0 ? 'right' : 'left'; const step = MOVE_SPEED; const tox = o.x + dxq * step; const toy = o.y + dyq * step; const pathStrictW = isQuizBattle() && quizBattlePathModeActive(mapData); if (pathStrictW) { const moved = quizBattleTryStepMovePlay(o.x, o.y, tox, toy, (nx, ny, fx, fy) => canWalkLikeLobbyForBot(nx, ny, fx, fy, o)); o.x = moved.x; o.y = moved.y; if (Math.abs(o.x - ox) < 1e-5 && Math.abs(o.y - oy) < 1e-5) { o.botWanderTX = undefined; o.botWanderTY = undefined; } } else if (canWalkLikeLobbyForBot(tox, toy, o.x, o.y, o)) { o.x = tox; o.y = toy; } else if (canWalkLikeLobbyForBot(tox, o.y, o.x, o.y, o)) { o.x = tox; } else if (canWalkLikeLobbyForBot(o.x, toy, o.x, o.y, o)) { o.y = toy; } else { o.botWanderTX = undefined; o.botWanderTY = undefined; } } 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 = []; /** เลเซอร์ = รูป cut obstacles-1.png (emitter หัว + ลำแสง + emitter ท้าย ในรูปเดียว) ยืดเต็มความสูง ถ้ารูปยังไม่โหลด → fallback ลำแสงแดงวาดเอง */ function drawGauntletLaserColumnScreen(rx, ry, rw, rh) { if (rh <= 1 || rw <= 0) return; const cx = rx + rw / 2; const spriteRec = ensureGauntletAssetImage(BASE + '/img/gauntlet-assets/obstacles-1.png'); if (spriteRec && spriteRec.img.complete && spriteRec.img.naturalWidth > 0) { const drawW = Math.max(14, rw * 1.5); try { ctx.drawImage(spriteRec.img, cx - drawW / 2, ry, drawW, rh); } catch (e) { /* ignore */ } return; } ctx.save(); const coreW = Math.max(2, rw * 0.30); const glowW = Math.max(coreW + 4, rw * 0.92); const prevComp = ctx.globalCompositeOperation; ctx.globalCompositeOperation = 'lighter'; /* glow รอบนอก */ let g1 = ctx.createLinearGradient(cx - glowW / 2, 0, cx + glowW / 2, 0); g1.addColorStop(0, 'rgba(255,40,60,0)'); g1.addColorStop(0.5, 'rgba(255,55,75,0.5)'); g1.addColorStop(1, 'rgba(255,40,60,0)'); ctx.fillStyle = g1; ctx.fillRect(cx - glowW / 2, ry, glowW, rh); /* แกนสว่าง */ let g2 = ctx.createLinearGradient(cx - coreW / 2, 0, cx + coreW / 2, 0); g2.addColorStop(0, 'rgba(255,110,130,0.85)'); g2.addColorStop(0.5, 'rgba(255,240,245,0.98)'); g2.addColorStop(1, 'rgba(255,110,130,0.85)'); ctx.fillStyle = g2; ctx.fillRect(cx - coreW / 2, ry, coreW, rh); ctx.globalCompositeOperation = prevComp; /* หัว/ท้าย emitter — รูปจริง gauntlet-dd51 (gauntletLaserTopUrl/BottomUrl) ที่บน-ล่างสุดของลำแสง กล่องเป็นสีเข้ม → ใส่ glow แดงข้างหลังให้เด่นบนพื้นมืด (ผนัง museum) */ const topRec = gauntletLaserTopUrl ? ensureGauntletAssetImage(gauntletLaserTopUrl) : null; const botRec = gauntletLaserBottomUrl ? ensureGauntletAssetImage(gauntletLaserBottomUrl) : null; const capW = Math.max(18, rw * 2.0); function laserEmitterGlow(gy) { const rad = capW * 0.8; const pc = ctx.globalCompositeOperation; ctx.globalCompositeOperation = 'lighter'; const gg = ctx.createRadialGradient(cx, gy, 0, cx, gy, rad); gg.addColorStop(0, 'rgba(255,120,140,0.9)'); gg.addColorStop(0.5, 'rgba(255,55,75,0.55)'); gg.addColorStop(1, 'rgba(255,40,60,0)'); ctx.fillStyle = gg; ctx.beginPath(); ctx.arc(cx, gy, rad, 0, Math.PI * 2); ctx.fill(); ctx.globalCompositeOperation = pc; } if (topRec && topRec.img.complete && topRec.img.naturalWidth > 0) { const ch = capW * topRec.img.naturalHeight / topRec.img.naturalWidth; laserEmitterGlow(ry + ch * 0.35); try { ctx.drawImage(topRec.img, cx - capW / 2, ry - ch * 0.1, capW, ch); } catch (e) { /* ignore */ } } if (botRec && botRec.img.complete && botRec.img.naturalWidth > 0) { const ch = capW * botRec.img.naturalHeight / botRec.img.naturalWidth; laserEmitterGlow(ry + rh - ch * 0.35); /* ท้าย — กลับหัวรูป (เครื่องยิงหันขึ้น) วางชิดล่างสุด */ ctx.save(); ctx.translate(cx, ry + rh - ch * 0.9); ctx.scale(1, -1); try { ctx.drawImage(botRec.img, -capW / 2, 0, capW, ch); } catch (e) { /* ignore */ } ctx.restore(); } 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 towerMapHud = isStackTowerMissionUiMapPlay(); /* MG3 live mission: HUD ย้ายไป DOM (#mg3-tower-hud) แล้ว — ไม่วาดบน canvas (object-fit:cover ครอปขอบหาย) */ if (towerMapHud) return; const lifeSlots = towerMapHud ? Math.max(1, Math.min(20, Math.floor(Number(playStackTeamMissesMax) || 3))) : 3; const livesRem = towerMapHud && stackMini.teamMissesLeft != null ? Math.max(0, Number(stackMini.teamMissesLeft) || 0) : Math.max(0, stackMini.lives != null ? stackMini.lives : 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(); } /** @param {{ headBust?: boolean, headSrcFrac?: number, bustLiftPx?: number }} [avatarOpts] — headBust + bustLiftPx ดันรูปขึ้นในกรอบ */ function drawHudAvatar(cx, baselineY, maxSize, characterId, playTint, avatarOpts) { 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 headBust = !!(avatarOpts && avatarOpts.headBust); let sx = 0; let sy = 0; let sww = iw; let shh = ih; if (headBust && iw > 0 && ih > 0) { const rawF = Number(avatarOpts && avatarOpts.headSrcFrac); const frac = Number.isFinite(rawF) && rawF > 0.22 && rawF < 0.85 ? rawF : 0.4; shh = Math.max(8, Math.round(ih * frac)); } 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 maxScale = headBust ? 2.45 : 1; const scale = Math.min(maxSize / sww, maxSize / shh, maxScale); const dw = sww * scale; const dh = shh * scale; const rawLift = headBust && avatarOpts ? Number(avatarOpts.bustLiftPx) : NaN; const bustLift = headBust ? (Number.isFinite(rawLift) ? Math.max(0, Math.min(maxSize * 0.45, rawLift)) : 22) : 0; ctx.drawImage(charImg, sx, sy, sww, shh, cx - dw / 2, baselineY - dh - bustLift, 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 || resolvePlayTintForPeer(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 || resolvePlayTintForPeer(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 + STACK_PREVIEW_TURN_COUNT * rowH + 10; if (!towerMapHud) { 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 === STACK_PREVIEW_TURN_COUNT) { drawRows(order); } else { const bots = [...others.keys()].filter(isPreviewBotId).sort(); const rows = [{ kind: 'human', seat: 1 }]; for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) { rows.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null }); } drawRows(rows); } } const framePad = 10; const bigAv = Math.min(72, Math.max(52, ch * 0.10)); const avBox = bigAv + 10; const integrityMinW = 120; const rightW = Math.min(300, Math.max(236, Math.round(cw * 0.34))); let rightX = cw - pad - rightW; const topY = 8; const leftReserve = towerMapHud ? (pad + 8) : (pad + boardW + 14); if (rightX < leftReserve) { rightX = Math.max(pad, cw - pad - rightW); } const integrityW = Math.max(integrityMinW, rightW - framePad * 2 - avBox - 10); const innerTop = topY + framePad; const integrityX = rightX + framePad; const integrityH = avBox + 2; const avFrameX = integrityX + integrityW + 10; const avFrameY = innerTop; const footerH = towerMapHud ? 0 : 26; const turnPanelH = framePad + integrityH + (towerMapHud ? framePad : (10 + footerH + framePad)); ctx.fillStyle = 'rgba(8, 12, 28, 0.9)'; roundRectPath(rightX, topY, rightW, turnPanelH, 8); ctx.fill(); ctx.strokeStyle = 'rgba(0, 230, 255, 0.55)'; ctx.lineWidth = 1.5; roundRectPath(rightX, topY, rightW, turnPanelH, 8); ctx.stroke(); let curEntry = null; if (order && order.length === STACK_PREVIEW_TURN_COUNT) { curEntry = order.find((e) => e.seat === currentSeat) || null; } const curV = curEntry ? resolveEntryVisual(curEntry) : resolveEntryVisual({ kind: 'human', seat: 1 }); drawStackTowerLifeIntegrityBarPlay(ctx, integrityX, innerTop - 2, integrityW, integrityH + 4, lifeSlots, livesRem); const avCx = avFrameX + avBox / 2; ctx.save(); ctx.shadowColor = 'rgba(0, 234, 255, 0.5)'; ctx.shadowBlur = 12; ctx.strokeStyle = 'rgba(0, 234, 255, 0.92)'; ctx.lineWidth = 2; roundRectPath(avFrameX - 1, avFrameY - 1, avBox + 2, avBox + 2, 6); ctx.stroke(); ctx.restore(); ctx.strokeStyle = 'rgba(0, 234, 255, 0.45)'; ctx.lineWidth = 1; roundRectPath(avFrameX - 1, avFrameY - 1, avBox + 2, avBox + 2, 6); ctx.stroke(); drawHudAvatar(avCx, avFrameY + avBox - 5, bigAv, curV.characterId, curV.playTint, towerMapHud ? { headBust: true } : undefined); if (!towerMapHud) { const footY = innerTop + integrityH + 12; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = livesRem <= 1 ? '#f7768e' : 'rgba(125, 252, 255, 0.88)'; ctx.font = monoSm; ctx.fillText( 'P' + currentSeat + ' · ' + curV.name.slice(0, 9) + ' · TEAM ' + teamScore + ' · COMBO x' + combo, rightX + rightW / 2, footY, ); } const termW = Math.min(380, Math.max(220, cw * 0.42)); const logLineH = 12; const logMaxLines = 5; const logBlockH = logMaxLines * logLineH; /** หัวข้อ + บล็อกล็อก + ช่องว่าง + ป้าย TOWER_LINK + แถบ — อย่าให้ทับกัน (เดิม termH=86 แคบเกิน) */ const termH = Math.max(118, 22 + logBlockH + 10 + 14 + 8 + 10); const termY = Math.max((towerMapHud ? 28 : 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; const logTop = termY + 24; let ly = logTop; const logLines = stackPreviewHudLog.length ? stackPreviewHudLog.slice(-logMaxLines) : ['>>> Awaiting stack sync…', '>>> Space / click = release (your turn only)']; logLines.forEach((ln) => { ctx.fillText(ln, pad + 10, ly); ly += logLineH; }); const decryptPct = stackTowerDecryptPctPlay(); const barPadX = 10; const barH = 8; const barW = termW - barPadX * 2; const barY = termY + termH - barH - 10; const labelY = barY - 14; if (labelY >= logTop + 4) { ctx.fillStyle = '#94e2d5'; ctx.font = monoSm; ctx.textBaseline = 'top'; ctx.fillText('TOWER_LINK ' + decryptPct + '%', pad + barPadX, labelY); } ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(pad + barPadX, barY, barW, barH); ctx.fillStyle = 'rgba(0, 255, 200, 0.65)'; ctx.fillRect(pad + barPadX, barY, Math.max(4, barW * decryptPct / 100), barH); ctx.strokeStyle = 'rgba(0, 234, 255, 0.4)'; ctx.strokeRect(pad + barPadX, barY, barW, barH); ctx.restore(); } function useCyberPlayHud() { return !!(mapData && (isJumpSurvive() || isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay())); } /** 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); } /** แมป mng8a80o ช่วงเล่น: คำถามอยู่บนแผนที่ (โซนทอง) — HUD กลางเหลือ TIME + แผ่นเวลา */ function syncQuizQuestionMissionCyberCenterHud() { const root = document.getElementById('play-cyber-hud'); const qBand = document.getElementById('play-cyber-quiz-mission-q-band'); const qText = document.getElementById('play-cyber-quiz-mission-q-text'); const qPlaque = document.getElementById('play-cyber-quiz-mission-q-plaque'); const tb = root ? root.querySelector('.play-cyber-time-block') : null; const on = isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay() || isSpaceShooterMissionHudActivePlay(); if (isQuizQuestionMissionUiMapPlay()) { if (qBand) { qBand.classList.add('is-hidden'); qBand.setAttribute('aria-hidden', 'true'); } if (qText) qText.textContent = ''; if (qPlaque) { qPlaque.classList.add('is-hidden'); qPlaque.removeAttribute('src'); delete qPlaque.dataset.qmQsrc; } } const plaqueImg = root ? document.getElementById('play-cyber-time-plaque-img') : null; const head = tb && tb.querySelector ? tb.querySelector('.play-cyber-time-head') : null; const clearQmTimePlaque = () => { if (plaqueImg) { plaqueImg.onload = null; plaqueImg.onerror = null; plaqueImg.removeAttribute('src'); plaqueImg.classList.add('is-hidden'); plaqueImg.setAttribute('aria-hidden', 'true'); delete plaqueImg.dataset.qmTimeSrc; } if (head) head.classList.remove('play-cyber-time-head--qm-plaque'); if (tb) { tb.style.backgroundImage = ''; tb.style.backgroundSize = ''; tb.style.backgroundPosition = ''; tb.style.backgroundRepeat = ''; tb.style.paddingTop = ''; tb.style.minWidth = ''; delete tb.dataset.qmTimeSrc; } }; if (tb && plaqueImg && head) { const tUrl = questionMissionHudAssetUrl('time.png'); if (on) { tb.style.backgroundImage = ''; tb.style.backgroundSize = ''; tb.style.backgroundPosition = ''; tb.style.backgroundRepeat = ''; tb.style.paddingTop = ''; tb.style.minWidth = ''; const applyPlaque = () => { head.classList.add('play-cyber-time-head--qm-plaque'); plaqueImg.classList.remove('is-hidden'); plaqueImg.setAttribute('aria-hidden', 'false'); }; if (plaqueImg.dataset.qmTimeSrc === tUrl && plaqueImg.getAttribute('src') === tUrl && plaqueImg.complete && (plaqueImg.naturalWidth || 0) > 0) { applyPlaque(); } else if (plaqueImg.dataset.qmTimeSrc !== tUrl || plaqueImg.getAttribute('src') !== tUrl) { plaqueImg.dataset.qmTimeSrc = tUrl; plaqueImg.onload = function () { if ((plaqueImg.naturalWidth || 0) > 0) applyPlaque(); else clearQmTimePlaque(); }; plaqueImg.onerror = function () { clearQmTimePlaque(); }; plaqueImg.setAttribute('src', tUrl); } } else { clearQmTimePlaque(); } } } function playCyberHudEsc(t) { return String(t == null ? '' : t) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function resolveCyberHudAvatarTintPlay(row) { if (row && row.playTint && row.playTint.head) return row.playTint; const peerId = row && row.id != null ? row.id : ''; if (row && row.isMe) { return me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me')); } const sid = peerId !== '' ? String(peerId) : ''; const ox = sid ? (others.get(peerId) || others.get(sid)) : null; if (ox && ox.playTint) return ox.playTint; return resolvePlayTintForPeer(sid); } function resolveRankedCharacterIdPlay(rankedRow) { if (!rankedRow) return ''; if (rankedRow.characterId) return String(rankedRow.characterId); const id = rankedRow.id; if (id == null) return ''; const ox = others.get(id) || others.get(String(id)); if (ox && ox.characterId) return String(ox.characterId); if (myId != null && String(id) === String(myId)) { return String(me.characterId || getPlayCharacterId() || ''); } return ''; } /** กัน cache/แสดง canvas ว่าง (ทำให้ช่องอันดับ 1 หายรูป แต่ retry หยุดเพราะ naturalWidth > 0) */ function playCanvasHasOpaquePixels(canvas) { if (!canvas || canvas.width < 2 || canvas.height < 2) return false; try { const ctx = canvas.getContext('2d', { willReadFrequently: true }); if (!ctx) return false; const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; for (let i = 3; i < data.length; i += 16) { if (data[i] > 12) return true; } } catch (e) { /* ignore */ } return false; } function ensureMePlayTintFromLobbyStorage() { if (me.playTint && me.playTint.head && me.playTint.body) return; const t = loadPlayTintFromLobbyPlayerStorage(); if (t) me.playTint = t; } function cyberHudScoreAvatarCacheKey(row) { const cid = row.characterId ? String(row.characterId) : ''; const tint = resolveCyberHudAvatarTintPlay(row); return [row.isMe ? 'me' : String(row.id), cid, tint.head, tint.hair, tint.body, 'faceV4'].join('|'); } function findPlayCyberHudScoreAvatarSrcForPeer(peerId) { if (peerId == null) return ''; const sid = String(peerId).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const ul = document.getElementById('play-cyber-score-list'); if (!ul) return ''; const row = ul.querySelector('.play-cyber-score-row[data-peer-id="' + sid + '"]'); if (!row) return ''; const av = row.querySelector('img.play-cyber-score-av'); if (!av || !av.src || av.src === defaultAvatarImg.src) return ''; if (!av.complete || av.naturalWidth < 8) return ''; return av.src; } /** crop หน้า idle ลง (down) + tint — ใช้ทั้ง HUD และหน้าสรุปผล ให้ตรงสีในเกม */ function composeCyberHudFacePortraitDataUrl(cid, tint) { if (!cid || !tint) return ''; const dir = 'down'; const nowT = Date.now(); preloadPlayTintForAvatar(cid, tint); ensurePlayLayerProbesAllDirections(cid); const raw = getCharacterFrame(cid, dir, nowT, false) || getCharacterImg(cid, dir); if (!raw) return ''; let srcCanvas = null; if (raw.complete && raw.naturalWidth > 0 && raw.naturalHeight > 0) { const composeFrame = isUploadedCharAssetId(cid) ? 0 : idleAnimPhaseIndex(nowT); const packIdle = tryComposePlayLayersFromFiles(raw, cid, dir, composeFrame, tint, { idle: true }); if (packIdle && packIdle.canvas && playCanvasHasOpaquePixels(packIdle.canvas)) { srcCanvas = packIdle.canvas; } if (!srcCanvas) { const tinted = getPlayTintedAvatarSource(raw, cid, dir, nowT, false, tint); if (tinted && tinted.tagName === 'CANVAS' && tinted.width > 0 && playCanvasHasOpaquePixels(tinted)) { srcCanvas = tinted; } } } /* ตัวละครที่ไม่มีไฟล์เลเยอร์สี (เช่น defaultChar) — compose คืน Image ดิบ → crop หน้าจากรูปดิบตรง ๆ กันช่องว่าง */ let drawSrc = srcCanvas; if (!drawSrc && raw && raw.complete && raw.naturalWidth > 0 && raw.naturalHeight > 0) { /* ตัวที่มีไฟล์เลเยอร์สี (tintable) แต่ composite ยังไม่พร้อม หรือ API characters ยังโหลดไม่เสร็จ → คืน '' ให้ applyCyberHudScoreAvatarImgWithRetry retry รอเลเยอร์โหลด (avatar bot จะไม่ถูกรับจนกว่าจะเป็น data URL ที่ย้อมสีแล้ว) อย่า fallback raw ที่ "ไม่มีสี" ไม่งั้น avatar ทุกคนเหมือนกัน = "สีไม่ขึ้น"; ใช้ raw เฉพาะตัวที่ API ยืนยันว่าไม่มีเลเยอร์ (defaultChar) */ if (playCharLayerApi.status !== 'done' || playCharLayerFromApi(cid) === true) return ''; drawSrc = raw; } if (!drawSrc) return ''; const face = document.createElement('canvas'); face.width = 120; face.height = 120; const ctx = face.getContext('2d'); if (!ctx) return ''; const sw = drawSrc.naturalWidth || drawSrc.width; const sh = drawSrc.naturalHeight || drawSrc.height; if (!sw || !sh) return ''; const sx = sw * 0.1; const sy = sh * 0.02; const sw2 = sw * 0.8; const sh2 = sh * 0.5; const cropSize = Math.min(sw2, sh2); const sxCenter = sx + (sw2 - cropSize) * 0.5; ctx.clearRect(0, 0, 120, 120); try { /* crop สี่เหลี่ยมจัตุรัสจากหัว → 120×120 ไม่ยืด */ ctx.drawImage(drawSrc, sxCenter, sy, cropSize, cropSize, 0, 0, 120, 120); } catch (e) { return ''; } if (!playCanvasHasOpaquePixels(face)) return ''; try { return face.toDataURL('image/png'); } catch (e) { return ''; } } function cyberHudScoreAvatarImgLooksReady(img) { return !!(img && img.src && img.src !== defaultAvatarImg.src && img.complete && img.naturalWidth > 8 && img.naturalHeight > 8); } /** crop หัว (จัตุรัส) จาก src เต็มตัว (lobby idle — ย้อมสีถูกแล้ว) → 120×120 data URL (async, cache) * ใช้เมื่อ composeCyberHudFacePortraitDataUrl คืน '' (เช่นตัวที่ layer ไม่ compose) → human เป็น "หน้า" เหมือนบอท ไม่ใช่เต็มตัว */ var cyberHudLobbyFaceCropCache = new Map(); function cyberHudLobbyIdleHeadFaceDataUrl(src) { if (!src) return ''; if (src.indexOf('data:') === 0 && src.length < 200) return ''; if (cyberHudLobbyFaceCropCache.has(src)) return cyberHudLobbyFaceCropCache.get(src); cyberHudLobbyFaceCropCache.set(src, ''); var im = new Image(); im.onload = function () { try { var sw = im.naturalWidth, sh = im.naturalHeight; if (!sw || !sh) { cyberHudLobbyFaceCropCache.set(src, ''); return; } var c = document.createElement('canvas'); c.width = 120; c.height = 120; var ctx = c.getContext('2d'); if (!ctx) { cyberHudLobbyFaceCropCache.set(src, ''); return; } var cw = sw * 0.8, ch = sh * 0.5, cs = Math.min(cw, ch); var sxC = sw * 0.1 + (cw - cs) * 0.5, syC = sh * 0.02; ctx.clearRect(0, 0, 120, 120); ctx.drawImage(im, sxC, syC, cs, cs, 0, 0, 120, 120); cyberHudLobbyFaceCropCache.set(src, playCanvasHasOpaquePixels(c) ? c.toDataURL('image/png') : ''); } catch (e) { cyberHudLobbyFaceCropCache.set(src, ''); } }; im.onerror = function () { cyberHudLobbyFaceCropCache.set(src, ''); }; im.src = src; return ''; } /** โปรไฟล์มุมขวาบน — mission mock ใช้หน้า crop สี่เหลี่ยม ไม่ดึง sprite เต็มตัว */ function setCyberHudProfilePortraitImg(portraitImg) { if (!portraitImg) return; const cid = me.characterId || getPlayCharacterId(); if (!cid) { portraitImg.src = defaultAvatarImg.src; return; } const row = { id: myId, characterId: cid, isMe: true, playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me')), }; if (isMissionMockHudPlay()) { const tint = resolveCyberHudAvatarTintPlay(row); const cacheKey = cyberHudScoreAvatarCacheKey(row) + '|profile'; const cached = cyberHudScoreAvatarUrlCache.get(cacheKey); if (cached) { portraitImg.src = cached; return; } const faceUrl = composeCyberHudFacePortraitDataUrl(cid, tint); if (faceUrl) { portraitImg.src = faceUrl; cyberHudScoreAvatarUrlCache.set(cacheKey, faceUrl); return; } } setCyberHudScoreAvatarImg(portraitImg, row); } function applyCyberHudProfilePortraitImgWithRetry(portraitImg, attempt) { attempt = attempt || 0; setCyberHudProfilePortraitImg(portraitImg); if (cyberHudScoreAvatarImgLooksReady(portraitImg)) return; if (attempt >= 16) return; const cid = me.characterId || getPlayCharacterId(); const delay = 40 + attempt * 90; setTimeout(function () { if (cid) { preloadPlayTintForAvatar(cid, resolveCyberHudAvatarTintPlay({ id: myId, characterId: cid, isMe: true, playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me')), })); } applyCyberHudProfilePortraitImgWithRetry(portraitImg, attempt + 1); }, delay); } /** แถบ Cyber SCORE + สรุปผล — หน้า idle down + tint ต่อผู้เล่น */ function setCyberHudScoreAvatarImg(avImg, row, skipReuse) { /* 'Chatest' = placeholder ไม่มี sprite จริง → ปฏิบัติเป็นว่าง ใช้ default (กัน 404 + retry วน ×16) */ const cid = (row.characterId && row.characterId !== LEGACY_PLACEHOLDER_CHARACTER_ID) ? String(row.characterId) : ''; if (!cid) { avImg.src = defaultAvatarImg.src; return; } const cacheKey = cyberHudScoreAvatarCacheKey(row); const cached = cyberHudScoreAvatarUrlCache.get(cacheKey); if (cached) { avImg.src = cached; return; } /* skipReuse: ตอน retry (เช่น crop หัวยังไม่เสร็จ) อย่าอ่าน src ปัจจุบันจาก DOM กลับมา ไม่งั้นจะได้ "รูปเต็มตัวชั่วคราว" กลับมาแล้ว cache ค้าง (human โชว์เต็มตัวคนเดียว) */ const hudSrc = skipReuse ? '' : findPlayCyberHudScoreAvatarSrcForPeer(row.id); if (hudSrc) { avImg.src = hudSrc; cyberHudScoreAvatarUrlCache.set(cacheKey, hudSrc); return; } const tint = resolveCyberHudAvatarTintPlay(row); /* ย้อมสีตาม tint ปัจจุบันก่อนเสมอ (รวมตัวเรา) — อย่าใช้ lobby idle ต่อ characterId เพราะสีไม่ตรงที่เลือกใน lobby */ const faceUrl = composeCyberHudFacePortraitDataUrl(cid, tint); if (faceUrl) { avImg.src = faceUrl; if (cyberHudScoreAvatarUrlCache.size >= CYBER_HUD_AV_URL_CACHE_CAP) { const first = cyberHudScoreAvatarUrlCache.keys().next(); if (!first.done) cyberHudScoreAvatarUrlCache.delete(first.value); } cyberHudScoreAvatarUrlCache.set(cacheKey, faceUrl); return; } if (row.isMe) { const lobbySrc = playLobbyIdleAvatarSrcForCharacter(cid); if (lobbySrc) { if (isMissionMockHudPlay()) { /* mock hud (mg2 ฯลฯ): crop หัวจาก lobby idle → "หน้า" จัตุรัสเหมือนบอท (กัน human โชว์เต็มตัวคนเดียว) */ const lobbyFace = cyberHudLobbyIdleHeadFaceDataUrl(lobbySrc); if (lobbyFace) { avImg.src = lobbyFace; cyberHudScoreAvatarUrlCache.set(cacheKey, lobbyFace); return; } /* crop ยังไม่เสร็จ (async) → ใช้ "หน้า default" ชั่วคราว (อย่าโชว์เต็มตัว — ผู้เล่นต้องเห็นแค่หัวเหมือนบอท) แล้ว retry จนได้หัวจริง จำกัดจำนวน retry กัน loop ถ้า crop ล้มเหลวถาวร */ avImg.src = defaultAvatarImg.src; var hudAvTries = avImg.__hudAvHeadTries || 0; if (hudAvTries < 12) { avImg.__hudAvHeadTries = hudAvTries + 1; setTimeout(function () { if (avImg.isConnected) setCyberHudScoreAvatarImg(avImg, row, true); }, 200); } return; } avImg.src = lobbySrc; cyberHudScoreAvatarUrlCache.set(cacheKey, lobbySrc); return; } } const dir = 'down'; const nowT = Date.now(); const frameIm = getCharacterFrame(cid, dir, nowT, false) || getCharacterImg(cid, dir); if (frameIm && frameIm.src) { if (!frameIm.complete || !frameIm.naturalWidth) { avImg.src = frameIm.src; frameIm.addEventListener('load', function onHudAvFrame() { frameIm.removeEventListener('load', onHudAvFrame); setCyberHudScoreAvatarImg(avImg, row); }, { once: true }); return; } } avImg.src = defaultAvatarImg.src; } /** ลองโหลด avatar ซ้ำจน sprite/tint พร้อม (กันรูปหายตอนเปิด overlay) */ function applyCyberHudScoreAvatarImgWithRetry(avImg, row, attempt) { attempt = attempt || 0; setCyberHudScoreAvatarImg(avImg, row); /* mock hud: ต้องได้ "หน้า" 120×120 (naturalWidth≥100) เท่านั้นถึงหยุด — กันค้างที่ raw frame 64×64 (เต็มตัว) หรือ /img/ sprite เต็มตัว ก่อน composeCyberHudFacePortraitDataUrl จะได้หน้า (ตอน char-layer API ยังโหลด) */ const mockFaceOk = !isMissionMockHudPlay() || (avImg.naturalWidth >= 100 && avImg.naturalHeight >= 100); const ok = cyberHudScoreAvatarImgLooksReady(avImg) && mockFaceOk && (avImg.src.indexOf('data:image/') === 0 || (row.isMe && !isMissionMockHudPlay() && avImg.src.indexOf('/img/') >= 0)); if (ok) return; if (attempt >= 16) return; const cid = row.characterId ? String(row.characterId) : ''; const delay = 40 + attempt * 90; setTimeout(function () { if (cid) preloadPlayTintForAvatar(cid, resolveCyberHudAvatarTintPlay(row)); applyCyberHudScoreAvatarImgWithRetry(avImg, row, attempt + 1); }, delay); } function refreshGcmTfMockAvatarsPlay(rowEl, ranked) { if (!rowEl || !Array.isArray(ranked)) return; const imgs = rowEl.querySelectorAll('img.gcm-av'); ranked.forEach(function (r, idx) { const img = imgs[idx]; if (!img) return; const cid = resolveRankedCharacterIdPlay(r); const avRow = { id: r.id, characterId: cid, isMe: myId != null && r.id != null && String(r.id) === String(myId), playTint: resolveCyberHudAvatarTintPlay({ id: r.id, characterId: cid, isMe: myId != null && r.id != null && String(r.id) === String(myId), }), }; applyCyberHudScoreAvatarImgWithRetry(img, avRow, 0); }); } function quizQuestionMissionJoystickResetVisual() { quizQuestionMissionJoyVecX = 0; quizQuestionMissionJoyVecY = 0; const knob = document.getElementById('quiz-q-mission-joystick-knob'); if (knob) knob.style.transform = 'translate(-50%, -50%)'; } function quizQuestionMissionJoystickUpdateFromClientXY(clientX, clientY) { const base = document.getElementById('quiz-q-mission-joystick-base'); const knob = document.getElementById('quiz-q-mission-joystick-knob'); if (!base || !knob) return; const r = base.getBoundingClientRect(); const cx = r.left + r.width * 0.5; const cy = r.top + r.height * 0.5; let dx = clientX - cx; let dy = clientY - cy; const half = Math.min(r.width, r.height) * 0.5; const maxKn = Math.max(18, half * 0.52); const len = Math.hypot(dx, dy); if (len > maxKn && len > 1e-6) { dx = (dx / len) * maxKn; dy = (dy / len) * maxKn; } const nlen = Math.hypot(dx, dy); const dead = maxKn * 0.12; if (nlen < dead) { quizQuestionMissionJoyVecX = 0; quizQuestionMissionJoyVecY = 0; knob.style.transform = 'translate(-50%, -50%)'; return; } quizQuestionMissionJoyVecX = dx / maxKn; quizQuestionMissionJoyVecY = dy / maxKn; knob.style.transform = 'translate(calc(-50% + ' + dx + 'px), calc(-50% + ' + dy + 'px))'; } function syncQuizQuestionMissionJoystickPlay() { const root = document.getElementById('quiz-question-mission-joystick'); if (!root) return; const jumperOn = isJumpSurviveMissionHudActivePlay(); const on = !!((isQuizQuestionMissionHudActivePlay() || jumperOn) && !isChatFocused()); root.classList.toggle('is-hidden', !on); root.setAttribute('aria-hidden', on ? 'false' : 'true'); if (on) { /* สลับรูป joystick ตามฉาก: jumper → /img/Jumper/, quiz คำถาม → /img/QUESTION/ (เปลี่ยน src เฉพาะตอน skin เปลี่ยน) */ const skin = jumperOn ? 'jumper' : 'question'; const bg = document.getElementById('quiz-q-mission-joystick-bg'); const knobImg = document.getElementById('quiz-q-mission-joystick-knob-img'); if (bg && bg.dataset.joySkin !== skin) { bg.src = jumperOn ? jumperAssetUrl('btn-joystick-1.png') : questionMissionAssetUrl('btn-joystick-1.png'); bg.dataset.joySkin = skin; } if (knobImg && knobImg.dataset.joySkin !== skin) { knobImg.src = jumperOn ? jumperAssetUrl('btn-joystick-2.png') : questionMissionAssetUrl('btn-joystick-2.png'); knobImg.dataset.joySkin = skin; } } if (!on) { const base = document.getElementById('quiz-q-mission-joystick-base'); if (base && quizQuestionMissionJoyPointerId != null) { try { base.releasePointerCapture(quizQuestionMissionJoyPointerId); } catch (_e) { /* ignore */ } } quizQuestionMissionJoyPointerId = null; quizQuestionMissionJoystickResetVisual(); } } /* mng8a80o: scale HUD ทั้งก้อน (fix 1920×1080) ให้พอดี container แบบรักษา 16:9 — เทียบ scale.js ของ mockup Minigame-1 สำคัญ: คำนวณเฉพาะตอนเข้าโหมด/resize เท่านั้น (ห้ามอ่าน layout ทุกเฟรมใน draw loop → กระตุก) */ let _quizTfScaleLast = 0; let _quizTfMockActivePrev = false; function updateQuizTfMockScale(root) { root = root || document.getElementById('play-cyber-hud'); if (!root || !root.classList.contains('play-cyber-hud--quiz-tf-mock')) return; const stack = document.getElementById('play-canvas-stack'); if (!stack) return; const r = stack.getBoundingClientRect(); const cw = Math.max(1, r.width || stack.clientWidth || 0); const ch = Math.max(1, r.height || stack.clientHeight || 0); const s = Math.min(cw / 1920, ch / 1080); if (!(s > 0 && isFinite(s))) return; root.style.position = 'fixed'; root.style.left = Math.round(r.left) + 'px'; root.style.top = Math.round(r.top) + 'px'; root.style.width = Math.round(cw) + 'px'; root.style.height = Math.round(ch) + 'px'; root.style.right = 'auto'; root.style.bottom = 'auto'; root.style.transform = 'none'; const stage = root.querySelector('.play-cyber-hud-mock-stage'); if (stage) stage.setAttribute('aria-hidden', 'false'); if (Math.abs(s - _quizTfScaleLast) < 0.0005 && root.dataset.qtfStackW === String(Math.round(cw)) && root.dataset.qtfStackH === String(Math.round(ch))) { syncMg2MockJumpButtonBoundsPlay(r, s); return; } _quizTfScaleLast = s; root.dataset.qtfStackW = String(Math.round(cw)); root.dataset.qtfStackH = String(Math.round(ch)); root.style.setProperty('--qtf-scale', String(s)); syncMg2MockJumpButtonBoundsPlay(r, s); } function syncMg2MockJumpButtonBoundsPlay(stackRect, scale) { const jumpBtn = document.getElementById('gauntlet-crown-jump-btn'); const hud = document.getElementById('play-cyber-hud'); const _jbSkin = hud ? hud.dataset.missionMockSkin : ''; if (!jumpBtn || !hud || (_jbSkin !== 'gauntlet_crown' && _jbSkin !== 'jumper' && _jbSkin !== 'mega_virus')) { if (jumpBtn) { jumpBtn.style.removeProperty('left'); jumpBtn.style.removeProperty('top'); jumpBtn.style.removeProperty('width'); jumpBtn.style.removeProperty('height'); jumpBtn.style.removeProperty('right'); jumpBtn.style.removeProperty('bottom'); jumpBtn.style.removeProperty('transform'); jumpBtn.style.removeProperty('filter'); } return; } if (jumpBtn.classList.contains('is-hidden')) return; const r = stackRect || (document.getElementById('play-canvas-stack') || {}).getBoundingClientRect?.(); const s = Number.isFinite(scale) ? scale : _quizTfScaleLast; if (!r || !(s > 0)) return; jumpBtn.style.position = 'fixed'; jumpBtn.style.left = Math.round(r.left + 1648 * s) + 'px'; jumpBtn.style.top = Math.round(r.top + 818 * s) + 'px'; jumpBtn.style.width = Math.round(231 * s) + 'px'; jumpBtn.style.height = Math.round(231 * s) + 'px'; jumpBtn.style.right = 'auto'; jumpBtn.style.bottom = 'auto'; jumpBtn.style.transform = 'none'; jumpBtn.style.filter = 'drop-shadow(0 0 ' + Math.max(8, Math.round(18 * s)) + 'px rgba(255, 110, 220, 0.45))'; } function clearQuizTfMockHudBoundsPlay(root) { root = root || document.getElementById('play-cyber-hud'); if (!root) return; const stage = root.querySelector('.play-cyber-hud-mock-stage'); if (stage) stage.setAttribute('aria-hidden', 'true'); root.style.removeProperty('left'); root.style.removeProperty('top'); root.style.removeProperty('width'); root.style.removeProperty('height'); root.style.removeProperty('right'); root.style.removeProperty('bottom'); root.style.removeProperty('transform'); delete root.dataset.qtfStackW; delete root.dataset.qtfStackH; syncMg2MockJumpButtonBoundsPlay(null, 0); } /* mng8a80o: สรุปผลภารกิจ (06-summary) — scale popup 1451×851 ตามสูตรเดียวกับ HUD (min vw/1920, vh/1080) */ function missionMockOverlayScalePlay() { const stack = document.getElementById('play-canvas-stack'); const r = stack && stack.getBoundingClientRect ? stack.getBoundingClientRect() : null; const cw = Math.max(1, r && r.width ? r.width : window.innerWidth); const ch = Math.max(1, r && r.height ? r.height : window.innerHeight); return Math.min(cw / 1920, ch / 1080); } function updateGcmTfMockScale(ov) { ov = ov || document.getElementById('gauntlet-crown-mission-overlay'); if (!ov) return; const s = missionMockOverlayScalePlay(); if (s > 0 && isFinite(s)) ov.style.setProperty('--gcm-tf-scale', String(s)); } /* popup HOW TO PLAY (01-howto) — scale stage 1920×1080 · MG2 ยึด #play-canvas-stack เหมือน HUD */ function updateGchTfMockScale(ov) { ov = ov || document.getElementById('gauntlet-crown-howto-overlay'); if (!ov) return; const mg2 = ov.classList.contains('gch-mg2-mock'); if (mg2) { const stack = document.getElementById('play-canvas-stack'); if (!stack) return; const r = stack.getBoundingClientRect(); const cw = Math.max(1, r.width || stack.clientWidth || 0); const ch = Math.max(1, r.height || stack.clientHeight || 0); const s = Math.min(cw / 1920, ch / 1080); if (!(s > 0 && isFinite(s))) return; ov.style.position = 'fixed'; ov.style.left = Math.round(r.left) + 'px'; ov.style.top = Math.round(r.top) + 'px'; ov.style.width = Math.round(cw) + 'px'; ov.style.height = Math.round(ch) + 'px'; ov.style.right = 'auto'; ov.style.bottom = 'auto'; ov.style.transform = 'none'; ov.style.setProperty('--gch-tf-scale', String(s)); const stage = ov.querySelector('.gch-mg2-stage'); if (stage) stage.setAttribute('aria-hidden', 'false'); return; } clearGchMg2OverlayBoundsPlay(ov); const s = missionMockOverlayScalePlay(); if (s > 0 && isFinite(s)) ov.style.setProperty('--gch-tf-scale', String(s)); } let _quizTfScaleObserversBound = false; function ensureQuizTfScaleResizeListener() { if (_quizTfScaleObserversBound) return; _quizTfScaleObserversBound = true; const recompute = function () { const r = document.getElementById('play-cyber-hud'); if (r && r.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(r); const ov = document.getElementById('gauntlet-crown-mission-overlay'); if (ov && ov.classList.contains('gcm-quiz-tf-mock') && !ov.classList.contains('is-hidden')) updateGcmTfMockScale(ov); const hv = document.getElementById('gauntlet-crown-howto-overlay'); if (hv && hv.classList.contains('gch-quiz-tf-mock') && !hv.classList.contains('is-hidden')) updateGchTfMockScale(hv); }; window.addEventListener('resize', recompute); window.addEventListener('orientationchange', recompute); /* ResizeObserver จับ container ที่เปลี่ยนขนาดโดยไม่มี window resize (เช่น ลากแบ่งจอ editor) */ if (typeof ResizeObserver === 'function') { try { const ro = new ResizeObserver(recompute); const host = document.querySelector('.game-wrap') || document.documentElement; const stack = document.getElementById('play-canvas-stack'); if (host) ro.observe(host); if (stack) ro.observe(stack); } catch (e) { /* ignore */ } } } 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 (!on) { root.classList.remove('play-cyber-hud--question-mission'); root.classList.remove('play-cyber-hud--score-flush-left'); root.classList.remove('play-cyber-hud--jump-mission-hide-score'); root.classList.remove('play-cyber-hud--ss-self-integrity-left'); root.classList.remove('play-cyber-hud--last-light-no-time'); root.classList.remove('play-cyber-hud--quiz-tf-mock'); clearQuizTfMockHudBoundsPlay(root); root.style.removeProperty('--qtf-scale'); _quizTfScaleLast = 0; _quizTfMockActivePrev = false; document.documentElement.classList.remove('play-mission-mock-hud'); document.documentElement.classList.remove('play-mg2-mock-hud'); } } 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; ensureMePlayTintFromLobbyStorage(); root.classList.toggle( 'play-cyber-hud--question-mission', !!(isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay() || isSpaceShooterMissionHudActivePlay() || isBalloonBossMissionHudActivePlay()), ); /* Minigame 1–7 mission — HUD fix 1920×1080 ตาม mock (score-bar / profile qb-room) */ const missionMockOn = !!isMissionMockHudPlay(); root.classList.toggle('play-cyber-hud--quiz-tf-mock', missionMockOn); document.documentElement.classList.toggle('play-mission-mock-hud', missionMockOn && on); document.documentElement.classList.toggle('play-mg2-mock-hud', missionMockOn && on && (getMissionMockHudSkinPlay() === 'gauntlet_crown' || getMissionMockHudSkinPlay() === 'quiz_carry')); applyMissionMockHudCssVarsPlay(root); const tfMockOn = root.classList.contains('play-cyber-hud--quiz-tf-mock'); if (tfMockOn) { ensureQuizTfScaleResizeListener(); updateQuizTfMockScale(root); if (!_quizTfMockActivePrev) { requestAnimationFrame(function () { const r = document.getElementById('play-cyber-hud'); if (r && r.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(r); }); } } else if (_quizTfMockActivePrev) { clearQuizTfMockHudBoundsPlay(root); root.style.removeProperty('--qtf-scale'); _quizTfScaleLast = 0; document.documentElement.classList.remove('play-mission-mock-hud'); document.documentElement.classList.remove('play-mg2-mock-hud'); } _quizTfMockActivePrev = tfMockOn; root.classList.toggle( 'play-cyber-hud--stack-tower-canvas-hud', !!(playBotsEnabled() && stackMini && isStack() && isStackTowerMissionUiMapPlay()), ); root.classList.toggle('play-cyber-hud--ss-self-integrity-left', !!isSpaceShooterMissionHudActivePlay()); /** แถบ SCORE แนวนอน (mno9kb07) — ใช้รูปแบบเดียวกับ quiz_carry / พรีวิว editor */ root.classList.toggle( 'play-cyber-hud--gauntlet-crown-strip', !!(on && (isGauntletCrownHeistMapPlay() || isQuizCarry()) && !(missionMockOn && (getMissionMockHudSkinPlay() === 'gauntlet_crown' || getMissionMockHudSkinPlay() === 'quiz_carry'))), ); root.classList.toggle('play-cyber-hud--score-flush-left', !!(on && isQuizCarry())); root.classList.toggle('play-cyber-hud--jump-mission-hide-score', !!isJumpSurviveMissionUiMapPlay()); /** Last Light mno9kb07: ไม่แสดง TIME / ซูมใต้เวลา — จบด้วยรันเวย์ ไม่ใช้นาฬิกา */ root.classList.toggle('play-cyber-hud--last-light-no-time', !!(on && isGauntletCrownHeistMapPlay())); 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) { const bbMegaLiveHud = isBalloonBoss() && isMegaVirusMissionShellMapPlay() && !isGauntletCrownPregameBlockingPlay(); timeSub.textContent = isQuizQuestionMissionHudActivePlay() ? (playQuizPhaseLocal === 'read' ? 'READ · อ่านคำถาม' : (playQuizPhaseLocal === 'answer' ? 'ANSWER · เดินไปโซน จริง / เท็จ' : 'QUIZ MISSION · ประลองความรู้')) : (isSpaceShooterMissionHudActivePlay() ? 'SPACE SHOOTER · ARCADE — TIME (sec) · ภารกิจยิงยาน' : (isStackTowerMissionHudActivePlay() ? 'TOWER STACK · DECRYPT — TIME (sec) · เหลือเวลาถอดรหัส' : (isQuizCarry() ? 'QUIZ CARRY · COURT — หยิบป้ายถูกแล้วส่งที่ฮับ (F / Grab)' : (isGauntlet() ? 'GAUNTLET · SURVIVAL RUN' : (isSpaceShooter() ? 'SPACE SHOOTER · ARCADE' : (isBalloonBoss() ? (bbMegaLiveHud ? '' : 'BALLOON BOSS · MEGA VIRUS') : (isJumpSurviveMissionUiMapPlay() ? 'JUMPER · SURVIVE — เหลือเวลา (วินาที) · TIME (sec)' : 'JUMP SURVIVE · NODE UPLINK'))))))); timeSub.style.display = bbMegaLiveHud ? 'none' : ''; } const zoomHintEl = document.getElementById('play-cyber-embed-zoom-hint'); if (zoomHintEl) { const showEmbedZoom = !!(previewMode && editorEmbedReturn && mapData && !isQuizQuestionMissionHudActivePlay() && !(isBalloonBoss() && isMegaVirusMissionShellMapPlay())); if (showEmbedZoom) { const zNum = Number(playEmbedUserZoomMul.toFixed(2)); zoomHintEl.textContent = '×' + String(zNum); zoomHintEl.classList.remove('is-hidden'); zoomHintEl.setAttribute('aria-hidden', 'false'); } else { zoomHintEl.textContent = ''; zoomHintEl.classList.add('is-hidden'); zoomHintEl.setAttribute('aria-hidden', 'true'); } } if (timeVal) { if (isQuiz() && isQuizQuestionMissionUiMapPlay()) { if (quizQuestionMissionPhase !== 'live' || quizQuestionMissionPhase === 'ended') { timeVal.textContent = '···'; } else if (!playQuizPhaseEndsAt) { timeVal.textContent = '—'; } else { timeVal.textContent = String(Math.max(0, Math.ceil((playQuizPhaseEndsAt - serverNow()) / 1000))); } } else if (isStack() && isStackTowerMissionUiMapPlay()) { if (stackTowerMissionPhase === 'live') { const remSt = Math.max(0, Math.ceil(stackTowerMissionTimeLimitSecPlay() - stackTowerMissionElapsedSecPlay())); timeVal.textContent = String(remSt); } else if (stackTowerMissionPhase === 'howto' || stackTowerMissionPhase === 'countdown') { timeVal.textContent = '···'; } else { timeVal.textContent = '0'; } } else if (isQuizCarry()) { if (quizCarrySessionEnded) { timeVal.textContent = '0'; } else if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedCountdownEndAt > Date.now()) { const rem = Math.max(0, Math.ceil((quizCarryEmbedCountdownEndAt - Date.now()) / 1000)); timeVal.textContent = String(rem); } else if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > Date.now()) { const rem2 = Math.max(0, Math.ceil((quizCarryEmbedPreOptionCountdownEndAt - Date.now()) / 1000)); timeVal.textContent = String(rem2); } 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 - serverNow()) / 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 (isSpaceShooterMissionUiMapPlay()) { if (spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) { const limSs = spaceShooterTimeLimitSecPlay(); if (spaceShooterMissionPhase === 'howto' || spaceShooterMissionPhase === 'countdown') { timeVal.textContent = String(limSs > 0 ? limSs : 0); } else { timeVal.textContent = '0'; } } else { const remSs = spaceShooterRemainingSecPlay(); timeVal.textContent = remSs != null ? String(remSs) : '∞'; } } else if (spaceShooterGameEnded) { timeVal.textContent = '0'; } else { const rem = spaceShooterRemainingSecPlay(); timeVal.textContent = rem != null ? String(rem) : '∞'; } } else if (isBalloonBoss()) { if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) { timeVal.textContent = '···'; } else 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) { applyCyberHudProfilePortraitImgWithRetry(portrait, 0); } if (statusEl) { if (isSpaceShooterMissionHudActivePlay()) { /* MG6 life ย้ายไป overlay เฉพาะ #mg6-life-hud (syncSpaceShooterLifeDomHudPlay) — คุมตำแหน่ง/ขนาดเท่า MG3 */ statusEl.textContent = ''; } else if (isQuizQuestionMissionHudActivePlay()) { statusEl.textContent = 'QUIZ LINK · ประลองความรู้'; } else if (isStackTowerMissionHudActivePlay()) { statusEl.textContent = 'DECRYPT UPLINK · TOWER'; } else if (isJumpSurviveMissionHudActivePlay()) { statusEl.textContent = 'JUMPER LINK · SURVIVE'; } else 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 if (isGauntletCrownHeistMapPlay()) { statusEl.textContent = ''; } else { statusEl.textContent = 'OPERATOR ONLINE'; } } if (hintEl) { if (isQuizQuestionMissionHudActivePlay()) { hintEl.textContent = ''; hintEl.setAttribute('aria-hidden', 'true'); } else { hintEl.removeAttribute('aria-hidden'); if (isStackTowerMissionHudActivePlay()) { hintEl.textContent = 'กด SPACE / ENTER หรือคลิกที่จอ = DROP · วางต่อเนื่องแม่น = โบนัส · ถอดรหัสให้ครบ 100% ภายในเวลา'; } else 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' : (isMegaVirusMissionShellMapPlay() ? 'ลูกศรบนวงหมุนเอง — ยิงตามมุมลูกศร ณ ตอนกด Space · Ring spins · Space = fire along arrow' : 'ยานมีแรงเฉื่อย — 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 (playBotsEnabled() && mapData) { const human = countPlayHumans(); const bots = [...others.keys()].filter(isPreviewBotId).length; const botTarget = playBotTargetHeadcount(); const simLabel = detectiveCaseFillBots ? 'CASE' : 'SIM'; previewEl.textContent = `${simLabel} · humans ${human} + bots ${bots} · target ${botTarget}`; previewEl.classList.remove('is-hidden'); } else { previewEl.textContent = ''; previewEl.classList.add('is-hidden'); } } syncQuizQuestionMissionCyberCenterHud(); const ul = document.getElementById('play-cyber-score-list'); if (!ul) return; /* mnptfts2 — ไม่แสดงแผง SCORE (เหลือ TIME + โปรไฟล์) */ if (isJumpSurviveMissionUiMapPlay()) { ul.innerHTML = ''; ul.dataset.cyberSig = ''; const boardSkip = ul.closest('.play-cyber-scoreboard'); if (boardSkip) boardSkip.classList.remove('play-cyber-scoreboard--crown-strip'); ul.classList.remove('play-cyber-score-list--crown-strip'); return; } const stripScoreHud = isGauntletCrownHeistMapPlay() || isQuizCarry(); const mg2MockHud = isMissionMockHudPlay() && (getMissionMockHudSkinPlay() === 'gauntlet_crown' || getMissionMockHudSkinPlay() === 'quiz_carry'); const board = ul.closest('.play-cyber-scoreboard'); if (board) { board.classList.toggle('play-cyber-scoreboard--crown-strip', !!stripScoreHud && !mg2MockHud); board.classList.toggle('play-cyber-scoreboard--mg2-mock', !!mg2MockHud); board.classList.toggle('mg2-score-bar', !!mg2MockHud); } ul.classList.toggle('play-cyber-score-list--crown-strip', !!stripScoreHud && !mg2MockHud); ul.classList.toggle('play-cyber-score-list--mg2-mock', !!mg2MockHud); const panelTitle = root.querySelector('.play-cyber-panel-title'); const crownHead = document.getElementById('play-cyber-crown-score-head'); const crownImg = document.getElementById('play-cyber-crown-score-img'); if (panelTitle && crownHead && crownImg) { if (mg2MockHud) { const scoreSrc = missionMockHudAssetUrl('score.png'); crownHead.classList.remove('is-hidden'); crownHead.setAttribute('aria-hidden', 'false'); if (crownImg.getAttribute('src') !== scoreSrc) crownImg.setAttribute('src', scoreSrc); crownImg.alt = 'SCORE :'; panelTitle.classList.add('is-hidden'); } else if (stripScoreHud) { crownHead.classList.add('is-hidden'); crownHead.setAttribute('aria-hidden', 'true'); crownImg.removeAttribute('src'); delete crownImg.dataset.gauntletScoreSrc; crownImg.alt = ''; crownImg.onerror = null; crownImg.onload = null; panelTitle.textContent = 'SCORE :'; panelTitle.classList.remove('is-hidden'); } else { crownHead.classList.add('is-hidden'); crownHead.setAttribute('aria-hidden', 'true'); crownImg.removeAttribute('src'); delete crownImg.dataset.gauntletScoreSrc; crownImg.alt = ''; crownImg.onerror = null; crownImg.onload = null; panelTitle.textContent = 'SCORE'; panelTitle.classList.remove('is-hidden'); } } const safeYv = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1); const jumpMissionHud = isJumpSurvive() && isJumpSurviveMissionUiMapPlay(); const quizMissionHud = isQuizQuestionMissionHudActivePlay(); const stackMissionHud = isStackTowerMissionHudActivePlay(); const jumpMissionLiveHud = isJumpSurviveMissionHudActivePlay(); const spaceMissionLiveHud = isSpaceShooterMissionHudActivePlay(); const balloonMissionLiveHud = isBalloonBossMissionHudActivePlay(); /** แถบ SCORE แบบ mock mng8a80o (แนวนอน อวาตาร์+ชื่อ | คะแนน) — ภารกิจคำถาม + Stack Tower + Jumper mnptfts2 + Space mnpz6rkp + MegaVirus mnq1eml7 */ const cyberQmMockHud = quizMissionHud || stackMissionHud || jumpMissionLiveHud || spaceMissionLiveHud || balloonMissionLiveHud; /** mission mock HUD — layout แถว: [avatar | ชื่อบน/คะแนนล่าง] (meta column) */ const tfMockHud = isMissionMockHudPlay(); const stackTeamPts = Math.max(0, Number(stackMini && stackMini.score) || 0); const rows = []; rows.push({ id: myId, nickname: me.nickname || nick, characterId: me.characterId || getPlayCharacterId(), playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : (nick || 'local'))), score: (isQuizCarry() || quizMissionHud) ? cyberHudQuizCarryPeerScore(myId) : (stackMissionHud ? (playBotsEnabled() ? (me.stackPreviewHumanPts || 0) : stackTeamPts) : (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, playTint: o.playTint || resolvePlayTintForPeer(id), score: (isQuizCarry() || quizMissionHud) ? cyberHudQuizCarryPeerScore(id) : (stackMissionHud ? (playBotsEnabled() && isPreviewBotId(id) ? (o.stackBotScore || 0) : stackTeamPts) : (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 || quizMissionHud || stackMissionHud) { 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 || quizMissionHud || stackMissionHud) { 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; }); /* Minigame-2 รองรับสูงสุด 6 ผู้เล่น (GAUNTLET_MAX_PLAYERS) — แถบคะแนนต้องโชว์ครบ 6 รวมตัวเรา (เดิม cap 5 ตาม mockup ที่วาดตัวอย่าง 5 คน ทำให้ตัวเราคะแนนต่ำสุดถูกตัดออก) */ const cap = mg2MockHud ? 6 : (stripScoreHud ? (isQuizCarry() ? 8 : 6) : 8); let hudRows = rows.slice(0, Math.min(cap, rows.length)); if (stripScoreHud) { const mi = hudRows.findIndex((row) => row.isMe); if (mi >= 0) { const mine = hudRows.splice(mi, 1)[0]; hudRows.push(mine); } } /* กันกระตุก: rebuild DOM เฉพาะตอนเนื้อหา (id/score/order/สี/ตกรอบ) เปลี่ยน — ไม่ลบ-สร้างทุกเฟรม */ const listSig = [ cyberQmMockHud ? 'qm' : '', mg2MockHud ? 'mg2' : '', stripScoreHud ? 'strip' : '', leaderId == null ? '' : String(leaderId), hudRows.map(function (rw) { const t = rw.playTint || {}; return String(rw.id) + ':' + String(rw.score) + ':' + (rw.eliminated ? 1 : 0) + ':' + String(rw.characterId || '') + ':' + String(t.body || '') + ':' + String(t.head || ''); }).join(','), ].join('|'); if (ul.dataset.cyberSig === listSig) { const existImgs = ul.querySelectorAll('img.play-cyber-score-av'); if (existImgs.length === hudRows.length) { for (let i = 0; i < existImgs.length; i++) { /* ย้ำ retry ถ้ายังไม่ใช่หน้าย้อมสี (data-url) ใน mock hud — กันค้าง raw frame ไม่มีสี (bot/remote) throttle 600ms กันสร้าง retry-chain ซ้ำซ้อนทุกเฟรม */ const im = existImgs[i]; const needTinted = isMissionMockHudPlay() && !(im && typeof im.src === 'string' && im.src.indexOf('data:image/') === 0); if (!cyberHudScoreAvatarImgLooksReady(im) || needTinted) { const nowR = Date.now(); const lastR = Number(im && im.dataset ? im.dataset.hudAvRetryAt : 0) || 0; if (nowR - lastR > 600) { if (im && im.dataset) im.dataset.hudAvRetryAt = String(nowR); applyCyberHudScoreAvatarImgWithRetry(im, hudRows[i], 0); } } } return; } } ul.dataset.cyberSig = listSig; ul.innerHTML = ''; for (let r = 0; r < hudRows.length; r++) { const row = hudRows[r]; const li = document.createElement('li'); li.className = 'play-cyber-score-row' + (row.isMe ? ' is-me' : '') + (row.eliminated ? ' is-out' : '') + (cyberQmMockHud ? ' play-cyber-score-row--qm-mock' : '') + (mg2MockHud ? ' play-cyber-score-row--mg2-mock' : '') + (stripScoreHud && !mg2MockHud ? ' play-cyber-score-row--crown-strip' : ''); if (row.id != null) li.setAttribute('data-peer-id', String(row.id)); const av = document.createElement('img'); av.className = 'play-cyber-score-av' + (cyberQmMockHud ? ' play-cyber-score-av--qm' : '') + (mg2MockHud ? ' play-cyber-score-av--mg2' : '') + (stripScoreHud && !mg2MockHud ? ' play-cyber-score-av--crown-strip' : ''); av.alt = ''; /* ใช้เวอร์ชัน retry (preload tint+layers จน compose หน้าแบบย้อมสีสำเร็จ) กัน bot/remote โชว์ raw frame ไม่ย้อมสี เมื่อ character layers ยังโหลดไม่เสร็จ (พบบน host เพราะบอทไม่ได้ preload จาก plist) */ applyCyberHudScoreAvatarImgWithRetry(av, row, 0); const sc = document.createElement('span'); sc.className = 'play-cyber-score-val' + (cyberQmMockHud ? ' play-cyber-qm-score' : '') + (mg2MockHud ? ' play-cyber-mg2-score' : '') + (stripScoreHud && !mg2MockHud ? ' play-cyber-crown-strip-val' : ''); if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud || quizMissionHud || stackMissionHud) { sc.textContent = String(row.score); } else { sc.textContent = row.eliminated ? '—' : String(r + 1); } if (cyberQmMockHud) { if (row.id === leaderId && leaderId != null) { const crown = document.createElement('span'); crown.className = 'play-cyber-lead-crown'; crown.textContent = '♔'; crown.title = 'Leader'; sc.insertBefore(crown, sc.firstChild); } const qmLeft = document.createElement('div'); qmLeft.className = 'play-cyber-qm-left'; const avWrap = document.createElement('span'); avWrap.className = 'play-cyber-qm-av-wrap'; if (tfMockHud) { /* mng8a80o: หน้าตัวละครต้องอยู่ "บน" กรอบ (กรอบกลางทึบ) + clip ให้อยู่ในขอบ */ const avClip = document.createElement('span'); avClip.className = 'play-cyber-qm-av-clip'; avClip.appendChild(av); avWrap.appendChild(avClip); } else { avWrap.appendChild(av); } const qmName = document.createElement('span'); qmName.className = 'play-cyber-qm-name'; qmName.textContent = String(row.nickname || '').trim().slice(0, 28); qmLeft.appendChild(avWrap); if (tfMockHud) { /* mockup Minigame-1: avatar ซ้าย | (ชื่อบน / คะแนนล่าง) ในคอลัมน์ meta */ const qmMeta = document.createElement('div'); qmMeta.className = 'play-cyber-qm-meta'; qmMeta.appendChild(qmName); qmMeta.appendChild(sc); qmLeft.appendChild(qmMeta); li.appendChild(qmLeft); } else { qmLeft.appendChild(qmName); li.appendChild(qmLeft); li.appendChild(sc); } } else if (mg2MockHud) { const cell = document.createElement('div'); cell.className = 'play-cyber-mg2-player'; const name = document.createElement('div'); name.className = 'play-cyber-mg2-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 || '').trim().slice(0, 12))); const avWrap = document.createElement('div'); avWrap.className = 'play-cyber-mg2-av-wrap'; const avClip = document.createElement('span'); avClip.className = 'play-cyber-mg2-av-clip'; avClip.appendChild(av); avWrap.appendChild(avClip); cell.appendChild(name); cell.appendChild(avWrap); cell.appendChild(sc); li.appendChild(cell); } else if (stripScoreHud) { const cell = document.createElement('div'); cell.className = 'play-cyber-crown-strip-cell'; const avWrap = document.createElement('div'); avWrap.className = 'play-cyber-crown-strip-av-wrap'; avWrap.appendChild(av); const meta = document.createElement('div'); meta.className = 'play-cyber-crown-strip-meta'; const name = document.createElement('span'); name.className = 'play-cyber-crown-strip-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 || '').trim().slice(0, 16))); meta.appendChild(name); meta.appendChild(sc); cell.appendChild(avWrap); cell.appendChild(meta); li.appendChild(cell); } else { 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() || quizMissionHud) ? '' : (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); li.appendChild(av); li.appendChild(mid); li.appendChild(sc); } ul.appendChild(li); } } function draw() { try { if (!mapData) { document.documentElement.classList.remove('play-stack-tower-pixel-canvas'); return; } document.documentElement.classList.toggle('play-stack-tower-pixel-canvas', isStackTowerMissionUiMapPlay()); } catch (e) { /* ignore */ } if (isGauntletCrownHeistMapPlay()) ensureGauntletCrownScorePenaltyImgPlay(); ctx.setTransform(1, 0, 0, 1, 0, 0); const w = mapData.width, h = mapData.height; let zDraw = computePlayCameraZDrawPlay(); 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; } if (isSpaceShooter() || isBalloonBoss()) { const mwPx = w * tileSize, mhPx = h * tileSize; camX = mwPx * 0.5; camY = mhPx * 0.5; } else if (isQuizCarry()) { const qc = getQuizCarryMapCameraWorldCenterPxPlay(); if (qc) { camX = qc.cx; camY = qc.cy; } } else if (isQuizQuestionMissionHudActivePlay()) { const qmc = getQuizQuestionMissionMapCenterWorldPxPlay(); if (qmc) { camX = qmc.cx; camY = qmc.cy; } } else if (isQuizBattle()) { /* บอร์ดควิซแนวตั้ง (20×40) — ล็อกกล้องกลางแนวนอน (ไม่ไถลซ้าย/ขวาตามผู้เล่น) เลื่อนตามแนวตั้งเท่านั้น ตรง mock */ camX = (w * tileSize) / 2; } const gauntletGroupCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zDraw); if (gauntletGroupCam) { camX = gauntletGroupCam.px; camY = gauntletGroupCam.py; } /* Stack Tower (mnn93hpi): ไม่เลื่อนกล้องตาม floorWorldY — ใช้ cam จาก getStackCameraCentersPx() เหมือน Stack ทั่วไป (วางบล็อกแล้วจอไม่ไหล) */ lastPlayZDrawForInput = zDraw; const stackTowerWorldScrollScreenY = getStackTowerWorldLayerScrollScreenOffsetYPlay(zDraw); 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 (playScrollBgDrawActive()) { ctx.fillStyle = '#0a0e22'; ctx.fillRect(0, 0, canvas.width, canvas.height); drawPlayScrollBgFullCanvas(canvas.width, canvas.height); } else if (gauntletCrownRunwayBgDrawActivePlay()) { ctx.fillStyle = '#1a1b26'; ctx.fillRect(0, 0, canvas.width, canvas.height); drawGauntletCrownRunwayBgFullCanvasPlay(canvas.width, canvas.height, { worldMinX, worldMaxX, camX, camY, zDraw: zDraw, mapWpx, mapHpx, }); } else if (stackTowerScrollBgDrawActive()) { ctx.fillStyle = '#0a0e22'; ctx.fillRect(0, 0, canvas.width, canvas.height); drawStackTowerScrollBgFullCanvas(canvas.width, canvas.height, zDraw); } else if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) { /* กล้องเห็นพื้นที่นอก [0,map] ได้ — ห้ามสุ่มต้นทาง drawImage เกินขอบภาพ (จะเกิดซ้ำ/เส้นตัด/ซ้อนกันที่ขอบจอ) */ ctx.fillStyle = (isSpaceShooter() || isBalloonBoss()) ? '#0a0e22' : '#1a1b26'; ctx.fillRect(0, 0, canvas.width, canvas.height); drawStackTowerLoopSkyFillAboveMap(ctx, worldMinY, zDraw, 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 - (isStackTowerMissionUiMapPlay() ? stackTowerWorldScrollScreenY : 0); 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 - stackTowerWorldScrollScreenY; 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 - stackTowerWorldScrollScreenY; 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) { /* ไฮไลต์เส้นทางบาง ๆ (โชว์เฉพาะตอนเปิดกริด) ให้เห็นถนนจาก design ทะลุ */ ctx.fillStyle = 'rgba(150, 195, 255, 0.12)'; ctx.fillRect(sx + 1, sy + 1, size - 2, size - 2); } if (isQuizBattle() && mapData.quizBattleDomeArea && mapData.quizBattleDomeArea[y] && mapData.quizBattleDomeArea[y][x] === 1) { const cid = mapData.quizBattleDomeComp && mapData.quizBattleDomeComp[y] && mapData.quizBattleDomeComp[y][x]; const comp = mapData.quizBattleDomeComp; /* วาดหีบครั้งเดียวต่อโดม (ช่องบน-ซ้ายสุดของกลุ่ม comp) — กันวาดซ้อน */ const upSame = cid > 0 && comp && comp[y - 1] && comp[y - 1][x] === cid; const leftSame = cid > 0 && comp && comp[y] && comp[y][x - 1] === cid; if (!upSame && !leftSame) { const passed = cid > 0 && quizBattlePassedComps.has(cid); const wrong = passed && !quizBattleAnsweredComps.has(cid); drawQuizBattleChest(ctx, sx + size / 2, sy + size / 2, size, passed, cid || 0, wrong); } } 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); } } /* ไม่วาด P1–P6 บนกริดระหว่างเล่น — ตำแหน่งไทล์คงที่ แต่ตัวละครอยู่ world px + บอทขยับ จึงไม่ตรง design · Slot labels belong in Map Editor only */ 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() && 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 vIdx = jumpSurvivePlatformVariantIndexAtPlay(mapData, px, py); if (px > 0 && pa[py][px - 1] === 1 && jumpSurvivePlatformVariantIndexAtPlay(mapData, px - 1, py) === vIdx) { continue; } let runLen = 1; while (px + runLen < w && pa[py][px + runLen] === 1 && jumpSurvivePlatformVariantIndexAtPlay(mapData, px + runLen, py) === vIdx) runLen += 1; const cfg = playJumpSurvivePlatformTiles[vIdx - 1] || { url: '', w: 0, h: 0 }; const jumpPlatU = normalizeGauntletAssetUrlForPlay(cfg.url || ''); const jumpPlatRec = jumpPlatU ? ensureGauntletAssetImage(jumpPlatU) : null; const jumpPlatImg = jumpPlatRec && jumpPlatRec.ready && jumpPlatRec.img && jumpPlatRec.img.naturalWidth > 0 ? jumpPlatRec.img : null; const tileWorldLeft = px * tsz; const tileWorldRight = (px + runLen) * 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; const segScreenW = runLen * psz; let dw; let dh = cfg.h > 0 ? cfg.h * zDraw : psz - 4; if (runLen > 1) { dw = segScreenW; } else { dw = cfg.w > 0 ? cfg.w * zDraw : psz - 4; const maxDim = psz * 4; if (dw > maxDim) dw = maxDim; } const maxDh = psz * 4; if (dh > maxDh) dh = maxDh; const marginBot = 2; const dx = runLen > 1 ? psx : psx + (psz - dw) / 2; const dy = psy + psz - dh - marginBot; if (jumpPlatImg) { ctx.save(); try { ctx.imageSmoothingEnabled = true; } catch (e) { /* ignore */ } ctx.drawImage(jumpPlatImg, dx, dy, dw, dh); ctx.restore(); } else { const fillW = runLen > 1 ? segScreenW - 6 : psz - 6; ctx.fillStyle = 'rgba(0, 40, 52, 0.72)'; ctx.fillRect(psx + 3, psy + 3, fillW, psz - 6); 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, fillW - 2, 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'; const cx = psx + (runLen > 1 ? segScreenW * 0.5 : psz * 0.5); ctx.fillText('DATA', cx, 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', cx, psy + psz * 0.62); ctx.restore(); } } } } } } if (isJumpSurvive() && showGrid && mapData.jumpSurviveHazardArea) { const ha = mapData.jumpSurviveHazardArea; const tsz = tileSize; for (let py = startTileY; py <= endTileY; py++) { for (let px = startTileX; px <= endTileX; px++) { if (!ha[py] || ha[py][px] !== 1) continue; const [psx, psy] = worldToScreen(px * tsz, py * tsz); const psz = tsz * zDraw; ctx.fillStyle = 'rgba(200, 28, 55, 0.38)'; ctx.fillRect(psx + 1, psy + 1, psz - 2, psz - 2); ctx.strokeStyle = 'rgba(255, 90, 110, 0.82)'; ctx.lineWidth = 2; ctx.strokeRect(psx + 1, psy + 1, psz - 2, psz - 2); ctx.lineWidth = 1; } } } if (showGrid) { 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 gauntletObsAlpha = isGauntlet() ? gauntletCrownRunwayObstacleDrawAlphaPlay() : 1; const gauntletObsDraw = (isGauntlet() && gauntletObsAlpha > 0.001) ? getGauntletObsDrawPositionsAt(performance.now()) : []; if (isGauntlet() && gauntletObsDraw.length) { ctx.save(); ctx.globalAlpha = gauntletObsAlpha; 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; /* ใช้โซนที่ editor กำหนด (gauntletLaserRowStart..End เช่น 4..18) — หัว/ท้าย emitter อยู่ตามนั้น */ 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); } } ctx.restore(); } /** ป้ายติดตัว — กึ่งกลางลำตัว (ทับอก/เอว) แบบเดียวกับป้ายบนพื้น ไม่มีเส้นเชื่อม */ 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 padX = 12; const padY = 9; const maxInner = Math.min(maxPlaqueW - padX * 2, Math.max(72, tileSize * zDraw * 4.2)); const MAX_LINES = 3; const MIN_FONT = 8; let fontPx = Math.max(12, Math.min(22, tileSize * zDraw * 0.22 * heldPs)); let lines; if (!raw) { lines = []; ctx.font = `600 ${fontPx}px system-ui, "Segoe UI", "Kanit", sans-serif`; } else { /* ย่อฟอนต์จนข้อความพอดีกรอบ (คำไทยยาวไม่มีช่องว่าง → char-wrap + ย่อ ไม่ตัด/ล้นกรอบ) */ for (;;) { ctx.font = `600 ${fontPx}px system-ui, "Segoe UI", "Kanit", sans-serif`; lines = quizCarryWrapLinesFit(ctx, raw, maxInner); if (lines.length <= MAX_LINES || fontPx <= MIN_FONT) break; fontPx -= 1; } if (lines.length > MAX_LINES) { lines = lines.slice(0, MAX_LINES); let s = lines[MAX_LINES - 1]; while (s.length > 1 && ctx.measureText(s + '…').width > maxInner) s = s.slice(0, -1); lines[MAX_LINES - 1] = s + '…'; } if (!lines.length) lines = [raw]; } 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, crownPenaltyFxUntil) { 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 boxW = tileSize * zDraw * Math.max(cw, 1) * 1.15; const boxH = tileSize * zDraw * Math.max(ch, 1) * 1.35; const r = Math.max(14, (tileSize * zDraw * cellSpan) / 2 - 2); const dir = direction || 'down'; const charImg = resolvePlayDrawCharSource(characterId, dir, timeMs, isWalking, playTint, !!isMe); /* รองรับทั้ง และ canvas จากย้อมสี — กล่อง W×H ตาม footprint; อนุญาต upscale เลเยอร์เล็ก (ห้ามจำกัด scale≤1 ไม่งั้นตัวหดเป็นจุด) */ 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 > 0 && charImg.naturalHeight > 0) { iw = charImg.naturalWidth; ih = charImg.naturalHeight; } let halfSpriteW = r; let spriteTopY = sy - 2 * r; if (iw > 0 && ih > 0) { const scale = Math.min(boxW / iw, boxH / ih); let drawW = iw * scale; let drawH = ih * scale; if (isQuiz()) { const minPx = Math.max(28, r * 1.85); if (drawW < minPx || drawH < minPx) { const up = Math.max(minPx / Math.max(drawW, 0.01), minPx / Math.max(drawH, 0.01)); drawW *= up; drawH *= up; } } 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 || ''; /* gauntlet (Last Light) live: ชื่อ/คะแนน/-10 ใช้ DOM overlay (syncGauntletNameDom) — ไม่วาดบน canvas */ /* crown map (Minigame-2): ไม่วาดชื่อ/คะแนน/-10 ใต้ตัวละครบน canvas "ทุกเฟส" (ตรง mock — โชว์เฉพาะแถบคะแนนบน) เดิมเช็คเฉพาะ phase==='live' → ตอน summary/howto canvas วาดชื่อกลับมา ทับแถบคะแนน (ดูเหมือน avatar ซ้อน) */ const gntDomLabel = isGauntletCrownHeistMapPlay(); const withScore = isGauntlet() && typeof gauntletScoreLabel === 'number' && gauntletScoreLabel >= 0 ? (nameBase + ' · ' + gauntletScoreLabel) : nameBase; if (!gntDomLabel) ctx.fillText(withScore, sx, sy + 10); if (!gntDomLabel && isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && typeof crownPenaltyFxUntil === 'number' && crownPenaltyFxUntil > timeMs) { const penImg = ensureGauntletCrownScorePenaltyImgPlay(); if (penImg && penImg.complete && penImg.naturalWidth > 0) { const ref = Math.max(18, r * 2); const scale = Math.min(1.15, ref / Math.max(penImg.naturalWidth, penImg.naturalHeight, 1)); const dw = penImg.naturalWidth * scale; const dh = penImg.naturalHeight * scale; ctx.drawImage(penImg, 0, 0, penImg.naturalWidth, penImg.naturalHeight, sx - dw * 0.5, spriteTopY - dh - 5, dw, dh); } } 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 }; } function shouldGauntletCrownHeistSkipAvatarDrawPlay(ent, isMeFlag, offAx, offAy) { if (!isGauntletCrownHeistMapPlay() || gauntletCrownPregamePhase !== 'live') return false; if (!ent || ent.gauntletEliminated) return true; const { cw, ch } = getCharacterFootprintWH(mapData); const ax = safeX(ent.x) + (offAx || 0); const ay = safeY(ent.y) + (offAy || 0); const airTicks = isMeFlag ? (meGauntletJumpTicks || 0) : (Number(ent.gauntletJumpTicks) || 0); const airVis = isMeFlag ? (meGauntletJumpVis || 0) : (Number(ent.gauntletJumpVis) || 0); const air = airVis > 0.08 ? airVis : airTicks; let liftWorldY = 0; if (isGauntlet() && air > 0) { liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52; } const cxWorld = (ax + cw * 0.5) * tileSize; 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 pad = Math.max(28, r * 0.4); if (sx + r + pad < 0 || sx - r - pad > canvas.width) return true; if (sy + 52 < 0 || sy - r * 4 - pad > canvas.height) return true; return false; } 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 = resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local'))); me.playTint = mt; } const meTag = isFrogger() ? (' (กบ) ' + froggerScore) : (isGauntlet() && meGauntletJumpTicks > 0 ? ' (กระโดด)' : (isJumpSurvive() && jumpSurviveEliminated ? ' (ตกรอบ)' : ' (คุณ)')); const crownRunStripLive = isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay(); const meWalking = isFrogger() ? false : (isGauntlet() ? ((crownRunStripLive && !me.gauntletEliminated) || 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() ? ((crownRunStripLive && !o.gauntletEliminated) || (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 || resolvePlayTintForPeer(id); const faceDirOther = isGauntletFaceRightMapMno9kb07() ? 'right' : o.direction; const botOut = isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated; const peerName = botOut ? (o.nickname + ' (ตกรอบ)') : o.nickname; if (shouldGauntletCrownHeistSkipAvatarDrawPlay(o, false, off.ax, off.ay)) return; if (isJumpSurvive() && o.jumpSurviveEliminated) return; /* MG5 ตายแล้ว → ตัวละครหายไป */ const axO = safeX(o.x) + off.ax; const ayO = safeY(o.y) + off.ay; /* ผีต้องโชว์เสมอ แม้ทับโซนคำตอบ (เดินไปไหนก็ได้) */ const ghostActiveO = isQuizWrongGhostActiveForEnt(id); if (!ghostActiveO && playQuizAvatarHideWhileOverlappingAnswerZone(o, id, axO, ayO)) return; if (botOut) ctx.save(); if (botOut) ctx.globalAlpha = 0.4; /* ตอบผิดในแมป mng8a80o → แทน avatar ด้วยผีเลย */ if (ghostActiveO) { const ga = _quizGhostScreenAnchorPlay(axO, ayO, worldToScreen, zDraw); drawQuizWrongGhostOverlayPlay(ga.sx, ga.sy, ga.sizeH, id, peerName); } else { drawAvatar(axO, ayO, false, peerName, o.characterId, faceDirOther, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0, (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (o.gauntletScore || 0) : undefined, quizCarrySignForEntity(o), (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (o.gauntletCrownPenaltyFxUntil || 0) : 0); } if (botOut) ctx.restore(); } else { if (shouldGauntletCrownHeistSkipAvatarDrawPlay(me, true, 0, 0)) return; if (isJumpSurvive() && jumpSurviveEliminated) return; /* MG5 ตายแล้ว → ตัวละครหายไป */ const axMe = safeX(me.x); const ayMe = safeY(me.y); const ghostActiveMe = isQuizWrongGhostActiveForEnt(myId); if (!ghostActiveMe && playQuizAvatarHideWhileOverlappingAnswerZone(me, myId, axMe, ayMe)) return; const faceDirMe = isGauntletFaceRightMapMno9kb07() ? 'right' : me.direction; if (ghostActiveMe) { const gam = _quizGhostScreenAnchorPlay(axMe, ayMe, worldToScreen, zDraw); drawQuizWrongGhostOverlayPlay(gam.sx, gam.sy, gam.sizeH, myId, me.nickname + meTag); } else { drawAvatar(axMe, ayMe, true, me.nickname + meTag, me.characterId, faceDirMe, meWalking, mt, meGauntletJumpVis, (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (me.gauntletScore || 0) : undefined, quizCarrySignForEntity(me), (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (me.gauntletCrownPenaltyFxUntil || 0) : 0); } } }); /* Minigame-2: อัปเดต DOM overlay (ชื่อ/คะแนน/-10) ให้ตรงตำแหน่งตัวละครบน canvas */ try { syncGauntletNameDom(worldToScreen, zDraw, tileSize); } catch (eGntDom) { /* ignore */ } } /* ส่งสัญญาณให้ server ปล่อยไอคอนคำถามพิเศษเมื่อเกม live จริง (ครั้งเดียว/รอบ) */ maybeArmSpecialQuizPlay(); /* ไอคอนคำถามพิเศษในฉาก (วาดทับ map/ผู้เล่น) + ตรวจการเดินชน */ if (specialQuizIcon) { drawSpecialQuizIconWorld(worldToScreen, zDraw); checkSpecialQuizCollision(); } 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()) { /* Minigame-2 (Last Light): ใช้ cyber HUD + DOM overlay แล้ว — ไม่วาด debug text (คะแนน · ชน -10 · ขอบซ้ายตาย) บน canvas (เดิมโผล่มุมซ้ายบนทับ SCORE label เวลา useCyberPlayHud() = false ใน preview embed) */ } 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 (!(isGauntletCrownHeistMapPlay() && gauntletCrownRunwayBgDrawActivePlay())) { if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) { const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - serverNow()) / 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 (playBotsEnabled()) { if (!playHideTopChrome()) { 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); } /* Tower mnn93hpi: แผงขวา (ชีวิตทีม + อวตาร์) วาดบน canvas — ต้องวาดแม้ซ่อนแถบห้องบน */ if (!playHideTopChrome() || (isStackTowerMissionUiMapPlay() && useCyberPlayHud())) { 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 (playBotsEnabled() && 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'; const botTarget = playBotTargetHeadcount(); let line = detectiveCaseFillBots ? ('คดีสืบสวน: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + botTarget + ' คน)') : ('ทดสอบ: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + botTarget + ' คน)'); 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 (quizCarryEmbedMissionFlowActive() && (quizCarryEmbedCountdownEndAt > Date.now() || quizCarryEmbedPreOptionCountdownEndAt > Date.now())) { syncQuizCarryEmbedCountdownLayout(); } drawQuizTfScorePopupsLayer(ctx, worldToScreen, zDraw, timeMs); syncPlayCyberHud(); syncQuizQuestionMissionJoystickPlay(); syncQuizCarryGrabButton(); syncGauntletCrownJumpButton(); syncStackTowerDropButtonPlay(); syncStackTowerMissionDomHudPlay(); syncSpaceShooterLifeDomHudPlay(); syncQuizBattleHudPlay(); } document.addEventListener('keydown', (e) => { /* Card 3 Ban — ผู้ถูกแบนรอบนี้คุมตัวละครไม่ได้: บล็อกคีย์เกมทั้งหมด (ยังพิมพ์แชตได้) */ if (playBannedSpectator && !isChatFocused()) { if (isMovementKey(e.code) || ['Space', 'Enter', 'NumpadEnter', 'KeyF'].includes(e.code)) e.preventDefault(); return; } /* Test Mode shortcut: Ctrl+Q = ตอบถูกใน special quiz (server เติมคำตอบให้) */ if (e.ctrlKey && (e.code === 'KeyQ' || e.key === 'q' || e.key === 'Q')) { if (specialQuizActiveQuestion && !specialQuizAnswered && __isTestModeOnPlay()) { e.preventDefault(); specialQuizAnswered = true; const status = document.getElementById('sq-ov-status'); if (status) status.textContent = '[Test] ส่งคำตอบที่ถูกอัตโนมัติ — รอผู้เล่นคนอื่น...'; if (specialQuizSelfId()) markSpecialQuizMemberAnswered(specialQuizSelfId()); const choicesEl = document.getElementById('sq-ov-choices'); if (choicesEl) Array.prototype.forEach.call(choicesEl.children, function (b) { b.disabled = true; }); try { socket.emit('special-quiz-answer', { debugCorrect: true }, function () {}); } catch (eQ) { /* ignore */ } return; } } if (isMovementKey(e.code) && isChatFocused()) return; if (previewMode && editorEmbedReturn && mapData && !isChatFocused() && !isQuizQuestionMissionHudActivePlay() && !isStackTowerEmbedZoomLockedPlay()) { if (e.code === 'BracketLeft' || e.code === 'Minus' || e.code === 'NumpadSubtract') { e.preventDefault(); playEmbedUserZoomMul = Math.max(PLAY_EMBED_USER_ZOOM_MIN, playEmbedUserZoomMul / PLAY_EMBED_ZOOM_STEP_KEY); draw(); return; } if ( e.code === 'BracketRight' || e.code === 'NumpadAdd' || (e.code === 'Equal' && e.shiftKey) ) { e.preventDefault(); playEmbedUserZoomMul = Math.min(PLAY_EMBED_USER_ZOOM_MAX, playEmbedUserZoomMul * PLAY_EMBED_ZOOM_STEP_KEY); draw(); return; } } if (isSpaceShooter() && mapData && !isChatFocused() && isSpaceShooterMissionPregameBlockingPlay()) { if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) { e.preventDefault(); } return; } if (isQuiz() && mapData && !isChatFocused() && isQuizQuestionMissionPregameBlockingPlay()) { if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) { e.preventDefault(); } return; } if (isStack() && mapData && !isChatFocused() && isStackTowerMissionPregameBlockingPlay()) { if (['Space', 'Enter', 'NumpadEnter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) { e.preventDefault(); } return; } if (isBalloonBoss() && isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay() && mapData && !isChatFocused()) { if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) { e.preventDefault(); } 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.code === 'Enter' || e.code === 'NumpadEnter') { 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(); tryRequestGauntletJumpPlay(); 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; /* payload move มาตรฐาน — quiz_carry แนบ quizCarryHeld เพื่อให้คนอื่นเห็นว่าเราถืออะไร */ function buildPlayMovePayload() { const mv = { x: me.x, y: me.y, direction: me.direction }; if (isQuizCarry()) mv.quizCarryHeld = (me.quizCarryHeld == null ? null : me.quizCarryHeld); return mv; } function tick() { if (!mapData) { requestAnimationFrame(tick); return; } /* คำถามพิเศษเปิดอยู่ — หยุดเกมทุกอย่าง (input/sim/timer) เห็นแต่ภาพค้างไว้ ดันเวลาทุกตัวไปข้างหน้าเท่า delta ทุกเฟรม → countdown ที่แสดงหยุดนิ่งจริง */ if (specialQuizFreeze) { tickSpecialQuizFreezeShift(); if (me) me.isWalking = false; draw(); requestAnimationFrame(tick); return; } if (isStack()) { if (isStackTowerEmbedZoomLockedPlay()) { playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL; } const nowStBg = performance.now(); const dtStBg = Math.min(0.06, (nowStBg - lastStackTowerScrollBgTickMs) / 1000); lastStackTowerScrollBgTickMs = nowStBg; tickStackTowerScrollBg(dtStBg); if (isStackTowerMissionPregameBlockingPlay()) { updateStackTowerSwingYForStripGapPlay(); me.isWalking = false; draw(); requestAnimationFrame(tick); return; } stackTickFrame(); updateStackTowerSwingYForStripGapPlay(); me.isWalking = false; draw(); requestAnimationFrame(tick); return; } if (isJumpSurvive()) { if (isJumpSurviveMissionPregameBlockingPlay()) { me.isWalking = false; draw(); requestAnimationFrame(tick); return; } jumpSurviveTickFrame(); draw(); requestAnimationFrame(tick); return; } if (isQuiz() && isQuizQuestionMissionUiMapPlay() && isQuizQuestionMissionPregameBlockingPlay()) { me.isWalking = false; draw(); requestAnimationFrame(tick); return; } if (isSpaceShooter()) { if (isSpaceShooterMissionPregameBlockingPlay()) { me.isWalking = false; draw(); requestAnimationFrame(tick); return; } 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()) { { const nowBg = performance.now(); const dtBg = Math.min(0.06, lastGauntletCrownRunwayBgTickMs ? (nowBg - lastGauntletCrownRunwayBgTickMs) / 1000 : 0.016); lastGauntletCrownRunwayBgTickMs = nowBg; tickGauntletCrownRunwayBgPlay(dtBg); } if (isGauntletCrownPregameBlockingPlay()) { me.isWalking = false; draw(); requestAnimationFrame(tick); return; } me.isWalking = (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && !me.gauntletEliminated && gauntletCrownRunwayAvatarRunAllowedPlay()) || 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); }); clampPlayEntityFootprintToMap(me, mapData); others.forEach((o) => { if (o) clampPlayEntityFootprintToMap(o, mapData); }); let zGa = zoom; if (previewMode && editorEmbedReturn && mapData) zGa *= playEmbedUserZoomMul; const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGa); if (gCam) maybeEliminateGauntletPreviewBotsTailOffCameraPlay(gCam.px, gCam.py, zGa); draw(); requestAnimationFrame(tick); return; } /* บอท preview ขยับที่ o.x/o.y โดยตรง — ห้าม LERP กับ tx/ty เพราะ stepPreviewBots เคยตั้ง tx=ก่อนเดิน ทำให้โดนดึงถอย ~20%/เฟรม กระตุกและแทบไม่ไป */ others.forEach((o, id) => { /* host เดินบอทเองตรงๆ (o.x/o.y) → ข้าม lerp; แต่ client อื่นต้อง lerp ตามตำแหน่งที่ host ส่งมา (o.tx/o.ty) */ if (playBotsEnabled() && isPreviewBotId(id) && isMePlayHost()) return; /* MG4 (quiz_carry) + MG1 (quiz mng8a80o) บอทเดินต่อเนื่องเร็ว → non-host lerp 0.2 จะตามหลัง host ~1-2 ช่อง ("เดินไม่ตรงกัน") เร่ง lerp เฉพาะบอท quiz/quiz_carry ให้ตามตำแหน่ง host ทันขึ้น (ไม่แตะเกมอื่น/ผู้เล่นจริง) */ const lerpF = (isPreviewBotId(id) && (isQuizCarry() || isQuiz())) ? 0.5 : LERP; if (o.tx != null) o.x += (o.tx - o.x) * lerpF; if (o.ty != null) o.y += (o.ty - o.y) * lerpF; }); tickQuizCarryEmbedCountdown(); tickQuizCarryRoundTimers(); updateQuizCarryCarryPhaseHud(); stepPreviewBots(); if (isChatFocused()) { me.isWalking = false; enforceQuizBattleLaneOnMePlay(); enforceQuizBattleLaneOnPeersPlay(); 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; clampPlayEntityFootprintToMap(me, mapData); enforceQuizBattleLaneOnMePlay(); const t = Date.now(); if (t - lastSend > 80) { lastSend = t; socket.emit('move', buildPlayMovePayload()); } maybeAutoOpenQuizBattle(); 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) { let kx = 0; let ky = 0; if (keys['ArrowLeft'] || keys['KeyA']) kx -= 1; if (keys['ArrowRight'] || keys['KeyD']) kx += 1; if (keys['ArrowUp'] || keys['KeyW']) ky -= 1; if (keys['ArrowDown'] || keys['KeyS']) ky += 1; let jx = 0; let jy = 0; if (isQuizQuestionMissionHudActivePlay()) { jx = quizQuestionMissionJoyVecX; jy = quizQuestionMissionJoyVecY; } accX = kx + jx; accY = ky + jy; if (accX !== 0 || accY !== 0) { if (Math.abs(accY) > Math.abs(accX)) me.direction = accY > 0 ? 'down' : 'up'; else if (accX !== 0) me.direction = accX > 0 ? 'right' : 'left'; } } 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(moveSpeedTilesThisFrameForWalk(), len); const tox = me.x + (accX / len) * step; const toy = me.y + (accY / len) * step; const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData); if (pathStrict) { const moved = quizBattleTryStepMovePlay(me.x, me.y, tox, toy, (nx, ny, ox, oy) => canWalkLikeLobby(nx, ny, ox, oy)); me.x = moved.x; me.y = moved.y; } else if (canWalkLikeLobby(tox, toy, me.x, me.y)) { me.x = tox; me.y = toy; } else { if (canWalkLikeLobby(tox, me.y, me.x, me.y)) { me.x = tox; } else if (canWalkLikeLobby(me.x, toy, me.x, me.y)) { me.y = toy; } } } clampPlayEntityFootprintToMap(me, mapData); enforceQuizBattleLaneOnMePlay(); enforceQuizBattleLaneOnPeersPlay(); 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', buildPlayMovePayload()); } maybeAutoOpenQuizBattle(); 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(playDesignMainMenuHref()); } }); (function wireQuizBattleHudButtons() { /* EXIT → ออกห้อง กลับหน้าเลือกห้อง Quiz Battle */ const exitBtn = document.getElementById('qb-hud-exit'); if (exitBtn) { exitBtn.addEventListener('click', () => { try { saveQuizBattleProgress(); } catch (e) { /* ignore */ } try { socket.emit('leave-space'); } catch (e) { /* ignore */ } let href = '../Quiz-Battle/'; try { if (typeof appPath === 'function') href = appPath('/Quiz-Battle/'); } catch (e2) { /* ignore */ } window.location.href = href; }); } /* MY RESULT → popup ผลตอบของฉัน */ const myResultBtn = document.getElementById('qb-hud-myresult'); if (myResultBtn) myResultBtn.addEventListener('click', function () { openQuizBattleMyResult(); }); /* RANKING → popup อันดับ (global leaderboard) */ const rankingBtn = document.getElementById('qb-hud-ranking'); if (rankingBtn) rankingBtn.addEventListener('click', function () { openQuizBattleRanking(); }); /* ZOOM เข้า/ออก ในฉาก */ const applyQbZoom = function (mul) { quizBattleUserZoomMul = Math.max(PLAY_EMBED_USER_ZOOM_MIN, Math.min(PLAY_EMBED_USER_ZOOM_MAX, quizBattleUserZoomMul * mul)); try { draw(); } catch (e) { /* ignore */ } }; const zoomInBtn = document.getElementById('qb-hud-zoom-in'); if (zoomInBtn) zoomInBtn.addEventListener('click', function () { applyQbZoom(1.2); }); const zoomOutBtn = document.getElementById('qb-hud-zoom-out'); if (zoomOutBtn) zoomOutBtn.addEventListener('click', function () { applyQbZoom(1 / 1.2); }); /* ปิด popup: ปุ่ม X / backdrop (data-qb-close) */ ['qb-myresult-popup', 'qb-ranking-popup', 'qb-completed-popup'].forEach(function (pid) { const pop = document.getElementById(pid); if (!pop) return; pop.addEventListener('click', function (e) { const t = e.target; if (t && t.getAttribute && t.getAttribute('data-qb-close')) { closeQuizBattlePopup(t.getAttribute('data-qb-close')); } }); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { closeQuizBattlePopup('myresult'); closeQuizBattlePopup('ranking'); closeQuizBattlePopup('completed'); } }); })(); (function wireQuizBattleMcqModal() { const ov = document.getElementById('quiz-battle-mcq-overlay'); if (!ov) return; ov.addEventListener('click', (e) => { const t = e.target; if (!t) return; const choice = t.closest ? t.closest('.quiz-battle-choice') : null; if (choice) { const idx = parseInt(choice.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 wireGauntletCrownJumpButton() { const btn = document.getElementById('gauntlet-crown-jump-btn'); if (!btn) return; btn.addEventListener('click', (ev) => { ev.preventDefault(); if (isChatFocused()) return; /* MG5 jumper: click = queue jump (mirror keyboard Space/W/↑) */ if (mapData && isJumpSurviveMissionHudActivePlay()) { jumpSurviveJumpQueued = true; return; } /* MG7 mega_virus: click ปุ่ม SHOOT = ยิง 1 นัด (mirror Space) */ if (mapData && isMegaVirusMissionShellMapPlay()) { balloonBossFireBtnQueued = true; return; } if (!mapData || !isGauntlet()) return; tryRequestGauntletJumpPlay(); }); })(); (function wireStackTowerDropButton() { const btn = document.getElementById('stack-tower-drop-btn'); if (!btn) return; btn.addEventListener('click', (ev) => { ev.preventDefault(); if (!mapData || !isStack() || isChatFocused()) return; if (!btn.classList.contains('stack-tower-drop-btn--active')) return; tryHumanStackDrop(); }); })(); (function wireQuizQuestionMissionJoystick() { const base = document.getElementById('quiz-q-mission-joystick-base'); const bg = document.getElementById('quiz-q-mission-joystick-bg'); const knobImg = document.getElementById('quiz-q-mission-joystick-knob-img'); if (!base) return; if (bg) { bg.src = questionMissionAssetUrl('btn-joystick-1.png'); bg.dataset.joySkin = 'question'; bg.onerror = function () { this.onerror = null; this.removeAttribute('src'); }; } if (knobImg) { knobImg.src = questionMissionAssetUrl('btn-joystick-2.png'); knobImg.dataset.joySkin = 'question'; knobImg.onerror = function () { this.onerror = null; this.removeAttribute('src'); }; } function endJoy(e) { if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return; try { base.releasePointerCapture(e.pointerId); } catch (_err) { /* ignore */ } quizQuestionMissionJoyPointerId = null; quizQuestionMissionJoystickResetVisual(); } base.addEventListener('pointerdown', (e) => { if (!(isQuizQuestionMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay()) || isChatFocused()) return; if (e.button != null && e.button !== 0) return; e.preventDefault(); try { base.setPointerCapture(e.pointerId); } catch (_err) { /* ignore */ } quizQuestionMissionJoyPointerId = e.pointerId; quizQuestionMissionJoystickUpdateFromClientXY(e.clientX, e.clientY); }); base.addEventListener('pointermove', (e) => { if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return; e.preventDefault(); quizQuestionMissionJoystickUpdateFromClientXY(e.clientX, e.clientY); }); base.addEventListener('pointerup', endJoy); base.addEventListener('pointercancel', endJoy); base.addEventListener('lostpointercapture', (e) => { if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return; quizQuestionMissionJoyPointerId = null; quizQuestionMissionJoystickResetVisual(); }); })(); (function wireGauntletCrownHowtoPrimary() { function onHowtoPrimaryClick() { if (isJumpSurviveMissionUiMapPlay()) { if (jumpSurviveMissionPhase !== 'howto') return; const humans = quizCarryPregameHumanIds(); const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); /* เริ่มเองได้เฉพาะ offline/พรีวิว — ห้องจริงต้องผ่าน server (กัน host เริ่มคนเดียวก่อน client พร้อม) */ if (totPlayers === 1 && (previewMode || !socket || !socket.connected)) { if (!(humans.length === 1 || isMePlayHost())) return; beginJumpSurviveMissionCountdownThenRun(); return; } if (!isMePlayHost()) { if (myId == null) return; const sid = String(myId); const next = !gauntletCrownLobbyReadyMap[sid]; gauntletCrownLobbyReadyMap[sid] = next; updateJumpSurviveMissionHowtoHud(); if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: next }); return; } if (myId == null) return; const humansReady = gauntletCrownAllReadyPlay(); if (humansReady) { socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (r && r.ok === false) showCrownStartBlockedNoticePlay(r); }); return; } const sidH = String(myId); const nextH = !gauntletCrownLobbyReadyMap[sidH]; gauntletCrownLobbyReadyMap[sidH] = nextH; updateJumpSurviveMissionHowtoHud(); if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextH }); return; } if (isSpaceShooterMissionUiMapPlay()) { if (spaceShooterMissionPhase !== 'howto') return; const humansSs = quizCarryPregameHumanIds(); const totPlayersSs = Math.max(1, quizCarryPregameTotalPlayers()); /* เริ่มเองได้เฉพาะ offline/พรีวิว — ห้องจริงต้องผ่าน server (กัน host เริ่มคนเดียวก่อน client พร้อม) */ if (totPlayersSs === 1 && (previewMode || !socket || !socket.connected)) { if (!(humansSs.length === 1 || isMePlayHost())) return; beginSpaceShooterMissionCountdownThenRun(); return; } if (!isMePlayHost()) { if (myId == null) return; const sidSs = String(myId); const nextSs = !gauntletCrownLobbyReadyMap[sidSs]; gauntletCrownLobbyReadyMap[sidSs] = nextSs; updateSpaceShooterMissionHowtoHud(); if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextSs }); return; } if (myId == null) return; const humansReadySs = gauntletCrownAllReadyPlay(); if (humansReadySs) { socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (r && r.ok === false) showCrownStartBlockedNoticePlay(r); }); return; } const sidHostSs = String(myId); const nextHostSs = !gauntletCrownLobbyReadyMap[sidHostSs]; gauntletCrownLobbyReadyMap[sidHostSs] = nextHostSs; updateSpaceShooterMissionHowtoHud(); if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextHostSs }); return; } if (isQuizQuestionMissionUiMapPlay()) { if (quizQuestionMissionPhase !== 'howto') return; const humans = quizCarryPregameHumanIds(); const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); /* เริ่มเองได้เฉพาะ offline/พรีวิว — ห้องจริงต้องผ่าน server (กัน host เริ่มคนเดียวก่อน client พร้อม) */ if (totPlayers === 1 && (previewMode || !socket || !socket.connected)) { beginQuizQuestionMissionCountdownThenRun(); return; } if (!isMePlayHost()) { if (myId == null) return; const sid = String(myId); const next = !gauntletCrownLobbyReadyMap[sid]; gauntletCrownLobbyReadyMap[sid] = next; updateQuizQuestionMissionHowtoHud(); if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: next }); return; } if (myId == null) return; const humansReady = gauntletCrownAllReadyPlay(); if (humansReady) { socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (r && r.ok === false) showCrownStartBlockedNoticePlay(r); }); return; } const sidH = String(myId); const nextH = !gauntletCrownLobbyReadyMap[sidH]; gauntletCrownLobbyReadyMap[sidH] = nextH; updateQuizQuestionMissionHowtoHud(); if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextH }); return; } if (isStackTowerMissionUiMapPlay()) { if (stackTowerMissionPhase !== 'howto') return; const humansSt = quizCarryPregameHumanIds(); const totPlayersSt = Math.max(1, quizCarryPregameTotalPlayers()); /* เริ่มเองได้เฉพาะ offline/พรีวิว — ห้องจริงต้องผ่าน server (กัน host เริ่มคนเดียวก่อน client พร้อม) */ if (totPlayersSt === 1 && (previewMode || !socket || !socket.connected)) { if (!(humansSt.length === 1 || isMePlayHost())) return; beginStackTowerMissionCountdownThenRun(); return; } if (!isMePlayHost()) { if (myId == null) return; const sidSt = String(myId); const nextSt = !gauntletCrownLobbyReadyMap[sidSt]; gauntletCrownLobbyReadyMap[sidSt] = nextSt; updateStackTowerMissionHowtoHud(); if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextSt }); return; } if (myId == null) return; const humansReadySt = gauntletCrownAllReadyPlay(); if (humansReadySt) { socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (r && r.ok === false) showCrownStartBlockedNoticePlay(r); }); return; } const sidHostSt = String(myId); const nextHostSt = !gauntletCrownLobbyReadyMap[sidHostSt]; gauntletCrownLobbyReadyMap[sidHostSt] = nextHostSt; updateStackTowerMissionHowtoHud(); if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextHostSt }); return; } if (!usesCrownLobbyShellPlay()) return; if (gauntletCrownPregamePhase !== 'howto') return; if (myId == null) return; /* guest (client) ต้องกด Ready เองได้ด้วย — เดิม block นี้ return ทันทีถ้าไม่ใช่ host ทำให้ MG2/MG7 (balloon_boss) client กด Ready ไม่ได้ → host start ไม่ได้ */ if (!isMePlayHost()) { const sidG = String(myId); const nextG = !gauntletCrownLobbyReadyMap[sidG]; gauntletCrownLobbyReadyMap[sidG] = nextG; updateGauntletCrownHowtoHud(); if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextG }); return; } const humans = quizCarryPregameHumanIds(); const humansReady = gauntletCrownAllReadyPlay(); if (humansReady) { /* เริ่มเองได้เฉพาะ offline/พรีวิว — ห้องจริงต้องผ่าน server (กัน host เริ่มคนเดียวก่อน client พร้อม) */ if (humans.length === 1 && isMePlayHost() && (previewMode || !socket || !socket.connected)) { beginGauntletCrownCountdownThenRun(); return; } socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (r && r.ok === false) showCrownStartBlockedNoticePlay(r); }); return; } const sid = String(myId); const next = !gauntletCrownLobbyReadyMap[sid]; gauntletCrownLobbyReadyMap[sid] = next; updateGauntletCrownHowtoHud(); socket.emit('gauntlet-crown-lobby-ready', { ready: next }); } ['btn-gch-ready', 'btn-gch-ready-mg2'].forEach((id) => { const btn = document.getElementById(id); if (btn) btn.addEventListener('click', onHowtoPrimaryClick); }); })(); (function wireQuizCarryPregameOverlay() { const primary = document.getElementById('quiz-carry-pregame-primary'); if (!primary) return; primary.addEventListener('click', () => { if (!quizCarryPregameActive || myId == null) return; const humans = quizCarryPregameHumanIds(); const humansReady = quizCarryAllReadyPlay(); if (humansReady) { /* ทุกคนพร้อมแล้ว → เฉพาะ host กด START */ if (!isMePlayHost()) return; /* เฉพาะ offline/พรีวิวที่ไม่ได้ต่อเซิร์ฟเวอร์เท่านั้นที่เริ่มได้เลย — ห้องจริงต้องผ่าน server (กัน host เริ่มคนเดียวทิ้ง client ตอน others ยังว่าง) */ if ((!socket || !socket.connected) && humans.length === 1) { endQuizCarryEmbedPregameAndStart(); return; } socket.emit('quiz-carry-lobby-start', {}, (r) => { if (!r || !r.ok) showQuizCarryToast((r && r.error) || 'START ไม่ได้', false); }); return; } /* READY phase: ทุกคน (รวม client) กดสลับสถานะ Ready ของตัวเองได้ */ const sid = String(myId); const next = !quizCarryLobbyReadyMap[sid]; quizCarryLobbyReadyMap[sid] = next; updateQuizCarryPregameHud(); if (socket && socket.connected) socket.emit('quiz-carry-lobby-ready', { ready: next }); }); })(); window.addEventListener('beforeunload', () => { try { saveQuizBattleProgress(); } catch (e) { /* ignore */ } socket.emit('leave-space'); }); window.addEventListener('resize', () => { if (!mapData || !canvas) return; resizeCanvas(); draw(); }); /** แคนวาส flex ขยายทีหลัง (จอใหญ่ / editor iframe) — ResizeObserver จับได้ดีกว่า resize อย่างเดียว */ (function setupPlayCanvasStackResizeObserver() { const stack = document.getElementById('play-canvas-stack'); if (!stack || typeof ResizeObserver === 'undefined') return; let roRaf = null; const run = () => { roRaf = null; if (!mapData || !canvas) return; resizeCanvas(); draw(); }; const ro = new ResizeObserver(() => { if (roRaf) cancelAnimationFrame(roRaf); roRaf = requestAnimationFrame(run); }); ro.observe(stack); const wrap = stack.closest('.game-wrap'); if (wrap) ro.observe(wrap); const stageEl = document.getElementById('play-canvas-stage'); if (stageEl) ro.observe(stageEl); })(); })();