diff --git a/www/html/Create Room/FONTS/NotoSansThai-Black.ttf b/www/html/Create Room/FONTS/NotoSansThai-Black.ttf new file mode 100644 index 0000000..aec52a6 Binary files /dev/null and b/www/html/Create Room/FONTS/NotoSansThai-Black.ttf differ diff --git a/www/html/Create Room/FONTS/NotoSansThai-Bold.ttf b/www/html/Create Room/FONTS/NotoSansThai-Bold.ttf new file mode 100644 index 0000000..9c6798c Binary files /dev/null and b/www/html/Create Room/FONTS/NotoSansThai-Bold.ttf differ diff --git a/www/html/Create Room/FONTS/NotoSansThai-ExtraBold.ttf b/www/html/Create Room/FONTS/NotoSansThai-ExtraBold.ttf new file mode 100644 index 0000000..0ed192b Binary files /dev/null and b/www/html/Create Room/FONTS/NotoSansThai-ExtraBold.ttf differ diff --git a/www/html/Create Room/FONTS/NotoSansThai-Medium.ttf b/www/html/Create Room/FONTS/NotoSansThai-Medium.ttf new file mode 100644 index 0000000..cea375a Binary files /dev/null and b/www/html/Create Room/FONTS/NotoSansThai-Medium.ttf differ diff --git a/www/html/Create Room/FONTS/NotoSansThai-SemiBold.ttf b/www/html/Create Room/FONTS/NotoSansThai-SemiBold.ttf new file mode 100644 index 0000000..4a04f1a Binary files /dev/null and b/www/html/Create Room/FONTS/NotoSansThai-SemiBold.ttf differ diff --git a/www/html/Create Room/FONTS/NotoSansThai-VariableFont_wdth,wght.ttf b/www/html/Create Room/FONTS/NotoSansThai-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000..99a7ec7 Binary files /dev/null and b/www/html/Create Room/FONTS/NotoSansThai-VariableFont_wdth,wght.ttf differ diff --git a/www/html/Create Room/create-room.js b/www/html/Create Room/create-room.js index efd5901..16cabf5 100644 --- a/www/html/Create Room/create-room.js +++ b/www/html/Create Room/create-room.js @@ -171,7 +171,6 @@ name: name, isPrivate: isPrivate, maxPlayers: maxPlayersForSlot(selectedSlot), - botSlotCount: botCountForSlot(selectedSlot), }; fetch(SERVER + '/api/spaces', { method: 'POST', diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index 890bf52..59b92be 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -1760,6 +1760,27 @@ return img; } + /** อุ่น cache เลเยอร์สีของตัวละคร (ทุกทิศ + เดิน 0-3 + idle) ก่อนเข้าเกม กันกระตุกตอนโหลดสีกลางเกม */ + const _playPreloadedChars = new Set(); + function preloadPlayCharacterLayers(charId) { + if (!charId || _playPreloadedChars.has(charId)) return; + _playPreloadedChars.add(charId); + try { ensurePlayCharLayerListFetch(); } catch (e) { /* ignore */ } + try { ensurePlayLayerProbesAllDirections(charId); } catch (e) { /* ignore */ } + const dirs = ['down', 'up', 'left', 'right']; + const warm = function (urls) { if (urls && urls.length) ensurePlayLayerImage(urls[0]); }; + dirs.forEach(function (dir) { + for (let fi = 0; fi < CHARACTER_ANIM_FRAMES; fi++) { + PLAY_LAYER_ORDER.forEach(function (ln) { + warm(ln === 'shadow' ? shadowUrlCandidates(charId, dir, fi) : layerUrlCandidates(charId, dir, ln, fi)); + }); + } + PLAY_LAYER_ORDER.forEach(function (ln) { + warm(ln === 'shadow' ? shadowUrlCandidatesIdle(charId, dir, 0) : layerUrlCandidatesIdle(charId, dir, ln, 0)); + }); + }); + } + /** ลำดับลอง URL: เฟรมปัจจุบันแบบ multi → เฟรม 0 → แบบ single (ไม่มี _0_) เพื่อกันชื่อไฟล์ไม่ตรงกับที่เซิร์ฟเวอร์เขียน */ function layerUrlCandidates(id, dir, layerName, frameIndex) { const enc = encodeURIComponent(id); @@ -14169,6 +14190,11 @@ gauntletObsRenderNext = []; gauntletObsBlendT0 = 0; me.playTint = playTintFromPeerId(String((myId != null && myId !== '') ? myId : (nick || 'local'))); + // อุ่น cache เลเยอร์สีตัวละครก่อนเข้าเกม (ตัวเรา + เพื่อนร่วมห้อง) กันกระตุกตอนโหลดสีกลางเกม + try { + preloadPlayCharacterLayers(me.characterId || getPlayCharacterId()); + (Array.isArray(plist) ? plist : []).forEach(function (p) { if (p && p.characterId) preloadPlayCharacterLayers(p.characterId); }); + } 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)); diff --git a/www/html/Game/public/js/play.js.bak-20260526-151951 b/www/html/Game/public/js/play.js.bak-20260526-151951 new file mode 100644 index 0000000..890bf52 --- /dev/null +++ b/www/html/Game/public/js/play.js.bak-20260526-151951 @@ -0,0 +1,18704 @@ +(function () { + const BASE = '/Game'; + /** รูปเลข 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'); + const nick = params.get('nick') || 'ผู้เล่น'; + const previewMode = params.get('preview') === '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; + + 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; + } + } + + if (myId != null) tryHit(me, me.spaceShooterCx, me.spaceShooterCy); + others.forEach(function (o) { + if (!o) return; + tryHit(o, o.spaceShooterCx, o.spaceShooterCy); + }); + if (spaceShooterMissionEveryParticipantEliminatedPlay()) { + endSpaceShooterMissionRound('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(); + }; + if (socket && socket.connected) { + socket.emit('detective-minigame-finished', {}, function (res) { + go(); + }); + } else { + go(); + } + } + + /** จบมินิเกมโหมดสืบสวน → 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; + + function rebuildStackPreviewTurnOrder() { + if (!playBotsEnabled() || !mapData || mapData.gameType !== 'stack') { + stackPreviewTurnOrder = null; + return; + } + const bots = [...others.keys()].filter(isPreviewBotId).sort(); + stackPreviewTurnOrder = [{ kind: 'human', seat: 1 }]; + for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) { + stackPreviewTurnOrder.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null }); + } + stackPreviewTurnIndex = 0; + stackPreviewBotThinkUntil = 0; + markStackNextDropVisualDirty(); + } + + function advanceStackPreviewTurn() { + if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return; + stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % STACK_PREVIEW_TURN_COUNT; + stackPreviewBotThinkUntil = 0; + markStackNextDropVisualDirty(); + } + + function isStackPreviewHumanTurn() { + if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return true; + const cur = stackPreviewTurnOrder[stackPreviewTurnIndex]; + return !!(cur && cur.kind === 'human'); + } + + function getStackPreviewCurrentSeat() { + if (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return 1; + const cur = stackPreviewTurnOrder[stackPreviewTurnIndex]; + return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1; + } + + function getStackDropActorSeat() { + if (playBotsEnabled() && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) { + 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(BASE + '/lobby.html'); return; } + + const socket = io({ path: BASE + '/socket.io' }); + const canvas = document.getElementById('game-canvas'); + const ctx = canvas.getContext('2d'); + let mapData = null, tileSize = 32, myId = null; + let stackMini = null; + let stackFall = null; + /** 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; + /** @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(); + 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); + 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; + /** >0 = หยุดเลื่อนแล้ว (timestamp ms) รอ POST_STOP ก่อน latch */ + let gauntletCrownRunwayBgStripFreezeSinceMs = 0; + /** พรีวิว 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 gauntletCrownRunwayBgHideObstaclesPlay() { + return gauntletCrownRunwayBgDrawActivePlay() && gauntletCrownRunwayBgFinishLatched; + } + + /** 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) { + 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); + if (gauntletCrownRunwayBgFinishLatched && gauntletCrownRunwayBgImageSlotReadyPlay(finish)) { + if (align) { + const { camX, camY, zDraw, mapWpx, mapHpx } = worldAlign; + ctx.fillStyle = '#1a1b26'; + ctx.fillRect(0, 0, canvasW, canvasH); + ctx.save(); + /* เมทริกซ์เดียวกับ worldToScreen + คลิปโลก [0,map] — รูปจบครอบทั้งแผนที่ที่ world origin */ + ctx.setTransform(zDraw, 0, 0, zDraw, canvasW / 2 - camX * zDraw, canvasH / 2 - camY * zDraw); + ctx.beginPath(); + ctx.rect(0, 0, mapWpx, mapHpx); + ctx.clip(); + drawImageCoverCanvasPlay(ctx, finish, Math.max(1, Math.round(mapWpx)), Math.max(1, Math.round(mapHpx))); + ctx.restore(); + } else { + drawImageCoverCanvasPlay(ctx, finish, Math.max(1, Math.round(canvasW)), Math.max(1, Math.round(canvasH))); + } + return; + } + /* ใช้ความกว้างมองเห็นแบบ 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: gauntletCrownRunwayBg.finishStopScreenFrac 0.08–0.96 = จุดกลางจอต้องถึงภายในแถบ FINISH ก่อนหยุด (ค่าเริ่ม ~0.56 ใกล้กล่องหยุดขวาเส้น FINISH บนงานศิลป์) + */ + 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 viewport center crosses “หยุดที่ FINISH” (ไม่ใช้แค่ขอบขวาถึงปลายแทร็ก) */ + function gauntletCrownRunwayScrollStripStopZoneReachedPlay() { + const g = gauntletCrownRunwayScrollGeometryPlay(); + if (!g) return false; + const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + let frac = Number(raw.finishStopScreenFrac); + if (!Number.isFinite(frac)) frac = 0.56; + frac = Math.max(0.08, Math.min(0.96, frac)); + const targetCenter = g.finishStart + g.w4Safe * frac; + const center = g.scrollView + g.cwWorld * 0.5; + if (g.scrollView < g.minScrollForFinishPass - 0.01) return false; + if (center < targetCenter - 1.5) return false; + return true; + } + + 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; + gauntletCrownRunwayBgStripFreezeSinceMs = 0; + 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; + /** พรีรัน (howto / countdown) — ห้ามเลื่อนก่อน GO · Mega Virus ใช้ shell เดียวกัน */ + if (usesCrownLobbyShellPlay() && gauntletCrownPregamePhase !== 'live') return; + if (gauntletCrownRunwayBgFinishLatched) return; + + if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) { + 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()) { + gauntletCrownRunwayBgStripFreezeSinceMs = Date.now(); + try { + syncGauntletCrownJumpButton(); + } catch (_sj) { /* ignore */ } + try { + draw(); + } catch (_d) { /* ignore */ } + return; + } + gauntletCrownRunwayBgScrollPx += (gauntletCrownRunwayBgSpeedPxPerSec || 48) * dtSec; + } + + /** 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(); + /** 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 ด้วยจำนวนเฟรมที่โหลดได้แล้ว (ไม่งั้นเฟรมกลางหาย/ลูปสั้นลง) */ + function walkAnimPhaseIndex(now, isWalking) { + const t = isWalking ? (typeof now === 'number' ? now : Date.now()) : 0; + return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES; + } + + /** เลือกเฟรมสูงสุดที่โหลดแล้วและ <= phase (เดิมถอย) */ + function pickLoadedWalkFrameIndex(anim, phase) { + if (!anim || !anim.frames || !anim.frames.length) return -1; + const maxK = Math.min(phase, anim.frames.length - 1, CHARACTER_ANIM_FRAMES - 1); + for (let k = maxK; k >= 0; k--) { + const f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return k; + } + return -1; + } + + /** ตัวละครอัปโหลดจากระบบมักเป็น char- + ตัวเลข (อาจมี suffix) — ไม่มี idle strip / idle layer ครบทุกทิศ */ + function isUploadedCharAssetId(id) { + if (!id) return false; + const s = String(id).trim(); + return /^char-\d/i.test(s); + } + + function useMultiFrameIdleSpriteSheets(id) { + if (!id) return true; + return !isUploadedCharAssetId(id); + } + + function ensureCharacterIdleAnim(id, dir) { + const d = dir || 'down'; + /* char-* ไม่มี *_idle_0.png — ใช้ strip เดิน dir_0..3 เป็น idle ต่อทิศ (มีลูป) แทนรูปเดียวที่มักไม่มี */ + if (!useMultiFrameIdleSpriteSheets(id)) { + const key = String(id) + '_' + d + '_idle'; + let anim = characterIdleAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterIdleAnimations[key] = anim; + const enc = encodeURIComponent(id); + for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + const img = new Image(); + img.src = BASE + '/img/characters/' + enc + '_' + d + '_' + i + '.png'; + anim.frames.push(img); + } + const fb = new Image(); + fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png'; + anim.fallback = fb; + } + return anim; + } + const key = id + '_' + d + '_idle'; + let anim = characterIdleAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterIdleAnimations[key] = anim; + const enc = encodeURIComponent(id); + for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + const img = new Image(); + img.src = BASE + '/img/characters/' + enc + '_' + d + '_idle_' + i + '.png'; + anim.frames.push(img); + } + const fb = new Image(); + fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png'; + anim.fallback = fb; + } + return anim; + } + + /** ลูป idle ขณะยืน — ใช้จังหวะเวลาเหมือนเดิน (CHARACTER_ANIM_FRAME_MS) */ + function idleAnimPhaseIndex(now) { + const t = typeof now === 'number' ? now : Date.now(); + return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES; + } + + function characterIdleSpritesVisible(id, dir) { + if (!id) return false; + const anim = ensureCharacterIdleAnim(id, dir || 'down'); + if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return true; + for (let i = 0; i < anim.frames.length; i++) { + const f = anim.frames[i]; + if (f && f.complete && f.naturalWidth) return true; + } + return false; + } + + /** สุ่มสีตามเลเยอร์เดียวกับ character.html: bodyColor / hairColor / headColor (ลำดับซ้อนเหมือน composeLayeredFrame) */ + const PLAY_LAYER_ORDER = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; + const PLAY_LAYER_TINT_KEY = { bodyColor: 'body', headColor: 'head', hairColor: 'hair' }; + const PLAY_TINT_HEAD = ['#eaa78a', '#fbd5c4', '#fae9e1']; + const PLAY_TINT_HAIR = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#ef62b9']; + const PLAY_TINT_BODY = ['#fb4941', '#feaa11', '#fefe6d', '#adfd85', '#45fbfd', '#799afe', '#f87dff', '#fec1fe']; + const playLayerMode = {}; + /** คิว probe ครบ up/down/left/right แล้ว — กันทิศที่ไม่มีไฟล์ layer ไปตก fallback แถบทั้งทั้งที่ทิศอื่นมี */ + const playLayerAllDirsQueued = {}; + /** จาก GET /api/characters — hasLayerFiles สแกนจากชื่อไฟล์จริง (แม่นกว่า probe จาก อย่างเดียว) */ + let playCharLayerApi = { status: 'idle', map: null }; + /** id → Set เลเยอร์ที่มีไฟล์จริง (จาก GET /api/characters) — กันยิง URL เลเยอร์ที่ไม่เคยอัปโหลด → 404 รกคอนโซล */ + const playCharDiskLayersById = Object.create(null); + + function ensurePlayCharLayerListFetch() { + if (playCharLayerApi.status !== 'idle') return; + playCharLayerApi.status = 'loading'; + fetch(BASE + '/api/characters') + .then((r) => r.json()) + .then((list) => { + const map = Object.create(null); + 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], + }; + } + + function hexToRgb01(hex) { + const h = (hex || '').replace('#', '').trim(); + if (h.length !== 6) return [1, 1, 1]; + return [ + parseInt(h.slice(0, 2), 16) / 255, + parseInt(h.slice(2, 4), 16) / 255, + parseInt(h.slice(4, 6), 16) / 255, + ]; + } + + function tintPlayLayerImageData(imageData, tintHex) { + const rgb = hexToRgb01(tintHex); + const tr = rgb[0], tg = rgb[1], tb = rgb[2]; + const d = imageData.data; + for (let i = 0; i < d.length; i += 4) { + if (d[i + 3] < 12) continue; + const r = d[i], g = d[i + 1], b = d[i + 2]; + const L = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + d[i] = Math.min(255, Math.round(tr * 255 * L)); + d[i + 1] = Math.min(255, Math.round(tg * 255 * L)); + d[i + 2] = Math.min(255, Math.round(tb * 255 * L)); + } + } + + function drawPlayTintedLayer(ctx, w, h, img, tintHex) { + if (!tintHex) { + ctx.drawImage(img, 0, 0, w, h); + return; + } + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + const x = c.getContext('2d'); + x.drawImage(img, 0, 0, w, h); + try { + const idata = x.getImageData(0, 0, w, h); + tintPlayLayerImageData(idata, tintHex); + x.putImageData(idata, 0, 0); + } catch (e) { + x.clearRect(0, 0, w, h); + x.drawImage(img, 0, 0, w, h); + } + ctx.drawImage(c, 0, 0, w, h); + } + + function ensurePlayLayerImage(url) { + let img = playLayerImageCache.get(url); + if (!img) { + img = new Image(); + img.src = url; + playLayerImageCache.set(url, img); + } + return img; + } + + /** ลำดับลอง URL: เฟรมปัจจุบันแบบ multi → เฟรม 0 → แบบ single (ไม่มี _0_) เพื่อกันชื่อไฟล์ไม่ตรงกับที่เซิร์ฟเวอร์เขียน */ + function layerUrlCandidates(id, dir, layerName, frameIndex) { + const enc = encodeURIComponent(id); + const base = BASE + '/img/characters/' + enc + '_' + dir; + const out = []; + function add(u) { + if (out.indexOf(u) === -1) out.push(u); + } + add(base + '_' + frameIndex + '_layer_' + layerName + '.png'); + if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png'); + add(base + '_layer_' + layerName + '.png'); + return out; + } + + /** เลเยอร์ idle: id__idle__layer_.png หรือ id__idle_layer_.png */ + function layerUrlCandidatesIdle(id, dir, layerName, frameIndex) { + const enc = encodeURIComponent(id); + const base = BASE + '/img/characters/' + enc + '_' + dir + '_idle'; + const out = []; + function add(u) { + if (out.indexOf(u) === -1) out.push(u); + } + // 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) { + const img = characterId ? getCharacterFrame(characterId, direction, now, isWalking) : null; + if (img) return img; + return defaultAvatarImg; + } + 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(); + } + + /** บอทพรีวิว: เลือกรหัสคนละตัวจาก 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 = {}; + 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 = {}; + /** เกมถูก/ผิด — ตรงกับ 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; + 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; + 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; + let balloonBossGameEnded = false; + let balloonBossHitFx = []; + /** Mega Virus — ป๊อปดาเมจต่อบอส (ข้อความ +2) เมื่อกระสุนโดนบอส */ + let balloonBossScorePopups = []; + let balloonBossBossFireAcc = 0; + /** quiz_battle — โดม MCQ กด E (ข้อจาก battleQuizMcq) */ + let quizBattleMcqPool = []; + let quizBattleAnsweredComps = new Set(); + let quizBattleModalCompId = null; + const SPACE_SHOOTER_SHIP_COLORS = ['#50fa7b', '#ff79c6', '#ff5555', '#f1fa8c', '#bd93f9', '#8be9fd']; + + document.getElementById('room-id').textContent = spaceId; + + function hidePlayQuizHud() { + const sb = document.getElementById('play-quiz-scoreboard'); + if (sb) sb.classList.add('is-hidden'); + const fb = document.getElementById('play-quiz-feedback'); + if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; } + } + + function renderPlayQuizScoreboard(scores) { + const wrap = document.getElementById('play-quiz-scoreboard'); + const ul = document.getElementById('play-quiz-scoreboard-list'); + if (!wrap || !ul || !mapData || (!isQuiz() && !isQuizCarry() && !isQuizBattle())) return; + if (!scores || typeof scores !== 'object') return; + wrap.classList.remove('is-hidden'); + ul.textContent = ''; + const merged = { ...scores }; + others.forEach((_, id) => { if (merged[id] == null) merged[id] = 0; }); + if (myId != null && merged[myId] == null) merged[myId] = 0; + const rows = []; + if (myId != null) { + rows.push({ id: myId, nick: me.nickname || 'คุณ', sc: merged[myId] != null ? merged[myId] : 0 }); + } + others.forEach((o, id) => { + rows.push({ id, nick: (o && o.nickname) ? String(o.nickname) : id, sc: merged[id] != null ? merged[id] : 0 }); + }); + rows.sort((a, b) => b.sc - a.sc || a.nick.localeCompare(b.nick, 'th')); + rows.forEach((row) => { + const li = document.createElement('li'); + if (row.id === myId) li.className = 'play-quiz-scoreboard-me'; + const spN = document.createElement('span'); + spN.className = 'play-quiz-scoreboard-name'; + spN.textContent = row.nick; + const spV = document.createElement('span'); + spV.className = 'play-quiz-scoreboard-val'; + spV.textContent = String(row.sc); + li.appendChild(spN); + li.appendChild(spV); + ul.appendChild(li); + }); + if (isQuizCarry() && useCyberPlayHud()) { + wrap.classList.add('is-hidden'); + } + /* แมป mng8a80o: คะแนนอยู่ที่ cyber SCORE ซ้ายแล้ว — ซ่อนกล่อง "คะแนน" มุมขวาไม่ให้ซ้ำกับ mock */ + if (isQuizQuestionMissionUiMapPlay()) { + 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 = {}; + 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; + } + } + 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 (!previewMode || !editorEmbedReturn || !mapData || !isQuizCarry()) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + const ov = document.getElementById('quiz-carry-embed-countdown'); + const inner = document.getElementById('quiz-carry-embed-countdown-inner'); + const canvasEl = document.getElementById('game-canvas'); + if (!ov || !inner || !canvasEl) return; + if (ov.classList.contains('is-hidden')) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + const anchor = carryEmbedCountdownAnchorResolved(); + if (anchor === 'screen') { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + let bounds = null; + if (anchor === 'grid') { + bounds = getTileBoundsForOnesGrid(mapData.carryEmbedCountdownArea); + if (!bounds) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + } else { + bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData); + if (!bounds) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + } + const 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 - Date.now()) / 1000)); + el.textContent = s + ' วินาที'; + } + + function clampPreviewMs(n, def, minV, maxV) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(minV, Math.min(maxV, Math.floor(v))); + } + + function clampCarrySessionLen(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(0, Math.min(500, Math.floor(v))); + } + + function buildQuizPoolFromMap(md) { + if (!md || !Array.isArray(md.quizQuestions)) return []; + return md.quizQuestions + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })); + } + + function 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); + } + + 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; + } + } + } + } + } + + /** สอดคล้องกับ server pickSpawnForJoin — ใช้พรีวิวบอท / ทดสอบ */ + function pickSpawnForJoinPlay(md, joinOrderIndex) { + if (!md) return { x: 1, y: 1 }; + const mode = md.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMapPlay(md); + const j = Math.min(Math.max(0, ord), 5); + if (mode === 'fixed' && md.spawn) { + const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1; + const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1; + const w = md.width || 20; + const h = md.height || 15; + const x = Math.max(0, Math.min(w - 1, fx)); + const y = Math.max(0, Math.min(h - 1, fy)); + if (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; + } + + function rebalancePreviewBots() { + if (!playBotsEnabled() || !mapData) return; + const human = countPlayHumans(); + const wantBots = Math.max(0, playBotTargetHeadcount() - human); + [...others.keys()].filter(isPreviewBotId).forEach((bid) => { + const o = others.get(bid); + if (!o) return; + if (o.botTier == null) { + const tierRoll = Math.random(); + 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) { + const id = PREVIEW_BOT_PREFIX + (++previewBotSeq); + 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 { + 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; + } + if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) { + const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(x, y); + x = pathSnap.x; + y = pathSnap.y; + } + 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: playTintFromPeerId(id), + botTier, + gauntletJumpTicks: 0, + gauntletJumpVis: 0, + gauntletScore: crownStart ? 100 : 0, + gauntletEliminated: false, + quizCannotTrue: false, + quizCannotFalse: false, + quizCarryHeld: null, + botPath: [], + botAnswerWander: false, + botWanderDx: wd[0], + botWanderDy: wd[1], + botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 1000), + jumpSurviveEliminated: false, + spaceShooterScore: 0, + balloonBossScore: 0, + balloonBossBossDmg: 0, + balloonBossBalloons: mapData && mapData.gameType === 'balloon_boss' ? balloonBossBalloonsStartPlay() : 5, + balloonBossEliminated: false, + botQuizCarryPathfindAfter: performance.now() + previewBotSeq * 75 + Math.floor(Math.random() * 160), + }); + botIds.push(id); + } + let k = 0; + [...others.keys()].filter(isPreviewBotId).sort().forEach((bid) => { + const o = others.get(bid); + if (!o) return; + const tag = o.botTier === 'sharp' ? '(ฉลาด)' : o.botTier === 'weak' ? '(พลาดบ่อย)' : '(กลาง)'; + o.nickname = 'บอท ' + (++k) + ' ' + tag; + }); + [...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(); + } + + 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; + 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; + 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; + } + 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; + } else if (isQuizQuestionMissionHudActivePlay()) { + const qmc = getQuizQuestionMissionMapCenterWorldPxPlay(); + if (qmc) { + zDraw = Math.min(canvas.width / qmc.mwPx, canvas.height / qmc.mhPx) * 0.96; + } + } else if (isQuizCarry()) { + const mwPx = w * tileSize; + const mhPx = h * tileSize; + zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96; + } + 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; + let py = sumCy / ents.length; + 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() || !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'; + } + + 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 = 1.06; + const lw = dw * lp; + const lh = dh * lp; + const lnx = lightImg.naturalWidth; + const lny = lightImg.naturalHeight; + ctx.save(); + ctx.globalAlpha = Math.min(0.9, pulse + 0.12); + ctx.globalCompositeOperation = 'screen'; + try { + ctx.drawImage(lightImg, 0, 0, lnx, lny, cx - 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(); + } + } + + 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'); + } + } + + /** + * สัดส่วนสูงของ 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) { + ensureStackTowerLifeHudImagesPlay(); + const bar = stackTowerLifeBarImg; + const fullIm = (stackTowerLifeFullImg && stackTowerLifeFullImg.complete && stackTowerLifeFullImg.naturalWidth > 0) + ? stackTowerLifeFullImg + : ensureStackTowerHeartMinusImgPlay(); + const hitIm = stackTowerLifeHitImg && stackTowerLifeHitImg.complete && stackTowerLifeHitImg.naturalWidth > 0 + ? stackTowerLifeHitImg + : 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.beginPath(); + ctx.rect(sx, sy, drawW, drawH); + ctx.clip(); + ctx.globalCompositeOperation = 'source-over'; + ctx.shadowColor = 'rgba(255, 210, 40, 0.9)'; + ctx.shadowBlur = Math.max(3, Math.min(10, 4 + pulse * 5)); + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.globalAlpha = Math.min(0.85, 0.4 + pulse * 0.34); + ctx.fillStyle = 'rgba(255, 228, 72, 0.52)'; + ctx.fillRect(sx, sy, drawW, drawH); + ctx.shadowBlur = 0; + ctx.shadowColor = 'transparent'; + ctx.globalAlpha = Math.min(0.72, 0.26 + pulse * 0.32); + ctx.strokeStyle = 'rgba(255, 250, 200, 0.78)'; + const sw = Math.max(1.2, Math.min(2.8, drawW * 0.022)); + ctx.lineWidth = sw; + ctx.strokeRect(sx + sw * 0.5, sy + sw * 0.5, drawW - sw, drawH - sw); + 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; + const humans = quizCarryPregameHumanIds(); + const tot = Math.max(1, quizCarryPregameTotalPlayers()); + const num = gauntletCrownPregameReadyNumerator(); + st.classList.remove('is-hidden'); + st.textContent = 'Ready Status : ' + num + '/' + tot; + const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); + btn.classList.toggle('is-start-phase', humansReady); + btn.classList.toggle('is-read-only', !isMePlayHost()); + btn.disabled = !isMePlayHost(); + btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false')); + btn.title = isMePlayHost() + ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY')) + : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); + } + + /** 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; + const humans = quizCarryPregameHumanIds(); + const tot = Math.max(1, quizCarryPregameTotalPlayers()); + const num = gauntletCrownPregameReadyNumerator(); + st.classList.remove('is-hidden'); + st.textContent = 'Ready Status : ' + num + '/' + tot; + const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); + btn.classList.toggle('is-start-phase', humansReady); + btn.classList.toggle('is-read-only', !isMePlayHost()); + btn.disabled = !isMePlayHost(); + btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false')); + btn.title = isMePlayHost() + ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY')) + : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); + } + + /** 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; + const humans = quizCarryPregameHumanIds(); + const tot = Math.max(1, quizCarryPregameTotalPlayers()); + const num = gauntletCrownPregameReadyNumerator(); + st.classList.remove('is-hidden'); + st.textContent = 'Ready Status : ' + num + '/' + tot; + const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); + btn.classList.toggle('is-start-phase', humansReady); + btn.classList.toggle('is-read-only', !isMePlayHost()); + btn.disabled = !isMePlayHost(); + btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false')); + btn.title = isMePlayHost() + ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY')) + : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); + } + + function 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'); + let n = 3; + setCountdown321QuestionAssetGraphic(numEl, n); + const step = function () { + n--; + if (n > 0) { + setCountdown321QuestionAssetGraphic(numEl, n); + stackTowerMissionCountdownTimer = setTimeout(step, 1000); + } else { + stackTowerMissionCountdownTimer = null; + runFinish(); + } + }; + stackTowerMissionCountdownTimer = setTimeout(step, 1000); + } + + 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; + stackTowerMissionEndedOnce = true; + stackTowerMissionPhase = 'ended'; + applyStackTowerMissionPanelImages(); + let outcome = 'time_up'; + if (pct >= 100) outcome = 'decrypt_complete'; + else if (missesOut) outcome = 'misses_exhausted'; + beginStackTowerResultFlashThenGcm(stackTowerMissionBuildPayload({ outcome: outcome })); + } + + function isJumpSurviveMissionUiMapPlay() { + return isJumpSurvive() && currentPlayMapId() === JUMP_SURVIVE_MISSION_MAP_ID; + } + + function isJumpSurviveMissionPregameBlockingPlay() { + if (!isJumpSurviveMissionUiMapPlay()) return false; + return jumpSurviveMissionPhase != null && jumpSurviveMissionPhase !== 'live' && jumpSurviveMissionPhase !== 'ended'; + } + + function jumperAssetUrl(file) { + return BASE + '/img/Jumper/' + file; + } + + function hideConflictingOverlaysForJumpSurviveMission() { + hideConflictingOverlaysForGauntletCrown(); + } + + function jumpSurviveTimeLimitSecForMission() { + const m = Number(mapData && mapData.jumpSurviveTimeSec); + if (Number.isFinite(m) && m > 0 && m < 7200) return Math.floor(m); + const j = Number(playJumpSurviveMissionTimeSec); + if (Number.isFinite(j) && j > 0 && j < 7200) return Math.floor(j); + return 60; + } + + function jumpSurviveRemainingSecMission() { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveSessionStartMs <= 0) return null; + const lim = jumpSurviveTimeLimitSecForMission(); + if (!(lim > 0)) return null; + const elapsed = (performance.now() - jumpSurviveSessionStartMs) / 1000; + return Math.max(0, Math.ceil(lim - elapsed)); + } + + function applyJumpSurviveJumperPanelImages() { + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + howtoBg.src = jumperAssetUrl('popup-Howto.png'); + howtoBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; + }; + } + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg) { + resBg.src = jumperAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + } + + function showJumpSurviveMissionHowtoOverlay() { + if (!isJumpSurviveMissionUiMapPlay()) return; + hideConflictingOverlaysForJumpSurviveMission(); + applyJumpSurviveJumperPanelImages(); + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + const cd = document.getElementById('gauntlet-crown-countdown'); + if (cd) cd.classList.add('is-hidden'); + jumpSurviveMissionPhase = 'howto'; + const ov = document.getElementById('gauntlet-crown-howto-overlay'); + if (!ov) return; + gauntletCrownHowtoVisible = true; + ov.classList.remove('is-hidden'); + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) st.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; + applyJumpSurvivePreviewSpawnLayout(false); + }; + if (!cd || !numEl) { + runFinish(); + return; + } + cd.classList.remove('is-hidden'); + let n = 3; + setCountdown321QuestionAssetGraphic(numEl, n); + const step = function () { + n--; + if (n > 0) { + setCountdown321QuestionAssetGraphic(numEl, n); + jumperMissionCountdownTimer = setTimeout(step, 1000); + } else { + jumperMissionCountdownTimer = null; + runFinish(); + } + }; + jumperMissionCountdownTimer = setTimeout(step, 1000); + } + + /** Jumper ภารกิจ mnptfts2: เกรดจากจำนวนผู้รอด — 6=A … 3=D; น้อยกว่า 2 = F; พอดี 2 คน = E */ + function jumpSurviveGradeFromSurvivorCount(survivors) { + const s = Math.max(0, Math.floor(Number(survivors) || 0)); + if (s >= 6) return 'A'; + if (s === 5) return 'B'; + if (s === 4) return 'C'; + if (s === 3) return 'D'; + if (s === 2) return 'E'; + return 'F'; + } + + function jumpSurviveRollRewardCardForGrade(grade) { + if (grade === 'F') return null; + const r = Math.random(); + if (grade === 'A' || grade === 'B') { + if (r < 0.35) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.7) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'C' || grade === 'D') { + if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'E') { + if (r < 0.12) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + + function jumpSurviveMissionTotalParticipants() { + let n = myId != null ? 1 : 0; + if (others && typeof others.size === 'number') n += others.size; + return n; + } + + function jumpSurviveMissionCountAlive() { + let n = 0; + if (myId != null && !jumpSurviveEliminated) n++; + if (others && typeof others.forEach === 'function') { + others.forEach(function (o) { + if (o && !o.jumpSurviveEliminated) n++; + }); + } + return n; + } + + /** จบทันทีเมื่อไม่มีผู้รอด — เหลือคนสุดท้ายยังเล่นต่อจนหมดเวลา (ไม่จบเร็วแบบ “ชนะขาดลอย”) */ + 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 = rows.slice(0, 5); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: row.baseScore, + eliminated: row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: row.baseScore, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + const rewardCard = jumpSurviveRollRewardCardForGrade(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'jumper', + survivorCount: survivorCount, + participantCount: participantCount, + }; + } + + function jumpSurviveMergePreviewBotsMission(mission) { + if (!mission || mission.uiSkin !== 'jumper' || !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 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; + 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'); + let n = 3; + setCountdown321QuestionAssetGraphic(numEl, n); + const step = function () { + n--; + if (n > 0) { + setCountdown321QuestionAssetGraphic(numEl, n); + spaceShooterMissionCountdownTimer = setTimeout(step, 1000); + } else { + spaceShooterMissionCountdownTimer = null; + runFinish(); + } + }; + spaceShooterMissionCountdownTimer = setTimeout(step, 1000); + } + + 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; + 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 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); + 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 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); + const ranked = top.map((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((r) => r.baseScore); + 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, + }; + } + + 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) { + return questionMissionAssetUrl(file); + } + + 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() { + 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'); + let n = 3; + setCountdown321QuestionAssetGraphic(numEl, n); + const step = function () { + n--; + if (n > 0) { + setCountdown321QuestionAssetGraphic(numEl, n); + quizQuestionMissionCountdownTimer = setTimeout(step, 1000); + } else { + quizQuestionMissionCountdownTimer = null; + runFinish(); + } + }; + quizQuestionMissionCountdownTimer = setTimeout(step, 1000); + } + + 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'; } + /** 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 quizCarryOptionHeldByAnyone(optionIdx) { + if (optionIdx == null || optionIdx < 0) return false; + if (me.quizCarryHeld === optionIdx) return true; + for (const o of others.values()) { + if (o && o.quizCarryHeld === optionIdx) return true; + } + return false; + } + + /** สไปรต์ทับช่องโซนกลาง (ม่วง) อย่างน้อย 1 ช่อง */ + function spriteOverlapsQuizCarryHubArea(md, sp) { + if (!md || md.gameType !== 'quiz_carry' || !sp) return false; + const hub = md.quizCarryHubArea; + if (!hub || !hub.length) return false; + const mw = md.width || 20, mh = md.height || 15; + const x0 = sp.x | 0, y0 = sp.y | 0, ww = sp.w | 0, hh = sp.h | 0; + for (let yy = y0; yy < y0 + hh; yy++) { + if (yy < 0 || yy >= mh) continue; + const row = hub[yy]; + if (!row) continue; + for (let xx = x0; xx < x0 + ww; xx++) { + if (xx < 0 || xx >= mw) continue; + if (row[xx] === 1) return true; + } + } + return false; + } + + /** ตัวเลือกหนึ่งข้อ — มีคนถือป้ายอยู่ได้แค่คนเดียว (หยิบซ้ำไม่ได้) */ + function pickQuizCarryTargetOptionIndexAvoidingTaken(preferredIdx) { + const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0; + if (n < 1) return null; + let p = preferredIdx | 0; + if (p < 0 || p >= n) p = 0; + if (!quizCarryOptionHeldByAnyone(p)) return p; + const avail = []; + for (let i = 0; i < n; i++) { + if (!quizCarryOptionHeldByAnyone(i)) avail.push(i); + } + if (!avail.length) return null; + return avail[Math.floor(Math.random() * avail.length)]; + } + + /** + * วาดป้ายมุมมน + ขอบเรือง + ข้อความ (รูปแบบเดียวกันทั้งแมปและติดตัว) — สีจาก carryChoicePlaqueThemes[choiceIndex] · รูปจาก plaqueExtra.imageUrl + * @param tether ถ้ามี วาดเส้นขาวจาก (x0,y0) ถึง (x1,y1) ก่อนป้าย + * @param plaqueExtra optional { imageUrl } + */ + function drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, tether, plaqueExtra) { + const th = getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex); + const neon = th.borderMode === 'fixed' ? th.fixedBorder : quizCarryNeonColorForChoice(choiceIndex); + const fillCol = th.fillBg || 'rgba(12, 10, 20, 0.88)'; + const textCol = th.textColor || '#f8f9ff'; + const baseLw = Math.max(0.5, Math.min(8, Number(th.borderWidthPx))); + const imgUrl = plaqueExtra && plaqueExtra.imageUrl ? String(plaqueExtra.imageUrl) : ''; + const elImg = plaqueExtra && plaqueExtra.imageElement; + const imgFromEl = (elImg && elImg.complete && elImg.naturalWidth > 0) ? elImg : null; + const img = !imgFromEl && imgUrl ? getQuizCarryChoiceImageCached(imgUrl) : null; + const drawPlaqueImg = imgFromEl || ((img && img.complete && img.naturalWidth > 0) ? img : null); + const rr = Math.max(6, Math.min(12, Math.min(w, h) * 0.2)); + const simplePreviewPlaque = previewMode && editorEmbedReturn; + function pathBoard() { + ctx.beginPath(); + if (typeof ctx.roundRect === 'function') ctx.roundRect(signX, signY, w, h, rr); + else ctx.rect(signX, signY, w, h); + } + if (!simplePreviewPlaque) { + for (let g = 4; g >= 1; g--) { + ctx.strokeStyle = neon; + ctx.globalAlpha = 0.07 + g * 0.06; + ctx.lineWidth = baseLw + g * 3.2; + pathBoard(); + ctx.stroke(); + } + } + ctx.globalAlpha = 1; + ctx.fillStyle = fillCol; + pathBoard(); + ctx.fill(); + if (drawPlaqueImg) { + ctx.save(); + pathBoard(); + ctx.clip(); + const padImg = Math.max(2, padY * 0.65); + const ix = signX + padImg; + const iy = signY + padImg; + const iw = Math.max(0, w - padImg * 2); + const ih = Math.max(0, h - padImg * 2); + if (iw > 0 && ih > 0) { + const nw = drawPlaqueImg.naturalWidth; + const nh = drawPlaqueImg.naturalHeight; + const ir = nw / nh; + let dw; + let dh; + if (iw / ih > ir) { + dh = ih; + dw = dh * ir; + } else { + dw = iw; + dh = dw / ir; + } + const dx = ix + (iw - dw) / 2; + const dy = iy + (ih - dh) / 2; + ctx.drawImage(drawPlaqueImg, 0, 0, nw, nh, dx, dy, dw, dh); + } + ctx.restore(); + } + ctx.strokeStyle = neon; + if (simplePreviewPlaque) { + ctx.lineWidth = Math.max(1, baseLw * 0.85); + ctx.shadowBlur = 0; + pathBoard(); + ctx.stroke(); + } else { + ctx.lineWidth = baseLw; + ctx.shadowColor = neon; + ctx.shadowBlur = 14; + pathBoard(); + ctx.stroke(); + ctx.shadowBlur = 8; + pathBoard(); + ctx.stroke(); + ctx.shadowBlur = 0; + } + const attachX = signX + w / 2; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = textCol; + const lh = lineH || 12; + if (!simplePreviewPlaque && lines && lines.length) { + ctx.shadowColor = 'rgba(0,0,0,0.55)'; + ctx.shadowBlur = 3; + } + if (lines && lines.length) { + const n = lines.length; + const textBlockH = n * lh; + const y0 = signY + Math.max(padY, (h - textBlockH) / 2); + const firstLineCenterY = y0 + lh / 2; + for (let li = 0; li < n; li++) { + ctx.fillText(lines[li], attachX, firstLineCenterY + li * lh); + } + } + ctx.shadowBlur = 0; + if (tether && Number.isFinite(tether.x0) && Number.isFinite(tether.y0)) { + ctx.strokeStyle = 'rgba(255,255,255,0.95)'; + ctx.lineWidth = 1.5; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.shadowBlur = 0; + ctx.beginPath(); + ctx.moveTo(tether.x0, tether.y0); + ctx.lineTo(tether.x1, tether.y1); + ctx.stroke(); + } + } + + /** + * กล่องป้ายบนจอ (signX, signY, w, h) ให้ตรงกับ drawQuizCarryChoiceLabels สำหรับช่อง idx 0-based + */ + function computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, idx0Based) { + if (!isQuizCarry() || !mapData || !quizCarryCurrent) return null; + const q = quizCarryCurrent; + const choices = q.choices; + if (!choices || !choices.length || idx0Based < 0 || idx0Based >= choices.length) return null; + const i = idx0Based; + const b = getQuizCarryOptionClusterBounds(mapData, i + 1); + if (!b) return null; + const ts = tileSize * zoom; + const ps = quizCarryPlaqueMapScaleClampedPlay(); + const choiceText = String(choices[i] || '').trim(); + const imgUrl = getQuizCarryPlaqueImageUrlForIndex(q, i); + const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i); + const plaqueExtra = imgUrl ? { imageUrl: imgUrl } + : (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {}); + if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) return null; + const tileSpanX = (b.maxX - b.minX + 1); + const tileSpanY = (b.maxY - b.minY + 1); + const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps); + const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps); + const wx = b.cx * tileSize; + const wy = b.cy * tileSize; + const [sx, sy] = worldToScreen(wx, wy); + ctx.save(); + let signX; + let signY; + let w; + let h; + if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) { + const minW = minPlaqueW; + const minH = minPlaqueH; + signX = sx - minW / 2; + signY = sy - minH / 2; + w = minW; + h = minH; + ctx.restore(); + return { signX, signY, w, h }; + } + const line = choiceText; + const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps)); + ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; + let maxW = Math.max(56, tileSpanX * ts - 14); + const lineH = fontPx * 1.2; + let textLines = canvasWordWrapLines(ctx, line, maxW); + if (!textLines.length) textLines = [line]; + if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) { + const fp2 = Math.max(9, fontPx * 0.85); + ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; + const lh2 = fp2 * 1.2; + maxW = Math.max(48, maxW - 4); + textLines = canvasWordWrapLines(ctx, line, maxW); + if (textLines.length * lh2 > tileSpanY * ts - 8) { + textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2))); + } + for (let ti = 0; ti < textLines.length; ti++) { + let s = textLines[ti]; + if (ctx.measureText(s).width <= maxW) continue; + while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); + textLines[ti] = s + '…'; + } + const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40); + const padX = 10; + const padY = 8; + w = Math.ceil(maxLineW + padX * 2); + h = Math.ceil(textLines.length * lh2 + padY * 2); + if (imgUrl || plaqueExtra.imageElement) { + w = Math.max(w, minPlaqueW); + h = Math.max(h, minPlaqueH); + } + signX = sx - w / 2; + signY = sy - h / 2; + ctx.restore(); + return { signX, signY, w, h }; + } + for (let ti = 0; ti < textLines.length; ti++) { + let s = textLines[ti]; + if (ctx.measureText(s).width <= maxW) continue; + while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); + textLines[ti] = s + '…'; + } + let maxLineW = 0; + for (let ti = 0; ti < textLines.length; ti++) { + const lw = ctx.measureText(textLines[ti]).width; + if (lw > maxLineW) maxLineW = lw; + } + const padX = 10; + const padY = 8; + w = Math.ceil(Math.max(maxLineW, 52) + padX * 2); + h = Math.ceil(textLines.length * lineH + padY * 2); + if (imgUrl || plaqueExtra.imageElement) { + w = Math.max(w, minPlaqueW); + h = Math.max(h, minPlaqueH); + } + signX = sx - w / 2; + signY = sy - h / 2; + ctx.restore(); + return { signX, signY, w, h }; + } + + /** + * ขอบเรืองรอบช่องตัวเลือก (carryEmbedCountdownHighlight + carryEmbedCountdownHighlightColor) + * ขนาดกรอบเท่าป้ายบนแมป (เดียวกับ drawQuizCarryChoiceLabels) ไม่ใช่ทั้ง cluster กริด + */ + function drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zoom, timeMs) { + if (!isQuizCarry() || !mapData) return; + if (mapData.carryEmbedCountdownHighlight === false) return; + const optIdx = getQuizCarryLocalGrabbableOptionIndex(); + if (optIdx == null) return; + const pr = computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, optIdx); + if (!pr || pr.w < 4 || pr.h < 4) return; + const rx = pr.signX; + const ry = pr.signY; + const rw = pr.w; + const rh = pr.h; + const rr = Math.max(6, Math.min(16, Math.min(rw, rh) * 0.14)); + const colRaw = mapData.carryEmbedCountdownHighlightColor; + const strokeBase = typeof colRaw === 'string' && /^#[0-9a-fA-F]{6}$/.test(colRaw.trim()) ? colRaw.trim() : '#ffb44a'; + const pulse = 0.55 + 0.45 * Math.sin((timeMs || 0) / 180); + ctx.save(); + function pathRr() { + ctx.beginPath(); + if (typeof ctx.roundRect === 'function') ctx.roundRect(rx, ry, rw, rh, rr); + else ctx.rect(rx, ry, rw, rh); + } + pathRr(); + ctx.fillStyle = strokeBase; + ctx.globalAlpha = 0.12 * pulse; + ctx.fill(); + for (let g = 6; g >= 1; g--) { + ctx.strokeStyle = strokeBase; + ctx.globalAlpha = (0.07 + g * 0.055) * pulse; + ctx.lineWidth = 2.5 + g * 4; + pathRr(); + ctx.stroke(); + } + ctx.globalAlpha = 0.98; + ctx.strokeStyle = strokeBase; + ctx.lineWidth = 4; + ctx.shadowColor = strokeBase; + ctx.shadowBlur = 22 * pulse; + pathRr(); + ctx.stroke(); + ctx.shadowBlur = 12 * pulse; + pathRr(); + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.globalAlpha = 1; + ctx.restore(); + } + + function drawQuizCarryChoiceLabels(ctx, worldToScreen, zoom) { + if (!isQuizCarry() || !mapData || !quizCarryCurrent) return; + if (!quizCarryOptionsPickableNow()) return; + const choices = quizCarryCurrent.choices; + if (!choices || !choices.length) return; + const maxSlots = choices.length; + const ts = tileSize * zoom; + const ps = quizCarryPlaqueMapScaleClampedPlay(); + ctx.save(); + for (let i = 0; i < Math.min(choices.length, maxSlots); i++) { + if (quizCarryOptionHeldByAnyone(i)) continue; + const b = getQuizCarryOptionClusterBounds(mapData, i + 1); + if (!b) continue; + const choiceText = String(choices[i] || '').trim(); + const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, i); + const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i); + const plaqueExtra = imgUrl ? { imageUrl: imgUrl } + : (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {}); + if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) continue; + const tileSpanX = (b.maxX - b.minX + 1); + const tileSpanY = (b.maxY - b.minY + 1); + const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps); + const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps); + if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) { + const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps)); + const lineH = fontPx * 1.2; + const padY = 8; + const minW = minPlaqueW; + const minH = minPlaqueH; + const wx = b.cx * tileSize; + const wy = b.cy * tileSize; + const [sx, sy] = worldToScreen(wx, wy); + drawQuizCarryNeonPlaque(ctx, sx - minW / 2, sy - minH / 2, minW, minH, [], lineH, padY, i, null, plaqueExtra); + continue; + } + const line = choiceText; + const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps)); + ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; + let maxW = Math.max(56, tileSpanX * ts - 14); + const lineH = fontPx * 1.2; + let textLines = canvasWordWrapLines(ctx, line, maxW); + if (!textLines.length) textLines = [line]; + if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) { + const fp2 = Math.max(9, fontPx * 0.85); + ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; + const lh2 = fp2 * 1.2; + maxW = Math.max(48, maxW - 4); + textLines = canvasWordWrapLines(ctx, line, maxW); + if (textLines.length * lh2 > tileSpanY * ts - 8) { + textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2))); + } + for (let ti = 0; ti < textLines.length; ti++) { + let s = textLines[ti]; + if (ctx.measureText(s).width <= maxW) continue; + while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); + textLines[ti] = s + '…'; + } + const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40); + const padX = 10; + const padY = 8; + let w = Math.ceil(maxLineW + padX * 2); + let h = Math.ceil(textLines.length * lh2 + padY * 2); + if (imgUrl || plaqueExtra.imageElement) { + w = Math.max(w, minPlaqueW); + h = Math.max(h, minPlaqueH); + } + const wx = b.cx * tileSize; + const wy = b.cy * tileSize; + const [sx, sy] = worldToScreen(wx, wy); + const signX = sx - w / 2; + const signY = sy - h / 2; + drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lh2, padY, i, null, plaqueExtra); + continue; + } + for (let ti = 0; ti < textLines.length; ti++) { + let s = textLines[ti]; + if (ctx.measureText(s).width <= maxW) continue; + while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); + textLines[ti] = s + '…'; + } + let maxLineW = 0; + for (let ti = 0; ti < textLines.length; ti++) { + const lw = ctx.measureText(textLines[ti]).width; + if (lw > maxLineW) maxLineW = lw; + } + const padX = 10; + const padY = 8; + let w = Math.ceil(Math.max(maxLineW, 52) + padX * 2); + let h = Math.ceil(textLines.length * lineH + padY * 2); + if (imgUrl || plaqueExtra.imageElement) { + w = Math.max(w, minPlaqueW); + h = Math.max(h, minPlaqueH); + } + const wx = b.cx * tileSize; + const wy = b.cy * tileSize; + const [sx, sy] = worldToScreen(wx, wy); + const signX = sx - w / 2; + const signY = sy - h / 2; + drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lineH, padY, i, null, plaqueExtra); + } + ctx.restore(); + } + + function quizCarryHubCellPlay(md, tx, ty) { + const g = md && md.quizCarryHubArea; + return !!(g && g[ty] && g[ty][tx] === 1); + } + + function quizCarryInteractiveCellPlay(md, tx, ty) { + const g = md && md.interactive; + return !!(g && g[ty] && g[ty][tx] === 1); + } + + function quizCarryMapHasAnswerInteractive(md) { + if (!md || !md.interactive || !md.interactive.length) return false; + const h = md.height || 15, w = md.width || 20; + for (let y = 0; y < h; y++) { + const row = md.interactive[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (row[x] === 1) return true; + } + } + return false; + } + + /** @returns {number|null} index ใน choices จากช่องหมายเลข 1..N บนแมป */ + function quizCarryOptionIndexAtPlay(md, tx, ty) { + const g = md && md.quizCarryOptionArea; + if (!g || !g[ty]) return null; + const v = g[ty][tx]; + if (v < 1 || v > QUIZ_CARRY_MAX_OPTION_SLOTS) return null; + const idx = v - 1; + const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0; + if (n < 1 || idx >= n) return null; + return idx; + } + + /** ร่างตัวละครทับช่องโซนกลาง (ม่วง) — ใช้บล็อกการเดิน */ + function quizCarryFootprintOverlapsHub(px, py) { + if (!mapData || !isQuizCarry()) return false; + const md = mapData; + for (const k of quizTilesFootprintPlay(px, py)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (quizCarryHubCellPlay(md, tx, ty)) return true; + } + return false; + } + + /** ยืนข้างนอกแต่ชิดโซนกลาง — ใช้ให้กด F ส่งคำตอบได้โดยไม่ต้องย่ำบนม่วง */ + function quizCarryFootprintAdjacentToHub(md, px, py) { + if (!md) return false; + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + for (const k of quizTilesFootprintPlay(px, py)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (quizCarryHubCellPlay(md, tx, ty)) continue; + for (let di = 0; di < dirs.length; di++) { + const nx = tx + dirs[di][0], ny = ty + dirs[di][1]; + if (quizCarryHubCellPlay(md, nx, ny)) return true; + } + } + return false; + } + + function quizCarryCanSubmitAtHub(md, px, py) { + if (!md) return false; + return quizCarryFootprintOverlapsHub(px, py) || quizCarryFootprintAdjacentToHub(md, px, py); + } + + function quizCarryFootprintOverlapsInteractive(md, px, py) { + if (!md) return false; + for (const k of quizTilesFootprintPlay(px, py)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (quizCarryInteractiveCellPlay(md, tx, ty)) return true; + } + return false; + } + + function quizCarryFootprintAdjacentToInteractive(md, px, py) { + if (!md) return false; + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + for (const k of quizTilesFootprintPlay(px, py)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (quizCarryInteractiveCellPlay(md, tx, ty)) continue; + for (let di = 0; di < dirs.length; di++) { + const nx = tx + dirs[di][0], ny = ty + dirs[di][1]; + if (quizCarryInteractiveCellPlay(md, nx, ny)) return true; + } + } + return false; + } + + function quizCarryCanSubmitAtInteractiveZone(md, px, py) { + if (!md) return false; + return quizCarryFootprintOverlapsInteractive(md, px, py) || quizCarryFootprintAdjacentToInteractive(md, px, py); + } + + /** ส่งคำตอบ: ถ้ามีโซน interactive (เขียว) ในแมป ใช้โซนนั้น — ไม่มี fallback ชิดโซนกลาง */ + function quizCarryCanSubmitAnswerAt(md, px, py) { + if (!md) return false; + if (quizCarryMapHasAnswerInteractive(md)) { + return quizCarryCanSubmitAtInteractiveZone(md, px, py); + } + return quizCarryCanSubmitAtHub(md, px, py); + } + + /** ข้อความใน HUD / โซนคำถาม — ไม่รวมตัวเลือก (แสดงบนแผนที่ตามจุดสี) */ + function formatQuizCarryQuestionHud(q) { + if (!q) return ''; + return String(q.text || q.question || q.prompt || q.title || '').trim(); + } + + function quizCarryRestoreEmbedPhaseLabel() { + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (!phaseEl || !mapData) return; + phaseEl.textContent = quizCarryMapHasAnswerInteractive(mapData) + ? 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งที่โซน interactive (เขียว) — เล่นพร้อมกันได้' + : 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งเมื่อชิดโซนกลาง (ม่วงเป็นกำแพง) — เล่นพร้อมกันได้'; + } + + function isQuizCarryEmbedCountdownBlockingMovement() { + if (!(previewMode && editorEmbedReturn && isQuizCarry())) return false; + const now = Date.now(); + return (quizCarryEmbedCountdownEndAt > now) || (quizCarryEmbedPreOptionCountdownEndAt > now); + } + + function isQuizCarryEmbedLobbyBlockingMovement() { + return !!(previewMode && editorEmbedReturn && isQuizCarry() && quizCarryPregameActive); + } + + function quizCarryPregameHumanIds() { + const ids = []; + if (myId != null && !isPreviewBotId(myId)) ids.push(String(myId)); + others.forEach((_, id) => { + if (!isPreviewBotId(id)) ids.push(String(id)); + }); + return ids; + } + + function quizCarryPregameBotCount() { + let n = 0; + others.forEach((_, id) => { + if (isPreviewBotId(id)) n++; + }); + return n; + } + + function quizCarryPregameReadyNumerator() { + let n = quizCarryPregameBotCount(); + quizCarryPregameHumanIds().forEach((id) => { + if (quizCarryLobbyReadyMap[id]) n++; + }); + return n; + } + + function quizCarryPregameTotalPlayers() { + return quizCarryPregameHumanIds().length + quizCarryPregameBotCount(); + } + + function isMePlayHost() { + if (myId == null) return false; + if (playHostId == null) return true; + return String(playHostId) === String(myId); + } + + function hideQuizCarryPregameOverlay() { + const ov = document.getElementById('quiz-carry-pregame-overlay'); + if (ov) { + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + } + + function showQuizCarryPregameOverlay() { + const ov = document.getElementById('quiz-carry-pregame-overlay'); + if (ov) { + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + } + updateQuizCarryPregameHud(); + } + + function quizCarrySyncGuestReadyIfNeeded() { + if (!quizCarryPregameActive || myId == null || isMePlayHost()) return; + const sid = String(myId); + if (quizCarryLobbyReadyMap[sid]) return; + quizCarryLobbyReadyMap[sid] = true; + if (socket && socket.connected) socket.emit('quiz-carry-lobby-ready', { ready: true }); + } + + function updateQuizCarryPregameHud() { + if (!quizCarryPregameActive) return; + const st = document.getElementById('quiz-carry-pregame-status'); + const primary = document.getElementById('quiz-carry-pregame-primary'); + const primaryImg = document.getElementById('quiz-carry-pregame-primary-img'); + const num = quizCarryPregameReadyNumerator(); + const tot = Math.max(1, quizCarryPregameTotalPlayers()); + if (st) { + st.textContent = 'Ready Status : ' + num + '/' + tot; + } + const humans = quizCarryPregameHumanIds(); + const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]); + const meReady = !!(myId && quizCarryLobbyReadyMap[String(myId)]); + if (primaryImg) { + primaryImg.src = humansReady ? '/Game/img/quiz-carry/btn-start.png' : '/Game/img/quiz-carry/btn-ready.png'; + primaryImg.alt = humansReady ? 'START' : 'READY'; + } + if (primary) { + primary.classList.toggle('is-start-phase', humansReady); + primary.classList.toggle('is-read-only', !isMePlayHost()); + primary.classList.toggle('is-pressed', isMePlayHost() && meReady && !humansReady); + primary.disabled = !isMePlayHost(); + primary.setAttribute('aria-pressed', humansReady ? 'false' : (meReady ? 'true' : 'false')); + primary.title = isMePlayHost() + ? (humansReady ? 'START' : (meReady ? 'ยกเลิก READY' : 'READY')) + : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); + } + } + + function beginQuizCarryEmbedPregame() { + if (!(previewMode && editorEmbedReturn && isQuizCarry())) return; + quizCarryPregameActive = true; + quizCarryLobbyReadyMap = {}; + quizCarryPregameHumanIds().forEach((id) => { quizCarryLobbyReadyMap[id] = false; }); + showQuizCarryPregameOverlay(); + if (socket && socket.connected) socket.emit('quiz-carry-lobby-sync-request'); + quizCarrySyncGuestReadyIfNeeded(); + updateQuizCarryPregameHud(); + } + + function endQuizCarryEmbedPregameAndStart() { + if (!quizCarryPregameActive) return; + quizCarryPregameActive = false; + hideQuizCarryPregameOverlay(); + quizCarryPickNextQuestion(); + const nowEmb = Date.now(); + if (!(previewMode && editorEmbedReturn && quizCarryEmbedCountdownEndAt > nowEmb) + && !(previewMode && editorEmbedReturn && quizCarryEmbedPreOptionCountdownEndAt > nowEmb)) { + quizCarryRestoreEmbedPhaseLabel(); + } + } + + function quizCarryOptionsPickableNow() { + if (!isQuizCarry() || !quizCarryCurrent) return true; + /* embed: ระหว่าง 3–2–1 รอบสอง (คำถามขึ้นแล้ว) — ยังไม่วาด/ไม่ให้หยิบป้ายตัวเลือก */ + if (previewMode && editorEmbedReturn && 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()) 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 (previewMode && editorEmbedReturn && 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 (!previewMode || !editorEmbedReturn || !isQuizCarry()) 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(); + } + + 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; + } + let q; + let guard = 0; + do { + q = quizCarryPool[Math.floor(Math.random() * quizCarryPool.length)]; + guard++; + } while (guard < 12 && quizCarryPool.length > 1 && quizCarryCurrent && q && q.text === quizCarryCurrent.text); + if (previewMode && editorEmbedReturn) { + quizCarryEmbedPendingQuestion = q; + preloadQuizCarryChoiceImages(q); + quizCarryEmbedCountdownStartAt = Date.now(); + quizCarryEmbedCountdownEndAt = quizCarryEmbedCountdownStartAt + 3000; + 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(); + return; + } + quizCarryCurrent = q; + preloadQuizCarryChoiceImages(q); + playQuizText = formatQuizCarryQuestionHud(q); + clearQuizMapQuestionCarryFeedback(); + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = playQuizText; + me.quizCarryHeld = null; + others.forEach((o) => { + if (!o) return; + o.quizCarryHeld = null; + }); + quizCarryApplyPhaseTimersForCurrentQuestion(); + } + + /** หลังจบข้อหนึ่งข้อ (ถูกหรือหมดเวลา): พรีวิวนับรอบจนครบ carrySessionLength แล้วจบเซสชัน */ + function quizCarryAfterRoundResolved() { + if (!isQuizCarry()) return; + if (isDetectiveMinigamePlay()) { + finishDetectiveMinigameAndReturnLobby(); + return; + } + if (!previewMode) { + quizCarryPickNextQuestion(); + return; + } + quizCarryRoundsCompleted++; + const lim = quizCarrySessionLength; + if (lim > 0 && quizCarryRoundsCompleted >= lim) { + showQuizCarrySessionCompleteOverlay(); + return; + } + quizCarryPickNextQuestion(); + } + + function playQuizCarryAvatarUrlForMission(characterId) { + if (!characterId) return ''; + return BASE + '/img/characters/' + encodeURIComponent(String(characterId)) + '_down.png'; + } + + /** อวาตาร์สรุปภารกิจ / DOM — ใช้สี gen เดียวกับในเกม (เลเยอร์ + tint) */ + function applyQuizCarryRankAvatarTint(imgEl, characterId, peerId) { + if (!imgEl || !characterId) return; + let attempts = 0; + const fallbackUrl = playQuizCarryAvatarUrlForMission(characterId); + const tick = () => { + attempts++; + const nowT = Date.now(); + const rawImg = getCharacterFrame(characterId, 'down', nowT, false) || getCharacterImg(characterId, 'down'); + if (!rawImg) { + if (fallbackUrl) imgEl.src = fallbackUrl; + return; + } + if ((!rawImg.complete || !rawImg.naturalWidth) && attempts < 28) { + if (fallbackUrl) imgEl.src = fallbackUrl + '?p=' + String(attempts); + setTimeout(tick, 90); + return; + } + if (!rawImg.naturalWidth) { + if (fallbackUrl) imgEl.src = fallbackUrl; + return; + } + const tint = (peerId != null && myId != null && String(peerId) === String(myId)) + ? (me.playTint || playTintFromPeerId(String(peerId))) + : playTintFromPeerId(peerId != null ? String(peerId) : String(characterId)); + const out = getPlayTintedAvatarSource(rawImg, characterId, 'down', nowT, false, tint); + if (out && out.tagName === 'CANVAS' && out.width > 0) { + try { + imgEl.src = out.toDataURL('image/png'); + return; + } catch (e) { /* ต่อไปลอง walk หรือ URL */ } + } + if (out && out.src && out !== rawImg) { + imgEl.src = out.src; + return; + } + if (attempts < 28 && playCharLayerDiscoveryPending(characterId)) { + setTimeout(tick, 120); + return; + } + imgEl.src = rawImg.src || fallbackUrl; + }; + tick(); + } + + /** เกรดจากค่าเฉลี่ยคะแนนทีม (0–100 หลังปัด) — A 80–100, B 60–79, C 0–59 */ + function quizCarryGradeFromTeamAverage(avgRounded) { + if (avgRounded >= 80) return 'A'; + if (avgRounded >= 60) return 'B'; + return 'C'; + } + + function quizCarryRollCardRewardForGrade(grade) { + const u = Math.random() * 100; + if (grade === 'A') { + if (u < 30) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' }; + if (u < 80) return { th: 'การ์ด Rare', en: 'Rare card' }; + return { th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'B') { + if (u < 20) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' }; + if (u < 50) return { th: 'การ์ด Rare', en: 'Rare card' }; + return { th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'C') { + if (u < 20) return { th: 'การ์ด Rare', en: 'Rare card' }; + return { th: 'การ์ดธรรมดา', en: 'Common card' }; + } + return null; + } + + function quizCarryBuildMissionRankList() { + const ranks = []; + if (myId != null) { + ranks.push({ + id: myId, + nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', + score: Math.max(0, Number(playLiveQuizScores[myId]) || 0), + characterId: me.characterId || getPlayCharacterId(), + }); + } + others.forEach((o, id) => { + if (!o) return; + ranks.push({ + id, + nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id), + score: Math.max(0, Number(playLiveQuizScores[id]) || 0), + characterId: o.characterId || null, + }); + }); + ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th')); + return ranks.slice(0, 5); + } + + function cancelEmbedPreviewLobbyReturnTimer() { + if (embedPreviewLobbyReturnTimer != null) { + clearTimeout(embedPreviewLobbyReturnTimer); + embedPreviewLobbyReturnTimer = null; + } + } + + function cancelQuizCarryResultEndAfterTimeup() { + if (quizCarryResultEndTimer != null) { + clearTimeout(quizCarryResultEndTimer); + quizCarryResultEndTimer = null; + } + cancelEmbedPreviewLobbyReturnTimer(); + } + + 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); + } + } + 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'); + 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(); + showQuizCarryMissionSummaryOverlay(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; + const show = !!(mapData && isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && !me.gauntletEliminated && gauntletCrownRunwayAvatarRunAllowedPlay()); + if (!show) { + btn.classList.add('is-hidden'); + btn.setAttribute('aria-hidden', 'true'); + btn.style.pointerEvents = 'none'; + return; + } + btn.classList.remove('is-hidden'); + btn.setAttribute('aria-hidden', 'false'); + btn.style.pointerEvents = ''; + } + + 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'; + } + + 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 (isDetectiveMinigamePlay()) { + if (snap.carryReadMs != null) s.carryReadMs = snap.carryReadMs; + if (snap.carryAnswerMs != null) s.carryAnswerMs = snap.carryAnswerMs; + s.carrySessionLength = 1; + } else { + 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 (previewMode && editorEmbedReturn) { + beginQuizCarryEmbedPregame(); + } else if (isDetectiveMinigamePlay()) { + quizCarryPickNextQuestion(); + } else { + quizCarryPickNextQuestion(); + } + const nowJoin = Date.now(); + if (!(previewMode && editorEmbedReturn && quizCarryEmbedCountdownEndAt > nowJoin) + && !(previewMode && editorEmbedReturn && 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; + if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) { + const d = pickRandomPreviewBotWanderDir(); + o.botWanderDx = d[0]; + o.botWanderDy = d[1]; + } + if (typeof o.botWanderNextTurn !== 'number') o.botWanderNextTurn = now + 600; + if (now >= o.botWanderNextTurn) { + o.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200); + if (Math.random() < 0.55) { + const d = pickRandomPreviewBotWanderDir(); + o.botWanderDx = d[0]; + o.botWanderDy = d[1]; + } + } + const accX = o.botWanderDx; + const accY = o.botWanderDy; + if (Math.abs(accY) > Math.abs(accX)) o.direction = accY > 0 ? 'down' : 'up'; + else if (accX !== 0) o.direction = accX > 0 ? 'right' : 'left'; + const step = MOVE_SPEED * quizCarryWalkSpeedMultActive; + const nx = o.x + accX * step; + const ny = o.y + accY * step; + const ox = o.x; + const oy = o.y; + if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) { + o.x = nx; + o.y = ny; + } else { + if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) { + o.x = nx; + } else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) { + o.y = ny; + } else { + const d = pickRandomPreviewBotWanderDir(); + o.botWanderDx = d[0]; + o.botWanderDy = d[1]; + o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600); + } + } + if (!Number.isFinite(o.x)) o.x = 0.5; + if (!Number.isFinite(o.y)) o.y = 0.5; + clampPlayEntityFootprintToMap(o, mapData); + if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true; + } + + function stepQuizCarryPreviewBots() { + if (quizCarrySessionEnded) return; + if (quizCarryPregameActive) return; + if (!playBotsEnabled() || !isQuizCarry() || !mapData) return; + const w = mapData.width || 20, h = mapData.height || 15; + const nowPf = performance.now(); + const pathfindMinGap = editorEmbedReturn ? 280 : 140; + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + if (quizCarryFootprintOverlapsHub(o.x, o.y)) { + const sn = snapPositionOutOfQuizCarryHubIfNeeded(o.x, o.y); + if (Math.abs(sn.x - o.x) > 0.02 || Math.abs(sn.y - o.y) > 0.02) { + o.x = sn.x; + o.y = sn.y; + o.botPath = []; + o.botPathStuckTicks = 0; + o.botQuizCarryPathfindAfter = 0; + } + } + if (!o.botPath || o.botPath.length === 0) { + tryQuizCarryInteractionForPlayer(id, o.x, o.y, { silent: true }); + } + /* ช่วงนับถอยหลัง / เปลี่ยนข้อ — ยังให้เดินได้ */ + if (!quizCarryCurrent) { + quizCarryPreviewBotLobbyWanderStep(o, w, h); + return; + } + const tier = o.botTier || 'avg'; + const pCorrect = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.35 : 0.65; + const rawWant = Math.random() < pCorrect ? quizCarryCurrent.correctIndex + : Math.floor(Math.random() * Math.max(2, (quizCarryCurrent.choices || []).length)); + const wantIdx = pickQuizCarryTargetOptionIndexAvoidingTaken(rawWant); + if (!o.botPath || !o.botPath.length) { + if (typeof o.botQuizCarryPathfindAfter !== 'number') o.botQuizCarryPathfindAfter = 0; + if (nowPf < o.botQuizCarryPathfindAfter) { + quizCarryPreviewBotLobbyWanderStep(o, w, h); + return; + } + const jitter = (String(id).length * 31 + ((o.x | 0) ^ (o.y | 0)) * 7) % 160; + if (quizCarryOptionsPickableNow()) { + if (o.quizCarryHeld == null && wantIdx != null) { + const dest = pickRandomTileForQuizCarryOption(mapData, wantIdx, o); + if (dest) { + const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o); + if (path && path.length > 1) o.botPath = path.slice(1); + } + } else { + const sub = pickRandomTileForQuizCarrySubmit(mapData, o); + if (sub) { + const path = pathfindPlayForBot(o.x, o.y, sub.x + 0.5, sub.y + 0.5, o); + if (path && path.length > 1) o.botPath = path.slice(1); + } + } + o.botQuizCarryPathfindAfter = nowPf + pathfindMinGap + jitter; + } else { + o.botQuizCarryPathfindAfter = nowPf + 120; + quizCarryPreviewBotLobbyWanderStep(o, w, h); + } + } else { + stepPreviewBotAlongPath(o, w, h); + } + clampPlayEntityFootprintToMap(o, mapData); + }); + separateClumpedPreviewBots(); + [...others.keys()].filter(isPreviewBotId).forEach((bid) => { + const ob = others.get(bid); + if (ob) clampPlayEntityFootprintToMap(ob, mapData); + }); + } + + function getStackAreaBoundsPlay(area, w, h) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + let any = false; + for (let y = 0; y < h; y++) { + const row = area && area[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (row[x] === 1) { + any = true; + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + } + } + if (!any) return null; + return { + minX, maxX, minY, maxY, + cx: (minX + maxX + 1) / 2, + cy: (minY + maxY + 1) / 2, + }; + } + + /** ปิดการตามความสูงหอจาก 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; + 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(); + } 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(); + } + } + } + + /** + * 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' && + (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) { + rebuildStackPreviewTurnOrder(); + } + if (playBotsEnabled() && !isStackPreviewHumanTurn()) return false; + 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; + if (playBotsEnabled()) { + lastStackPreviewActorId = '__human__'; + lastStackPreviewActorUntil = Date.now() + 900; + } + 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; + o.stackBotGlowUntil = Date.now() + Math.max(900, 400 + (stackFall && stackFall.dur ? stackFall.dur : 800)); + lastStackPreviewActorId = botId; + lastStackPreviewActorUntil = Date.now() + Math.max(1000, 500 + (stackFall && stackFall.dur ? stackFall.dur : 800)); + } + + /** Stack preview: สลับตา — เฉพาะบอทที่ถึงตาถึงจะจำลองปล่อยหนึ่งครั้งแล้วเลื่อนตา */ + function stepPreviewStackTurnBased(nowMs) { + if (!playBotsEnabled() || !isStack() || !stackMini || stackFall || stackMini.settling) return; + if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return; + if (mapData && mapData.gameType === 'stack' && + (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) { + rebuildStackPreviewTurnOrder(); + } + const order = stackPreviewTurnOrder; + if (!order || order.length !== STACK_PREVIEW_TURN_COUNT) return; + const cur = order[stackPreviewTurnIndex]; + if (!cur || cur.kind !== 'bot') return; + if (!cur.botId) { + advanceStackPreviewTurn(); + return; + } + const o = others.get(cur.botId); + if (!o) { + advanceStackPreviewTurn(); + return; + } + if (stackPreviewBotThinkUntil === 0) { + 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); + } + + function stackTickFrame() { + stackTowerMissionMaybeEndPlay(); + const now = performance.now(); + const dt = Math.min(0.06, (now - lastStackTickMs) / 1000); + lastStackTickMs = now; + if (stackMini && stackMini.settling) { + const s = stackMini.settling; + const u = (now - s.t0) / s.dur; + if (u >= 1) { + const wasHuman = !!s.previewHumanDrop; + const settleBotId = s.previewBotId || null; + applyStackHitFromDrop({ miss: true, perfect: false, pts: 0, landOffsetTiles: 0 }, undefined); + if (playBotsEnabled() && (wasHuman || settleBotId)) { + stackPreviewLogStackDrop({ miss: true, perfect: false, pts: 0 }, { human: wasHuman, botId: settleBotId }); + } + stackMini.settling = null; + stackMini.lastDropAt = Date.now(); + 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) { + 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) { + stackMini.phase += stackMini.phaseSpeed * dt * Math.PI * 2; + } + 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); + } + } + + 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; + const peakAt = 0.28; + if (t >= peakAt) { + const span = 1 - peakAt; + const u = span > 0 ? (t - peakAt) / span : 0; + return Math.max(0, 1 - u * u * 1.2); + } + return Math.min(1, t / peakAt); + } + + function getGauntletObsDrawPositionsAt(nowMs) { + if (!gauntletObsRenderNext.length) return []; + const elapsed = nowMs - gauntletObsBlendT0; + const alpha = gauntletObsSmoothstep(elapsed / gauntletRuntimeTickMs); + const prevMap = new Map(gauntletObsRenderPrev.map((o) => [o.id, o])); + const out = []; + for (let i = 0; i < gauntletObsRenderNext.length; i++) { + const n = gauntletObsRenderNext[i]; + if (!n) continue; + const p = prevMap.get(n.id); + let drawX = n.x; + if (p && Number.isFinite(p.x) && Number.isFinite(n.x)) drawX = p.x + (n.x - p.x) * alpha; + const row = { id: n.id, kind: n.kind, drawX, y: n.y }; + if (n.kind === 'laser') { + if (n.y0 != null) row.y0 = n.y0; + if (n.y1 != null) row.y1 = n.y1; + } + out.push(row); + } + return out; + } + + function pushGauntletObsRenderFrame(newObs) { + const now = performance.now(); + const next = cloneGauntletObsSnap(newObs); + if (!gauntletObsRenderNext.length) { + gauntletObsRenderPrev = next.slice(); + gauntletObsRenderNext = next.slice(); + } else { + const curDraw = getGauntletObsDrawPositionsAt(now); + const curMap = new Map(curDraw.map((o) => [o.id, o.drawX])); + gauntletObsRenderPrev = next.map((n) => { + const row = { + id: n.id, + kind: n.kind, + x: curMap.has(n.id) ? curMap.get(n.id) : n.x, + y: n.y, + }; + if (n.kind === 'laser') { + if (n.y0 != null) row.y0 = n.y0; + if (n.y1 != null) row.y1 = n.y1; + } + return row; + }); + gauntletObsRenderNext = next; + } + gauntletObsBlendT0 = now; + } + + function lerpGauntletEntityPos(x, y, tx, ty) { + let nx = x; + let ny = y; + if (tx != null && Number.isFinite(tx)) { + nx += (tx - nx) * GAUNTLET_VIS_LERP; + if (Math.abs(tx - nx) < 0.02) nx = tx; + } + if (ty != null && Number.isFinite(ty)) { + ny += (ty - ny) * GAUNTLET_VIS_LERP; + if (Math.abs(ty - ny) < 0.02) ny = ty; + } + return { nx, ny }; + } + + /** + * บอท preview ใน Gauntlet: ตัดสินใจกระโดด (client-only; เซิร์ฟเวอร์ไม่รู้จักบอท) + * — ตอบสนองสิ่งกีดขวางที่ชนเซลล์เดียวกัน + กระโดดล่วงหน้าเมื่อ threat อยู่คอลัมน์ถัดไป + */ + function maybePreviewBotGauntletJump(o, obstacles, w, h) { + if (isGauntletCrownHeistMapPlay() && o.gauntletEliminated) return; + let px = Math.floor(Number(o.x)) || 0; + let py = Math.floor(Number(o.y)) || 0; + px = Math.max(0, Math.min(w - 1, px)); + py = Math.max(0, Math.min(h - 1, py)); + if ((o.gauntletJumpTicks || 0) > 0) return; + const { ch: gauntletCh } = getCharacterFootprintWH(mapData); + let sameCell = false; + let incomingCol = false; + for (let i = 0; i < obstacles.length; i++) { + const obs = obstacles[i]; + if (!obs) continue; + if (obs.kind === 'lane' && typeof obs.y === 'number') { + if (obs.x === px && obs.y >= py && obs.y < py + gauntletCh) sameCell = true; + if (obs.x === px + 1 && obs.y >= py && obs.y < py + gauntletCh) incomingCol = true; + } + if (obs.kind === 'laser' && typeof obs.x === 'number') { + if (obs.x === px && gauntletLaserOverlapsPlayerRow(obs, py, h)) sameCell = true; + if (obs.x === px + 1 && gauntletLaserOverlapsPlayerRow(obs, py, h)) incomingCol = true; + } + } + const tier = o.botTier || 'avg'; + const roll = Math.random(); + const pSame = tier === 'sharp' ? 0.96 : tier === 'weak' ? 0.62 : 0.86; + const pAhead = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.48 : 0.72; + if (sameCell && roll < pSame) { + o.gauntletJumpTicks = gauntletRuntimeJumpTicks; + return; + } + if (incomingCol && roll < pAhead) o.gauntletJumpTicks = gauntletRuntimeJumpTicks; + } + + /** หนึ่ง tick เดียวกับ runGauntletTick ฝั่งเซิร์ฟเวอร์ (เฉพาะ collision + เลื่อน x) */ + function applyGauntletPhysicsToPreviewBot(o, obstacles, w, h) { + if (isGauntletCrownHeistMapPlay() && o.gauntletEliminated) return; + let px = Math.floor(Number(o.x)) || 0; + let py = Math.floor(Number(o.y)) || 0; + px = Math.max(0, Math.min(w - 1, px)); + py = Math.max(0, Math.min(h - 1, py)); + const air = (o.gauntletJumpTicks || 0) > 0; + const { ch: gauntletCh2 } = getCharacterFootprintWH(mapData); + const crown = isGauntletCrownHeistMapPlay(); + let advanceX = false; + let hitBack = false; + for (let i = 0; i < obstacles.length; i++) { + const ob = obstacles[i]; + if (!ob) continue; + if (ob.kind === 'lane' && typeof ob.y === 'number' && ob.x === px && ob.y >= py && ob.y < py + gauntletCh2) { + if (air) advanceX = true; + else hitBack = true; + } + if (ob.kind === 'laser' && typeof ob.x === 'number' && ob.x === px) { + if (!gauntletLaserOverlapsPlayerRow(ob, py, h)) { + /* เลเซอร์ไม่ครอบแถวนี้ */ + } else if (air) { + advanceX = true; + } else { + hitBack = true; + } + } + } + if (advanceX) { + px = Math.min(w - 2, px + 1); + o.gauntletJumpTicks = 0; + if (!crown) { + o.gauntletScore = (o.gauntletScore || 0) + 1; + } + } else if (hitBack) { + if (crown) { + if (px <= 0) { + o.gauntletEliminated = true; + o.gauntletScore = 0; + } else { + o.gauntletScore = Math.max(0, (o.gauntletScore || 0) - 10); + 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() || !mapData || mapData.gameType !== 'gauntlet') return; + if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return; + const w = mapData.width || 20; + const h = mapData.height || 15; + others.forEach((o, id) => { + if (!isPreviewBotId(id) || !o) return; + maybePreviewBotGauntletJump(o, gauntletObstacles, w, h); + applyGauntletPhysicsToPreviewBot(o, gauntletObstacles, w, h); + }); + } + + /** แจ้งเซิร์ฟเวอร์แถว y ที่มีคุณ+บอททดสอบ — lane spawn ใช้เฉพาะแถวที่มี peer; บอทไม่ใช่ peer */ + function emitGauntletPreviewRowsToServer() { + if (!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'; + } + + function gauntletCrownPregameReadyNumerator() { + let n = quizCarryPregameBotCount(); + quizCarryPregameHumanIds().forEach((id) => { + if (gauntletCrownLobbyReadyMap[id]) n++; + }); + return n; + } + + function gauntletCrownSyncGuestReadyIfNeeded() { + const inQuizQuestionHowto = isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto'; + const inStackTowerHowto = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto'; + const inJumpSurviveHowto = isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto'; + const inSpaceShooterHowto = isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto'; + if (gauntletCrownPregamePhase !== 'howto' && !inQuizQuestionHowto && !inStackTowerHowto && !inJumpSurviveHowto && !inSpaceShooterHowto) return; + if (myId == null || isMePlayHost()) return; + const sid = String(myId); + if (gauntletCrownLobbyReadyMap[sid]) return; + gauntletCrownLobbyReadyMap[sid] = true; + if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: true }); + } + + /** ภารกิจคำถาม 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; + const humans = quizCarryPregameHumanIds(); + const tot = Math.max(1, quizCarryPregameTotalPlayers()); + const num = gauntletCrownPregameReadyNumerator(); + st.classList.remove('is-hidden'); + st.textContent = 'Ready Status : ' + num + '/' + tot; + const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); + btn.classList.toggle('is-start-phase', humansReady); + btn.classList.toggle('is-read-only', !isMePlayHost()); + btn.disabled = !isMePlayHost(); + btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false')); + btn.title = isMePlayHost() + ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY')) + : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); + } + + function updateGauntletCrownHowtoHud() { + const st = document.getElementById('gauntlet-crown-howto-status'); + const btn = document.getElementById('btn-gch-ready'); + if (!btn || !usesCrownLobbyShellPlay() || gauntletCrownPregamePhase !== 'howto') return; + const humans = quizCarryPregameHumanIds(); + const tot = Math.max(1, quizCarryPregameTotalPlayers()); + const num = gauntletCrownPregameReadyNumerator(); + if (st) { + st.classList.remove('is-hidden'); + st.textContent = 'Ready Status : ' + num + '/' + tot; + } + const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); + btn.classList.toggle('is-start-phase', humansReady); + btn.classList.toggle('is-read-only', !isMePlayHost()); + btn.disabled = !isMePlayHost(); + btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false')); + btn.title = isMePlayHost() + ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY')) + : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); + } + + function beginGauntletCrownCountdownThenRun() { + if (!usesCrownLobbyShellPlay()) return; + if (gauntletCrownCountdownTimer) { + clearTimeout(gauntletCrownCountdownTimer); + gauntletCrownCountdownTimer = null; + } + gauntletCrownPregamePhase = 'countdown'; + if (playBotsEnabled() && mapData && mapData.gameType === 'gauntlet') { + applyGauntletPreviewSpawnLayout(false); + } + trySnapGauntletCrownRunwayScrollToSpawnsPlay(); + const howto = document.getElementById('gauntlet-crown-howto-overlay'); + if (howto) howto.classList.add('is-hidden'); + gauntletCrownHowtoVisible = false; + const cd = document.getElementById('gauntlet-crown-countdown'); + const numEl = document.getElementById('gauntlet-crown-countdown-num'); + function applyMegaBalloonLiveStartPlay() { + balloonBossSessionStartMs = performance.now(); + balloonBossLastTickMs = performance.now(); + balloonBossGameEnded = false; + balloonBossPendingShots = []; + balloonBossPlayerBullets = []; + balloonBossBossBullets = []; + balloonBossBossFireAcc = 0; + balloonBossScorePopups = []; + 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; + } + cd.classList.remove('is-hidden'); + let n = 3; + setCountdown321QuestionAssetGraphic(numEl, n); + const step = () => { + n--; + if (n > 0) { + setCountdown321QuestionAssetGraphic(numEl, n); + gauntletCrownCountdownTimer = setTimeout(step, 1000); + } else { + gauntletCrownCountdownTimer = null; + runFinish(); + } + }; + gauntletCrownCountdownTimer = setTimeout(step, 1000); + } + + function showGauntletCrownHowtoOverlay() { + if (!usesCrownLobbyShellPlay()) return; + hideConflictingOverlaysForGauntletCrown(); + if (isMegaVirusMissionShellMapPlay()) applyMegaVirusMissionPanelImages(); + const cd = document.getElementById('gauntlet-crown-countdown'); + if (cd) cd.classList.add('is-hidden'); + 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 (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); + gauntletCrownSyncGuestReadyIfNeeded(); + updateGauntletCrownHowtoHud(); + } + + /** Editor embed: quiz_carry / crown ใช้เกรด F = gameover — Jumper: ภาพจบใช้เฉพาะเมื่อไม่มีผู้รอด (0 คน) */ + function gauntletCrownEmbedMissionAnyOk(mission) { + if (!mission) return false; + if (mission.uiSkin === '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, 5); + 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 { + 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, + }); + }); + + baseRows.sort((a, b) => { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + const nick = String(a.nickname).localeCompare(String(b.nickname), 'th'); + if (nick !== 0) return nick; + return String(a.id).localeCompare(String(b.id)); + }); + const top = baseRows.slice(0, 5); + const ranked = top.map((row, idx) => { + const pos = idx + 1; + const rankBonus = gauntletCrownRankBonusLocal(pos); + const rankLabel = pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos); + const finalScore = row.baseScore + rankBonus; + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: row.baseScore, + eliminated: row.eliminated, + rank: pos, + rankLabel: rankLabel, + rankBonus: rankBonus, + finalScore: finalScore, + }; + }); + + const totalSum = ranked.reduce(function (s, r) { return s + r.finalScore; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + const grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; + const rewardCard = gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: ranked.map(function (r) { return r.finalScore; }), + }; + } + + function showGauntletCrownMissionOverlay(mission) { + const ov = document.getElementById('gauntlet-crown-mission-overlay'); + const rowEl = document.getElementById('gcm-rank-row'); + const totalEl = document.getElementById('gcm-total'); + const gradeEl = document.getElementById('gcm-grade'); + const bonusEl = document.getElementById('gcm-bonus'); + const btn = document.getElementById('btn-gcm-done'); + if (!ov || !rowEl || !mission || !Array.isArray(mission.ranked)) return; + let disp = gauntletCrownBuildDisplayMission(mission); + if (mission.uiSkin === 'jumper') { + disp = jumpSurviveMergePreviewBotsMission(disp) || disp; + } 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; + rowEl.innerHTML = ''; + disp.ranked.forEach((r) => { + const cell = document.createElement('div'); + cell.className = 'gcm-cell'; + const tag = document.createElement('div'); + tag.className = 'gcm-rank-tag'; + const rk = Math.floor(Number(r.rank)) || 0; + if (rk >= 1 && rk <= 3) { + const tagImg = document.createElement('img'); + tagImg.src = gcmRankBadgeUrl(rk); + tagImg.alt = '[' + String(r.rankLabel || rk) + ']'; + tagImg.style.maxWidth = '100%'; + tagImg.style.height = 'auto'; + tag.appendChild(tagImg); + } else { + tag.textContent = '[' + String(r.rankLabel || rk) + ']'; + } + const frame = document.createElement('div'); + frame.className = 'gcm-frame'; + const img = document.createElement('img'); + img.className = 'gcm-av'; + img.alt = ''; + const cid = r.characterId ? String(r.characterId) : ''; + if (cid) { + setCyberHudScoreAvatarImg(img, { + id: r.id, + characterId: cid, + isMe: myId != null && r.id != null && String(r.id) === String(myId), + }); + } else { + img.src = defaultAvatarImg.src; + } + img.onerror = function () { + this.onerror = null; + this.src = defaultAvatarImg.src; + }; + frame.appendChild(img); + const qMissionLose = mission && mission.uiSkin === 'question_mission' && r.hadQuizWrong; + 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)) + || (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)); + 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 (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'; + if (missionOk) { + const row = document.createElement('div'); + 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'; + }; + } + chk.width = 26; + chk.height = 26; + const sp = document.createElement('span'); + sp.style.color = '#9ece6a'; + sp.style.fontWeight = '700'; + sp.textContent = 'ภารกิจสำเร็จ'; + row.appendChild(chk); + row.appendChild(sp); + bonusEl.appendChild(row); + } + const rc = disp.rewardCard; + if (missionOk && rc && rc.th) { + const pl = document.createElement('div'); + pl.className = 'gcm-plaque'; + pl.innerHTML = '' + String(rc.th) + '' + String(rc.en || '') + ''; + bonusEl.appendChild(pl); + } else if (!missionOk) { + const pl = document.createElement('div'); + pl.className = 'gcm-plaque'; + pl.style.borderColor = 'rgba(148, 153, 168, 0.45)'; + pl.style.opacity = '0.95'; + pl.innerHTML = 'ไม่ได้รับการ์ดเกรด ' + gLetter + ' · ภารกิจไม่ผ่านเกณฑ์'; + bonusEl.appendChild(pl); + } + } + const gcmHead = document.getElementById('gcm-heading'); + if (gcmHead) { + if (mission && (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'); + } + ov.classList.remove('is-hidden'); + 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')) { + 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; + const comp = Array(h).fill(0).map(() => Array(w).fill(0)); + let nextId = 0; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (grid[y][x] !== 1 || comp[y][x]) continue; + nextId++; + const stack = [[x, y]]; + comp[y][x] = nextId; + while (stack.length) { + const c = stack.pop(); + const cx = c[0], cy = c[1]; + for (let i = 0; i < 4; i++) { + const dx = (i === 0 ? 1 : i === 1 ? -1 : 0); + const dy = (i === 2 ? 1 : i === 3 ? -1 : 0); + const nx = cx + dx, ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; + if (grid[ny][nx] !== 1 || comp[ny][nx]) continue; + comp[ny][nx] = nextId; + stack.push([nx, ny]); + } + } + } + } + md.quizBattleDomeComp = comp; + } + + /** เส้นทาง Quiz Battle — วาดอย่างน้อย 1 ช่องแล้วจะเดินได้เฉพาะบนเส้นทาง (เปลี่ยนแค่รูปพื้นหลังได้) */ + function normalizeQuizBattlePathInPlay(md) { + if (!md || md.gameType !== 'quiz_battle') return; + const w = md.width || 20, h = md.height || 15; + const src = md.quizBattlePathArea || []; + const grid = []; + for (let y = 0; y < h; y++) { + const r = src[y]; + const row = []; + for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0); + grid.push(row); + } + md.quizBattlePathArea = grid; + } + + function quizBattlePathModeActive(md) { + if (!md || md.gameType !== 'quiz_battle' || !md.quizBattlePathArea) return false; + const g = md.quizBattlePathArea; + for (let y = 0; y < g.length; y++) { + const row = g[y]; + if (!row) continue; + for (let x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; + } + + function quizBattleFootprintFullyOnPath(md, px, py) { + if (!md || !quizBattlePathModeActive(md)) return true; + if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false; + const tiles = quizTilesFootprintPlay(px, py); + if (tiles.size === 0) return false; + const g = md.quizBattlePathArea; + for (const k of tiles) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (!g[ty] || g[ty][tx] !== 1) return false; + } + return true; + } + + /** สแน็ปบนเลน — เช็คแค่กำแพง + อยู่บน path (ให้ตรงกับ server) ไม่ใช้ blockPlayer/ผู้เล่นคนอื่น เพราะจะทำให้หาจุดสแน็ปไม่ได้แล้วค้างนอกเลน */ + function quizBattleSnapTargetValidPlay(x, y) { + if (!mapData || !mapData.objects) return false; + if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false; + const w = mapData.width || 20, h = mapData.height || 15; + for (const k of quizTilesWallCollisionFootprintPlay(x, y)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const row = mapData.objects[ty]; + if (!row || row[tx] === 1) return false; + } + if (quizBattlePathModeActive(mapData) && !quizBattleFootprintFullyOnPath(mapData, x, y)) return false; + return true; + } + + function snapPositionOntoQuizBattlePathIfNeeded(px, py) { + if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return { x: px, y: py }; + if (quizBattleSnapTargetValidPlay(px, py)) return { x: px, y: py }; + const w = mapData.width || 20, h = mapData.height || 15; + const g = mapData.quizBattlePathArea; + let bestX = null, bestY = null, bestD = Infinity; + for (let ty = 0; ty < h; ty++) { + for (let tx = 0; tx < w; tx++) { + if (!g[ty] || g[ty][tx] !== 1) continue; + const nx = tx + 0.01; + const ny = ty + 0.01; + if (!quizBattleSnapTargetValidPlay(nx, ny)) continue; + const d = Math.abs(nx - px) + Math.abs(ny - py); + if (d < bestD) { bestD = d; bestX = nx; bestY = ny; } + } + } + if (bestX != null) return { x: bestX, y: bestY }; + /* footprint ใหญ่กว่าเลน (เช่น 2×2 บนทางแคบ 1 ช่อง): หาช่อง path ใกล้สุดที่เดินได้อย่างน้อยที่เซ็นเตอร์ — ดีกว่าปล่อยค้างนอกเลน */ + for (let ty = 0; ty < h; ty++) { + for (let tx = 0; tx < w; tx++) { + if (!g[ty] || g[ty][tx] !== 1) continue; + if (!spawnTileWalkablePlay(mapData, tx, ty)) continue; + const nx = tx + 0.5; + const ny = ty + 0.5; + const d = Math.abs(nx - px) + Math.abs(ny - py); + if (d < bestD) { bestD = d; bestX = nx; bestY = ny; } + } + } + if (bestX != null) return { x: bestX, y: bestY }; + return { x: px, y: py }; + } + + /** บังคับให้ผู้เล่นอยู่บนเลน — เรียกก่อน return กลาง tick (แชท / path หมด) เพราะเดิมข้ามบล็อกสแน็ปท้าย tick */ + function enforceQuizBattleLaneOnMePlay() { + if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return; + if (quizBattleSnapTargetValidPlay(me.x, me.y)) return; + const snapped = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y); + me.x = snapped.x; + me.y = snapped.y; + } + + function hideQuizBattleMcqModal() { + const ov = document.getElementById('quiz-battle-mcq-overlay'); + if (ov) { + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + } + + function flashQuizBattleFeedback(text, ok) { + const el = document.getElementById('play-quiz-feedback'); + if (!el) return; + el.textContent = text || ''; + el.classList.remove('is-hidden', 'play-quiz-feedback-ok', 'play-quiz-feedback-bad'); + el.classList.add(ok ? 'play-quiz-feedback-ok' : 'play-quiz-feedback-bad'); + if (typeof window.__quizBattleFbT === 'number') clearTimeout(window.__quizBattleFbT); + window.__quizBattleFbT = setTimeout(() => { el.classList.add('is-hidden'); }, 1800); + } + + function getQuizBattleDomeCompIdsUnderFootprint(px, py) { + if (!mapData || !isQuizBattle()) return []; + const comp = mapData.quizBattleDomeComp; + const grid = mapData.quizBattleDomeArea; + if (!comp || !grid) return []; + const ids = []; + const seen = Object.create(null); + for (const k of quizTilesFootprintPlay(px, py)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + const cid = comp[ty] && comp[ty][tx]; + if (grid[ty] && grid[ty][tx] === 1 && cid > 0 && !seen[cid]) { + seen[cid] = 1; + ids.push(cid); + } + } + return ids; + } + + function getPrimaryQuizBattleDomeCompAt(px, py) { + const arr = getQuizBattleDomeCompIdsUnderFootprint(px, py); + if (!arr.length) return null; + return Math.min.apply(null, arr); + } + + function showQuizBattleMcqModal(q) { + const ov = document.getElementById('quiz-battle-mcq-overlay'); + const textEl = document.getElementById('quiz-battle-mcq-text'); + if (!ov || !textEl || !q) return; + textEl.textContent = q.text || ''; + const labels = ['A', 'B', 'C']; + for (let i = 0; i < 3; i++) { + const btn = ov.querySelector('.quiz-battle-choice[data-idx="' + i + '"]'); + if (btn) { + const ch = (q.choices && q.choices[i]) ? String(q.choices[i]) : ''; + btn.textContent = labels[i] + (ch ? (': ' + ch.slice(0, 96)) : ''); + } + } + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + } + + function trySubmitQuizBattleChoice(idx) { + if (quizBattleModalCompId == null || !mapData || !isQuizBattle()) return; + const compId = quizBattleModalCompId; + const pool = quizBattleMcqPool; + if (!pool.length) { + flashQuizBattleFeedback('ยังไม่มีคำถาม — ตั้ง battleQuizMcq ใน Admin → Quiz Battle', false); + quizBattleModalCompId = null; + hideQuizBattleMcqModal(); + return; + } + const q = pool[(compId - 1) % pool.length]; + if (!q) return; + const ok = Number(q.correctIndex) === Number(idx); + if (ok) { + quizBattleAnsweredComps.add(compId); + if (myId != null) { + if (!playLiveQuizScores) playLiveQuizScores = {}; + playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + 1; + renderPlayQuizScoreboard(playLiveQuizScores); + } + flashQuizBattleFeedback('ถูกต้อง · +1', true); + } else { + flashQuizBattleFeedback('ยังไม่ถูก — ลองใหม่ (กด E อีกครั้ง)', false); + } + quizBattleModalCompId = null; + hideQuizBattleMcqModal(); + } + + function tryOpenQuizBattleFromKey() { + if (!isQuizBattle() || !mapData || myId == null) return; + const compId = getPrimaryQuizBattleDomeCompAt(me.x, me.y); + if (compId == null) { + flashQuizBattleFeedback('ยังไม่ยืนบนโดมคำถาม', false); + return; + } + if (quizBattleAnsweredComps.has(compId)) { + flashQuizBattleFeedback('ตอบโดมนี้ถูกแล้ว', false); + return; + } + const pool = quizBattleMcqPool; + if (!pool.length) { + flashQuizBattleFeedback('ยังไม่มีคำถามในระบบ (Admin → Quiz Battle)', false); + return; + } + const q = pool[(compId - 1) % pool.length]; + if (!q) return; + quizBattleModalCompId = compId; + showQuizBattleMcqModal(q); + } + + function pushSanitizedBattleMcqFromPayload(s) { + if (!s || !Array.isArray(s.battleQuizMcq)) return 0; + let n = 0; + for (let i = 0; i < s.battleQuizMcq.length; i++) { + const raw = s.battleQuizMcq[i]; + if (!raw || !String(raw.text || '').trim()) continue; + const ch = Array.isArray(raw.choices) ? raw.choices : []; + if (ch.length !== 3) continue; + const a = String(ch[0] || '').trim(); + const b = String(ch[1] || '').trim(); + const c = String(ch[2] || '').trim(); + if (!a || !b || !c) continue; + let ci = Number(raw.correctIndex); + if (!Number.isFinite(ci)) ci = 0; + ci = Math.max(0, Math.min(2, Math.floor(ci))); + quizBattleMcqPool.push({ + text: String(raw.text).trim(), + choices: [a, b, c], + correctIndex: ci, + }); + n++; + } + return n; + } + + /** + * โหลดชุดข้อ A/B/C — ลอง Node ก่อน แล้ว fallback PHP อ่านจากดิสก์ (กรณี nginx ไม่ส่ง /api/quiz-settings ถึง Node) + */ + async function loadQuizBattleMcqPool() { + quizBattleMcqPool = []; + const bust = '_=' + Date.now(); + const tryUrls = [ + BASE + '/api/quiz-settings?' + bust, + BASE + '/api-quiz-battle-mcq.php?' + bust, + ]; + for (let u = 0; u < tryUrls.length; u++) { + try { + const r = await fetch(tryUrls[u], { cache: 'no-store' }); + if (!r.ok) continue; + const s = await r.json(); + if (pushSanitizedBattleMcqFromPayload(s) > 0) break; + } catch (e) { /* next url */ } + } + } + + function resetQuizBattlePlayState() { + quizBattleMcqPool = []; + quizBattleAnsweredComps = new Set(); + quizBattleModalCompId = null; + hideQuizBattleMcqModal(); + } + + function setupPlayQuizBattleUi() { + if (playQuizTimerInterval) { + clearInterval(playQuizTimerInterval); + playQuizTimerInterval = null; + } + const ov = document.getElementById('quiz-game-overlay'); + /* ทดสอบจากเอดิเตอร์: อย่าเปิดแผงคำถามเต็มจอล่าง — บังมองเห็นบอท/แผนที่ (overlay ยังอัปเดตข้อความไว้ถ้าเปิด preview แบบไม่ embed) */ + if (ov) { + if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden'); + else ov.classList.remove('is-hidden'); + } + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = 'Quiz Battle'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = 'เดินทับโดม (สีฟ้า–ขอบแดง) แล้วกด E เพื่อตอบ A / B / C'; + const tEl = document.getElementById('quiz-game-timer'); + if (tEl) tEl.textContent = ''; + const leg = document.getElementById('quiz-play-legend'); + if (leg) { + leg.textContent = quizBattlePathModeActive(mapData) + ? 'โหมดเส้นทาง: เดินได้เฉพาะช่องเส้นทาง (ม่วงบนแผนที่) — วางโดมบนเส้นทาง · ไม่วาดเส้นทางใน Editor = เดินอิสระ · เปลี่ยนรูปพื้นหลังได้ทีหลัง' + : 'คำถามจาก Admin → Quiz Battle (battleQuizMcq) · ช่องโดมติดกัน = 1 ข้อ · ถูกแล้วได้ +1 ต่อโดม'; + } + quizBattleAnsweredComps = new Set(); + quizBattleModalCompId = null; + quizBattleMcqPool = []; + hideQuizBattleMcqModal(); + playLiveQuizScores = {}; + if (myId != null) playLiveQuizScores[myId] = 0; + others.forEach((_, id) => { playLiveQuizScores[id] = 0; }); + renderPlayQuizScoreboard(playLiveQuizScores); + loadQuizBattleMcqPool().then(() => { + const q2 = document.getElementById('quiz-game-question'); + if (!q2) return; + if (!quizBattleMcqPool.length) { + q2.textContent = 'ยังไม่มีคำถาม — ไปที่ Admin แท็บ Quiz Battle แล้วบันทึกข้อ A/B/C (ครบคำถาม + A + B + C) · หรือเช็คว่าเซิร์ฟเวอร์เกม (Node) รันอยู่ · ลองรีเฟรชแบบ hard refresh (Ctrl+F5)'; + } else { + const q0 = quizBattleMcqPool[0]; + const snippet = q0 && q0.text ? String(q0.text).trim().slice(0, 100) : ''; + const more = q0 && q0.text && String(q0.text).trim().length > 100 ? '…' : ''; + q2.textContent = snippet + ? ('โหลดคำถามแล้ว ' + quizBattleMcqPool.length + ' ข้อ · ตัวอย่าง: 「' + snippet + more + '」\nเข้าโดมสีฟ้า (ขอบแดง) แล้วกด E เพื่อเลือก A / B / C') + : ('โหลดคำถามแล้ว ' + quizBattleMcqPool.length + ' ข้อ — เดินเข้าโดม (ฟ้า–ขอบแดง) แล้วกด E เพื่อตอบ A / B / C'); + } + const ov2 = document.getElementById('quiz-game-overlay'); + if (ov2 && previewMode && editorEmbedReturn) ov2.classList.add('is-hidden'); + }); + } + + function normalizeJumpSurvivePlatformAreaInPlay(md) { + if (!md || md.gameType !== 'jump_survive') return; + const w = md.width || 20, h = md.height || 15; + const src = md.jumpSurvivePlatformArea || []; + const rows = []; + for (let y = 0; y < h; y++) { + const r = src[y]; + const row = []; + for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0); + rows.push(row); + } + md.jumpSurvivePlatformArea = rows; + } + + function 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; + } + + function buildSpaceShooterParticipantRefsPlay() { + const refs = []; + if (me) refs.push({ id: myId, ref: me }); + const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); + humanIds.forEach((id) => { + const o = others.get(id); + if (o) refs.push({ id, ref: o }); + }); + const botIds = [...others.keys()].filter(isPreviewBotId).sort(); + botIds.forEach((id) => { + const o = others.get(id); + if (o) refs.push({ id, ref: o }); + }); + return refs.filter((r) => r.ref); + } + + function applySpaceShooterSpawnLayoutPlay() { + if (!mapData || mapData.gameType !== 'space_shooter') return; + normalizeShooterSpawnSlotsInPlay(mapData); + const ts = tileSize; + const w = mapData.width || 20, h = mapData.height || 15; + const { cw, ch } = getCharacterFootprintWH(mapData); + const mw = w * ts, mh = h * ts; + const refs = buildSpaceShooterParticipantRefsPlay(); + const n = refs.length || 1; + refs.forEach((entry, idx) => { + const ent = entry.ref; + const slot = (idx % 6) + 1; + const cen = getShooterSpawnWorldCenterFromMap(mapData, slot, ts); + let cx, cy; + if (cen) { + cx = cen.cx; + cy = cen.cy; + } else { + cx = ((idx + 1) / (n + 1)) * mw; + cy = Math.min(mh - ts * 1.2, (h - 1.5) * ts); + } + cx = Math.max(ts * 0.5, Math.min(mw - ts * 0.5, cx)); + cy = clampSpaceShooterWorldCy(cy, mh); + ent.spaceShooterCx = cx; + ent.spaceShooterCy = cy; + 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 90; + } + + function spaceShooterRemainingSecPlay() { + if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return null; + const lim = spaceShooterTimeLimitSecPlay(); + if (lim <= 0) return null; + const elapsed = (performance.now() - spaceShooterSessionStartMs) / 1000; + return Math.max(0, Math.ceil(lim - elapsed)); + } + + function endSpaceShooterTimeUp() { + if (spaceShooterGameEnded || !mapData || mapData.gameType !== 'space_shooter') return; + 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() || !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; + spaceShooterBullets.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 16, vy: -500, ownerId: bid }); + } + }); + } + + 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) { + endSpaceShooterTimeUp(); + return; + } + + const interval = spaceShooterAsteroidSpawnIntervalMsPlay(); + 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; + if (o.tx != null) o.x += (o.tx - o.x) * 0.28; + if (o.ty != null) o.y += (o.ty - o.y) * 0.28; + o.spaceShooterCx = (o.x + cw * 0.5) * ts; + o.spaceShooterCy = clampSpaceShooterWorldCy((o.y + ch * 0.92) * ts, mh); + }); + + let vx = 0; + let vy = 0; + if (!isChatFocused()) { + if (keys['ArrowLeft'] || keys['KeyA']) vx -= 1; + if (keys['ArrowRight'] || keys['KeyD']) vx += 1; + if (keys['ArrowUp'] || keys['KeyW']) vy -= 1; + if (keys['ArrowDown'] || keys['KeyS']) vy += 1; + } + const moveSpd = 280; + 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; + spaceShooterBullets.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 20, vy: -580, ownerId: myId }); + } + + stepSpaceShooterPreviewBots(dt); + + for (let i = spaceShooterBullets.length - 1; i >= 0; i--) { + const b = spaceShooterBullets[i]; + b.y += b.vy * dt; + if (b.y < -50 || b.x < -30 || b.x > mw + 30) spaceShooterBullets.splice(i, 1); + } + for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) { + const a = spaceShooterAsteroids[ai]; + a.y += a.vy * dt; + if (a.y > mh + 100) spaceShooterAsteroids.splice(ai, 1); + } + + 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; + if (b.ownerId === myId) { + me.spaceShooterScore = Math.max(0, (me.spaceShooterScore || 0) + add); + spaceShooterPopups.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 }); + } else { + const o = others.get(b.ownerId); + if (o) { + o.spaceShooterScore = Math.max(0, (o.spaceShooterScore || 0) + add); + spaceShooterPopups.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 }); + } + } + if (a.hp <= 0) { + spaceShooterSpawnAsteroidExplosion(a.x, a.y, a.r); + spaceShooterAsteroids.splice(hit, 1); + } else { + a.r = spaceShooterAsteroidRadiusFromHpPlay(a.hp); + } + } + } + + const wallClock = Date.now(); + for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) { + /* until ใช้ Date.now() — ห้ามเทียบกับ performance.now() (สเกลคนละระบบ → ลบไม่ออก) */ + if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1); + } + + me.direction = 'up'; + me.isWalking = vx !== 0 || vy !== 0; + const tEmit = Date.now(); + if (tEmit - spaceShooterLastMoveEmit > 90 && socket && myId != null) { + spaceShooterLastMoveEmit = tEmit; + socket.emit('move', { + x: me.x, y: me.y, direction: me.direction, + spaceShooterScore: Math.max(0, me.spaceShooterScore | 0), + }); + } + } + + function drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs) { + if (!mapData || !isSpaceShooter()) return; + const refs = buildSpaceShooterParticipantRefsPlay(); + const wallNow = typeof timeMs === 'number' ? timeMs : Date.now(); + + 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 col = SPACE_SHOOTER_SHIP_COLORS[idx % SPACE_SHOOTER_SHIP_COLORS.length]; + const elimShip = !!(isSpaceShooterMissionUiMapPlay() && ent.spaceShooterEliminated); + const slot = Math.max(1, Math.min(6, Number(ent.spaceShooterSlot) || ((idx % 6) + 1))); + 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; + } + + 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 สุ่มต่อเครื่อง) + */ + 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; + const tSec = 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() { + const refs = []; + if (me) refs.push({ id: myId, ref: me }); + const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); + humanIds.forEach((id) => { + const o = others.get(id); + if (o) refs.push({ id, ref: o }); + }); + const botIds = [...others.keys()].filter(isPreviewBotId).sort(); + botIds.forEach((id) => { + const o = others.get(id); + if (o) refs.push({ id, ref: o }); + }); + return refs.filter((r) => r.ref); + } + + function balloonBossBalloonsStartPlay() { + const n = Number(mapData && mapData.balloonBossBalloonsPerPlayer); + /** ไม่มีค่าในแมป = 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) return lim; + const elapsed = (performance.now() - balloonBossSessionStartMs) / 1000; + return Math.max(0, Math.ceil(lim - elapsed)); + } + + function balloonBossTeamDamagePlay() { + let s = Math.max(0, me.balloonBossScore | 0); + others.forEach((o) => { s += Math.max(0, o.balloonBossScore | 0); }); + return s; + } + + /** รวมดาเมจที่ทำกับบอส (ไม่เท่ากับคะแนน — คะแนน +10 ต่อครั้ง, HP บอส -2 ต่อครั้ง) */ + function balloonBossTeamBossDamagePlay() { + 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'; + if (typeof ent.balloonBossAimRad !== 'number' || !Number.isFinite(ent.balloonBossAimRad)) { + ent.balloonBossAimRad = Math.random() * Math.PI * 2; + } + }); + } + + 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(); + }; + } + } + + function stepBalloonBossPreviewBots(dt, mw, mh, bossCx, bossCy, ts) { + if (!playBotsEnabled() || !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 delayMs = balloonBossFireDelayMsPlay(); + const dx = bossCx - o.balloonBossCx; + const dy = bossCy - o.balloonBossCy; + const L = Math.hypot(dx, dy) || 1; + const bspd = 500 + Math.random() * 90; + balloonBossPendingShots.push({ + releaseAt: performance.now() + delayMs, + sx: o.balloonBossCx, sy: o.balloonBossCy, + vx: (dx / L) * bspd, + vy: (dy / L) * bspd, + ownerId: bid, + }); + } + }); + } + + 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(); + if (remSec != null && remSec <= 0) { + endBalloonBossGame('time'); + return; + } + if (teamBossDmg >= maxHp) { + endBalloonBossGame('victory'); + return; + } + const allRefsEarly = buildBalloonBossParticipantRefsPlay(); + if (allRefsEarly.length > 0 && allRefsEarly.every((e) => e.ref && e.ref.balloonBossEliminated)) { + endBalloonBossGame('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; + 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; + aliveRefs.forEach((entry) => { + if (hit) 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; + const canBalloonHit = isPreviewBotId(entry.id) || ent === me; + 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; + 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; + 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 { + 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; + if (typeof r.balloonBossAimRad !== 'number' || !Number.isFinite(r.balloonBossAimRad)) { + r.balloonBossAimRad = Math.random() * Math.PI * 2; + } + r.balloonBossAimRad += 1.02 * dt; + }); + + balloonBossPlayerFireCd -= dt; + const wantFire = !isChatFocused() && !!keys['Space'] && !me.balloonBossEliminated; + if (wantFire && balloonBossPlayerFireCd <= 0 && me.balloonBossCy != null) { + balloonBossPlayerFireCd = 0.35; + const delayMs = balloonBossFireDelayMsPlay(); + const bspd = 480; + let vx; + let vy; + if (isMegaVirusMissionShellMapPlay()) { + const aim = (typeof me.balloonBossAimRad === 'number' && Number.isFinite(me.balloonBossAimRad)) + ? me.balloonBossAimRad + : Math.atan2(bossC.cy - me.balloonBossCy, bossC.cx - me.balloonBossCx); + vx = Math.cos(aim) * bspd; + vy = Math.sin(aim) * bspd; + } else { + const dx = bossC.cx - me.balloonBossCx; + const dy = bossC.cy - me.balloonBossCy; + const L = Math.hypot(dx, dy) || 1; + vx = (dx / L) * bspd; + vy = (dy / L) * bspd; + } + balloonBossPendingShots.push({ + releaseAt: now + delayMs, + sx: me.balloonBossCx, sy: me.balloonBossCy, + vx, + vy, + ownerId: myId, + }); + } + + stepBalloonBossPreviewBots(dt, mw, mh, bossC.cx, bossC.cy, ts); + + const tEmit = Date.now(); + if (tEmit - balloonBossLastMoveEmit > 95 && socket && myId != null) { + balloonBossLastMoveEmit = tEmit; + socket.emit('move', { + x: me.x, y: me.y, direction: me.direction, + balloonBossScore: Math.max(0, me.balloonBossScore | 0), + balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0), + balloonBossBalloons: Math.max(0, me.balloonBossBalloons | 0), + balloonBossEliminated: !!me.balloonBossEliminated, + }); + } + } + + function drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs) { + if (!mapData || !isBalloonBoss()) return; + const ts = tileSize; + const w = mapData.width || 20, h = mapData.height || 15; + const mw = w * ts, mh = h * ts; + const bossC = 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(); + const leaderId = (() => { + let best = null, bs = -1; + refs.forEach((e) => { + const sc = Math.max(0, e.ref.balloonBossScore | 0); + if (sc > bs) { bs = sc; best = e.id; } + }); + return best; + })(); + 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 [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; + 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 = (typeof ent.balloonBossSkinSlot === 'number' && ent.balloonBossSkinSlot >= 1 && ent.balloonBossSkinSlot <= 6) + ? ent.balloonBossSkinSlot + : ((idx % 6) + 1); + const ringR = 25 * z; + /** จุดกลางฟองบนจอ — ยกจากจุด world เล็กน้อยให้ตัวอยู่กลางวง ลูกโป่งชิดขอบบน (ตาม mock) */ + const bubbleCy = sy - ringR * 0.42; + /** มุมลูกศร Artboard 9 — หมุนอัตโนมัติ; Mega Virus ยิงตามมุมนี้ ณ ตอนกด Space */ + const angRing = (typeof ent.balloonBossAimRad === 'number' && Number.isFinite(ent.balloonBossAimRad)) + ? ent.balloonBossAimRad + : ((timeMs * 0.0019 + idx * 1.73) % (Math.PI * 2)); + /** หันหน้าเข้ากล้อง (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(); + if (ent.balloonBossEliminated) ctx.globalAlpha = 0.38; + 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 +Y) หมุนตาม angRing — tail ชี้ทิศยิง */ + ctx.save(); + ctx.translate(sx, bubbleCy); + ctx.rotate(angRing - Math.PI / 2); + 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 = getPlayTintedAvatarSource( + getAvatarImg(ent.characterId, faceDir, timeMs, !!ent.isWalking), + ent.characterId, faceDir, timeMs, !!ent.isWalking, + ent.playTint || playTintFromPeerId(entry.id) + ); + const size = 26 * z; + if (charImg && ((charImg.tagName === 'CANVAS' && charImg.width > 0) || (charImg.complete && charImg.naturalWidth))) { + const iw = charImg.tagName === 'CANVAS' ? charImg.width : charImg.naturalWidth; + const ih = charImg.tagName === 'CANVAS' ? charImg.height : charImg.naturalHeight; + const sc = Math.min(size / iw, size / ih, 1); + const dw = iw * sc, dh = ih * sc; + 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 บนฟ้าแล้วลอย) */ + 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; + } + vy += g; + + let nx = pxc; + if (moveLeft) nx -= moveX; + if (moveRight) nx += moveX; + nx = Math.max(bw + 1, Math.min(w * ts - bw - 1, nx)); + + const headY0 = feetY - bh; + const tryL = nx - bw; + const tryR = nx + bw; + if (!jumpSurviveOverlapsSolidForHorzMove(md, tryL, headY0, tryR, feetY)) { + pxc = nx; + } + + feetY += vy * dt; + let headTop = feetY - bh; + if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) { + if (vy > 0) { + 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)); + for (let s = 0; s < maxSteps; s++) { + feetY -= step; + headTop = feetY - bh; + if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) { + vy = 0; + onGround = true; + break; + } + } + } else if (vy < 0) { + 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 (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) { + vy = 0; + break; + } + } + } + } else { + onGround = false; + } + + headTop = feetY - bh; + const headBandBottom = headTop + bh * 0.42; + if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY) && + jumpSurviveOverlapsObjectWall(md, pxc - bw, headTop, pxc + bw, headBandBottom)) { + return { pxc, feetY, vy, onGround, died: true }; + } + + headTop = feetY - bh; + if (jumpSurviveOverlapsHazardArea(md, pxc - bw, headTop, pxc + bw, feetY)) { + return { pxc, feetY, vy, onGround, died: true }; + } + + headTop = feetY - bh; + /* ไม่ใช้ขอบกล้อง (cam topKill/botKill) — ตายเฉพาะ hazard ที่วาง + ตกออกนอกโลกแมปจริง (ไม่ตายในอากาศระหว่างแพลตฟอร์ม) */ + const mapHpx = h * ts; + if (feetY > mapHpx + ts * 8 || headTop < -ts * 32) { + 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() || !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 = 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; + + let zJumpSurviveWorld = zoom; + if (previewMode && editorEmbedReturn && mapData) zJumpSurviveWorld *= playEmbedUserZoomMul; + const halfViewH = canvas.height / (2 * zJumpSurviveWorld); + const rise = (Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42) * dt; + jumpSurvivePlatformScrollPx += rise; + + const camTgt = jumpSurviveCameraTargetPx(md); + const ck = Math.min(1, dt * 7); + jumpSurviveCamCenterX += (camTgt.px - jumpSurviveCamCenterX) * ck; + jumpSurviveCamCenterY += (camTgt.py - jumpSurviveCamCenterY) * ck; + + if (jumpSurviveEliminated) { + stepJumpSurvivePreviewBots(dt, halfViewH); + jumpSurviveMissionMaybeEarlyFinish(); + return; + } + + const { cw, ch } = getCharacterFootprintWH(md); + let pxc = (me.x + cw * 0.5) * ts; + let feetY = (me.y + ch) * ts; + + const jq = jumpSurviveJumpQueued; + jumpSurviveJumpQueued = false; + + const r = jumpSurvivePhysicsIntegrate(md, { + dt, + cw, + ch, + pxc0: pxc, + feetY0: feetY, + vy0: jumpSurviveVy, + wasOnGround: jumpSurviveOnGround, + jumpQueued: jq, + moveLeft: !!(keys['ArrowLeft'] || keys['KeyA']), + moveRight: !!(keys['ArrowRight'] || keys['KeyD']), + }); + + if (r.died) { + jumpSurviveEliminated = true; + jumpSurviveVy = 0; + jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY); + ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'KeyA', 'KeyW', 'KeyS', 'KeyD'].forEach((c) => { keys[c] = false; }); + } else { + jumpSurviveVy = r.vy; + jumpSurviveOnGround = r.onGround; + jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY); + } + + stepJumpSurvivePreviewBots(dt, halfViewH); + + if (!jumpSurviveEliminated) { + me.isWalking = Math.abs(jumpSurviveVy) > 40 || !!(keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD']); + if (keys['ArrowRight'] || keys['KeyD']) me.direction = 'right'; + else if (keys['ArrowLeft'] || keys['KeyA']) me.direction = 'left'; + + if (Date.now() - jumpSurviveLastEmitT > 80) { + jumpSurviveLastEmitT = Date.now(); + socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); + } + } + + jumpSurviveMissionMaybeEarlyFinish(); + } + + function gridImageLibIdleUrlPlay(entry) { + if (entry == null) return ''; + if (typeof entry === 'string') return entry; + if (typeof entry === 'object' && entry.idle) return String(entry.idle); + return ''; + } + function gridImageLibHeldUrlPlay(entry) { + if (entry == null || typeof entry === 'string') return ''; + if (typeof entry === 'object' && entry.held && String(entry.held).length) return String(entry.held); + return ''; + } + + function normalizeGridImageCellsOnMap(md) { + if (!md) return; + const w = md.width || 20, h = md.height || 15; + if (!Array.isArray(md.gridImageLibrary)) md.gridImageLibrary = []; + md.gridImageLibrary = md.gridImageLibrary + .map((raw) => { + if (typeof raw === 'string' && raw.length > 0 && raw.length < 30000000) return { idle: raw, held: null }; + if (raw && typeof raw === 'object' && typeof raw.idle === 'string' && raw.idle.length > 0 && raw.idle.length < 30000000) { + let held = typeof raw.held === 'string' && raw.held.length > 0 && raw.held.length < 30000000 ? raw.held : null; + if (held && held === raw.idle) held = null; + return { idle: raw.idle, held }; + } + return null; + }) + .filter(Boolean); + const libLen = md.gridImageLibrary.length; + let placements = []; + if (Array.isArray(md.gridImageSprites) && md.gridImageSprites.length) { + placements = md.gridImageSprites.map((raw) => { + const i = Math.max(0, Math.floor(Number(raw.i))); + const x = Math.floor(Number(raw.x)); + const y = Math.floor(Number(raw.y)); + const ww = Math.max(1, Math.floor(Number(raw.w)) || 1); + const hh = Math.max(1, Math.floor(Number(raw.h)) || 1); + const rawB = raw.bindCarryOption; + const bn = typeof rawB === 'number' ? rawB : parseInt(String(rawB), 10); + const base = { i, x, y, w: ww, h: hh }; + if (Number.isFinite(bn) && bn >= 1 && bn <= QUIZ_CARRY_MAX_OPTION_SLOTS) base.bindCarryOption = bn; + return base; + }).filter((s) => Number.isFinite(s.i) && s.i >= 0 && s.i < libLen && + Number.isFinite(s.x) && Number.isFinite(s.y) && s.w >= 1 && s.h >= 1 && + s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0); + placements = placements.map((s) => { + let x = s.x, y = s.y, ww = s.w, hh = s.h; + if (x < 0) { ww += x; x = 0; } + if (y < 0) { hh += y; y = 0; } + if (x + ww > w) ww = w - x; + if (y + hh > h) hh = h - y; + const out = { i: s.i, x, y, w: Math.max(1, ww), h: Math.max(1, hh) }; + if (s.bindCarryOption != null) out.bindCarryOption = s.bindCarryOption; + return out; + }).filter((s) => s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0); + } else { + const cells = md.gridImageCells; + if (Array.isArray(cells) && cells.length === h && libLen > 0) { + for (let yy = 0; yy < h; yy++) { + const row = cells[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (row[xx] == null) continue; + const iv = parseInt(row[xx], 10); + if (!Number.isFinite(iv) || iv < 0 || iv >= libLen) continue; + placements.push({ i: iv, x: xx, y: yy, w: 1, h: 1 }); + } + } + } + } + md.gridImagePlacements = placements; + const outCells = Array(h).fill(0).map(() => Array(w).fill(null)); + placements.forEach((sp) => { + for (let yy = sp.y; yy < sp.y + sp.h; yy++) { + for (let xx = sp.x; xx < sp.x + sp.w; xx++) { + if (yy >= 0 && yy < h && xx >= 0 && xx < w) outCells[yy][xx] = sp.i; + } + } + }); + md.gridImageCells = outCells; + } + + function loadMapGridImages() { + mapGridImageImgs = []; + mapGridImageHeldImgs = []; + if (!mapData || !Array.isArray(mapData.gridImageLibrary) || !mapData.gridImageLibrary.length) return; + mapData.gridImageLibrary.forEach((entry, i) => { + mapGridImageImgs[i] = null; + mapGridImageHeldImgs[i] = null; + const idle = gridImageLibIdleUrlPlay(entry); + if (!idle) return; + const im = new Image(); + im.onload = () => { try { draw(); } catch (e) {} }; + im.onerror = () => {}; + im.src = idle; + mapGridImageImgs[i] = im; + const held = gridImageLibHeldUrlPlay(entry); + if (held && held !== idle) { + const hm = new Image(); + hm.onload = () => { try { draw(); } catch (e) {} }; + hm.onerror = () => {}; + hm.src = held; + mapGridImageHeldImgs[i] = hm; + } + }); + } + + /** โซนตัวเลือกที่ทับสไปรต์มากที่สุด — quiz_carry สลับรูป idle/held · ถ้าไม่ทับเลยลองขยายขอบ 1 ช่อง (มาร์กเกอร์ชิดขอบสไปรต์) */ + function dominantQuizCarryOptionInSpriteRect(md, sp) { + if (!md || md.gameType !== 'quiz_carry' || !md.quizCarryOptionArea) return null; + const g = md.quizCarryOptionArea; + const mw = md.width || 20, mh = md.height || 15; + const bx = sp.x | 0, by = sp.y | 0, bw = sp.w | 0, bh = sp.h | 0; + const counts = new Map(); + function addOverlapRect(x0, y0, ww, hh) { + for (let yy = y0; yy < y0 + hh; yy++) { + if (yy < 0 || yy >= mh) continue; + const row = g[yy]; + if (!row) continue; + for (let xx = x0; xx < x0 + ww; xx++) { + if (xx < 0 || xx >= mw) continue; + const v = row[xx]; + const n = typeof v === 'number' ? v : parseInt(String(v), 10); + if (!Number.isFinite(n) || n < 1 || n > QUIZ_CARRY_MAX_OPTION_SLOTS) continue; + const idx = n - 1; + counts.set(idx, (counts.get(idx) || 0) + 1); + } + } + } + addOverlapRect(bx, by, bw, bh); + if (!counts.size) { + const pad = 1; + const x0 = Math.max(0, bx - pad); + const y0 = Math.max(0, by - pad); + const x1 = Math.min(mw, bx + bw + pad); + const y1 = Math.min(mh, by + bh + pad); + addOverlapRect(x0, y0, x1 - x0, y1 - y0); + } + if (!counts.size) return null; + let best = null; + let bestN = -1; + counts.forEach((n, k) => { + if (best == null || n > bestN || (n === bestN && k < best)) { + bestN = n; + best = k; + } + }); + return best; + } + + /** 0-based choice index สำหรับสลับ idle/held — ใช้ bindCarryOption จากแมปก่อน แล้วค่อยนับทับโซน */ + function quizCarryOptionIndexForGridSprite(md, sp) { + if (!sp) return null; + const rawB = sp.bindCarryOption; + if (rawB != null && rawB !== '') { + const n = typeof rawB === 'number' ? rawB : parseInt(String(rawB), 10); + if (Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS) return n - 1; + } + return dominantQuizCarryOptionInSpriteRect(md, sp); + } + + /** รูป held จากคลังกริดสำหรับข้อนี้ — ใช้วาดป้ายติดตัว (ไม่ผ่าน sanitize URL) */ + function findQuizCarryGridHeldImgForChoiceIndex(choiceIdx) { + if (choiceIdx == null || choiceIdx < 0 || !isQuizCarry() || !mapData) return null; + const pl = mapData.gridImagePlacements; + if (!Array.isArray(pl) || !pl.length) return null; + for (let gi = 0; gi < pl.length; gi++) { + const sp = pl[gi]; + const opt = quizCarryOptionIndexForGridSprite(mapData, sp); + if (opt !== choiceIdx) continue; + const gh = mapGridImageHeldImgs[sp.i]; + if (gh && gh.complete && gh.naturalWidth) return gh; + } + return null; + } + + /** รูป idle จากคลังกริดสำหรับข้อนี้ — วาดบนป้ายตัวเลือกบนพื้นเมื่อไม่มีรูปจากคำถาม/ธีม */ + function findQuizCarryGridIdleImgForChoiceIndex(choiceIdx) { + if (choiceIdx == null || choiceIdx < 0 || !isQuizCarry() || !mapData) return null; + const pl = mapData.gridImagePlacements; + if (!Array.isArray(pl) || !pl.length) return null; + for (let gi = 0; gi < pl.length; gi++) { + const sp = pl[gi]; + const opt = quizCarryOptionIndexForGridSprite(mapData, sp); + if (opt !== choiceIdx) continue; + const gid = mapGridImageImgs[sp.i]; + if (gid && gid.complete && gid.naturalWidth) return gid; + } + return null; + } + + function applyMapAndStart(gameMapData, res) { + const prevWasSpaceShooter = mapData && mapData.gameType === 'space_shooter'; + const prevWasBalloonBoss = mapData && mapData.gameType === 'balloon_boss'; + mapData = gameMapData; + 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); + } + 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'; + 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; + me.playTint = playTintFromPeerId(String((myId != null && myId !== '') ? myId : (nick || 'local'))); + { + 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) { + 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: playTintFromPeerId(p.id), + gauntletJumpTicks: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0, + gauntletJumpVis: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0, + gauntletScore: (() => { const s = Number(p.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + gauntletEliminated: !!p.gauntletEliminated, + jumpSurviveEliminated: false, + spaceShooterScore: (() => { const s = Number(p.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + balloonBossScore: (() => { const s = Number(p.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + 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, + }); + } + }); + if (mapData.gameType === 'gauntlet') { + me.tx = me.x; + me.ty = me.y; + if (res.gauntletEndsAt != null) { + const geJoin = Number(res.gauntletEndsAt); + gauntletEndsAtMs = Number.isFinite(geJoin) ? geJoin : null; + } else { + gauntletEndsAtMs = null; + } + fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((t) => { + if (t) applyGauntletTimingFromServer(t); + if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz; + }) + .catch(() => {}); + } else { + me.tx = null; + me.ty = null; + gauntletEndsAtMs = null; + } + rebalancePreviewBots(); + if (mapData.gameType === 'space_shooter') { + applySpaceShooterSpawnLayoutPlay(); + } + if (mapData.gameType === 'balloon_boss') { + applyBalloonBossSpawnLayoutPlay(); + } + if (isStack()) { + lastStackTickMs = performance.now(); + resetStackMinigameState(); + fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((t) => { + if (t) applyGauntletTimingFromServer(t); + else reapplyStackMiniSizingFromGlobals(); + if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz; + }) + .catch(() => { + if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz; + }); + 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(); + socket.emit('join-space', { + spaceId, + nickname: nick, + characterId: getPlayCharacterId(), + playMapId: joinPlayMapId || undefined, + }, (res) => { + if (!res || !res.ok) { + const errMsg = (res && res.error) || 'เข้าร่วมไม่ได้'; + alert(errMsg); + const caseLocked = /เริ่มคดี|ไม่รับผู้เล่น/.test(errMsg); + if (caseLocked) { + window.location.replace(buildRoomLobbyReturnHref()); + } else { + window.location.replace(BASE + '/lobby.html'); + } + return; + } + myId = socket.id; + let botSlots = parseInt(res.botSlotCount, 10); + if ((!botSlots || botSlots < 1) && res.maxPlayers > 0 && res.maxPlayers < 6) { + botSlots = 6 - res.maxPlayers; + } + if (botSlots > 0) { + detectiveCaseFillBots = true; + detectiveBotSlotCount = Math.min(6, Math.max(0, botSlots)); + } + if (isDetectiveMinigamePlay()) { + try { document.documentElement.classList.add('play-detective-minigame'); } catch (eD) { /* ignore */ } + } + if (res.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 (previewMode && editorEmbedReturn && res.mapData && res.mapData.gameType === 'lobby') { + const q = []; + q.push('space=' + encodeURIComponent(spaceId)); + q.push('nick=' + encodeURIComponent(nick)); + const mid = params.get('map'); + if (mid) q.push('map=' + encodeURIComponent(mid)); + q.push('preview=1'); + q.push('editorEmbed=1'); + if (params.get('defaultChar') != null) q.push('defaultChar=' + encodeURIComponent(params.get('defaultChar'))); + window.location.replace(BASE + '/room-lobby.html?' + q.join('&')); + return; + } + const playMapId = params.get('map'); + if (playMapId) { + fetch(BASE + '/api/maps/' + encodeURIComponent(playMapId)) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data) applyMapAndStart(data, res); + else applyMapAndStart(res.mapData, res); + }) + .catch(() => applyMapAndStart(res.mapData, res)); + } else { + applyMapAndStart(res.mapData, res); + } + }); + }); + }); + + const LERP = 0.2; + socket.on('user-joined', (data) => { + if (!data || isPreviewBotId(data.id)) return; + const x = Number(data.x); + const y = Number(data.y); + const px = Number.isFinite(x) ? x : 1; + const py = Number.isFinite(y) ? y : 1; + 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: playTintFromPeerId(data.id), + gauntletJumpTicks: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0, + gauntletJumpVis: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0, + gauntletScore: (() => { const s = Number(data.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + jumpSurviveEliminated: false, + spaceShooterScore: (() => { const s = Number(data.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + balloonBossScore: (() => { const s = Number(data.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + 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, + }); + if (playBotsEnabled()) rebalancePreviewBots(); + 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(); + }); + socket.on('quiz-carry-lobby-sync', (d) => { + if (!d || typeof d.readyMap !== 'object') return; + quizCarryLobbyReadyMap = { ...d.readyMap }; + if (quizCarryPregameActive && isQuizCarry()) { + quizCarrySyncGuestReadyIfNeeded(); + updateQuizCarryPregameHud(); + } + }); + socket.on('quiz-carry-lobby-started', () => { + if (!(previewMode && editorEmbedReturn && isQuizCarry())) return; + endQuizCarryEmbedPregameAndStart(); + }); + socket.on('gauntlet-crown-lobby-sync', (d) => { + if (!d || typeof d.readyMap !== 'object') return; + gauntletCrownLobbyReadyMap = { ...d.readyMap }; + if (gauntletCrownPregamePhase === 'howto') { + gauntletCrownSyncGuestReadyIfNeeded(); + updateGauntletCrownHowtoHud(); + } 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', () => { + 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(); + }); + 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 (data.x != null) { + const x = Number(data.x); + if (Number.isFinite(x)) me.x = x; + } + if (data.y != null) { + const y = Number(data.y); + if (Number.isFinite(y)) me.y = y; + } + const s = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y); + me.x = s.x; + me.y = s.y; + if (data.direction) me.direction = data.direction; + if (data.characterId != null) me.characterId = data.characterId; + playPath = []; + return; + } + if (data.x != null) { + const x = Number(data.x); + if (Number.isFinite(x)) me.x = x; + } + if (data.y != null) { + const y = Number(data.y); + if (Number.isFinite(y)) me.y = y; + } + if (data.direction) me.direction = data.direction; + if (data.characterId != null) me.characterId = data.characterId; + playPath = []; + return; + } + const o = others.get(data.id); + if (o) { + if (mapData && mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData) && !isPreviewBotId(data.id)) { + const px = Number(data.x); + const py = Number(data.y); + if (Number.isFinite(px) && Number.isFinite(py)) { + const sn = snapPositionOntoQuizBattlePathIfNeeded(px, py); + o.tx = sn.x; + o.ty = sn.y; + o.x = sn.x; + o.y = sn.y; + } else { + o.tx = data.x; + o.ty = data.y; + } + } else { + o.tx = data.x; + o.ty = data.y; + } + o.direction = data.direction || o.direction; + if (data.characterId != null) o.characterId = data.characterId; + if (mapData && mapData.gameType === 'space_shooter' && data.spaceShooterScore != null) { + const s = Number(data.spaceShooterScore); + if (Number.isFinite(s)) o.spaceShooterScore = Math.max(0, s); + } + if (mapData && mapData.gameType === 'balloon_boss') { + if (data.balloonBossScore != null) { + const s = Number(data.balloonBossScore); + if (Number.isFinite(s)) o.balloonBossScore = Math.max(0, s); + } + if (data.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; + others.delete(data.id); + if (playBotsEnabled()) rebalancePreviewBots(); + 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; + }); + + 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; + }); + } + if (isQuiz() && Array.isArray(r.results) && r.results.some(function (row) { return row && row.right; })) { + spawnQuizTrueFalseScorePopupPlay(!!r.correctTrue); + } + showPlayQuizFeedback(r); + }); + + socket.on('quiz-player-state', (st) => { + if (previewMode) return; + if (!st) return; + playQuizPlayerLocal = { + cannotTrue: !!st.cannotTrue, + cannotFalse: !!st.cannotFalse, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : (playQuizPlayerLocal.score || 0), + }; + }); + + socket.on('quiz-ended', () => { + if (previewMode && isQuiz()) return; + 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); + } + }); + + 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(); + } + }); + + socket.on('gauntlet-ended', (data) => { + cancelQuizCarryResultEndAfterTimeup(); + gauntletEndsAtMs = null; + gauntletCrownRunwayBgFinishLatched = false; + gauntletCrownRunwayBgStripFreezeSinceMs = 0; + 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; + 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; + }); + } + 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; + 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); + 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 = 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(); + } + + 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 (!previewMode || !editorEmbedReturn || !mapData) 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 — ให้ซูมได้ใน embed; 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); + 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 }); + })(); + + 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 }; + } + + /** + * ขนาดช่องที่ใช้ชนกำแพง (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() || !mapData) return; + const ids = [...others.keys()].filter(isPreviewBotId); + const minD = 0.52; + const pushAmt = MOVE_SPEED * 0.5; + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + const a = others.get(ids[i]); + const b = others.get(ids[j]); + if (!a || !b) continue; + let ddx = b.x - a.x, ddy = b.y - a.y; + let d = Math.sqrt(ddx * ddx + ddy * ddy); + if (d >= minD) continue; + if (d < 1e-5) { + ddx = (i + j * 2) % 2 === 0 ? 1 : -1; + ddy = ((i + j) % 3) - 1; + d = Math.sqrt(ddx * ddx + ddy * ddy) || 1; + } + const ux = -ddx / d, uy = -ddy / d; + const vx = ddx / d, vy = ddy / d; + const ax2 = a.x + ux * pushAmt, ay2 = a.y + uy * pushAmt; + const bx2 = b.x + vx * pushAmt, by2 = b.y + vy * pushAmt; + if (canWalkLikeLobbyForBot(ax2, ay2, a.x, a.y, a)) { a.x = ax2; a.y = ay2; } + if (canWalkLikeLobbyForBot(bx2, by2, b.x, b.y, b)) { b.x = bx2; b.y = by2; } + clampPlayEntityFootprintToMap(a, mapData); + clampPlayEntityFootprintToMap(b, mapData); + } + } + } + + function quizTilesFootprintPlay(px, py) { + const s = new Set(); + if (!mapData) return s; + if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s; + const { cw, ch } = getCharacterFootprintWH(mapData); + const w = mapData.width || 20, h = mapData.height || 15; + const minTx = Math.floor(px); + const minTy = Math.floor(py); + const maxTx = Math.min(w - 1, minTx + cw - 1); + const maxTy = Math.min(h - 1, minTy + ch - 1); + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) s.add(tx + ',' + ty); + } + } + return s; + } + + /** ร่าง (cw×ch) ของผู้เล่นที่ (px,py) ครอบคลุมช่อง (tx,ty) หรือไม่ — ใช้กับ blockPlayer แทนการเช็คแค่มุมซ้ายบน */ + function playEntityFootprintContainsTile(px, py, tx, ty) { + return quizTilesFootprintPlay(px, py).has(tx + ',' + ty); + } + + /** + * กล่องตรวจโซนห้ามซ้อน (blockPlayer) — วางเหมือนชนกำแพง: กลางแนวนอน + ชิดเท้า + * blockPlayerSeparationW/H: 0 = ใช้เต็มความกว้าง/สูงตัว · ค่าอื่น = จำกัด 1..cw / 1..ch + * รองรับ legacy blockPlayerSeparation เมื่อยังไม่มี W/H ใน JSON + */ + function resolveBlockPlayerNoOverlapBoxWH(md) { + const m = md || mapData; + if (!m) return { cw: 1, ch: 1, boxW: 1, boxH: 1 }; + const { cw, ch } = getCharacterFootprintWH(m); + let w = m.blockPlayerSeparationW != null && m.blockPlayerSeparationW !== '' ? Math.floor(Number(m.blockPlayerSeparationW)) : NaN; + let h = m.blockPlayerSeparationH != null && m.blockPlayerSeparationH !== '' ? Math.floor(Number(m.blockPlayerSeparationH)) : NaN; + const leg = m.blockPlayerSeparation != null && m.blockPlayerSeparation !== '' ? Math.floor(Number(m.blockPlayerSeparation)) : NaN; + const hasW = Number.isFinite(w) && w >= 0; + const hasH = Number.isFinite(h) && h >= 0; + if (!hasW && !hasH && Number.isFinite(leg) && leg >= 1) { + w = Math.min(leg, cw); + h = Math.min(leg, ch); + } else { + if (!hasW) w = 0; + if (!hasH) h = 0; + } + const boxW = !w || w <= 0 ? cw : Math.max(1, Math.min(cw, w)); + const boxH = !h || h <= 0 ? ch : Math.max(1, Math.min(ch, h)); + return { cw, ch, boxW, boxH }; + } + + /** ช่องกริดที่ถือว่า “ผู้เล่นที่ (px,py) กินพื้นที่” สำหรับตรวจโซน blockPlayer — กล่องเดียวกับแนวชนกำแพง */ + function quizTilesBlockPlayerPeerFootprintPlay(px, py) { + const s = new Set(); + if (!mapData) return s; + if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s; + const { cw, ch, boxW, boxH } = resolveBlockPlayerNoOverlapBoxWH(mapData); + const w = mapData.width || 20, h = mapData.height || 15; + const minTx = Math.floor(px); + const minTy = Math.floor(py); + const offX = minTx + Math.floor((cw - boxW) / 2); + const offY = minTy + (ch - boxH); + for (let ty = offY; ty < offY + boxH; ty++) { + for (let tx = offX; tx < offX + boxW; tx++) { + if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty); + } + } + return s; + } + + function playPeerBlockPlayerOccupiesTile(px, py, tx, ty) { + return quizTilesBlockPlayerPeerFootprintPlay(px, py).has(tx + ',' + ty); + } + + /** Footprint ชนกำแพง (objects=1) เท่านั้น — hub / interactive / blockPlayer / quiz ใช้ quizTilesFootprintPlay = เต็ม characterCells */ + function quizTilesWallCollisionFootprintPlay(px, py) { + const s = new Set(); + if (!mapData) return s; + if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s; + const { cw, ch, colW, colH } = getCharacterCollisionFootprintWH(mapData); + const w = mapData.width || 20, h = mapData.height || 15; + const minTx = Math.floor(px); + const minTy = Math.floor(py); + const offX = minTx + Math.floor((cw - colW) / 2); + const offY = minTy + (ch - colH); + for (let ty = offY; ty < offY + colH; ty++) { + for (let tx = offX; tx < offX + colW; tx++) { + if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty); + } + } + return s; + } + + function quizAnswerTileForbiddenForLock(lock, tx, ty) { + if (!lock || lock.eliminated) return false; + if (!mapData) return false; + const qt = mapData.quizTrueArea; + const qf = mapData.quizFalseArea; + if (lock.cannotTrue && qt && qt[ty] && qt[ty][tx] === 1) return true; + if (lock.cannotFalse && qf && qf[ty] && qf[ty][tx] === 1) return true; + return false; + } + + function quizAnswerTileForbiddenPlay(tx, ty) { + if (!playQuizPlayerLocal) return false; + return quizAnswerTileForbiddenForLock({ + cannotTrue: !!playQuizPlayerLocal.cannotTrue, + cannotFalse: !!playQuizPlayerLocal.cannotFalse, + eliminated: !!playQuizPlayerLocal.eliminated, + }, tx, ty); + } + + /** ยืนทับโซนต้องห้ามเมื่อโดนล็อก — ใช้กับ pathfind ปลายทาง / คลิก */ + function quizLockFootprintBlocksForLock(lock, px, py) { + if (!mapData || !isQuiz() || !lock || lock.eliminated) return false; + for (const k of quizTilesFootprintPlay(px, py)) { + const p = k.split(','); + const txi = +p[0], tyi = +p[1]; + if (quizAnswerTileForbiddenForLock(lock, txi, tyi)) return true; + } + return false; + } + + function quizLockFootprintBlocksPlay(px, py) { + if (!playQuizPlayerLocal) return false; + return quizLockFootprintBlocksForLock({ + cannotTrue: !!playQuizPlayerLocal.cannotTrue, + cannotFalse: !!playQuizPlayerLocal.cannotFalse, + eliminated: !!playQuizPlayerLocal.eliminated, + }, px, py); + } + + /** บล็อกเฉพาะการ «เข้า» ช่องตอบใหม่ — ให้เดินออกจากโซนได้ถ้าเคยยืนผิดแล้ว */ + function quizLockWouldEnterForbiddenForLock(lock, ox, oy, nx, ny) { + if (!mapData || !isQuiz() || !lock || lock.eliminated) return false; + const fromS = quizTilesFootprintPlay(ox, oy); + const toS = quizTilesFootprintPlay(nx, ny); + for (const k of toS) { + if (fromS.has(k)) continue; + const p = k.split(','); + const txi = +p[0], tyi = +p[1]; + if (quizAnswerTileForbiddenForLock(lock, txi, tyi)) return true; + } + return false; + } + + function quizLockWouldEnterForbiddenPlay(ox, oy, nx, ny) { + if (!playQuizPlayerLocal) return false; + return quizLockWouldEnterForbiddenForLock({ + cannotTrue: !!playQuizPlayerLocal.cannotTrue, + cannotFalse: !!playQuizPlayerLocal.cannotFalse, + eliminated: !!playQuizPlayerLocal.eliminated, + }, ox, oy, nx, ny); + } + + function botQuizLock(o) { + return { + cannotTrue: !!(o && o.quizCannotTrue), + cannotFalse: !!(o && o.quizCannotFalse), + eliminated: false, + }; + } + + /** Same walkability as room-lobby `canWalkLobby` (LobbyA / hall). */ + function canWalkLikeLobby(x, y, fromX, fromY) { + if (!mapData || !mapData.objects) return false; + if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false; + const w = mapData.width || 20, h = mapData.height || 15; + const wallTiles = quizTilesWallCollisionFootprintPlay(x, y); + for (const k of wallTiles) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const row = mapData.objects[ty]; + if (!row || row[tx] === 1) return false; + } + if (wallTiles.size === 0) return false; + const bp = mapData.blockPlayer; + if (bp) { + for (const k of quizTilesFootprintPlay(x, y)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (!bp[ty] || bp[ty][tx] !== 1) continue; + for (const [, o] of others) { + if (playPeerBlockPlayerOccupiesTile(o.x, o.y, tx, ty)) return false; + } + } + } + if (isQuiz() && playQuizPlayerLocal && !playQuizPlayerLocal.eliminated) { + const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY); + if (hasFrom) { + if (quizLockWouldEnterForbiddenPlay(fromX, fromY, x, y)) return false; + } else if (quizLockFootprintBlocksPlay(x, y)) { + return false; + } + } + if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false; + if (isQuizBattle() && quizBattlePathModeActive(mapData)) { + if (!quizBattleFootprintFullyOnPath(mapData, x, y)) return false; + } + return true; + } + + function canWalkLikeLobbyForBot(x, y, fromX, fromY, o) { + if (!mapData || !mapData.objects) return false; + if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false; + const w = mapData.width || 20, h = mapData.height || 15; + const wallTilesB = quizTilesWallCollisionFootprintPlay(x, y); + for (const k of wallTilesB) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const row = mapData.objects[ty]; + if (!row || row[tx] === 1) return false; + } + if (wallTilesB.size === 0) return false; + const bp = mapData.blockPlayer; + if (bp) { + for (const k of quizTilesFootprintPlay(x, y)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (!bp[ty] || bp[ty][tx] !== 1) continue; + for (const [, peer] of others) { + if (o && peer === o) continue; + if (playPeerBlockPlayerOccupiesTile(peer.x, peer.y, tx, ty)) return false; + } + if (o && playPeerBlockPlayerOccupiesTile(me.x, me.y, tx, ty)) return false; + } + } + if (isQuiz()) { + const lock = botQuizLock(o); + if (lock.cannotTrue || lock.cannotFalse) { + const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY); + if (hasFrom) { + if (quizLockWouldEnterForbiddenForLock(lock, fromX, fromY, x, y)) return false; + } else if (quizLockFootprintBlocksForLock(lock, x, y)) { + return false; + } + } + } + if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false; + if (isQuizBattle() && quizBattlePathModeActive(mapData)) { + if (!quizBattleFootprintFullyOnPath(mapData, x, y)) return false; + } + return true; + } + + /** ถ้าสปอว์น/โหลดมาทับโซน hub ให้หาจุดใกล้ ๆ ที่ footprint ไม่ทับ hub */ + function snapPositionOutOfQuizCarryHubIfNeeded(x, y) { + if (!mapData || !isQuizCarry()) return { x, y }; + if (!quizCarryFootprintOverlapsHub(x, y)) return { x, y }; + const w = mapData.width || 20, h = mapData.height || 15; + const maxR = Math.max(w, h) + 6; + for (let r = 1; r <= maxR; r++) { + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue; + const nx = x + dx * 0.45; + const ny = y + dy * 0.45; + if (nx < 0 || ny < 0 || nx > w - 0.02 || ny > h - 0.02) continue; + if (quizCarryFootprintOverlapsHub(nx, ny)) continue; + if (!canWalkLikeLobby(nx, ny, NaN, NaN)) continue; + return { x: nx, y: ny }; + } + } + } + return { x, y }; + } + + function stepPreviewBotAlongPath(o, w, h) { + const path = o.botPath; + if (!path || !path.length) return; + const way = path[0]; + const dx = way.x - o.x, dy = way.y - o.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist <= PATH_ARRIVE_THRESH) { + path.shift(); + while (path.length > 0) { + const w2 = path[0]; + const ux = w2.x - o.x, uy = w2.y - o.y; + if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break; + path.shift(); + } + clampPlayEntityFootprintToMap(o, mapData); + return; + } + const len = dist || 1; + const 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 (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) { + o.x = nx; + o.y = ny; + } else if (!pathStrict) { + if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) { + o.x = nx; + } else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) { + o.y = ny; + } else { + /* เส้นตรงไป waypoint อาจตัดมุม hub / block — ลองก้าวแนวแกนตามทิศหลักอย่างใดอย่างหนึ่ง */ + const cstep = Math.min(MOVE_SPEED * 1.2 * pathStepMul * 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() || !mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss()) return; + if (isQuizCarry()) { + stepQuizCarryPreviewBots(); + return; + } + const w = mapData.width || 20, h = mapData.height || 15; + const now = Date.now(); + const inAnswerPhase = previewMode && isQuiz() && previewQuizStep === 'answer'; + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + o.botIsWalking = false; + if (inAnswerPhase && o.botPath && o.botPath.length > 0 && !o.botAnswerWander) { + stepPreviewBotAlongPath(o, w, h); + return; + } + if (inAnswerPhase && o.botPath && o.botPath.length === 0 && !o.botAnswerWander) { + return; + } + /* ก่อนตอบ / พัก / บอทสับสน: เดินทุกเฟรมตามทิศ (เหมือนผู้เล่นกดค้าง) — เดิมรอเป็นจังหวะแล้วก้าวทีละครั้งเลยดูกระตุก */ + if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) { + const d = pickRandomPreviewBotWanderDir(); + o.botWanderDx = d[0]; + o.botWanderDy = d[1]; + } + if (typeof o.botWanderNextTurn !== 'number') o.botWanderNextTurn = now + 600; + if (now >= o.botWanderNextTurn) { + o.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200); + if (Math.random() < 0.55) { + const d = pickRandomPreviewBotWanderDir(); + o.botWanderDx = d[0]; + o.botWanderDy = d[1]; + } + } + const accX = o.botWanderDx; + const accY = o.botWanderDy; + if (Math.abs(accY) > Math.abs(accX)) o.direction = accY > 0 ? 'down' : 'up'; + else if (accX !== 0) o.direction = accX > 0 ? 'right' : 'left'; + const step = MOVE_SPEED; + const nx = o.x + accX * step; + const ny = o.y + accY * step; + const ox = o.x, oy = o.y; + const pathStrictW = isQuizBattle() && quizBattlePathModeActive(mapData); + if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) { + o.x = nx; + o.y = ny; + } else if (!pathStrictW) { + if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) { + o.x = nx; + } else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) { + o.y = ny; + } else { + const d = pickRandomPreviewBotWanderDir(); + o.botWanderDx = d[0]; + o.botWanderDy = d[1]; + o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600); + } + } else { + const d = pickRandomPreviewBotWanderDir(); + o.botWanderDx = d[0]; + o.botWanderDy = d[1]; + o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600); + } + if (!Number.isFinite(o.x)) o.x = 0.5; + if (!Number.isFinite(o.y)) o.y = 0.5; + clampPlayEntityFootprintToMap(o, mapData); + if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true; + }); + separateClumpedPreviewBots(); + [...others.keys()].filter(isPreviewBotId).forEach((bid) => { + const ob = others.get(bid); + if (ob) clampPlayEntityFootprintToMap(ob, mapData); + }); + if (isQuizBattle() && quizBattlePathModeActive(mapData)) { + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y); + o.x = s.x; + o.y = s.y; + o.tx = s.x; + o.ty = s.y; + }); + } + } + + /** A* เหมือน room-lobby — double-click ไปจุดบนแผนที่ */ + function pathfindPlay(fromX, fromY, toX, toY) { + if (!mapData) return []; + const w = mapData.width || 20, h = mapData.height || 15; + const fx = Math.floor(fromX), fy = Math.floor(fromY); + const tx = Math.floor(toX), ty = Math.floor(toY); + if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLikeLobby(tx + 0.5, ty + 0.5)) return []; + if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }]; + const key = (gx, gy) => gx + ',' + gy; + const open = [{ gx: fx, gy: fy, f: 0, g: 0 }]; + const closed = new Set(); + const cameFrom = {}; + const gScore = { [key(fx, fy)]: 0 }; + const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty); + const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }]; + while (open.length) { + open.sort((a, b) => a.f - b.f); + const cur = open.shift(); + const ck = key(cur.gx, cur.gy); + if (closed.has(ck)) continue; + closed.add(ck); + if (cur.gx === tx && cur.gy === ty) { + const path = []; + let u = cur; + while (u) { + path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 }); + u = cameFrom[key(u.gx, u.gy)]; + } + return path; + } + for (const d of dirs) { + const nx = cur.gx + d.dx, ny = cur.gy + d.dy; + if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue; + if (!canWalkLikeLobby(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5)) continue; + const nk = key(nx, ny); + if (closed.has(nk)) continue; + const g = (gScore[ck] ?? Infinity) + 1; + if (g >= (gScore[nk] ?? Infinity)) continue; + gScore[nk] = g; + cameFrom[nk] = cur; + open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g }); + } + } + return []; + } + + function pathfindPlayForBot(fromX, fromY, toX, toY, o) { + if (!mapData) return []; + const w = mapData.width || 20, h = mapData.height || 15; + const fx = Math.floor(fromX), fy = Math.floor(fromY); + const tx = Math.floor(toX), ty = Math.floor(toY); + if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLikeLobbyForBot(tx + 0.5, ty + 0.5, NaN, NaN, o)) return []; + if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }]; + const key = (gx, gy) => gx + ',' + gy; + const open = [{ gx: fx, gy: fy, f: 0, g: 0 }]; + const closed = new Set(); + const cameFrom = {}; + const gScore = { [key(fx, fy)]: 0 }; + const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty); + const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }]; + while (open.length) { + open.sort((a, b) => a.f - b.f); + const cur = open.shift(); + const ck = key(cur.gx, cur.gy); + if (closed.has(ck)) continue; + closed.add(ck); + if (cur.gx === tx && cur.gy === ty) { + const path = []; + let u = cur; + while (u) { + path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 }); + u = cameFrom[key(u.gx, u.gy)]; + } + return path; + } + for (const d of dirs) { + const nx = cur.gx + d.dx, ny = cur.gy + d.dy; + if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue; + if (!canWalkLikeLobbyForBot(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5, o)) continue; + const nk = key(nx, ny); + if (closed.has(nk)) continue; + const g = (gScore[ck] ?? Infinity) + 1; + if (g >= (gScore[nk] ?? Infinity)) continue; + gScore[nk] = g; + cameFrom[nk] = cur; + open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g }); + } + } + return []; + } + + let playPath = []; + + function drawGauntletLaserColumnScreen(rx, ry, rw, rh) { + ctx.save(); + const topRec = gauntletLaserTopUrl ? ensureGauntletAssetImage(gauntletLaserTopUrl) : null; + const botRec = gauntletLaserBottomUrl ? ensureGauntletAssetImage(gauntletLaserBottomUrl) : null; + const lineRec = gauntletLaserLineUrl ? ensureGauntletAssetImage(gauntletLaserLineUrl) : null; + let topH = 0; + let botH = 0; + if (topRec && topRec.img.complete && topRec.img.naturalWidth > 0) { + topH = Math.min(rh * 0.4, rw * topRec.img.naturalHeight / topRec.img.naturalWidth); + } + if (botRec && botRec.img.complete && botRec.img.naturalWidth > 0) { + botH = Math.min(rh * 0.4, rw * botRec.img.naturalHeight / botRec.img.naturalWidth); + } + const lineReady = !!(lineRec && lineRec.img.complete && lineRec.img.naturalWidth > 0 && rh > 1); + /* ไม่เติมสีคอลัมน์ทึบเมื่อมีรูปเส้นแล้ว — กันทึบโปร่งซ้าย/ขวาเลเซอร์ดูเหมือนแถบ UI/scrollbar */ + if (!lineReady) { + ctx.fillStyle = gauntletLaserFillColor; + ctx.fillRect(rx, ry, rw, rh); + } + /* เส้นกลาง tile ทั้งความสูงคอลัมน์ แล้วค่อยวาดหัว/ท้ายทับ — ให้ลำแสงต่อเนื่องแบบสินทรัพย์รวม (รูปอ้างอิง) */ + if (lineReady) { + const iw = lineRec.img.naturalWidth; + const ih = lineRec.img.naturalHeight; + const scale = rw / iw; + const step = Math.max(1, ih * scale); + let y = ry; + while (y < ry + rh) { + const piece = Math.min(step, ry + rh - y); + const srcH = piece / scale; + try { + ctx.drawImage(lineRec.img, 0, 0, iw, srcH, rx, y, rw, piece); + } catch (e) { /* ignore */ } + y += piece; + } + } + if (topH > 0 && topRec) { + try { ctx.drawImage(topRec.img, rx, ry, rw, topH); } catch (e) { /* ignore */ } + } + if (botH > 0 && botRec) { + try { ctx.drawImage(botRec.img, rx, ry + rh - botH, rw, botH); } catch (e) { /* ignore */ } + } + const lw = Number(gauntletLaserLineWidthPx) || 0; + if (lw > 0) { + ctx.strokeStyle = gauntletLaserStrokeColor; + ctx.lineWidth = lw; + ctx.strokeRect(rx + lw / 2, ry + lw / 2, rw - lw, rh - lw); + } + ctx.restore(); + } + + /** Stack preview: HUD แนว cyberdeck — SCORE ซ้าย, ตาปัจจุบันขวา, เทอร์มินัลล่างซ้าย */ + function drawStackPreviewCyberHud(ctx, cw, ch) { + if (!playBotsEnabled() || !stackMini || !mapData || !isStack()) return; + const now = Date.now(); + const pad = 12; + const currentSeat = getStackPreviewCurrentSeat(); + const order = stackPreviewTurnOrder; + const mono = '11px ui-monospace, "Cascadia Mono", Consolas, monospace'; + const monoSm = '9px ui-monospace, Consolas, monospace'; + const towerMapHud = isStackTowerMissionUiMapPlay(); + 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 || playTintFromPeerId(String(myId || 'me')), + }; + } + const o = entry.botId ? others.get(entry.botId) : null; + return { + name: o ? String(o.nickname || 'BOT').replace(/\s+/g, ' ').toUpperCase().slice(0, 12) : '—', + score: o ? (o.stackBotScore || 0) : 0, + characterId: o ? o.characterId : null, + playTint: o ? (o.playTint || playTintFromPeerId(entry.botId)) : null, + }; + } + + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + + const boardW = Math.min(248, Math.max(200, cw * 0.36)); + const rowH = 42; + const headH = 38; + const boardH = headH + 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, '"'); + } + + /** แถบ Cyber SCORE — ใช้ tint เดียวกับในเกม (ไม่ใช้ PNG รวมซิลูเอตขาวจาก getCharacterImg อย่างเดียว) */ + function setCyberHudScoreAvatarImg(avImg, row) { + const cid = row.characterId ? String(row.characterId) : ''; + if (!cid) { + avImg.src = defaultAvatarImg.src; + return; + } + const dir = 'down'; + const nowT = Date.now(); + const walk = false; + const rawImg = getAvatarImg(cid, dir, nowT, walk); + const peerId = row.id; + const tint = row.isMe + ? (me.playTint || playTintFromPeerId(String(myId != null ? myId : 'me'))) + : (() => { + const ox = others.get(peerId); + return ox ? (ox.playTint || playTintFromPeerId(String(peerId))) : playTintFromPeerId(String(peerId)); + })(); + const cacheKey = [row.isMe ? 'me' : String(peerId), cid, tint.head, tint.hair, tint.body].join('|'); + const comp = rawImg && cid && tint + ? getPlayTintedAvatarSource(rawImg, cid, dir, nowT, walk, tint) + : rawImg; + if (comp && comp.tagName === 'CANVAS' && comp.width > 0) { + let dataUrl = cyberHudScoreAvatarUrlCache.get(cacheKey); + if (!dataUrl) { + try { + dataUrl = comp.toDataURL('image/png'); + } catch (e) { + dataUrl = ''; + } + if (dataUrl) { + if (cyberHudScoreAvatarUrlCache.size >= CYBER_HUD_AV_URL_CACHE_CAP) { + const first = cyberHudScoreAvatarUrlCache.keys().next(); + if (!first.done) cyberHudScoreAvatarUrlCache.delete(first.value); + } + cyberHudScoreAvatarUrlCache.set(cacheKey, dataUrl); + } + } + if (dataUrl) { + avImg.src = dataUrl; + return; + } + const im = getCharacterImg(cid, dir); + avImg.src = im && im.src ? im.src : defaultAvatarImg.src; + return; + } + if (comp && comp.src) { + avImg.src = comp.src; + } else { + const im = getCharacterImg(cid, dir); + avImg.src = im && im.src ? im.src : defaultAvatarImg.src; + } + } + + 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 on = !!(isQuizQuestionMissionHudActivePlay() && !isChatFocused()); + root.classList.toggle('is-hidden', !on); + root.setAttribute('aria-hidden', on ? 'false' : 'true'); + 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(); + } + } + + 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'); + } + } + 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; + root.classList.toggle( + 'play-cyber-hud--question-mission', + !!(isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay() + || isSpaceShooterMissionHudActivePlay()), + ); + 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())), + ); + 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 - Date.now()) / 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 (previewMode && editorEmbedReturn && quizCarryEmbedCountdownEndAt > Date.now()) { + const rem = Math.max(0, Math.ceil((quizCarryEmbedCountdownEndAt - Date.now()) / 1000)); + timeVal.textContent = String(rem); + } else if (previewMode && editorEmbedReturn && 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 - Date.now()) / 1000)); + const mm = Math.floor(rem / 60); + const ss = rem % 60; + timeVal.textContent = `${mm}:${String(ss).padStart(2, '0')}`; + } else if (gauntletRuntimeTimeLimitSec > 0) { + timeVal.textContent = '···'; + } else { + timeVal.textContent = '∞'; + } + } else if (isSpaceShooter()) { + if (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) { + const cid = me.characterId || getPlayCharacterId(); + const dir = isSpaceShooter() + ? 'down' + : (isGauntletFaceRightMapMno9kb07() ? 'right' : (me.direction || 'down')); + const nowT = Date.now(); + const walk = !!me.isWalking; + const rawImg = getAvatarImg(cid, dir, nowT, walk); + const tint = me.playTint || playTintFromPeerId(String(myId != null ? myId : 'me')); + const comp = rawImg && cid && tint + ? getPlayTintedAvatarSource(rawImg, cid, dir, nowT, walk, tint) + : rawImg; + if (comp && comp.tagName === 'CANVAS' && comp.width > 0) { + try { + portrait.src = comp.toDataURL('image/png'); + } catch (e) { + if (rawImg && rawImg.src) portrait.src = rawImg.src; + } + } else if (comp && comp.src) { + portrait.src = comp.src; + } else if (rawImg && rawImg.src) { + portrait.src = rawImg.src; + } + } + if (statusEl) { + if (isSpaceShooterMissionHudActivePlay()) { + ensureStackTowerLifeHudImagesPlay(); + const nn = String((me.nickname || nick || 'PILOT')).trim().slice(0, 24); + const hits = Math.max(0, Math.min(SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS, Number(me.spaceShooterHits) || 0)); + const rem = SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS - hits; + const esc = playCyberHudEsc(nn); + const W = 220; + const H = 58; + const dpr = Math.min(2, Math.max(1, typeof window !== 'undefined' && window.devicePixelRatio ? window.devicePixelRatio : 1)); + let canv = statusEl.querySelector('canvas.play-cyber-ss-integrity-canvas'); + let metaEl = statusEl.querySelector('.play-cyber-ss-meta'); + if (!canv || !metaEl) { + statusEl.textContent = ''; + canv = document.createElement('canvas'); + canv.className = 'play-cyber-ss-integrity-canvas'; + canv.setAttribute('aria-hidden', 'true'); + metaEl = document.createElement('div'); + metaEl.className = 'play-cyber-ss-meta'; + statusEl.appendChild(canv); + statusEl.appendChild(metaEl); + } + canv.width = Math.max(1, Math.floor(W * dpr)); + canv.height = Math.max(1, Math.floor(H * dpr)); + canv.style.width = W + 'px'; + canv.style.height = H + 'px'; + const ctxSs = canv.getContext('2d'); + if (ctxSs) { + ctxSs.setTransform(dpr, 0, 0, dpr, 0, 0); + ctxSs.clearRect(0, 0, W + 2, H + 2); + drawStackTowerLifeIntegrityBarPlay( + ctxSs, + 2, + 2, + W - 4, + H - 4, + SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS, + rem, + ); + } + metaEl.textContent = 'P1 · ' + esc + ' · TEAM 0 · COMBO x0'; + } 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 = ''; + 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 board = ul.closest('.play-cyber-scoreboard'); + if (board) board.classList.toggle('play-cyber-scoreboard--crown-strip', !!stripScoreHud); + ul.classList.toggle('play-cyber-score-list--crown-strip', !!stripScoreHud); + 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 (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(); + /** แถบ SCORE แบบ mock mng8a80o (แนวนอน อวาตาร์+ชื่อ | คะแนน) — ภารกิจคำถาม + Stack Tower + Jumper mnptfts2 + Space mnpz6rkp */ + const cyberQmMockHud = quizMissionHud || stackMissionHud || jumpMissionLiveHud || spaceMissionLiveHud; + const stackTeamPts = Math.max(0, Number(stackMini && stackMini.score) || 0); + const rows = []; + rows.push({ + id: myId, + nickname: me.nickname || nick, + characterId: me.characterId || getPlayCharacterId(), + 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, + 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; + }); + + ul.innerHTML = ''; + const cap = 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); + } + } + 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' : '') + + (stripScoreHud ? ' play-cyber-score-row--crown-strip' : ''); + const av = document.createElement('img'); + av.className = 'play-cyber-score-av' + (cyberQmMockHud ? ' play-cyber-score-av--qm' : '') + + (stripScoreHud ? ' play-cyber-score-av--crown-strip' : ''); + av.alt = ''; + setCyberHudScoreAvatarImg(av, row); + const sc = document.createElement('span'); + sc.className = 'play-cyber-score-val' + (cyberQmMockHud ? ' play-cyber-qm-score' : '') + + (stripScoreHud ? ' 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'; + 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); + qmLeft.appendChild(qmName); + li.appendChild(qmLeft); + li.appendChild(sc); + } 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; + } + } + 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) { + ctx.fillStyle = 'rgba(186, 130, 255, 0.4)'; + ctx.fillRect(sx + 1, sy + 1, size - 2, size - 2); + ctx.strokeStyle = 'rgba(220, 170, 255, 0.55)'; + ctx.strokeRect(sx + 1, sy + 1, size - 2, size - 2); + } + if (isQuizBattle() && showGrid && mapData.quizBattleDomeArea && mapData.quizBattleDomeArea[y] && mapData.quizBattleDomeArea[y][x] === 1) { + const cid = mapData.quizBattleDomeComp && mapData.quizBattleDomeComp[y] && mapData.quizBattleDomeComp[y][x]; + const done = cid > 0 && quizBattleAnsweredComps.has(cid); + ctx.fillStyle = done ? 'rgba(90, 90, 110, 0.4)' : 'rgba(100, 200, 255, 0.36)'; + ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4); + ctx.strokeStyle = done ? 'rgba(140, 140, 160, 0.65)' : 'rgba(255, 100, 130, 0.82)'; + ctx.lineWidth = 2; + ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); + ctx.lineWidth = 1; + } + if (isStack()) { + const sr = mapData.stackReleaseArea, sl = mapData.stackLandArea; + if (sr && sr[y] && sr[y][x] === 1) { + ctx.fillStyle = 'rgba(125, 207, 255, 0.28)'; + ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4); + ctx.strokeStyle = 'rgba(122, 220, 255, 0.55)'; + ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); + } + if (sl && sl[y] && sl[y][x] === 1) { + ctx.fillStyle = 'rgba(247, 118, 190, 0.26)'; + ctx.fillRect(sx + 3, sy + 3, size - 6, size - 6); + ctx.strokeStyle = 'rgba(255, 130, 200, 0.5)'; + ctx.strokeRect(sx + 3, sy + 3, size - 6, size - 6); + } + } + /* ไม่วาด 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 gauntletObsDraw = (isGauntlet() && !gauntletCrownRunwayBgHideObstaclesPlay()) + ? getGauntletObsDrawPositionsAt(performance.now()) + : []; + if (isGauntlet() && gauntletObsDraw.length) { + const stx = Math.max(0, Math.floor(worldMinX / tileSize)); + const enx = Math.min(w - 1, Math.ceil(worldMaxX / tileSize)); + const sty = Math.max(0, Math.floor(worldMinY / tileSize)); + const eny = Math.min(h - 1, Math.ceil(worldMaxY / tileSize)); + for (let i = 0; i < gauntletObsDraw.length; i++) { + const o = gauntletObsDraw[i]; + if (!o) continue; + if (o.kind === 'lane' && typeof o.y === 'number') { + if (o.drawX < stx - 2 || o.drawX > enx + 2 || o.y < sty || o.y > eny) continue; + const size = tileSize * zDraw; + const inner = Math.max(2, size - 4); + const [sxC, syCellBottom] = worldToScreen((o.drawX + 0.5) * tileSize, (o.y + 1) * tileSize); + const dx = sxC - inner / 2; + const dy = syCellBottom - inner; + const laneRec = pickGauntletLaneImageRec(o.id); + if (laneRec && laneRec.img.complete && laneRec.img.naturalWidth > 0) { + try { + ctx.drawImage(laneRec.img, dx, dy, inner, inner); + } catch (e) { + ctx.fillStyle = '#f7768e'; + ctx.fillRect(dx, dy, inner, inner); + } + } else { + ctx.fillStyle = '#f7768e'; + ctx.fillRect(dx, dy, inner, inner); + } + } else if (o.kind === 'laser' && typeof o.drawX === 'number') { + if (o.drawX < stx - 2 || o.drawX > enx + 2) continue; + const { y0: laserRow0, y1: laserRow1 } = gauntletLaserRowHitRange(o, h); + const wx0 = o.drawX * tileSize; + const wx1 = (o.drawX + 1) * tileSize; + const wy0 = laserRow0 * tileSize; + const wy1 = (laserRow1 + 1) * tileSize; + const [sx0, syTop] = worldToScreen(wx0, wy0); + const [sx1, syBot] = worldToScreen(wx1, wy1); + const rx = Math.min(sx0, sx1); + const rw = Math.max(2, Math.abs(sx1 - sx0)); + const ry = Math.min(syTop, syBot); + const rh = Math.abs(syBot - syTop); + drawGauntletLaserColumnScreen(rx, ry, rw, rh); + } + } + } + + /** ป้ายติดตัว — กึ่งกลางลำตัว (ทับอก/เอว) แบบเดียวกับป้ายบนพื้น ไม่มีเส้นเชื่อม */ + function drawQuizCarryHeldSignBoard(sxFeet, feetSy, halfSpriteW, spriteTopY, signText, _faceDir, choiceIndex, imageUrl, imageElementOpt) { + const imgUrlSan = sanitizeQuizCarryImageUrlClient(imageUrl); + const gridHeldDraw = imageElementOpt && imageElementOpt.complete && imageElementOpt.naturalWidth > 0 ? imageElementOpt : null; + const raw = String(signText || '').trim(); + if (!raw && !imgUrlSan && !gridHeldDraw) return; + ctx.save(); + const heldPs = quizCarryPlaqueMapScaleClampedPlay(); + const bodyH = Math.max(18, feetSy - spriteTopY); + const midBodyY = spriteTopY + bodyH * 0.48; + const bodyCx = sxFeet; + const halfW = Math.max(10, halfSpriteW || 0); + const baseMaxW = Math.min(280, Math.max(100, halfW * 2.4 + tileSize * zDraw * 0.5)); + const maxPlaqueW = Math.min(340, Math.round(baseMaxW * heldPs)); + + const fontPx = Math.max(12, Math.min(22, tileSize * zDraw * 0.22 * heldPs)); + ctx.font = `600 ${fontPx}px system-ui, "Segoe UI", "Kanit", sans-serif`; + const padX = 12; + const padY = 9; + const maxInner = Math.min(maxPlaqueW - padX * 2, Math.max(72, tileSize * zDraw * 4.2)); + let lines; + if (!raw) { + lines = []; + } else { + lines = canvasWordWrapLines(ctx, raw, maxInner); + if (!lines.length) lines = [raw]; + if (lines.length > 3) lines = lines.slice(0, 3); + for (let i = 0; i < lines.length; i++) { + let s = lines[i]; + if (ctx.measureText(s).width <= maxInner) continue; + while (s.length > 2 && ctx.measureText(s + '…').width > maxInner) s = s.slice(0, -1); + lines[i] = s + '…'; + } + } + let maxLineW = 0; + for (let i = 0; i < lines.length; i++) { + const lw = ctx.measureText(lines[i]).width; + if (lw > maxLineW) maxLineW = lw; + } + const lineH = fontPx * 1.22; + let w; + let h; + if (!lines.length && (imgUrlSan || gridHeldDraw)) { + w = Math.ceil(Math.min(maxPlaqueW, Math.max(104, maxInner + padX * 2))); + h = Math.ceil(Math.max(56, fontPx * 2.8 + padY * 2)); + } else { + w = Math.ceil(Math.max(maxLineW, 56) + padX * 2); + h = Math.ceil(lines.length * lineH + padY * 2); + w = Math.min(maxPlaqueW, Math.max(96, w)); + h = Math.max(40, h); + } + + let signX = bodyCx - w / 2; + let signY = midBodyY - h / 2; + + if (signX + w > canvas.width - 6) signX = canvas.width - w - 6; + if (signX < 6) signX = 6; + if (signY < 6) signY = 6; + if (signY + h > feetSy + 8) signY = feetSy + 8 - h; + + drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, null, { imageUrl: imgUrlSan, imageElement: gridHeldDraw }); + ctx.restore(); + } + + function drawAvatar(ax, ay, isMe, name, characterId, direction, isWalking, playTint, gauntletAirTicks, gauntletScoreLabel, quizCarrySignPayload, 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 r = Math.max(14, (tileSize * zDraw * cellSpan) / 2 - 2); + const size = r * 2.2; + const dir = direction || 'down'; + const rawImg = getAvatarImg(characterId, dir, timeMs, isWalking); + const charImg = playTint + ? getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, playTint) + : rawImg; + /* รองรับทั้ง และ canvas จากย้อมสี (canvas ไม่มี naturalWidth → เดิมตกวงกลม) */ + let iw = 0, ih = 0; + if (charImg && charImg.tagName === 'CANVAS' && charImg.width > 0 && charImg.height > 0) { + iw = charImg.width; + ih = charImg.height; + } else if (charImg && charImg.complete && charImg.naturalWidth) { + iw = charImg.naturalWidth; + ih = charImg.naturalHeight; + } + let halfSpriteW = r; + let spriteTopY = sy - 2 * r; + if (iw > 0 && ih > 0) { + const scale = Math.min(size / iw, size / ih, 1); + const drawW = iw * scale; + const drawH = ih * scale; + halfSpriteW = drawW / 2; + spriteTopY = sy - drawH; + ctx.drawImage(charImg, 0, 0, iw, ih, sx - drawW / 2, sy - drawH, drawW, drawH); + } else { + const cy = sy - r; + ctx.fillStyle = isMe ? '#7aa2f7' : '#9ece6a'; + ctx.beginPath(); + ctx.arc(sx, cy, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#c0caf5'; + ctx.lineWidth = 2; + ctx.stroke(); + spriteTopY = cy - r; + } + ctx.fillStyle = '#c0caf5'; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + const nameBase = name || ''; + const withScore = isGauntlet() && typeof gauntletScoreLabel === 'number' && gauntletScoreLabel >= 0 + ? (nameBase + ' · ' + gauntletScoreLabel) + : nameBase; + ctx.fillText(withScore, sx, sy + 10); + if (isGauntletCrownHeistMapPlay() + && 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 = playTintFromPeerId(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 || playTintFromPeerId(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 (botOut) ctx.save(); + if (botOut) ctx.globalAlpha = 0.4; + drawAvatar(safeX(o.x) + off.ax, safeY(o.y) + off.ay, false, peerName, o.characterId, faceDirOther, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0, o.gauntletScore || 0, quizCarrySignForEntity(o), isGauntletCrownHeistMapPlay() ? (o.gauntletCrownPenaltyFxUntil || 0) : 0); + if (botOut) ctx.restore(); + } else { + if (shouldGauntletCrownHeistSkipAvatarDrawPlay(me, true, 0, 0)) return; + if (isJumpSurvive() && jumpSurviveEliminated) ctx.save(); + if (isJumpSurvive() && jumpSurviveEliminated) ctx.globalAlpha = 0.4; + const faceDirMe = isGauntletFaceRightMapMno9kb07() ? 'right' : me.direction; + drawAvatar(safeX(me.x), safeY(me.y), true, me.nickname + meTag, me.characterId, faceDirMe, meWalking, mt, meGauntletJumpVis, me.gauntletScore || 0, quizCarrySignForEntity(me), isGauntletCrownHeistMapPlay() ? (me.gauntletCrownPenaltyFxUntil || 0) : 0); + if (isJumpSurvive() && jumpSurviveEliminated) ctx.restore(); + } + }); + } + if (isFrogger()) { + ctx.fillStyle = '#7aa2f7'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('โหมดกบ | คะแนน: ' + froggerScore, 10, 24); + } + if (isJumpSurvive() && !useCyberPlayHud()) { + ctx.fillStyle = 'rgba(148, 226, 213, 0.95)'; + ctx.font = '600 12px system-ui, "Segoe UI", "Kanit", sans-serif'; + ctx.textAlign = 'left'; + if (jumpSurviveEliminated) { + ctx.fillStyle = 'rgba(247, 118, 190, 0.95)'; + ctx.fillText('คุณตกรอบแล้ว (ชนหรือหลุดเพดาน/พื้นจอ) · เล่นต่อไม่ได้ — ดูเพื่อนได้ · รอรอบใหม่หรือออกจากห้อง', 10, 22); + } else { + ctx.fillText('กระโดดให้รอด · Space / W / ↑ · A D / ← → · แพลตฟอร์มวน · ชนหรือหลุดบน/ล่างจอ = ตกรอบ (ดูเพื่อนต่อ)', 10, 22); + } + } + if (isGauntlet() && !useCyberPlayHud()) { + ctx.fillStyle = '#f7768e'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'left'; + if (isGauntletCrownHeistMapPlay()) { + ctx.fillText('พรมแดงสุดท้าย | คะแนน ' + (me.gauntletScore || 0) + ' · ชน -10 · ขอบซ้ายตาย · สูงสุด 6 คน', 10, 24); + ctx.font = '12px sans-serif'; + ctx.fillStyle = '#c0caf5'; + ctx.fillText('Space / W / ↑ = กระโดด · เลน + เลเซอร์ · จบเกมมีโบนัสอันดับ + เกรดทีม', 10, 42); + } else { + ctx.fillText('พรมแดงสุดท้าย | คะแนน: ' + (me.gauntletScore || 0) + ' (ข้าม 1 ชิ้น +1) · สูงสุด 6 คน', 10, 24); + ctx.font = '12px sans-serif'; + ctx.fillStyle = '#c0caf5'; + ctx.fillText('Space / W / ↑ = กระโดด · เลน + เลเซอร์ · ข้ามสำเร็จ → ขวา 1 + คะแนน · ชน → ซ้าย 1', 10, 42); + } + ctx.fillStyle = '#e0af68'; + if (!(isGauntletCrownHeistMapPlay() && gauntletCrownRunwayBgDrawActivePlay())) { + if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) { + const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000)); + const mm = Math.floor(rem / 60); + const ss = rem % 60; + const clock = `${mm}:${String(ss).padStart(2, '0')}`; + ctx.fillText(`เหลือเวลา ${clock} · Time left ${clock}`, 10, 60); + } else if (gauntletRuntimeTimeLimitSec > 0) { + ctx.fillText('เวลา: กำลังซิงก์จากเซิร์ฟเวอร์... · Syncing timer...', 10, 60); + } else { + ctx.fillText('เวลา: ไม่จำกัด · No time limit (ตั้งได้ที่ Admin → เวลาเกม)', 10, 60); + } + } + } + if (isStack() && stackMini) { + if (playBotsEnabled()) { + ctx.fillStyle = 'rgba(125, 220, 255, 0.9)'; + ctx.font = '600 12px ui-sans-serif, system-ui, sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('Stack · shared tower · Space / click = release on your turn', 10, 22); + drawStackPreviewCyberHud(ctx, canvas.width, canvas.height); + } else { + ctx.fillStyle = '#7dcfff'; + ctx.font = 'bold 15px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('Stack | คะแนน ' + stackMini.score + ' · ชีวิต ' + stackMini.lives + ' · combo x' + (stackMini.combo || 0), 10, 24); + ctx.font = '12px sans-serif'; + ctx.fillStyle = '#c0caf5'; + ctx.fillText('Space / คลิก = ปล่อย', 10, 44); + } + } + if (isQuiz() || isQuizCarry() || isQuizBattle()) { + syncPlayQuizMapPanel(); + syncQuizCarryEmbedQuestionStrip(); + } + /* timeup ต้องซิงก์ทุกเฟรมแม้จบเซสชัน — อย่าให้อยู่ในเงื่อนไขด้านบนอย่างเดียว */ + syncQuizCarryTimeupDeskLayerPosition(); + if (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 (previewMode && editorEmbedReturn && isQuizCarry() + && (quizCarryEmbedCountdownEndAt > Date.now() || quizCarryEmbedPreOptionCountdownEndAt > Date.now())) { + syncQuizCarryEmbedCountdownLayout(); + } + drawQuizTfScorePopupsLayer(ctx, worldToScreen, zDraw, timeMs); + syncPlayCyberHud(); + syncQuizQuestionMissionJoystickPlay(); + syncQuizCarryGrabButton(); + syncGauntletCrownJumpButton(); + syncStackTowerDropButtonPlay(); + } + + document.addEventListener('keydown', (e) => { + 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; + function tick() { + if (!mapData) { 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) => { + if (playBotsEnabled() && isPreviewBotId(id)) return; + if (o.tx != null) o.x += (o.tx - o.x) * LERP; + if (o.ty != null) o.y += (o.ty - o.y) * LERP; + }); + tickQuizCarryEmbedCountdown(); + tickQuizCarryRoundTimers(); + updateQuizCarryCarryPhaseHud(); + stepPreviewBots(); + if (isChatFocused()) { + me.isWalking = false; + enforceQuizBattleLaneOnMePlay(); + draw(); + requestAnimationFrame(tick); + return; + } + if (isQuizCarryEmbedLobbyBlockingMovement() || isQuizCarryEmbedCountdownBlockingMovement()) { + me.isWalking = false; + playPath = []; + enforceQuizBattleLaneOnMePlay(); + draw(); + requestAnimationFrame(tick); + return; + } + const w = mapData.width || 20, h = mapData.height || 15; + let accX = 0, accY = 0; + let usePath = false; + const keyPressed = keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD']; + if (playPath.length > 0 && keyPressed) playPath = []; + if (playPath.length > 0) { + const way = playPath[0]; + const dx = way.x - me.x, dy = way.y - me.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist <= PATH_ARRIVE_THRESH) { + playPath.shift(); + /* ข้าม waypoint ที่ซ้ำตำแหน่ง — กัน acc=0 ทั้งที่ยังมี path (เดินขึ้น/ลงแล้วแอนิเมชันค้าง) */ + while (playPath.length > 0) { + const w2 = playPath[0]; + const ux = w2.x - me.x, uy = w2.y - me.y; + if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break; + playPath.shift(); + } + if (playPath.length === 0) { + me.isWalking = false; + me.x = Math.max(0, Math.min(w - 0.01, me.x)); + me.y = Math.max(0, Math.min(h - 0.01, me.y)); + enforceQuizBattleLaneOnMePlay(); + const t = Date.now(); + if (t - lastSend > 80) { lastSend = t; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } + draw(); + requestAnimationFrame(tick); + return; + } + usePath = true; + const next = playPath[0]; + accX = next.x - me.x; + accY = next.y - me.y; + } else { + usePath = true; + accX = dx; + accY = dy; + } + if (usePath && (accX !== 0 || accY !== 0)) { + if (Math.abs(accY) > Math.abs(accX)) me.direction = accY > 0 ? 'down' : 'up'; + else me.direction = accX > 0 ? 'right' : 'left'; + } + } + if (!usePath) { + 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 nx = me.x + (accX / len) * step; + const ny = me.y + (accY / len) * step; + /** Quiz Battle + เส้นทาง: ห้ามเลื่อนแกนเดียว (slide) — ไม่งั้นเดินทแยงหลุดออกนอกเลนได้ */ + const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData); + if (canWalkLikeLobby(nx, ny, me.x, me.y)) { + me.x = nx; + me.y = ny; + } else if (!pathStrict) { + if (canWalkLikeLobby(nx, me.y, me.x, me.y)) { + me.x = nx; + } else if (canWalkLikeLobby(me.x, ny, me.x, me.y)) { + me.y = ny; + } + } + } + me.x = Math.max(0, Math.min(w - 0.01, me.x)); + me.y = Math.max(0, Math.min(h - 0.01, me.y)); + enforceQuizBattleLaneOnMePlay(); + const movedThisTick = Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5; + me.isWalking = !!(accX !== 0 || accY !== 0) || playPath.length > 0 || movedThisTick; + const now = Date.now(); + if (now - lastSend > 80) { lastSend = now; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } + draw(); + requestAnimationFrame(tick); + } + + const chatForm = document.getElementById('chat-form'); + if (chatForm) { + chatForm.addEventListener('submit', (e) => { + e.preventDefault(); + const input = document.getElementById('chat-input'); + const text = (input && input.value || '').trim(); + if (text) { socket.emit('chat', text); if (input) input.value = ''; } + }); + } + document.getElementById('btn-leave').addEventListener('click', () => { + socket.emit('leave-space'); + const mid = params.get('map'); + if (previewMode && mid) { + var edQ = '?id=' + encodeURIComponent(mid) + (editorEmbedReturn ? '&embed=1' : ''); + window.location.replace(BASE + '/editor.html' + edQ); + } else { + window.location.replace(BASE + '/lobby.html'); + } + }); + (function wireQuizBattleMcqModal() { + const ov = document.getElementById('quiz-battle-mcq-overlay'); + if (!ov) return; + ov.addEventListener('click', (e) => { + const t = e.target; + if (!t) return; + if (t.classList && t.classList.contains('quiz-battle-choice')) { + const idx = parseInt(t.getAttribute('data-idx'), 10); + if (Number.isFinite(idx) && idx >= 0 && idx <= 2) trySubmitQuizBattleChoice(idx); + return; + } + if (t.id === 'quiz-battle-mcq-close') { + quizBattleModalCompId = null; + hideQuizBattleMcqModal(); + return; + } + if (t.classList && t.classList.contains('quiz-battle-mcq-backdrop')) { + quizBattleModalCompId = null; + hideQuizBattleMcqModal(); + } + }); + })(); + (function wireQuizCarryGrabButton() { + const btn = document.getElementById('quiz-carry-grab-btn'); + if (!btn) return; + btn.addEventListener('click', (ev) => { + if (!isQuizCarry() || !mapData || isChatFocused()) return; + if (!btn.classList.contains('quiz-carry-grab-btn--active')) return; + ev.preventDefault(); + if (myId == null) return; + tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true }); + }); + })(); + + (function wireGauntletCrownJumpButton() { + const btn = document.getElementById('gauntlet-crown-jump-btn'); + if (!btn) return; + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + if (!mapData || !isGauntlet() || isChatFocused()) 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.onerror = function () { + this.onerror = null; + this.removeAttribute('src'); + }; + } + if (knobImg) { + knobImg.src = questionMissionAssetUrl('btn-joystick-2.png'); + 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() || 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() { + const btn = document.getElementById('btn-gch-ready'); + if (!btn) return; + btn.addEventListener('click', () => { + if (isJumpSurviveMissionUiMapPlay()) { + if (jumpSurviveMissionPhase !== 'howto') return; + const humans = quizCarryPregameHumanIds(); + const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); + if (totPlayers === 1) { + 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 = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); + if (humansReady) { + socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } }); + 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()); + if (totPlayersSs === 1) { + 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 = humansSs.length > 0 && humansSs.every((id) => !!gauntletCrownLobbyReadyMap[id]); + if (humansReadySs) { + socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } }); + 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()); + if (totPlayers === 1) { + 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 = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); + if (humansReady) { + socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } }); + 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()); + if (totPlayersSt === 1) { + 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 = humansSt.length > 0 && humansSt.every((id) => !!gauntletCrownLobbyReadyMap[id]); + if (humansReadySt) { + socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } }); + 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 || !isMePlayHost()) return; + const humans = quizCarryPregameHumanIds(); + const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); + if (humansReady) { + if (humans.length === 1 && isMePlayHost()) { + beginGauntletCrownCountdownThenRun(); + return; + } + socket.emit('gauntlet-crown-lobby-start', {}, (r) => { + if (!r || !r.ok) { /* ignore */ } + }); + return; + } + const sid = String(myId); + const next = !gauntletCrownLobbyReadyMap[sid]; + gauntletCrownLobbyReadyMap[sid] = next; + updateGauntletCrownHowtoHud(); + socket.emit('gauntlet-crown-lobby-ready', { ready: next }); + }); + })(); + + (function wireQuizCarryPregameOverlay() { + const primary = document.getElementById('quiz-carry-pregame-primary'); + if (!primary) return; + primary.addEventListener('click', () => { + if (!quizCarryPregameActive || myId == null) return; + if (!isMePlayHost()) return; + const humans = quizCarryPregameHumanIds(); + const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]); + if (humansReady) { + /* ผู้เล่นจริงคนเดียว + พรีวิว: ไม่ต้องรอ ACK เซิร์ฟ (ห้องที่ map บนเซิร์ฟยังไม่ใช่ quiz_carry จะเคยล้มเหลว) */ + if (humans.length === 1 && isMePlayHost()) { + endQuizCarryEmbedPregameAndStart(); + return; + } + socket.emit('quiz-carry-lobby-start', {}, (r) => { + if (!r || !r.ok) showQuizCarryToast((r && r.error) || 'START ไม่ได้', false); + }); + return; + } + const sid = String(myId); + const next = !quizCarryLobbyReadyMap[sid]; + quizCarryLobbyReadyMap[sid] = next; + updateQuizCarryPregameHud(); + socket.emit('quiz-carry-lobby-ready', { ready: next }); + }); + })(); + window.addEventListener('beforeunload', () => socket.emit('leave-space')); + window.addEventListener('resize', () => { + if (!mapData || !canvas) return; + resizeCanvas(); + draw(); + }); + /** แคนวาส 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); + })(); +})(); diff --git a/www/html/Login/login.js b/www/html/Login/login.js index 90bc380..e623477 100644 --- a/www/html/Login/login.js +++ b/www/html/Login/login.js @@ -5,7 +5,7 @@ var linkPrivacy = document.getElementById('link-privacy'); function loadOauthPublic() { - return fetch('/Admin/api/oauth-public.php', { credentials: 'omit' }) + return fetch((typeof appPath === 'function' ? appPath('/Admin/api/oauth-public.php') : '/Admin/api/oauth-public.php'), { credentials: 'omit' }) .then(function (r) { return r.json(); }) .then(function (j) { if (!j || !j.ok) return null; @@ -18,7 +18,7 @@ // ไปหน้า Main Lobby แบบ Guest localStorage.setItem('isLoggedIn', 'true'); localStorage.setItem('loginType', 'guest'); - window.location.href = '/Main-Lobby/'; + window.location.href = typeof appPath === 'function' ? appPath('/Main-Lobby/') : '/Main-Lobby/'; } if (btnFacebook) { @@ -28,7 +28,7 @@ alert('ยังไม่ได้ตั้งค่า Facebook App ในหน้า /Admin/'); return; } - var ru = cfg.facebookRedirectUri || (location.origin + '/Login/facebook-callback.html'); + var ru = cfg.facebookRedirectUri || (location.origin + (typeof appPath === 'function' ? appPath('/Login/facebook-callback.html') : '/Login/facebook-callback.html')); var state = encodeURIComponent('fb-' + Date.now()); var url = 'https://www.facebook.com/v18.0/dialog/oauth?client_id=' + encodeURIComponent(cfg.facebookAppId) + '&redirect_uri=' + encodeURIComponent(ru) + '&state=' + state + '&scope=email,public_profile'; @@ -44,7 +44,7 @@ alert('ยังไม่ได้ตั้งค่า Google Client ในหน้า /Admin/'); return; } - var ru = cfg.googleRedirectUri || (location.origin + '/Login/google-callback.html'); + var ru = cfg.googleRedirectUri || (location.origin + (typeof appPath === 'function' ? appPath('/Login/google-callback.html') : '/Login/google-callback.html')); var state = encodeURIComponent('go-' + Date.now()); var url = 'https://accounts.google.com/o/oauth2/v2/auth?client_id=' + encodeURIComponent(cfg.googleClientId) + '&redirect_uri=' + encodeURIComponent(ru) + '&response_type=code&scope=' +