diff --git a/www/html/Admin/private/store.json b/www/html/Admin/private/store.json index 82937fa..a2ec39c 100644 --- a/www/html/Admin/private/store.json +++ b/www/html/Admin/private/store.json @@ -51,9 +51,9 @@ "providerUserId": "p_1775109142385_wq7wfy1p32j", "notes": "auto: player-coins", "blocked": false, - "coins": 2322, + "coins": 2382, "createdAt": "2026-04-02T05:52:21+00:00", - "updatedAt": "2026-06-27T11:14:02+00:00", + "updatedAt": "2026-06-27T18:23:36+00:00", "daily": { "anchorMs": 1781197200000, "claimedDays": [ @@ -67,10 +67,10 @@ ], "lockUntilMs": 0 }, - "score": 248, + "score": 308, "scoreByCase": { "1": 26, - "10": 616, + "10": 676, "8": 296, "11": 20, "13": 324, @@ -459,9 +459,9 @@ "providerUserId": "p_1781851275186_szcmp5pu3s", "notes": "auto: player-coins", "blocked": false, - "coins": 212, + "coins": 242, "createdAt": "2026-06-19T06:41:15+00:00", - "updatedAt": "2026-06-24T05:16:27+00:00", + "updatedAt": "2026-06-27T19:28:03+00:00", "daily": { "anchorMs": 1781802000000, "claimedDays": [ @@ -475,9 +475,9 @@ ], "lockUntilMs": 0 }, - "score": 192, + "score": 222, "scoreByCase": { - "10": 28, + "10": 58, "13": 36, "12": 82, "4": 28, @@ -485,7 +485,7 @@ }, "lbName": "MONE", "achievements": { - "d1_minigame_solver": 19, + "d1_minigame_solver": 20, "a1_first_deduction": 1, "a5_truth_hunter": 1 } @@ -663,15 +663,15 @@ "providerUserId": "p_1782016990238_0u37bv7pxlxs", "notes": "auto: player-coins", "blocked": false, - "coins": 572, + "coins": 592, "createdAt": "2026-06-21T04:43:11+00:00", - "updatedAt": "2026-06-27T11:14:02+00:00", + "updatedAt": "2026-06-27T18:51:26+00:00", "lobbyColorThemeIndex": 5, "lobbySkinToneIndex": 1, - "score": 532, + "score": 552, "scoreByCase": { "13": 182, - "10": 248, + "10": 268, "6": 8, "8": 20, "2": 46, diff --git a/www/html/Game/data/game-timing.json b/www/html/Game/data/game-timing.json index 06dea22..11a05ad 100644 --- a/www/html/Game/data/game-timing.json +++ b/www/html/Game/data/game-timing.json @@ -1,10 +1,15 @@ { "gauntletTickMs": 220, - "gauntletJumpTicks": 16, + "gauntletJumpTicks": 6, "gauntletTimeLimitSec": 0, - "gauntletLaneImageUrls": [], - "gauntletLaserTopUrl": "", - "gauntletLaserBottomUrl": "", + "gauntletLaneImageUrls": [ + "/Game/img/gauntlet-assets/gauntlet-077c293962b8fe07.png", + "/Game/img/gauntlet-assets/gauntlet-67a585790f67fe67.png", + "/Game/img/gauntlet-assets/gauntlet-c4d4482e2a60eac9.png", + "/Game/img/gauntlet-assets/gauntlet-6aa244ab734b12e4.png" + ], + "gauntletLaserTopUrl": "/Game/img/gauntlet-assets/gauntlet-dd51baed17270995.png", + "gauntletLaserBottomUrl": "/Game/img/gauntlet-assets/gauntlet-dd51baed17270995.png", "gauntletLaserLineUrl": "", "gauntletLaserFillColor": "rgba(140,230,255,0.42)", "gauntletLaserStrokeColor": "rgba(255,220,255,0.9)", @@ -90,15 +95,15 @@ "balloonBossPlayerBalloonFallbackUrl": "/Game/img/MegaVirus/Artboard%209.png", "balloonBossBalloonsPerPlayer": 3, "forcedMinigameKeys": [ - "quiz", "gauntlet", - "balloon_boss" + "space_shooter", + "stack" ], "testSpecialCardByMap": {}, "troublesomeForceOffer": false, "specialQuizIconExpireSec": 20, "pickVoteSec": 25, "trialVoteSec": 30, - "lobbyDebugHud": true, + "lobbyDebugHud": false, "lobbyDebugHudNames": [] } \ No newline at end of file diff --git a/www/html/Game/public/css/style.css b/www/html/Game/public/css/style.css index 5b441c2..f9e04a3 100644 --- a/www/html/Game/public/css/style.css +++ b/www/html/Game/public/css/style.css @@ -1975,7 +1975,14 @@ body.room-lobby--quiz-active .lobby-b-extra-row { font-size: 0.8rem; } -/* Stack Tower play — กัน #game-canvas ถูกกฎอื่นเป็น contain แล้วแถบดำรอบแคนวาส */ -html.play-stack-tower-pixel-canvas #play-canvas-stage #game-canvas { - object-fit: cover !important; +/* Stack Tower play — เฟรมกล้องต้อง "คงที่ทุก resolution" + cover สเกลตามด้านที่ใหญ่กว่า → จอกว้าง/เตี้ย ต่างกัน = เฟรม/ซูมเปลี่ยน (กล้องเพี้ยนเวลาเปลี่ยน resolution + บล็อกห้อย/จุดวางหลุดเฟรม) + contain = เห็นทั้งฉาก 16:9 (บล็อกห้อย+จุดวาง+หอ) เหมือนกันเป๊ะทุกจอ; แถบ letterbox กลืนสีฉาก (#0a0e22) ไม่ดูจอเสีย */ +html.play-stack-tower-pixel-canvas #play-canvas-stage.play-canvas-stage { + background: #0a0e22 !important; +} +html.play-stack-tower-pixel-canvas #play-canvas-stage #game-canvas { + object-fit: contain !important; + object-position: center center !important; + background: #0a0e22 !important; } diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index 7aba128..00b0df1 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -321,7 +321,19 @@ (เดิมทุกเครื่องคิดชนให้ทุกยานด้วยตำแหน่งดาวเคราะห์ local ที่ต่างกัน → ตัดสินไม่ตรงกัน) */ others.forEach(function (o, id) { if (!o) return; - if (isPreviewBotId(id) && isMePlayHost()) tryHit(o, o.spaceShooterCx, o.spaceShooterCy); + if (!(isPreviewBotId(id) && isMePlayHost())) return; + if (spaceShooterServerAuthActive()) { + /* server-auth (MG6 เกมจริง): host คิดการชนบอท (asteroid deterministic + ตำแหน่งบอทจาก server) + แล้ว "รายงาน" จำนวนชนให้ server ตัดสินตายจริง — ไม่ตัดสินตายเอง local (กันสถานะตายขัดกัน) */ + const h0 = Math.max(0, Number(o.spaceShooterHits) || 0); + tryHit(o, o.spaceShooterCx, o.spaceShooterCy); + const h1 = Math.max(0, Number(o.spaceShooterHits) || 0); + if (h1 > h0 && socket) { + try { socket.emit('space-shooter-bot-hit', { id, hits: h1 }); } catch (e) { /* ignore */ } + } + } else { + tryHit(o, o.spaceShooterCx, o.spaceShooterCy); + } }); /* เพิ่งตาย → broadcast ทันที ไม่รอ throttle 90ms ให้ทุกเครื่องเห็นพร้อมกัน */ if (!wasElim && me.spaceShooterEliminated && socket && myId != null) { @@ -679,6 +691,9 @@ let stackPreviewTurnOrder = null; let stackPreviewTurnIndex = 0; let stackPreviewBotThinkUntil = 0; + /* MG3 server-authoritative: เปิดเมื่อได้รับ stack-state ที่ srv:true → client เลิก host-sim + (ลำดับตา/แกว่ง/ดรอปบอท/จบเกม = server เป็นเจ้าของ; ผู้เล่นส่งเจตนา stack-drop-req) */ + let stackServerAuthPlay = false; /** ลำดับตา MG3 = ผู้เล่นจริงทุกคน + บอท เรียงตาม id แบบคงที่ → ทุกเครื่องได้ลำดับ/ที่นั่งเดียวกัน (shared tower) (เดิม [human=ตัวเอง seat1 + bots] ต่อเครื่อง → แต่ละเครื่องมองตัวเองเป็นคนเดียว เลยแยกตึกกัน) */ @@ -687,6 +702,7 @@ stackPreviewTurnOrder = null; return; } + if (stackServerAuthPlay) return; /* server-auth: ลำดับตามาจาก stack-state เท่านั้น */ if (!detectiveCaseFillBots) { /* editor preview (คนเดียว): คงพฤติกรรมเดิม [human ตัวเอง seat1 + บอท] */ const bots = [...others.keys()].filter(isPreviewBotId).sort(); @@ -738,6 +754,7 @@ /** host broadcast ลำดับตา + index ปัจจุบัน → non-host ใช้ตรงกัน (single source of truth) */ function broadcastStackStatePlay() { + if (stackServerAuthPlay) return; /* server-auth: server เป็นเจ้าของ stack-state */ if (!detectiveCaseFillBots || !isMePlayHost() || !socket || myId == null || !stackTurnOrderReadyPlay()) return; try { socket.emit('stack-state', { @@ -812,6 +829,7 @@ function advanceStackPreviewTurn() { if (!playBotsEnabled() || !stackTurnOrderReadyPlay()) return; + if (stackServerAuthPlay) return; /* server-auth: idx มาจาก server (stack-state) เท่านั้น — กัน double-advance */ /* เล่นจริง: index คุมโดย host เท่านั้น (non-host รับจาก stack-state) กันลำดับตาเหลื่อมกัน */ if (detectiveCaseFillBots && !isMePlayHost()) return; stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % stackPreviewTurnOrder.length; @@ -1572,6 +1590,9 @@ gauntletCrownRunwaySnapScrollToFinishStopPlay(); gauntletCrownRunwayWalkActive = true; gauntletCrownRunwayWalkStartMs = Date.now(); + /* G3: เข้าเฟสเดินเข้าเส้นชัย → เคลียร์ท่ากระโดด (กันค้างลอยกลางอากาศ — ต้องเดินเข้าเส้น ไม่ใช่กระโดดค้าง) */ + meGauntletJumpTicks = 0; meGauntletJumpVis = 0; + others.forEach((o) => { if (o) { o.gauntletJumpTicks = 0; o.gauntletJumpVis = 0; } }); /* แจ้ง server ครั้งเดียว: เข้าโซนเส้นชัย → หยุด obstacle + collision (กันโดนชน/-10 ตอนกำลังเดินเข้าเส้น) server จะรอ grace (GAUNTLET_FINISH_WALK_MS) ให้เดินครบก่อนค่อยจบ */ if (!gauntletFinishPhaseSignaled) { @@ -1792,7 +1813,9 @@ ctx.save(); ctx.setTransform(zDraw, 0, 0, zDraw, canvasW / 2 - camX * zDraw, canvasH / 2 - camY * zDraw); ctx.beginPath(); - ctx.rect(0, 0, mapWpx, mapHpx); + /* แนวนอน: คลิปเต็มความกว้างที่มองเห็น (worldMinX..worldMaxX) แทนตัดที่ความกว้างแมพ (mapWpx) + → จอกว้างทุก resolution รันเวย์โชว์ยาวเต็ม ไม่เหลือขอบดำซ้าย-ขวา · แนวตั้งคงคลิป=ความสูงแมพ (ไม่กระทบเส้นชัย) */ + ctx.rect(worldMinX, 0, cwR, mapHpx); ctx.clip(); ctx.translate(worldMinX, 0); if (gauntletCrownRunwayBgMapEligiblePlay()) { @@ -3575,7 +3598,7 @@ let meGauntletJumpVis = 0; /** ซิงก์จากเซิร์ฟเวอร์ (gauntlet-sync / GET /api/game-timing) */ let gauntletRuntimeTickMs = 220; - let gauntletRuntimeJumpTicks = 16; + let gauntletRuntimeJumpTicks = 6; /** Stack: รอบสวิงต่อวินาที — จาก GET /api/game-timing · ค่าน้อย = สวิงช้า */ let playStackSwingHz = 0.55; /** Stack: ความกว้างบล็อก (tile) — null = คำนวณจากโซนลงบนแผนที่ */ @@ -4019,6 +4042,9 @@ let jumpSurviveJumpQueued = false; let jumpSurviveOnGround = false; let jumpSurviveLastEmitT = 0; + let jumpSurviveServerAuthPlay = false; /* true เมื่อรับ jump-survive-bot-move/ended ครั้งแรก — server เป็นเจ้าของบอท/รอบ */ + let spaceShooterServerAuthPlay = false; /* MG6: true เมื่อรับ space-shooter-bot-move — server เป็นเจ้าของบอท/รอบ */ + let balloonBossServerAuthPlay = false; /* MG7: true เมื่อรับ balloon-boss-bot-move — server เป็นเจ้าของบอท/รอบ/HP บอส */ /** mnptfts2: howto → countdown → live → ended (คะแนน 100 ผู้รอดสุดท้าย) */ let jumpSurviveMissionPhase = null; let jumpSurviveGameEnded = false; @@ -5727,6 +5753,11 @@ */ function applyGauntletPreviewSpawnLayout(onlyBots) { if (!mapData || mapData.gameType !== 'gauntlet') return; + /* เกมจริง (ห้องจริงทุกแบบ: คนล้วน/มีบอท): server เป็นเจ้าของตำแหน่ง/เลนของทุกตัวผ่าน gauntlet-sync + → ห้าม client จัดเลนเอง เพราะ layout นี้สแนป me ไป pos[0] (เลนบนสุด, จุดสตาร์ท) เสมอ + ทำให้ตอน host หลุด me (host ใหม่) ไปทับบอท takeover ที่กินเลนบนสุด + เฉพาะ editor preview (preview=1, ไม่มี server ขับ) เท่านั้นที่ client จัดเลนเอง */ + if (!previewMode) return; const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); const botIds = [...others.keys()].filter(isPreviewBotId).sort(); const humanCount = 1 + realIds.length; @@ -6520,7 +6551,7 @@ /* #1 map fit: gauntlet เกมจริง — fit "ความสูงแมป (ลู่ทั้งหมด)" ให้พอดีจอ resolution-adaptive (เดิมใช้ zoom ดิบ ไม่ปรับตาม resolution = ไม่พอดี) · fit สูงอย่างเดียว เพราะ runway เลื่อนแนวนอน */ const mhPx = h * tileSize; - const fitZ = (canvas.height / mhPx) * 0.96; + const fitZ = (canvas.height / mhPx) * 1.0; /* เต็มจอบน-ล่าง (เดิม 0.96 = มีขอบดำบน-ล่าง) */ if (Number.isFinite(fitZ) && fitZ > 0) zDraw = fitZ; } if (isStackTowerEmbedZoomLockedPlay()) { @@ -6605,6 +6636,7 @@ const GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX = 40; function maybeEliminateGauntletPreviewBotsTailOffCameraPlay(camX, camY, zDrawVal) { if (!playBotsEnabled() || !isMePlayHost() || !isGauntletCrownHeistMapPlay() || !mapData) return; + if (detectiveCaseFillBots) return; /* MG2 เกมจริง: server ตัดสินการตาย/คัดออกของบอทเอง */ if (gauntletCrownPregamePhase !== 'live') return; const halfW = canvas.width / (2 * zDrawVal); const worldMinX = camX - halfW; @@ -6620,6 +6652,18 @@ function isStack() { return mapData && mapData.gameType === 'stack'; } function isJumpSurvive() { return mapData && mapData.gameType === 'jump_survive'; } + /* MG5 server-auth: server เป็นเจ้าของบอท/รอบ → host เลิก sim บอทเอง, ทุกเครื่องรับตำแหน่งบอทจาก server */ + function jumpSurviveServerAuthActive() { + return isJumpSurvive() && (jumpSurviveServerAuthPlay || isDetectiveMinigamePlay()); + } + /* MG6 server-auth: server เป็นเจ้าของบอท/รอบ → host เลิก sim บอท, ทุกเครื่องรับตำแหน่งบอทจาก server */ + function spaceShooterServerAuthActive() { + return isSpaceShooter() && (spaceShooterServerAuthPlay || isDetectiveMinigamePlay()); + } + /* MG7 server-auth: server เป็นเจ้าของบอท/รอบ/HP บอส → host เลิก sim บอท, ทุกเครื่องรับตำแหน่ง/ดาเมจบอทจาก server */ + function balloonBossServerAuthActive() { + return isBalloonBoss() && (balloonBossServerAuthPlay || isDetectiveMinigamePlay()); + } function currentPlayMapId() { const q = (playSessionMapId || '').trim(); @@ -7722,6 +7766,7 @@ if (!isStackTowerMissionUiMapPlay()) return; if (stackTowerMissionPhase !== 'live') return; if (stackTowerMissionEndedOnce) return; + if (stackServerAuthPlay) return; /* server-auth: server ตัดสินจบ + ส่ง stack-over */ const pct = stackTowerDecryptPctPlay(); const elapsed = stackTowerMissionElapsedSecPlay(); const timeLim = stackTowerMissionTimeLimitSecPlay(); @@ -8996,6 +9041,13 @@ function isBalloonBoss() { return mapData && mapData.gameType === 'balloon_boss'; } function isQuizCarry() { return mapData && mapData.gameType === 'quiz_carry'; } + /* MG4 server-authoritative: เกมจริง (detective) เท่านั้น — server เป็นเจ้าของ คำถาม/เฟส/หยิบ-ส่ง/คะแนน/บอท + editor preview (เล่นเดี่ยว) ยังเป็น client-sim เดิม (gate เดียวกับ server: space.detectiveMinigameActive) */ + let quizCarryServerAuthPlay = false; /* ตั้ง true เมื่อรับ quiz-carry-phase (srv) ครั้งแรก — กัน edge ก่อน flag */ + function quizCarryServerAuthActive() { + return isQuizCarry() && (quizCarryServerAuthPlay || isDetectiveMinigamePlay()); + } + /** พรีวิว editor + ภารกิจสืบสวน mnorwqx1 — flow เต็ม: HOWTO/READY, นับ 3-2-1, ครบ carrySessionLength, สรุปภารกิจ */ function quizCarryEmbedMissionFlowActive() { if (!isQuizCarry()) return false; @@ -9755,6 +9807,11 @@ function broadcastFillBotState() { if (!playBotsEnabled() || !isMePlayHost() || !mapData) return; if (isQuiz() && detectiveCaseFillBots) return; /* quiz เกมจริง: server เป็นเจ้าของบอท (quiz-bot-move) → ห้าม host ส่งทับ = บอทวาป/ผีหลุด area */ + if (isGauntlet() && detectiveCaseFillBots) return; /* MG2 เกมจริง: server เป็นเจ้าของบอท (gauntlet-sync) → ห้าม host ส่งทับ */ + if (quizCarryServerAuthActive()) return; /* MG4 เกมจริง: server เป็นเจ้าของบอท (quiz-carry-bot-move) → ห้าม host ส่งทับ */ + if (jumpSurviveServerAuthActive()) return; /* MG5 เกมจริง: server เป็นเจ้าของบอท (jump-survive-bot-move) → ห้าม host ส่งทับ */ + if (spaceShooterServerAuthActive()) return; /* MG6 เกมจริง: server เป็นเจ้าของบอท (space-shooter-bot-move) → ห้าม host ส่งทับ */ + if (balloonBossServerAuthActive()) return; /* MG7 เกมจริง: server เป็นเจ้าของบอท (balloon-boss-bot-move) → ห้าม host ส่งทับ */ const arr = []; others.forEach((o, id) => { if (!isPreviewBotId(id) || !o) return; @@ -10131,6 +10188,7 @@ } function tickQuizCarryRoundTimers() { + if (quizCarryServerAuthActive()) return; /* server คุมเวลาตอบ/จบรอบ */ if (quizCarrySessionEnded) return; if (quizCarryPregameActive) return; if (!isQuizCarry() || !mapData || !quizCarryCurrent) return; @@ -10302,6 +10360,7 @@ } function quizCarryPickNextQuestion() { + if (quizCarryServerAuthActive()) return; /* server เป็นเจ้าของคำถาม (quiz-carry-phase) */ if (quizCarrySessionEnded) return; if (quizCarryPregameActive) return; if (!quizCarryPool.length) { @@ -10350,6 +10409,7 @@ /** หลังจบข้อหนึ่งข้อ (ถูกหรือหมดเวลา): พรีวิวนับรอบจนครบ carrySessionLength แล้วจบเซสชัน */ function quizCarryAfterRoundResolved() { if (!isQuizCarry()) return; + if (quizCarryServerAuthActive()) return; /* server คุมการจบรอบ/เลื่อนข้อ (quiz-carry-result/ended) */ if (isDetectiveMinigamePlay() && !quizCarryEmbedMissionFlowActive()) { finishDetectiveMinigameAndReturnLobby(); return; @@ -10952,6 +11012,24 @@ if (quizCarryPregameActive) return false; if (quizCarrySessionEnded) return false; if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false; + /* MG4 server-auth: คนจริงกด F/GRAB → ส่ง intent ให้ server ตัดสิน (server เป็นเจ้าของ held/คะแนน/จบรอบ) + บอทถูก guard ปิด local-sim แล้ว จึงไม่เข้ามาทางนี้ */ + if (quizCarryServerAuthActive()) { + if (actorId === myId && !opts.silent && socket && socket.connected) { + /* feedback ถูก/ผิด ที่โต๊ะทันที — server เป็นเจ้าของ held/คะแนน/จบรอบ แต่ client รู้ held + correctIndex อยู่แล้ว + (เดิม server-auth ส่งแต่ intent ไม่โชว์อะไร → ผู้เล่นไม่เห็นว่าตอบถูก/ผิด โดยเฉพาะตอบผิด server ไม่แจ้งกลับ) */ + try { + const held = me.quizCarryHeld; + if (held != null && quizCarryCurrent && quizCarryOptionsPickableNow() && quizCarryCanSubmitAnswerAt(mapData, ox, oy)) { + const ok = held === quizCarryCurrent.correctIndex; + showQuizMapQuestionCarryFeedback(ok); + showQuizCarryToast(ok ? ('ถูกต้อง +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' แต้ม') : 'ผิด — คืนป้ายที่โซนตัวเลือกแล้ว กด F หยิบใหม่ได้', ok); + } + } catch (e) { /* ignore */ } + socket.emit('quiz-carry-grab'); + } + return false; + } const md = mapData; const footprint = quizTilesFootprintPlay(ox, oy); const ent = actorId === myId ? me : others.get(actorId); @@ -11452,6 +11530,7 @@ function resetQuizCarryPlayState() { cancelQuizCarrySessionCompleteResultToSummary(); + quizCarryServerAuthPlay = false; /* MG4: รีเซ็ต gate server-auth (ตั้งใหม่เมื่อ quiz-carry-phase แรกมา) */ const qcmo = document.getElementById('quiz-carry-mission-overlay'); if (qcmo) qcmo.classList.add('is-hidden'); quizCarryPregameActive = false; @@ -11784,6 +11863,7 @@ } function stepQuizCarryPreviewBots() { + if (quizCarryServerAuthActive()) return; /* server ขับบอท (quiz-carry-bot-move) */ if (quizCarrySessionEnded) return; if (quizCarryPregameActive) return; if (!playBotsEnabled() || !isQuizCarry() || !mapData) return; @@ -11966,6 +12046,7 @@ stackMini = null; stackTowerPost50AnimStartMs = null; stackTowerHeartMinusFx = null; + stackServerAuthPlay = false; /* re-arm: server จะเปิดใหม่ผ่าน stack-state(srv) เมื่อรอบใหม่เริ่ม */ if (!mapData || !isStack()) return; const w = mapData.width || 20, h = mapData.height || 15; const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h); @@ -12315,7 +12396,12 @@ const actorHeavy = !!m.pendingDropHeavy; const actorTheme = m.pendingDropTheme || null; /* remote drop = สีจาก host; local = null (resolve เอง) */ const tw = m.widthTiles * tileSize * getStackTowerBlockWorldScalePlay(); - const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize; + /* server-auth: ใช้ offset ปล่อยจริงจาก server (landOffsetTiles) เป็นจุดเริ่มร่วง → ตรงกับจุดลง = บล็อกร่วงตรง ไม่ไถลออกจากเชือก + (เดิมเริ่มจาก m.phase ฝั่ง client ที่อาจคลาดจาก server → บล็อกเริ่มคนละจุดกับที่ลง) */ + const swingReleaseOff = (stackServerAuthPlay && Number.isFinite(hit.landOffsetTiles)) + ? hit.landOffsetTiles + : (m.swingAmp * Math.sin(m.phase)); + const swingXW = (m.topCenterX + swingReleaseOff) * tileSize; const lh = m.layerWorldH || Math.max(14, tileSize * 0.3); const swingLift = getStackTowerSwingLiftWorldPx(m.layers.length, lh); const swingAttachY = m.swingWorldY - swingLift; @@ -12376,6 +12462,13 @@ /** @returns {boolean} เริ่มแอนิเมชันร่วงแล้วหรือไม่ */ function tryHumanStackDrop() { if (!stackHumanDropGateOkPlay()) return false; + if (stackServerAuthPlay) { + /* server-auth: ส่งเจตนากดวาง → server คำนวณ ณ เฟสปัจจุบัน แล้ว emit stack-drop กลับมาทุกเครื่อง (รวมเรา) */ + try { socket.emit('stack-drop-req'); } catch (e) { /* ignore */ } + stackMini.lastDropAt = Date.now(); /* กัน double-tap (server มี busyUntil อยู่แล้ว) */ + if (specialQuizIcon) sendSpecialQuizCollide(); + return true; + } ensureStackNextDropVisual(); const hit = computeStackDropResult(stackMini); if (!startStackFallAnimation(hit, playBotsEnabled() ? { human: true, id: myId != null ? String(myId) : null } : null)) return false; @@ -12408,6 +12501,7 @@ /** Stack preview: สลับตา — เฉพาะบอทที่ถึงตาถึงจะจำลองปล่อยหนึ่งครั้งแล้วเลื่อนตา */ function stepPreviewStackTurnBased(nowMs) { + if (stackServerAuthPlay) return; /* server-auth: บอทถูกขับโดย server (รับผลผ่าน stack-drop) */ if (!playBotsEnabled() || !isStack() || !stackMini || stackFall || stackMini.settling) return; /* เล่นจริง: host เท่านั้นจำลองบอท (แล้ว broadcast) — non-host รอรับ stack-drop; editor preview (previewFillBots) จำลองเองได้ */ if (detectiveCaseFillBots && !isMePlayHost()) return; @@ -12450,6 +12544,8 @@ stackMini.pendingDropHeavy = !!item.heavy; stackMini.pendingDropTheme = item.theme || null; /* สีจาก host สำหรับบล็อกนี้ (remote drop) */ stackMini.widthTiles = stackBlockWidthTilesForPlay(stackMini.initialWidthTiles, !!item.heavy); + /* snap เฟสเชือกให้ตรงจุดปล่อยของ server → เชือก+บล็อกที่ร่วงตรงกัน (กันบล็อกไม่ตรงเชือก) */ + if (Number.isFinite(item.phase)) stackMini.phase = item.phase; return startStackFallAnimation(item.hit, item.actor || { human: true }); } @@ -12459,7 +12555,7 @@ const dt = Math.min(0.06, (now - lastStackTickMs) / 1000); lastStackTickMs = now; /* เล่นจริง: host ส่งลำดับตา+index เป็นระยะ → non-host adopt (ครอบ late-join: client โหลดช้า host จะรวมเข้า order ให้) */ - if (detectiveCaseFillBots && isMePlayHost()) { + if (detectiveCaseFillBots && isMePlayHost() && !stackServerAuthPlay) { /* rebuild เมื่อ (ก) ยังไม่มีคิว หรือ (ข) คน/บอท เปลี่ยน (เช่น client เข้าทีหลัง = late-join) → ให้ทุกคนได้ seat + สี */ if (!stackTurnOrderReadyPlay() || stackOrderNeedsRebuildPlay()) rebuildStackPreviewTurnOrder(); const ts = Date.now(); @@ -12538,12 +12634,17 @@ /* เล่นจริง: แกว่งเฉพาะตาคนที่คุม (เรา หรือ host คุมบอท) แล้ว broadcast เฟสให้คนอื่น mirror; editor preview / เล่นเดี่ยว: แกว่งเองตลอดเหมือนเดิม */ let advancePhase = true; - if (detectiveCaseFillBots) { + let emitSwing = false; + if (stackServerAuthPlay) { + advancePhase = true; /* server-auth: เดินเฟส local ให้ลื่น → reconcile ตอนรับ stack-swing */ + emitSwing = false; /* แต่ไม่ emit — server เป็นเจ้าของเฟส */ + } else if (detectiveCaseFillBots) { advancePhase = isMyStackTurn() || (isMePlayHost() && isStackCurrentBotTurnPlay()); + emitSwing = advancePhase; } if (advancePhase) { stackMini.phase += stackMini.phaseSpeed * dt * Math.PI * 2; - if (detectiveCaseFillBots && socket && myId != null) { + if (emitSwing && detectiveCaseFillBots && socket && myId != null) { const tnow = Date.now(); if (tnow - stackLastSwingEmit > 50) { stackLastSwingEmit = tnow; @@ -12765,6 +12866,14 @@ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLiveStartMs')) { const _ls = Number(payload.gauntletLiveStartMs); gauntletCrownRunwayServerLiveStartMs = (Number.isFinite(_ls) && _ls > 0) ? _ls : 0; /* G1: anchor scroll จาก server (เดียวกันทุกเครื่อง) */ + /* G2: รับ liveStartMs จริง (อาจ "อนาคต" = pre-run hold) → re-anchor ทับค่า fallback ที่ snap ล็อกไว้ตอน howto + (ไม่แตะระหว่างเฟสเส้นชัย/เดินเข้าเส้น เพื่อไม่ดึง scroll กลับ) */ + if (gauntletCrownRunwayServerLiveStartMs > 0 + && gauntletCrownRunwaySpawnScrollSnapped + && !gauntletCrownRunwayBgFinishLatched + && !gauntletCrownRunwayWalkActive) { + gauntletCrownRunwayLiveAnchorServerMs = gauntletCrownRunwayServerLiveStartMs; + } } if (Object.prototype.hasOwnProperty.call(payload, 'gauntletEndsAt')) { if (payload.gauntletEndsAt == null) gauntletEndsAtMs = null; @@ -13042,6 +13151,7 @@ /* อาร์คกระโดดนุ่มแบบ design (rn-jump): sine โค้งสมมาตร ease-in-out peak กลางอากาศ เดิม: ขาขึ้น quadratic / ขาลง linear (อสมมาตร peak ~72% เวลา → ขาลงกระตุก) */ const p = Math.max(0, Math.min(1, 1 - t)); /* 0 = เริ่มกระโดด, 1 = แตะพื้น */ + /* อาร์ค sine นุ่มสมมาตร — ขึ้น-ลงลื่นไม่กระตุก (เวลากระโดดสั้นลงแล้ว = ไม่ลอยค้าง, sine นุ่ม = ไม่วาปลง) */ return Math.sin(Math.PI * p); } @@ -13278,6 +13388,7 @@ function applyGauntletPreviewBotsAfterSync() { if (!playBotsEnabled() || !isMePlayHost() || !mapData || mapData.gameType !== 'gauntlet') return; + if (detectiveCaseFillBots) return; /* MG2 เกมจริง: server เดินบอทเอง (host หลุดก็ไม่หยุด) */ if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return; if (isGauntletCrownHeistMapPlay() && gauntletCrownRunwayStripFrozenPlay()) return; const w = mapData.width || 20; @@ -13292,6 +13403,7 @@ /** แจ้งเซิร์ฟเวอร์แถว y ที่มีคุณ+บอททดสอบ — lane spawn ใช้เฉพาะแถวที่มี peer; บอทไม่ใช่ peer */ function emitGauntletPreviewRowsToServer() { if (!playBotsEnabled() || !mapData || mapData.gameType !== 'gauntlet') return; + if (detectiveCaseFillBots) return; /* MG2 เกมจริง: server รู้แถวเลนของบอทเอง (gr.bots) */ if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return; const h = mapData.height || 15; const ysSet = new Set(); @@ -13943,8 +14055,11 @@ 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); + const survivorCount = baseRows.filter(function (r) { return !r.eliminated; }).length; + /* เกรด F = ไม่มีผู้รอด (ตรงกับ local builder + server) → ไม่มีการ์ดรางวัล */ + 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, @@ -13952,6 +14067,8 @@ grade: grade, rewardCard: rewardCard, totalParts: ranked.map(function (r) { return r.finalScore; }), + participantCount: baseRows.length, + survivorCount: survivorCount, }; } @@ -16863,6 +16980,7 @@ } function stepSpaceShooterPreviewBots(dt) { + if (spaceShooterServerAuthActive()) return; /* server-auth: บอทถูกขับโดย server (รับผ่าน space-shooter-bot-move) */ if (spaceShooterGameEnded || !playBotsEnabled() || !isMePlayHost() || !mapData || mapData.gameType !== 'space_shooter') return; if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return; const ts = tileSize; @@ -16985,7 +17103,15 @@ } others.forEach((o, id) => { - if (isPreviewBotId(id)) return; + if (isPreviewBotId(id)) { + /* บอท server-auth: lerp ยานเข้าหาเป้า (ตำแหน่งจาก server) ทุกเฟรม ~60fps → ลื่น ไม่กระตุก */ + if (spaceShooterServerAuthActive() && !o.spaceShooterEliminated) { + if (Number.isFinite(o.spaceShooterTargetCx) && Number.isFinite(o.spaceShooterCx)) o.spaceShooterCx += (o.spaceShooterTargetCx - o.spaceShooterCx) * 0.32; + if (Number.isFinite(o.spaceShooterTargetCy) && Number.isFinite(o.spaceShooterCy)) o.spaceShooterCy += (o.spaceShooterTargetCy - o.spaceShooterCy) * 0.32; + try { syncSpaceShooterFootFromShipCenter(o); o.tx = o.x; o.ty = o.y; } catch (e) { /* ignore */ } + } + return; + } /* lerp แรงขึ้น (0.45) → ยานผู้เล่นคนอื่นตามตำแหน่งจริงทันขึ้น ลดอาการ "ไม่ตรง/ยืด" */ if (o.tx != null) o.x += (o.tx - o.x) * 0.45; if (o.ty != null) o.y += (o.ty - o.y) * 0.45; @@ -17570,7 +17696,8 @@ /** รวมดาเมจที่ทำกับบอส (ไม่เท่ากับคะแนน — คะแนน +10 ต่อครั้ง, HP บอส -2 ต่อครั้ง) non-host: ใช้ค่าที่ host เป็นเจ้าของ (single source of truth) ถ้าได้รับแล้ว — HP บอสตรงกันทุกเครื่อง */ function balloonBossTeamBossDamagePlay() { - if (!isMePlayHost() && balloonBossHostBossDmg != null) { + /* server-auth: ดาเมจบอท (balloon-boss-bot-move) + คน (user-move) sync ครบทุกเครื่อง → รวม local เองได้ (host-independent) */ + if (!balloonBossServerAuthActive() && !isMePlayHost() && balloonBossHostBossDmg != null) { return Math.max(0, balloonBossHostBossDmg | 0); } let s = Math.max(0, me.balloonBossBossDmg | 0); @@ -17709,6 +17836,7 @@ } function stepBalloonBossPreviewBots(dt, mw, mh, bossCx, bossCy, ts) { + if (balloonBossServerAuthActive()) return; /* server-auth: บอทถูกขับโดย server (รับผ่าน balloon-boss-bot-move) */ if (!playBotsEnabled() || !isMePlayHost() || !mapData || mapData.gameType !== 'balloon_boss') return; [...others.keys()].filter(isPreviewBotId).forEach((bid) => { const o = others.get(bid); @@ -17805,7 +17933,7 @@ const allRefsEarly = buildBalloonBossParticipantRefsPlay(); /* จบเกมแบบ host-authoritative: host ตัดสิน + broadcast, ทุกเครื่องจบพร้อมกัน (เดิมแต่ละเครื่องตัดสินเองจาก teamBossDmg/elimination ที่สะสมไม่พร้อมกัน → game over ไม่เท่ากัน) */ - if (isMePlayHost()) { + if (isMePlayHost() && !balloonBossServerAuthActive()) { if (remSec != null && remSec <= 0) { if (teamBossDmg >= maxHp) { triggerBalloonBossOverPlay('victory'); return; } /* หมดเวลาแต่บอสยังไม่ตาย → ลูกโป่งของคนที่ยังเหลือ "แตกหมด" ตายทั้งหมด (แพ้) */ @@ -17929,7 +18057,7 @@ applyBalloonBossHitToEntity(me); } } - if (!hit && isMePlayHost()) { + if (!hit && isMePlayHost() && !balloonBossServerAuthActive()) { aliveRefs.forEach((entry) => { if (hit || !isPreviewBotId(entry.id)) return; const ent = entry.ref; @@ -17962,7 +18090,7 @@ if (String(entry.id) === String(b.ownerId)) continue; /* ลูกโป่งของตัวเอง = เจ้าตัวตัดสิน · บอท = host ตัดสินคนเดียว (non-host เอาตาย/ลูกโป่งจาก fill-bot-state) เดิม non-host คิด damage บอทเองจากกระสุนที่ relay มา → บอทตายคนละจังหวะ 2 จอ */ - const canBalloonHit = (ent === me) || (isPreviewBotId(entry.id) && isMePlayHost()); + const canBalloonHit = (ent === me) || (isPreviewBotId(entry.id) && isMePlayHost() && !balloonBossServerAuthActive()); if (!canBalloonHit) continue; const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy); const pdx = b.x - cc.cx, pdy = b.y - cc.cy; @@ -18012,7 +18140,7 @@ balloonBossEliminated: !!me.balloonBossEliminated, }); } - } else if (isPreviewBotId(b.ownerId) && isMePlayHost()) { + } else if (isPreviewBotId(b.ownerId) && isMePlayHost() && !balloonBossServerAuthActive()) { const o = others.get(b.ownerId); if (o) { o.balloonBossScore = Math.max(0, (o.balloonBossScore | 0) + addScore); @@ -18119,7 +18247,7 @@ } /* MG7 host-authoritative boss HP: host broadcast ดาเมจรวม + สถานะผู้เล่นคนอื่น ~7Hz → ทุกเครื่องแสดง HP บอส/ลูกโป่ง/การตาย ตรงกับ host (single source of truth) */ - if (isMePlayHost() && socket && myId != null && tEmit - balloonBossLastSyncEmit > 140) { + if (isMePlayHost() && !balloonBossServerAuthActive() && socket && myId != null && tEmit - balloonBossLastSyncEmit > 140) { balloonBossLastSyncEmit = tEmit; const syncRefs = buildBalloonBossParticipantRefsPlay(); socket.emit('balloon-boss-sync', { @@ -19199,6 +19327,7 @@ } function stepJumpSurvivePreviewBots(dt, halfViewH) { + if (jumpSurviveServerAuthActive()) return; /* server-auth: บอทถูกขับโดย server (รับผ่าน jump-survive-bot-move) */ if (!playBotsEnabled() || !isMePlayHost() || !mapData || !isJumpSurvive()) return; const md = mapData; const { cw, ch } = getCharacterFootprintWH(md); @@ -19319,8 +19448,11 @@ /* ผู้เล่นจริงระยะไกล (ไม่ใช่บอท): เลอร์ปตำแหน่งตาม move ที่รับมา (o.tx/o.ty) เดิม jump_survive ไม่ได้เลอร์ป others เลย → host/เพื่อนค้างที่จุดเกิดขณะกล้องเลื่อนขึ้น = ตัวละครหายจากจอ (เกมอื่นทำที่ tick กลาง 23235 แต่ jump_survive return ก่อนถึงตรงนั้น จึงต้องทำในนี้เอง) */ + const jsSrvAuth = jumpSurviveServerAuthActive(); others.forEach((o, id) => { - if (isPreviewBotId(id) || !o) return; + if (!o) return; + /* ผู้เล่นจริงระยะไกล → lerp ตาม move; บอท → ปกติ host sim เอง (ข้าม) แต่ server-auth: บอทขับโดย server → lerp ด้วย */ + if (isPreviewBotId(id) && !jsSrvAuth) 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; }); @@ -20355,6 +20487,121 @@ if (isMePlayHost() || !isQuizCarry() || !quizCarryEmbedMissionFlowActive()) return; showQuizCarrySessionCompleteOverlay(); }); + + /* ===== MG4 server-authoritative (เกมจริง detective): adopt server state ===== */ + /** แปลง epoch ฝั่ง server → epoch ท้องถิ่น (ชดเชย clock offset) */ + function quizCarryServerEpochToLocal(se) { + const n = Number(se); + if (!Number.isFinite(n) || n <= 0) return 0; + return n - (serverNow() - Date.now()); + } + function applyQuizCarryPhaseFromServer(p) { + if (!p || !isQuizCarry()) return; + quizCarryServerAuthPlay = true; + quizCarryPregameActive = false; + quizCarrySessionEnded = false; + quizCarryAnswerTimeupAwaitNext = false; + quizCarryEmbedPendingQuestion = null; + quizCarryEmbedCountdownStartAt = 0; + quizCarryEmbedCountdownEndAt = 0; + quizCarryEmbedPreOptionCountdownStartAt = 0; + quizCarryEmbedPreOptionCountdownEndAt = 0; + try { hideQuizCarryEmbedCountdownOverlay(); } catch (e) { /* ignore */ } + try { hideQuizCarryTimeupOnDeskLayer(); } catch (e) { /* ignore */ } + const q = { + text: String(p.text || ''), + choices: Array.isArray(p.choices) ? p.choices.slice() : [], + correctIndex: Number.isFinite(Number(p.correctIndex)) ? Math.floor(Number(p.correctIndex)) : 0, + }; + if (Array.isArray(p.choiceImageUrls)) q.choiceImageUrls = p.choiceImageUrls.slice(); + quizCarryCurrent = q; + preloadQuizCarryChoiceImages(q); + playQuizText = formatQuizCarryQuestionHud(q); + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = playQuizText; + clearQuizMapQuestionCarryFeedback(); + if (p.phase === 'answer') { + quizCarryOptionRevealAt = quizCarryServerEpochToLocal(p.optionRevealAt) || Date.now(); + quizCarryAnswerCloseAt = quizCarryServerEpochToLocal(p.endsAt); + } else { + /* read: optionRevealAt อยู่อนาคต → ยังหยิบไม่ได้ จนกว่าเฟส answer */ + quizCarryOptionRevealAt = quizCarryServerEpochToLocal(p.optionRevealAt); + quizCarryAnswerCloseAt = 0; + } + me.quizCarryHeld = null; + others.forEach((o) => { if (o) { o.quizCarryHeld = null; o.botPath = []; } }); + try { renderPlayQuizScoreboard(playLiveQuizScores); } catch (e) { /* ignore */ } + try { syncPlayQuizMapPanel(); } catch (e) { /* ignore */ } + try { updateQuizCarryCarryPhaseHud(); } catch (e) { /* ignore */ } + try { syncQuizCarryGrabButton(); } catch (e) { /* ignore */ } + } + socket.on('quiz-carry-phase', (p) => { + if (!p || !p.srv || !isQuizCarry()) return; + applyQuizCarryPhaseFromServer(p); + }); + /* บอท carry: server เป็นเจ้าของการเดิน/หยิบ (BFS) → รับตำแหน่ง+held แล้ว lerp ใน tick (o.tx/o.ty) */ + socket.on('quiz-carry-bot-move', (d) => { + if (!d || !Array.isArray(d.bots) || !isQuizCarry()) return; + quizCarryServerAuthPlay = true; + d.bots.forEach((rb) => { + if (!rb || typeof rb.id !== 'string') return; + const o = others.get(rb.id); + if (!o) return; /* บอท avatar ถูกสร้างโดย rebalancePreviewBots (id ตรงกับ server) */ + if (Number.isFinite(rb.x)) { const big = Number.isFinite(o.x) && Math.abs(rb.x - o.x) > 3; o.tx = rb.x; if (big || !Number.isFinite(o.x)) o.x = rb.x; } + if (Number.isFinite(rb.y)) { const big = Number.isFinite(o.y) && Math.abs(rb.y - o.y) > 3; o.ty = rb.y; if (big || !Number.isFinite(o.y)) o.y = rb.y; } + if (rb.direction) o.direction = rb.direction; + o.botIsWalking = !!rb.isWalking; + o.quizCarryHeld = (rb.held == null) ? null : Math.floor(Number(rb.held)); + }); + }); + /* held ของคนจริง (หยิบ/ส่ง/วาง) จาก server */ + socket.on('quiz-carry-held', (d) => { + if (!d || d.id == null || !isQuizCarry()) return; + const raw = d.held; + const held = (raw == null) ? null : Math.floor(Number(raw)); + const val = (held == null || !Number.isFinite(held)) ? null : held; + if (String(d.id) === String(myId)) me.quizCarryHeld = val; + else { const o = others.get(d.id) || others.get(String(d.id)); if (o) o.quizCarryHeld = val; } + try { syncQuizCarryGrabButton(); } catch (e) { /* ignore */ } + }); + /* จบรอบ: server แจ้งผล (ใครถูก/correctIndex/คะแนน) → merge + เคลียร์ held */ + socket.on('quiz-carry-result', (d) => { + if (!d || !isQuizCarry()) return; + quizCarryServerAuthPlay = true; + if (d.scores && typeof d.scores === 'object') { + playLiveQuizScores = {}; + for (const id in d.scores) playLiveQuizScores[id] = Math.max(0, Number(d.scores[id]) || 0); + } + me.quizCarryHeld = null; + others.forEach((o) => { if (o) { o.quizCarryHeld = null; o.botPath = []; } }); + if (d.winnerId != null && String(d.winnerId) === String(myId)) { + try { showQuizMapQuestionCarryFeedback(true); } catch (e) { /* ignore */ } + } + try { renderPlayQuizScoreboard(playLiveQuizScores); } catch (e) { /* ignore */ } + try { syncQuizCarryGrabButton(); } catch (e) { /* ignore */ } + }); + /* จบเซสชัน: server สรุปคะแนน แล้วพากลับ LobbyB (detective) — ไม่ต้องคุม UI เอง */ + socket.on('quiz-carry-ended', (d) => { + if (!isQuizCarry()) return; + quizCarryServerAuthPlay = true; + quizCarrySessionEnded = true; + if (d && d.scores && typeof d.scores === 'object') { + playLiveQuizScores = {}; + for (const id in d.scores) playLiveQuizScores[id] = Math.max(0, Number(d.scores[id]) || 0); + try { renderPlayQuizScoreboard(playLiveQuizScores); } catch (e) { /* ignore */ } + } + }); + /* disconnect takeover: คนหลุดกลางเกม → บอท server แทน (เก็บ avatar เดิม + ติดป้าย "(บอท)") — เหมือน MG1 */ + socket.on('quiz-carry-player-to-bot', (d) => { + if (!d || d.id == null || !isQuizCarry()) return; + const o = others.get(d.id) || others.get(String(d.id)); + if (!o) return; + o.isQuizCarryTakeoverBot = true; + o.tx = o.x; o.ty = o.y; + let nn = (o.nickname != null && String(o.nickname).trim()) ? String(o.nickname).trim() : (d.nickname ? String(d.nickname).trim() : 'ผู้เล่น'); + if (nn.indexOf('(บอท)') < 0) nn = nn + ' (บอท)'; + o.nickname = nn; + }); socket.on('gauntlet-crown-lobby-sync', (d) => { if (!d || typeof d.readyMap !== 'object') return; gauntletCrownLobbyReadyMap = { ...d.readyMap }; @@ -20415,6 +20662,16 @@ if (!data || !Array.isArray(data.bots)) return; /* quiz เกมจริง: server เป็นเจ้าของบอท (quiz-bot-move) + roster (play-roster) → ไม่รับ fill-bot-state ทับ (กันบอทวาป/ผีหลุด) */ if (isQuiz() && detectiveCaseFillBots) return; + /* MG2 เกมจริง: server เป็นเจ้าของบอท (gauntlet-sync) → ไม่รับ fill-bot-state ทับ */ + if (isGauntlet() && detectiveCaseFillBots) return; + /* MG4 เกมจริง: server เป็นเจ้าของบอท (quiz-carry-bot-move) → ไม่รับ fill-bot-state ทับ */ + if (quizCarryServerAuthActive()) return; + /* MG5 เกมจริง: server เป็นเจ้าของบอท (jump-survive-bot-move) → ไม่รับ fill-bot-state ทับ (กันบอทวาป) */ + if (jumpSurviveServerAuthActive()) return; + /* MG6 เกมจริง: server เป็นเจ้าของบอท (space-shooter-bot-move) → ไม่รับ fill-bot-state ทับ */ + if (spaceShooterServerAuthActive()) return; + /* MG7 เกมจริง: server เป็นเจ้าของบอท (balloon-boss-bot-move) → ไม่รับ fill-bot-state ทับ */ + if (balloonBossServerAuthActive()) return; /* force รายชื่อ "คนจริง" ให้ตรง host — ลบผี (peer ที่ host ไม่มี แต่ค้างบน client จาก reconnect/snapshot) id-based (ไม่ชนชื่อ default). debounce 3 ครั้งติด กัน peer ที่ host ยังไม่เห็นชั่วคราวถูกลบผิด (self-heal) */ if (Array.isArray(data.humans)) { @@ -20515,7 +20772,7 @@ /* MG3 stack (shared tower): รับผลการวางจากเจ้าของตา → เข้าคิว แล้วทยอย apply ทีละอัน (กันทิ้งดรอปตอนบล็อกกำลังร่วง = ตึก/% ต่างกัน) */ socket.on('stack-drop', (d) => { - if (!d || !mapData || !isStack() || !stackMini || !detectiveCaseFillBots) return; + if (!d || !mapData || !isStack() || !stackMini || !(detectiveCaseFillBots || stackServerAuthPlay)) return; const hit = { miss: !!d.miss, perfect: !!d.perfect, pts: Number(d.pts) || 0, progressDelta: Number(d.progressDelta) || 0, landOffsetTiles: Number(d.landOffsetTiles) || 0, @@ -20527,6 +20784,7 @@ stackRemoteDropQueue.push({ hit: hit, seat: Number(d.seat) || 1, heavy: !!d.heavy, theme: parsePlayLobbyThemeIndex(d.theme) || null, /* สีจาก host → freeze บนบล็อกที่วาง (ตรงทุกจอ) */ + phase: Number(d.phase), /* เฟส ณ ปล่อยจาก server → snap เชือกให้ตรงบล็อก */ /* เก็บ id ของคนวาง (ไม่ใช่แค่ human: true) → คะแนนเข้าคนที่วางจริง ไม่เข้า me ทุกครั้ง */ actor: d.isBot ? { botId: String(d.actorId || '') } : { human: true, id: d.actorId != null ? String(d.actorId) : null }, }); @@ -20534,22 +20792,46 @@ /* MG3 stack (shared tower): รับเฟสแกว่งจากเจ้าของตา → mirror บล็อกที่กำลังแกว่ง (เห็นเหมือนกันจนกว่าจะถึงตาตัวเอง) */ socket.on('stack-swing', (d) => { - if (!d || !mapData || !isStack() || !stackMini || !detectiveCaseFillBots) return; - if (isMyStackTurn()) return; /* ตาเรา → คุมเฟสเอง */ - if (isMePlayHost() && isStackCurrentBotTurnPlay()) return; /* host คุมบอทเอง */ + if (!d || !mapData || !isStack() || !stackMini || !(detectiveCaseFillBots || stackServerAuthPlay)) return; + if (!stackServerAuthPlay) { + if (isMyStackTurn()) return; /* ตาเรา → คุมเฟสเอง */ + if (isMePlayHost() && isStackCurrentBotTurnPlay()) return; /* host คุมบอทเอง */ + } + const sp = Number(d.speed); + if (Number.isFinite(sp) && sp > 0) stackMini.phaseSpeed = sp; /* ใช้ความเร็วแกว่งจาก server → local advance ตรงกัน */ + /* server-auth: ยึด swingAmp+topCenterX ตามโมเดล server เป๊ะ → บล็อกที่ห้อย (sin*amp) ตรงกับ landOffset ตอนปล่อย + (เดิม client คำนวณ swingAmp เองมี "rel boost" ที่ server ไม่มี → แกว่งกว้างกว่า บล็อกเลยวาปตอนวาง) */ + if (stackServerAuthPlay) { + const sa = Number(d.swingAmp); + if (Number.isFinite(sa) && sa > 0 && !stackFall && !stackMini.settling) stackMini.swingAmp = sa; + const tcx = Number(d.topCenterX); + if (Number.isFinite(tcx) && !stackFall && !stackMini.settling) stackMini.topCenterX = tcx; + } const ph = Number(d.phase); - if (Number.isFinite(ph)) stackMini.phase = ph; + if (Number.isFinite(ph)) { + if (stackServerAuthPlay && !stackFall && !stackMini.settling) { + /* server-auth: ปล่อยเฟส local เดินเองที่ความเร็ว server (ลื่น 60fps) — "ไม่" ดึงกลับเข้าหา sample ที่มาช้า + เพราะ sample จาก server เป็นค่าของอดีต (network latency) → local เดินนำหน้าเสมอ การ chase กลับ = บล็อกสะดุด/ถอยหลังทุก ~60ms (อาการกระตุก) + จึง snap เฉพาะตอน drift ใหญ่จริง (เปลี่ยนตา/ดรอป/หลุด sync) นอกนั้นเดิน local ล้วน */ + let diff = ph - stackMini.phase; + diff = ((diff + Math.PI) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) - Math.PI; + if (Math.abs(diff) > Math.PI * 0.6) stackMini.phase = ph; + } else { + stackMini.phase = ph; + } + } }); /* MG3 stack — non-host รับลำดับตา+index จาก host (single source of truth) → เลิกแยกตึก/ตาเหลื่อม */ socket.on('stack-state', (d) => { - if (!d || !mapData || !isStack() || !detectiveCaseFillBots) return; + if (d && d.srv) stackServerAuthPlay = true; /* สัญญาณ: server เป็นเจ้าของเกม stack รอบนี้ */ + if (!d || !mapData || !isStack() || !(detectiveCaseFillBots || stackServerAuthPlay)) return; /* ได้รับ stack-state = server relay จาก "host จริง" (socket.to ไม่ส่งกลับผู้ส่ง) → เรา "ไม่ใช่ host" แก้ playHostId ที่ผิด (dual-host: เข้าใจผิดว่าเป็น host แล้ว sim เอง = ลำดับ/สี block หลุดกับ host) */ if (d.hostId != null && myId != null && String(d.hostId) !== String(myId)) { if (String(playHostId) !== String(d.hostId)) playHostId = d.hostId; } - if (isMePlayHost()) return; + if (isMePlayHost() && !stackServerAuthPlay) return; /* server-auth: host ก็ adopt order จาก server */ if (Array.isArray(d.order)) { stackPreviewTurnOrder = d.order.map((e) => ({ kind: e.kind, seat: e.seat, @@ -20584,7 +20866,7 @@ /* MG3 stack — host ตัดสินจบรอบ → non-host จบพร้อมกันด้วย outcome เดียวกัน (กัน "จบไม่พร้อมกัน") */ socket.on('stack-over', (d) => { - if (!mapData || !isStack() || !detectiveCaseFillBots) return; + if (!mapData || !isStack() || !(detectiveCaseFillBots || stackServerAuthPlay)) return; const outcome = (d && typeof d.outcome === 'string') ? d.outcome : 'time_up'; finishStackTowerMissionLocalPlay(outcome); }); @@ -20593,6 +20875,7 @@ fast-forward ตำแหน่งตามเวลาที่ผ่านไปจาก t0 (กระสุนผู้เล่นไม่มีแรงโน้มถ่วง → ตำแหน่ง = spawn + v*elapsed เป๊ะทุกเครื่อง) */ socket.on('balloon-boss-shot', (d) => { if (!d || !mapData || mapData.gameType !== 'balloon_boss') return; + if (specialQuizFreeze) return; /* คำถามพิเศษ → ทิ้งกระสุนที่เข้ามา (กันค้างสะสมแล้วไหลรัวหลังตอบ) */ if (d.ownerId != null && myId != null && String(d.ownerId) === String(myId)) return; /* ของตัวเองเพิ่มไปแล้ว */ const sx = Number(d.sx), sy = Number(d.sy), vx = Number(d.vx), vy = Number(d.vy); if (![sx, sy, vx, vy].every(Number.isFinite)) return; @@ -20645,6 +20928,7 @@ /* MG6 space_shooter — รับกระสุนผู้เล่น/บอทคนอื่น (ตรงดิ่งขึ้น vy คงที่) → ตำแหน่ง = y + vy*elapsed เป๊ะทุกเครื่อง */ socket.on('space-shooter-shot', (d) => { if (!d || !mapData || mapData.gameType !== 'space_shooter') return; + if (specialQuizFreeze) return; /* คำถามพิเศษ → ทิ้งกระสุนที่เข้ามา (กันค้างสะสมแล้วไหลรัวหลังตอบ) */ if (d.ownerId != null && myId != null && String(d.ownerId) === String(myId)) return; const x = Number(d.x), y = Number(d.y), vy = Number(d.vy); if (![x, y, vy].every(Number.isFinite)) return; @@ -20840,7 +21124,7 @@ [...others.keys()].forEach((id) => { if (isPreviewBotId(id)) return; const oo = others.get(id); - if (oo && oo.isQuizTakeoverBot) return; /* บอทที่แปลงจากคนหลุด — ไม่อยู่ใน peers แล้ว แต่ห้าม prune */ + if (oo && (oo.isQuizTakeoverBot || oo.isGauntletTakeoverBot)) return; /* บอทที่แปลงจากคนหลุด — ไม่อยู่ใน peers แล้ว แต่ห้าม prune */ if (myId != null && String(id) === String(myId)) return; if (!live.has(String(id))) { others.delete(id); removed++; } }); @@ -20861,6 +21145,141 @@ } }); + /* คนหลุด MG2 → server แปลงเป็นบอท "ในที่เดิม" (สี/ตำแหน่ง/คะแนนเดิม) → client เก็บ avatar ไว้ (ไม่ลบ) แล้วให้ server ขับผ่าน gauntlet-sync */ + socket.on('gauntlet-player-to-bot', (d) => { + if (!d || d.id == null) return; + const o = others.get(d.id) || others.get(String(d.id)); + if (o) { + o.isGauntletTakeoverBot = true; + o.tx = o.x; o.ty = o.y; /* หยุดนิ่งจนกว่า gauntlet-sync ตัวแรกจะขับ */ + if (o.nickname && String(o.nickname).indexOf('(บอท)') < 0) o.nickname = String(o.nickname).trim() + ' (บอท)'; + } + }); + + /* MG3 stack — ผู้เล่นหลุดกลางรอบ → server เปลี่ยน seat เป็นบอท (เก็บ avatar เดิม) → mark + ติดป้าย */ + socket.on('stack-player-to-bot', (d) => { + if (!d || d.id == null) return; + const o = others.get(d.id) || others.get(String(d.id)); + if (o) { + o.isStackTakeoverBot = true; + if (o.nickname && String(o.nickname).indexOf('(บอท)') < 0) o.nickname = String(o.nickname).trim() + ' (บอท)'; + } + }); + + /* MG5 jump_survive — บอท server-authoritative: รับตำแหน่ง/ทิศ/anim/ตาย แล้ว lerp ใน jumpSurviveTickFrame (o.tx/o.ty) */ + socket.on('jump-survive-bot-move', (d) => { + if (!d || !Array.isArray(d.bots) || !isJumpSurvive()) return; + jumpSurviveServerAuthPlay = true; /* สัญญาณ: server เป็นเจ้าของบอท/รอบรอบนี้ */ + if (isJumpSurviveMissionPregameBlockingPlay()) return; /* ยังนับถอยหลัง 3-2-1 → อย่าเพิ่งขยับบอท (กันวาปก่อน "1") */ + d.bots.forEach((rb) => { + if (!rb || typeof rb.id !== 'string') return; + const o = others.get(rb.id); + if (!o) return; /* avatar บอทยังไม่ถูกสร้างฝั่ง client — รอ rebalancePreviewBots */ + /* lerp ปกติ; แต่ delta ใหญ่ (>3 tile = spawn เริ่ม/หลุด sync) → snap ทันที กันบอทไถลข้ามจอ */ + if (Number.isFinite(rb.x)) { const big = !Number.isFinite(o.x) || Math.abs(rb.x - o.x) > 3; o.tx = rb.x; if (big) o.x = rb.x; } + if (Number.isFinite(rb.y)) { const big = !Number.isFinite(o.y) || Math.abs(rb.y - o.y) > 3; o.ty = rb.y; if (big) o.y = rb.y; } + if (rb.dir) o.direction = rb.dir; + o.botIsWalking = !!rb.walking; + o.jumpSurviveRemoteWalking = !!rb.walking; + if (rb.elim && !o.jumpSurviveEliminated) { + o.jumpSurviveEliminated = true; + if (Number.isFinite(rb.x)) o.x = rb.x; + if (Number.isFinite(rb.y)) o.y = rb.y; /* ตาย → snap ไม่ต้อง lerp ต่อ */ + } + }); + }); + + /* MG5 — คนหลุด → server แปลงเป็นบอท "ในที่เดิม" → client คง avatar + ติดป้าย (บอท) แล้วให้ server ขับผ่าน jump-survive-bot-move */ + socket.on('jump-survive-player-to-bot', (d) => { + if (!d || d.id == null) return; + jumpSurviveServerAuthPlay = true; + const o = others.get(d.id) || others.get(String(d.id)); + if (o) { + o.isJumpSurviveTakeoverBot = true; + o.tx = o.x; o.ty = o.y; /* นิ่งจนกว่า bot-move ตัวแรกจะขับ */ + if (o.nickname && String(o.nickname).indexOf('(บอท)') < 0) o.nickname = String(o.nickname).trim() + ' (บอท)'; + } + }); + + /* MG5 — server เป็นเจ้าของจบรอบ (หมดเวลา/ตายหมด) → ทุกเครื่องจบพร้อมกัน (host หลุดก็จบได้) */ + socket.on('jump-survive-ended', (d) => { + if (!isJumpSurvive()) return; + jumpSurviveServerAuthPlay = true; + const reason = (d && d.reason === 'all_dead') ? 'all_dead' : 'time_up'; + try { if (typeof endJumpSurviveMissionRound === 'function') endJumpSurviveMissionRound(reason); } catch (e) { /* ignore */ } + }); + + /* MG6 space_shooter — บอท server-authoritative: รับตำแหน่งยาน (cx/cy world px)/คะแนน/ตาย → ตั้งเป้าแล้ว lerp ใน tick (กันกระตุก) */ + socket.on('space-shooter-bot-move', (d) => { + if (!d || !Array.isArray(d.bots) || !isSpaceShooter()) return; + spaceShooterServerAuthPlay = true; /* สัญญาณ: server เป็นเจ้าของบอท/รอบรอบนี้ */ + d.bots.forEach((rb) => { + if (!rb || typeof rb.id !== 'string') return; + const o = others.get(rb.id); + if (!o) return; /* avatar บอทยังไม่ถูกสร้าง — รอ rebalancePreviewBots */ + /* ตั้ง "เป้าหมาย" แล้ว lerp ใน spaceShooterTickFrame (เดิม snap ทุก ~60ms → กระตุก); delta ใหญ่ค่อย snap */ + if (Number.isFinite(rb.cx)) { const big = !Number.isFinite(o.spaceShooterCx) || Math.abs(rb.cx - o.spaceShooterCx) > 140; o.spaceShooterTargetCx = rb.cx; if (big) o.spaceShooterCx = rb.cx; } + if (Number.isFinite(rb.cy)) { const big = !Number.isFinite(o.spaceShooterCy) || Math.abs(rb.cy - o.spaceShooterCy) > 140; o.spaceShooterTargetCy = rb.cy; if (big) o.spaceShooterCy = rb.cy; } + o.direction = rb.dir || 'up'; + if (rb.score != null) o.spaceShooterScore = Math.max(0, rb.score | 0); + /* จำนวนครั้งที่บอทโดนอุกาบาต (server รวมจาก host) → วาด overlay รอยร้าวบนยานบอท เหมือนคนเล่น */ + if (Number.isFinite(rb.hits)) o.spaceShooterHits = Math.max(0, rb.hits | 0); + if (rb.elim && !o.spaceShooterEliminated) { + o.spaceShooterEliminated = true; + o.spaceShooterTargetCx = null; o.spaceShooterTargetCy = null; + /* บอทโดนอุกาบาตตาย → โชว์ระเบิด + OUT ให้ทุกเครื่องเห็นพร้อมกัน (ตำแหน่งจาก server) */ + try { + if (Number.isFinite(o.spaceShooterCx) && Number.isFinite(o.spaceShooterCy)) { + spaceShooterSpawnAsteroidExplosion(o.spaceShooterCx, o.spaceShooterCy, 26); + spaceShooterPopups.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 50, text: 'OUT', until: Date.now() + 1400 }); + } + } catch (e) { /* ignore */ } + } + try { syncSpaceShooterFootFromShipCenter(o); o.tx = o.x; o.ty = o.y; } catch (e) { /* ignore */ } + }); + }); + + /* MG6 — คนหลุด → server แปลงเป็นบอท "ในที่เดิม" → client คง avatar + ติดป้าย (บอท) แล้วให้ server ขับผ่าน space-shooter-bot-move */ + socket.on('space-shooter-player-to-bot', (d) => { + if (!d || d.id == null) return; + spaceShooterServerAuthPlay = true; + const o = others.get(d.id) || others.get(String(d.id)); + if (o) { + o.isSpaceShooterTakeoverBot = true; + if (o.nickname && String(o.nickname).indexOf('(บอท)') < 0) o.nickname = String(o.nickname).trim() + ' (บอท)'; + } + }); + + /* MG7 balloon_boss — บอท server-authoritative: รับตำแหน่ง (cx/cy world px)/คะแนน/ดาเมจบอส/ลูกโป่ง/ตาย → สแนปวาดตรงๆ */ + socket.on('balloon-boss-bot-move', (d) => { + if (!d || !Array.isArray(d.bots) || !isBalloonBoss()) return; + balloonBossServerAuthPlay = true; /* สัญญาณ: server เป็นเจ้าของบอท/รอบ/HP บอสรอบนี้ */ + d.bots.forEach((rb) => { + if (!rb || typeof rb.id !== 'string') return; + const o = others.get(rb.id); + if (!o) return; /* avatar บอทยังไม่ถูกสร้าง — รอ rebalancePreviewBots */ + if (Number.isFinite(rb.cx)) o.balloonBossCx = rb.cx; + if (Number.isFinite(rb.cy)) o.balloonBossCy = rb.cy; + o.direction = 'down'; + if (rb.score != null) o.balloonBossScore = Math.max(0, rb.score | 0); + if (rb.dmg != null) o.balloonBossBossDmg = Math.max(0, rb.dmg | 0); + if (rb.balloons != null) o.balloonBossBalloons = Math.max(0, rb.balloons | 0); + if (rb.elim) o.balloonBossEliminated = true; + try { syncBalloonBossFootFromCenter(o); o.tx = o.x; o.ty = o.y; } catch (e) { /* ignore */ } + }); + }); + + /* MG7 — คนหลุด → server แปลงเป็นบอท "ในที่เดิม" → client คง avatar + ติดป้าย (บอท) แล้วให้ server ขับผ่าน balloon-boss-bot-move */ + socket.on('balloon-boss-player-to-bot', (d) => { + if (!d || d.id == null) return; + balloonBossServerAuthPlay = true; + const o = others.get(d.id) || others.get(String(d.id)); + if (o) { + o.isBalloonBossTakeoverBot = true; + if (o.nickname && String(o.nickname).indexOf('(บอท)') < 0) o.nickname = String(o.nickname).trim() + ' (บอท)'; + } + }); + /* บอท MG1 quiz: server เป็นเจ้าของการเดิน (host หลุดก็ไม่หยุด) → รับตำแหน่ง แล้ว lerp ใน tick (o.tx/o.ty) รับทุก quiz (ไม่ผูก detectiveCaseFillBots) → รองรับ takeover bot แม้เริ่มเกมไม่มีบอท */ socket.on('quiz-bot-move', (d) => { @@ -21423,6 +21842,44 @@ } me.gauntletEliminated = !!p.gauntletEliminated; clampGauntletCrownHeistEntityXInPlacePlay(me); + } else if (isPreviewBotId(p.id) && p.isBot && isGauntlet() && detectiveCaseFillBots + && !(playBannedBotSlot >= 0 && pid === (PREVIEW_BOT_PREFIX + playBannedBotSlot))) { + /* บอท MG2 server-authoritative — รับตำแหน่ง/คะแนน/elim จาก server (entity/สี client สร้างจาก id เดียวกัน) + ข้ามบอทที่ถูกแบนรอบนี้ (กันบอทแบนฟื้นจาก sync) */ + let o = others.get(p.id) ?? others.get(pid); + const px = Number(p.x); + const py = Number(p.y); + if (!o && Number.isFinite(px) && Number.isFinite(py)) { + o = { + x: px, y: py, tx: px, ty: py, + direction: p.direction || 'right', + nickname: 'บอท', + characterId: null, + spawnJoinOrder: 999, + playTint: playTintForPreviewBotId(p.id) || resolvePlayTintForPeer(p.id), + botTier: 'avg', + gauntletJumpTicks: 0, gauntletJumpVis: 0, gauntletScore: 0, + gauntletEliminated: !!p.gauntletEliminated, + _isServerGauntletBot: true, + }; + others.set(p.id, o); + } + if (o) { + o._serverConfirmedAt = Date.now(); + const prevSc = Math.max(0, Number(o.gauntletScore) || 0); + 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); + } } else if (!isPreviewBotId(p.id)) { let o = others.get(p.id) ?? others.get(pid); const px = Number(p.x); @@ -21446,6 +21903,7 @@ spaceShooterScore: 0, balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossBalloons: balloonBossBalloonsStartPlay(), balloonBossEliminated: false, quizCarryHeld: null, bannedSpectator: false, + isGauntletTakeoverBot: !!p.isTakeover, _notInHostRoster: 0, }; others.set(p.id, o); @@ -21454,6 +21912,11 @@ if (o) { o._serverConfirmedAt = Date.now(); o._notInHostRoster = 0; + /* บอท takeover (คนหลุด): server ส่งชื่อ+"(บอท)" มาด้วย → คงชื่อ/สถานะบอทไว้ทุก sync (กันชื่อกลับเป็นค่าเดิม/ผู้เล่น) */ + if (p.isTakeover) { + o.isGauntletTakeoverBot = true; + if (p.nickname && o.nickname !== p.nickname) o.nickname = p.nickname; + } const prevSc = Math.max(0, Number(o.gauntletScore) || 0); if (Number.isFinite(px)) o.tx = px; if (Number.isFinite(py)) o.ty = py; @@ -21484,7 +21947,7 @@ [...others.keys()].forEach((id) => { if (isPreviewBotId(id)) return; const oo = others.get(id); - if (!oo || oo.isQuizTakeoverBot) return; + if (!oo || oo.isQuizTakeoverBot || oo.isGauntletTakeoverBot) return; if (myId != null && String(id) === String(myId)) return; if (syncLive.has(String(id))) { oo._notInGSync = 0; return; } oo._notInGSync = (oo._notInGSync || 0) + 1; @@ -25734,11 +26197,18 @@ 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; + /* air = ticks เหลือ (เริ่มกระโดด = สูงสุด แล้วนับถอยหลังลง 0); height = sin arc + ขาขึ้น/เริ่มกระโดด (t >= v): snap ทันที — กัน "เด้งปลอม" ตอน vis ไล่ขึ้นจาก 0→ticks (เคยลอยค้าง/วาป) + ขาลง (นับถอยหลัง t < v): glide นุ่มระหว่าง tick + snap เมื่อใกล้ถึง — กันลอยค้าง asymptote/วาปลง */ + if (t >= v) return t; + v = v + (t - v) * 0.34; + if (v - t < 0.12) v = t; + return v; }; - meGauntletJumpVis = stepGauntletJumpVis(meGauntletJumpVis, meGauntletJumpTicks); + /* เฟสเดินเข้าเส้นชัย/freeze → บังคับไม่มีท่ากระโดด (ทุกคนเดินเข้าเส้น ไม่ค้างลอย) */ + const gauntletFinishWalkPose = gauntletCrownRunwayWalkActive || gauntletCrownRunwayBgFinishLatched; + if (gauntletFinishWalkPose) { meGauntletJumpTicks = 0; meGauntletJumpVis = 0; } + else meGauntletJumpVis = stepGauntletJumpVis(meGauntletJumpVis, meGauntletJumpTicks); const mp = lerpGauntletEntityPos(me.x, me.y, me.tx, me.ty); me.x = mp.nx; me.y = mp.ny; @@ -25746,6 +26216,7 @@ const op = lerpGauntletEntityPos(o.x, o.y, o.tx, o.ty); o.x = op.nx; o.y = op.ny; + if (gauntletFinishWalkPose) { o.gauntletJumpTicks = 0; o.gauntletJumpVis = 0; return; } if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks || 0; o.gauntletJumpVis = stepGauntletJumpVis(o.gauntletJumpVis, o.gauntletJumpTicks || 0); }); @@ -25764,12 +26235,12 @@ /* บอท preview ขยับที่ o.x/o.y โดยตรง — ห้าม LERP กับ tx/ty เพราะ stepPreviewBots เคยตั้ง tx=ก่อนเดิน ทำให้โดนดึงถอย ~20%/เฟรม กระตุกและแทบไม่ไป */ others.forEach((o, id) => { /* host เดินบอทเองตรงๆ (o.x/o.y) → ข้าม lerp; แต่ client อื่นต้อง lerp ตามตำแหน่งที่ host ส่งมา (o.tx/o.ty) - ยกเว้น quiz เกมจริง: server เดินบอท → ทุกคน (รวม host) lerp ตาม server */ - if (playBotsEnabled() && isPreviewBotId(id) && isMePlayHost() && !(isQuiz() && detectiveCaseFillBots)) return; + ยกเว้น server-auth (MG1 quiz / MG4 quiz_carry): server เดินบอท → ทุกคน (รวม host) lerp ตาม server */ + if (playBotsEnabled() && isPreviewBotId(id) && isMePlayHost() && !(isQuiz() && detectiveCaseFillBots) && !quizCarryServerAuthActive()) return; /* MG4 (quiz_carry) + MG1 (quiz mng8a80o) บอทเดินต่อเนื่องเร็ว → non-host lerp 0.2 จะตามหลัง host ~1-2 ช่อง ("เดินไม่ตรงกัน") เร่ง lerp เฉพาะบอท quiz/quiz_carry ให้ตามตำแหน่ง host ทันขึ้น (ไม่แตะเกมอื่น/ผู้เล่นจริง) */ - /* บอท MG1 server-driven: server ส่งทุก 60ms → lerp นุ่ม 0.22 (เดิม 0.5 = ถึงเป้าใน 1-2 เฟรมแล้วค้าง = กระตุก) */ - const serverDrivenQuizBot = isPreviewBotId(id) && isQuiz() && detectiveCaseFillBots; + /* บอท MG1/MG4 server-driven: server ส่งทุก 60ms → lerp นุ่ม 0.22 (เดิม 0.5 = ถึงเป้าใน 1-2 เฟรมแล้วค้าง = กระตุก) */ + const serverDrivenQuizBot = isPreviewBotId(id) && ((isQuiz() && detectiveCaseFillBots) || (isQuizCarry() && quizCarryServerAuthActive())); const lerpF = serverDrivenQuizBot ? 0.22 : ((isPreviewBotId(id) && (isQuizCarry() || isQuiz())) ? 0.5 : LERP); const lerpFN = 1 - Math.pow(1 - lerpF, playDtMul); /* frame-rate-normalized → นุ่มเท่ากันทุก FPS */ if (o.tx != null) o.x += (o.tx - o.x) * lerpFN; diff --git a/www/html/Game/public/js/play.js.bak-mg2srvbot-115724 b/www/html/Game/public/js/play.js.bak-mg2srvbot-115724 new file mode 100644 index 0000000..7aba128 --- /dev/null +++ b/www/html/Game/public/js/play.js.bak-mg2srvbot-115724 @@ -0,0 +1,26341 @@ +(function () { + const BASE = '/Game'; + function playDesignMainMenuHref() { + try { + if (typeof globalThis !== 'undefined' && typeof globalThis.designMainMenuHref === 'function') { + return globalThis.designMainMenuHref(); + } + } catch (e) { /* ignore */ } + return '/Main-Menu/'; + } + function playDesignMainLobbyHref() { + try { + if (typeof globalThis !== 'undefined' && typeof globalThis.designMainLobbyHref === 'function') { + return globalThis.designMainLobbyHref(); + } + } catch (e) { /* ignore */ } + return '/Main-Lobby/'; + } + /** รูปเลข 3–2–1 — เสิร์ฟที่ /Game/img/QUESTION/ (nginx alias /Game/ → public; อย่าใช้ /Game/public/img/) */ + const COUNTDOWN_321_IMG_BASE = BASE + '/img/QUESTION/'; + /** แผงคำถามบนแมป quiz_carry — ไฟล์อยู่ public/img/quiz-carry */ + const QUIZ_CARRY_MAP_FEEDBACK_IMG = { + correct: BASE + '/img/quiz-carry/icon-Correct.png', + incorrect: BASE + '/img/quiz-carry/icon-Incorrect.png', + scorePlus: BASE + '/img/quiz-carry/score+10.png', + }; + const QUIZ_MAP_CARRY_FEEDBACK_MS = 1400; + const params = new URLSearchParams(window.location.search); + const spaceId = params.get('space'); + function readStoredDisplayName() { + try { + return (localStorage.getItem('roomLobbyDisplayName') || localStorage.getItem('playerName') || '').trim(); + } catch (e) { return ''; } + } + let nick = (params.get('nick') || '').trim() || readStoredDisplayName() || 'ผู้เล่น'; + const previewMode = params.get('preview') === '1'; + /* Card 3 Ban — ผู้ถูกแบนรอบนี้: เข้ามาดูเพื่อนเล่นได้ แต่ควบคุมตัวละคร/ทำแอ็กชันไม่ได้ (รอบหน้าเล่นปกติ) */ + const playBannedSpectator = params.get('banned') === '1'; + /* Card 3 Ban (กรณีโหวตแบน "บอท") — slot ของ __pv_bot_N ที่ถูกแบน → ไม่ spawn ลงเล่นรอบนี้ (-1 = ไม่มี) */ + const playBannedBotSlot = (() => { const v = parseInt(params.get('banBot'), 10); return Number.isFinite(v) && v >= 0 ? v : -1; })(); + /* shield โปร่งใสคลุมทั้งจอ (z 200) กิน pointer/touch ทุกอย่าง → ปุ่มเกม(jump z59/drop z60/joystick/shoot)+canvas กดไม่ได้ + แต่ต่ำกว่า modal (z 10000+) เลย special quiz / สรุปผล ยังกดได้ปกติ + ป้ายบอกสถานะด้านบน */ + function setupBannedSpectatorUi() { + if (!playBannedSpectator || document.getElementById('banned-spectator-shield')) return; + const shield = document.createElement('div'); + shield.id = 'banned-spectator-shield'; + shield.style.cssText = 'position:fixed;inset:0;z-index:200;background:transparent;pointer-events:auto;cursor:not-allowed;touch-action:none;-webkit-tap-highlight-color:transparent'; + ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick', 'touchstart', 'touchend', 'touchmove', 'wheel', 'contextmenu'].forEach(function (ev) { + shield.addEventListener(ev, function (e) { e.stopPropagation(); e.preventDefault(); }, { passive: false }); + }); + document.body.appendChild(shield); + const banner = document.createElement('div'); + banner.id = 'banned-spectator-banner'; + banner.style.cssText = 'position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:201;pointer-events:none;background:linear-gradient(180deg,#2a1430,#160a1d);border:1px solid rgba(247,118,142,.6);border-radius:14px;padding:10px 20px;text-align:center;box-shadow:0 10px 30px rgba(0,0,0,.55)'; + banner.innerHTML = '
BAN CARD
' + + '
คุณถูกแบนรอบนี้ · กำลังดูเพื่อนเล่น
' + + '
รอบหน้ากลับมาเล่นได้ตามปกติ
'; + document.body.appendChild(banner); + } + const specialQuizForceDebug = params.get('sqForce') === '1'; + const forceDefaultCharacter = params.get('defaultChar') === '1'; + const editorEmbedReturn = params.get('editorEmbed') === '1'; + if (previewMode && editorEmbedReturn) { + try { document.documentElement.classList.add('play-preview-editor-embed'); } catch (e) { /* ignore */ } + } + + /* #5 หน้าโหลดตอนเข้าเกม — กันเห็นจอเปล่า/asset แว่บก่อนฉากพร้อม. โชว์ทันที ซ่อนเมื่อ draw() เฟรมแรกมี mapData + (+ หน่วงเล็กน้อยให้ asset แรกโหลด) มี safety timeout กันค้าง. ไม่โชว์ใน editor embed preview */ + let playLoadingHidden = false; + let playLoadingHideScheduled = false; + const PLAY_LOADING_MIN_MS = 450; /* โชว์อย่างน้อยเท่านี้ กันแว่บหาย (ไม่ทันเห็น) */ + let playLoadingShownAt = (typeof performance !== 'undefined' ? performance.now() : Date.now()); + function nowMsPlay() { return (typeof performance !== 'undefined' ? performance.now() : Date.now()); } + function hidePlayLoadingOverlay() { + if (playLoadingHidden) return; + const elapsed = nowMsPlay() - playLoadingShownAt; + if (elapsed < PLAY_LOADING_MIN_MS) { setTimeout(hidePlayLoadingOverlay, PLAY_LOADING_MIN_MS - elapsed); return; } + playLoadingHidden = true; + try { + const el = document.getElementById('play-loading-overlay'); + if (el) { el.style.opacity = '0'; el.style.pointerEvents = 'none'; setTimeout(function () { if (el && el.parentNode) el.parentNode.removeChild(el); }, 340); } + } catch (_e) { /* ignore */ } + } + (function showPlayLoadingOverlay() { + /* editor embed preview: ไม่ต้องมี loading → เอา overlay static ออก */ + if (previewMode && editorEmbedReturn) { + playLoadingHidden = true; + try { const pe = document.getElementById('play-loading-overlay'); if (pe && pe.parentNode) pe.parentNode.removeChild(pe); } catch (_e) {} + return; + } + try { + if (!document.body) return; + /* overlay เป็น static ใน play.html แล้ว — ถ้าไม่มี (html เก่า cache) ค่อย inject เสริม */ + if (!document.getElementById('play-loading-overlay')) { + const css = document.createElement('style'); + css.textContent = '' + + '#play-loading-overlay{position:fixed;inset:0;z-index:99990;display:flex;flex-direction:column;align-items:center;' + + 'justify-content:center;gap:18px;background:radial-gradient(circle at 50% 42%,#10203c,#060b16 75%);transition:opacity .32s;}' + + '#play-loading-overlay .plo-spin{width:62px;height:62px;border-radius:50%;border:6px solid rgba(120,190,255,.18);' + + 'border-top-color:#5fd0ff;animation:ploSpin .85s linear infinite;}' + + '#play-loading-overlay .plo-text{color:#cfe8ff;font:600 19px system-ui,"Kanit",sans-serif;letter-spacing:.5px;text-shadow:0 2px 10px rgba(0,0,0,.5);}' + + '@keyframes ploSpin{to{transform:rotate(360deg);}}'; + document.head.appendChild(css); + const ov = document.createElement('div'); + ov.id = 'play-loading-overlay'; + ov.innerHTML = '
กำลังโหลดเกม…
'; + document.body.insertBefore(ov, document.body.firstChild); + } + /* safety กันค้าง — แต่ "อย่าซ่อนถ้าเกมยังไม่ render" (เน็ตช้า join นาน → ถ้าซ่อน 8 วิ = จอดำเปล่า ไม่รู้สถานะ) + เช็คทุก 1.5 วิ: เกมเริ่มวาดแล้ว (playLoadingHideScheduled) → ปล่อย path ปกติซ่อน · ยังไม่วาด+ช้า → โชว์ "เน็ตช้า (N วิ)" คง spinner · เกิน 30 วิ ค่อยซ่อนกันค้างถาวร */ + const _ploT0 = Date.now(); + const _ploCheck = function () { + if (playLoadingHidden || playLoadingHideScheduled) return; /* ซ่อนแล้ว/เกมเริ่มวาดแล้ว */ + const waited = Date.now() - _ploT0; + if (waited > 30000) { hidePlayLoadingOverlay(); return; } /* hard limit กันค้างถาวร */ + if (waited > 5000) { + const txt = document.querySelector('#play-loading-overlay .plo-text'); + if (txt) txt.textContent = 'เน็ตช้า… กำลังโหลด (' + Math.floor(waited / 1000) + ' วิ)'; + } + setTimeout(_ploCheck, 1500); + }; + setTimeout(_ploCheck, 1500); + } catch (_e) { playLoadingHidden = true; } + })(); + 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'; + /* ใช้ map จริง (currentPlayMapId) ไม่ใช่ ?map= ใน URL — live play ไม่มี ?map= → เดิม false → + mission flow ไม่ทำงาน → MG4 จบหลังตอบ 1 ข้อ (bug #41). เป็นฟังก์ชันเพราะ map รู้ตอน join */ + function quizCarryUseMissionSummaryOverlay() { return currentPlayMapId() === 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 = 34; + const SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP = 8; + + /* PRNG แบบ seed ได้ (mulberry32) — ใช้สุ่มสิ่งแวดล้อมมินิเกมแบบ deterministic + seed เดียวกัน (อิง liveBeginsAt ของ server) → ทุกเครื่องได้ลำดับสุ่มเดียวกัน = อุกาบาต/บอลลูนชุดเดียวกัน */ + function makeSeededRng(seed) { + let t = (seed >>> 0) || 1; + return function () { + t = (t + 0x6D2B79F5) >>> 0; + let r = Math.imul(t ^ (t >>> 15), 1 | t); + r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; + return ((r ^ (r >>> 14)) >>> 0) / 4294967296; + }; + } + /* seed ต่อ index ของ spawn — รวม anchor (liveBeginsAt) กับ index ให้ก้อนแต่ละลำดับสุ่มต่างกันแต่ตรงกันทุกเครื่อง */ + function envSpawnSeed(anchorMs, index) { + return (((anchorMs >>> 0) ^ Math.imul((index | 0) + 1, 2654435761)) >>> 0); + } + + function spaceShooterAsteroidRadiusFromHpPlay(hp) { + const cap = SPACE_SHOOTER_ASTEROID_MAX_HP; + const h = Math.max(0, Math.min(cap, Math.floor(Number(hp)) || 0)); + const frac = h <= 0 ? 0 : h / cap; + return SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP + + (SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL - SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP) * frac; + } + + function spaceShooterShipFootprintCellsPlay(md) { + const m = md || mapData; + if (!m) return { cw: 1, ch: 1 }; + const { cw, ch } = getCharacterFootprintWH(m); + return { + cw: Math.max(1, Math.min(4, cw)), + ch: Math.max(1, Math.min(4, ch)), + }; + } + + /** ขนาดวาดยานบนจอ — สเกลตาม footprint แผนที่ (editor: กว้าง/สูงตัวละคร) เทียบ tileSize */ + function spaceShooterShipBodyScreenPxPlay(zDrawVal) { + const zD = Number(zDrawVal) > 0 ? zDrawVal : (typeof zoom === 'number' && zoom > 0 ? zoom : 1); + const ts = tileSize || 32; + const { cw, ch } = spaceShooterShipFootprintCellsPlay(mapData); + const kw = 14 / 32; + const kh = 22 / 32; + return { + bodyW: cw * ts * zD * kw, + bodyH: ch * ts * zD * kh, + }; + } + + /** รัศมีชนอุกาบาต (พิกัดโลก) — โตตาม footprint เพื่อให้สมกับยานที่วาดใหญ่ขึ้น */ + function spaceShooterShipHitRadiusWorldPxPlay() { + if (!mapData || mapData.gameType !== 'space_shooter') return SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS; + const ts = tileSize || 32; + const { cw, ch } = spaceShooterShipFootprintCellsPlay(mapData); + const base = SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS; + const geom = Math.sqrt(Math.max(1, cw * ch)); + const cap = Math.min(54, 0.42 * Math.min(cw * ts, ch * ts)); + return Math.max(9, Math.min(cap, base * geom)); + } + + /** ระยะห่างจากขอบโลกซ้าย/ขวาเมื่อบังคับยาน — กันยานใหญ่หลุดกรอบ */ + function spaceShooterShipEdgePadWorldPxPlay() { + if (!mapData || mapData.gameType !== 'space_shooter') return 18; + const ts = tileSize || 32; + const { cw } = spaceShooterShipFootprintCellsPlay(mapData); + return Math.max(18, cw * ts * 0.48); + } + + function spaceShooterResetMissionShipStateForAllPlay() { + function resetEnt(ent) { + if (!ent) return; + ent.spaceShooterHits = 0; + ent.spaceShooterEliminated = false; + ent.spaceShooterInvulnUntil = 0; + } + resetEnt(me); + others.forEach(function (o) { + resetEnt(o); + }); + } + + function spaceShooterMissionResolveShipAsteroidHitsPlay() { + if (!mapData || mapData.gameType !== 'space_shooter') return; + if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) return; + const now = performance.now(); + const shipR = spaceShooterShipHitRadiusWorldPxPlay(); + + function tryHit(ent, cx, cy) { + if (!ent || ent.spaceShooterEliminated || cx == null || cy == null) return; + if (ent.spaceShooterInvulnUntil != null && now < ent.spaceShooterInvulnUntil) return; + for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) { + const a = spaceShooterAsteroids[ai]; + if (!a) continue; + const dx = cx - a.x; + const dy = cy - a.y; + const rr = (a.r + shipR) * (a.r + shipR); + if (dx * dx + dy * dy > rr) continue; + ent.spaceShooterHits = Math.max(0, Number(ent.spaceShooterHits) || 0) + 1; + ent.spaceShooterInvulnUntil = now + SPACE_SHOOTER_MISSION_HIT_INVULN_MS; + spaceShooterSpawnAsteroidExplosion(a.x, a.y, a.r); + spaceShooterAsteroids.splice(ai, 1); + spaceShooterPopups.push({ x: cx, y: cy - 28, text: 'HIT', until: Date.now() + 650 }); + if (ent.spaceShooterHits >= SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS) { + ent.spaceShooterEliminated = true; + spaceShooterPopups.push({ x: cx, y: cy - 50, text: 'OUT', until: Date.now() + 1400 }); + } + break; + } + } + + const wasElim = !!me.spaceShooterEliminated; + if (myId != null) tryHit(me, me.spaceShooterCx, me.spaceShooterCy); + /* คิดการชนเฉพาะยานของเรา (เจ้าของตัดสินเอง) + บอท (host ตัดสิน) เท่านั้น + ผู้เล่นมนุษย์คนอื่นรอสถานะ eliminated/hits จาก move ของเจ้าตัว — กัน "ตายไม่พร้อมกัน" + (เดิมทุกเครื่องคิดชนให้ทุกยานด้วยตำแหน่งดาวเคราะห์ local ที่ต่างกัน → ตัดสินไม่ตรงกัน) */ + others.forEach(function (o, id) { + if (!o) return; + if (isPreviewBotId(id) && isMePlayHost()) tryHit(o, o.spaceShooterCx, o.spaceShooterCy); + }); + /* เพิ่งตาย → broadcast ทันที ไม่รอ throttle 90ms ให้ทุกเครื่องเห็นพร้อมกัน */ + if (!wasElim && me.spaceShooterEliminated && socket && myId != null) { + try { + spaceShooterLastMoveEmit = Date.now(); + socket.emit('move', { + x: me.x, y: me.y, direction: me.direction, + spaceShooterScore: Math.max(0, me.spaceShooterScore | 0), + spaceShooterHits: Math.max(0, Number(me.spaceShooterHits) || 0), + spaceShooterEliminated: true, + }); + } catch (eE) { /* ignore */ } + } + if (spaceShooterMissionEveryParticipantEliminatedPlay()) { + /* host ตัดสิน all_dead + broadcast คะแนน; non-host รอรับ space-shooter-over */ + if (isMePlayHost()) triggerSpaceShooterOverPlay('all_dead'); + } + } + + /** Space Shooter ภารกิจ: ผู้เข้าร่วมทุกคนถูกกำจัดแล้ว (รวมเรา) */ + function spaceShooterMissionEveryParticipantEliminatedPlay() { + if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) return false; + if (myId == null) return false; + if (!me.spaceShooterEliminated) return false; + let anyAlive = false; + others.forEach(function (o) { + if (!o) return; + if (!o.spaceShooterEliminated) anyAlive = true; + }); + return !anyAlive; + } + + /** จนกว่าโหลด /api/characters — placeholder ชั่วคราวก่อน join */ + const LEGACY_PLACEHOLDER_CHARACTER_ID = 'Chatest'; + let firstCharacterDefaultResolved = null; + /** รหัสตัวละครจาก GET /api/characters — ใช้สลับบอทพรีวิวให้ไม่ซ้ำรูปกับผู้เล่น */ + let playCharacterIdRoster = []; + const firstCharacterDefaultPromise = fetch(BASE + '/api/characters') + .then((r) => r.json()) + .then((list) => { + playCharacterIdRoster = []; + if (Array.isArray(list)) { + list.forEach((c) => { + if (c && c.id) playCharacterIdRoster.push(String(c.id)); + }); + } + if (Array.isArray(list) && list.length > 0 && list[0] && list[0].id) { + firstCharacterDefaultResolved = String(list[0].id); + } else { + firstCharacterDefaultResolved = ''; + } + }) + .catch(() => { + firstCharacterDefaultResolved = ''; + playCharacterIdRoster = []; + }) + .finally(() => { + try { + if (playBotsEnabled() && mapData) rebalancePreviewBots(); + } catch (e) { /* map ยังไม่พร้อม */ } + }); + const lobbyLevelParam = params.get('lobbyLevel'); + const lobbyCaseParam = params.get('case'); + if (lobbyLevelParam || lobbyCaseParam) { + try { + window.__detectiveLobbyMeta = { level: lobbyLevelParam, caseId: lobbyCaseParam }; + } catch (e) { /* ignore */ } + } + + const POST_CASE_LOBBY_MAP_ID = 'mn8nx46h'; + + function isDetectiveMinigamePlay() { + if (params.get('detectiveReturn') === '1') return true; + try { + return sessionStorage.getItem('detectiveMinigameReturn') === '1'; + } catch (e) { + return false; + } + } + + function buildRoomLobbyReturnHref() { + let h = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); + h += '&map=' + encodeURIComponent(POST_CASE_LOBBY_MAP_ID); + const drParam = (params.get('displayRoom') || '').trim(); + if (drParam) { + h += '&displayRoom=' + encodeURIComponent(drParam); + } else { + try { + const dr = localStorage.getItem('lastCreatedSpaceName'); + if (dr) h += '&displayRoom=' + encodeURIComponent(dr); + } catch (e) { /* ignore */ } + } + try { + const meta = window.__detectiveLobbyMeta; + if (meta && meta.level != null && String(meta.level).trim()) { + h += '&lobbyLevel=' + encodeURIComponent(String(meta.level).trim()); + } + if (meta && meta.caseId != null && String(meta.caseId).trim()) { + h += '&caseId=' + encodeURIComponent(String(meta.caseId).trim()); + } + } catch (e2) { /* ignore */ } + try { + if (sessionStorage.getItem('detectiveMinigameReturn') === '1') { + h += '&detectiveReturn=1'; + sessionStorage.removeItem('detectiveMinigameReturn'); + } + } catch (e3) { /* ignore */ } + if (params.get('detectiveReturn') === '1' && h.indexOf('detectiveReturn') < 0) { + h += '&detectiveReturn=1'; + } + return h; + } + + function finishDetectiveMinigameAndReturnLobby() { + quizCarrySessionEnded = true; + cancelQuizCarryAnswerRoundTimeupAuto(); + quizCarryAnswerTimeupAwaitNext = false; + hideQuizCarryTimeupOnDeskLayer(); + try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ } + const go = function () { + window.location.href = buildRoomLobbyReturnHref(); + }; + /* รายงาน score/survived ของเกมที่ client นับเอง (stack/jump_survive) → server ใช้จัดอันดับเหรียญ + (เดิมส่ง {} เปล่า → reportedMiniScore/Survived ไม่ถูกตั้ง → coin ranking MG3/MG5 เพี้ยน) */ + let finishReport = {}; + try { + if (isStack() && stackMini) finishReport = { score: Math.max(0, stackMini.score | 0), survived: true }; + else if (isJumpSurvive()) finishReport = { score: 0, survived: !jumpSurviveEliminated }; + } catch (eFR) { /* ignore */ } + if (socket && socket.connected) { + socket.emit('detective-minigame-finished', finishReport, function (res) { + try { + if (res && Array.isArray(res.myPlayerEvidence)) { + var hasAny = res.myPlayerEvidence.some(function (r) { return Array.isArray(r) && r.length; }); + if (hasAny) sessionStorage.setItem('justiceDetectiveMyEvidence', JSON.stringify(res.myPlayerEvidence)); + else sessionStorage.removeItem('justiceDetectiveMyEvidence'); + } + } catch (e) { /* ignore */ } + /* MG2: เข้าเส้นชัยแล้วแต่ยังมีคนวิ่งอยู่ → รอในฉาก (server จะพากลับ LobbyB พร้อมกันตอนทุกคนจบ) */ + if (res && res.holdInScene) { + showGauntletFinishWaitOverlay(); + return; + } + /* โชว์การ์ดหลักฐานที่เพิ่งได้ "ในฉากมินิเกม" ก่อนกลับ LobbyB */ + if (res && typeof res.suspectIndex === 'number' && res.suspectIndex >= 0) { + showInSceneEvidenceReveal(res, go); + } else { + go(); + } + }); + } else { + go(); + } + } + + /** MG2: ป้าย "เข้าเส้นชัยแล้ว — รอผู้เล่นอื่น" ระหว่างรอคนที่ยังวิ่งอยู่ (จะกลับ LobbyB พร้อมกันตอนเกมจบ) */ + function showGauntletFinishWaitOverlay() { + var ov = document.getElementById('gauntlet-finish-wait'); + if (!ov) { + ov = document.createElement('div'); + ov.id = 'gauntlet-finish-wait'; + ov.style.cssText = 'position:fixed;inset:0;z-index:100000;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;background:radial-gradient(ellipse at center,rgba(10,16,32,.82),rgba(6,9,20,.95));backdrop-filter:blur(3px)'; + ov.innerHTML = + '
🏁 เข้าเส้นชัยแล้ว!
' + + '
รอผู้เล่นคนอื่นเข้าเส้นชัย...
' + + '
• • •
'; + document.body.appendChild(ov); + } + ov.style.display = 'flex'; + } + + /* แจ้ง host ว่าทำไม START ยังกดเริ่มไม่ได้ (เดิม reply ถูก ignore เงียบ → ปุ่มเหมือนเสีย) + ใช้ร่วม MG2/5/6/7 (crown lobby shell) — โชว์ "รอผู้เล่น x/N" หรือ "ยังมีคนไม่ Ready" แล้วซ่อนเอง */ + function showCrownStartBlockedNoticePlay(r) { + if (!r) return; + let msg = r.error || 'ยังเริ่มไม่ได้'; + if (r.waitingPlayers) msg = 'รอผู้เล่นเข้าเกมให้ครบก่อน (' + (r.present || 0) + '/' + (r.total || 0) + ')'; + let ov = document.getElementById('crown-start-notice'); + if (!ov) { + ov = document.createElement('div'); + ov.id = 'crown-start-notice'; + ov.style.cssText = 'position:fixed;left:50%;top:14%;transform:translateX(-50%);z-index:100001;padding:12px 22px;border-radius:14px;background:rgba(20,12,16,.92);border:1px solid rgba(255,120,120,.5);color:#ffd9d9;font:700 18px/1.3 Kanit,system-ui,sans-serif;text-align:center;box-shadow:0 6px 24px rgba(0,0,0,.5);pointer-events:none'; + document.body.appendChild(ov); + } + ov.textContent = '⏳ ' + msg; + ov.style.display = 'block'; + clearTimeout(ov._hideT); + ov._hideT = setTimeout(function () { ov.style.display = 'none'; }, 2600); + } + + /* ===== หน้าโชว์การ์ดหลักฐานในฉากมินิเกม (ก่อนกลับ LobbyB) ===== */ + var __evidenceConfigCache = null; + function ensureEvidenceConfigLoaded(cb) { + if (__evidenceConfigCache) { cb(__evidenceConfigCache); return; } + try { + fetch(BASE + '/api/evidence-cards?_=' + Date.now(), { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (j) { __evidenceConfigCache = (j && j.cases) ? j : { cases: {} }; cb(__evidenceConfigCache); }) + .catch(function () { cb({ cases: {} }); }); + } catch (e) { cb({ cases: {} }); } + } + + var EVIDENCE_RARITY_GLOW = { + common: 'rgba(168,180,200,.55)', + rare: 'rgba(94,170,255,.7)', + legendary: 'rgba(255,186,72,.85)', + }; + var EVIDENCE_RARITY_LABEL = { common: 'การ์ดธรรมดา', rare: 'การ์ด Rare', legendary: 'การ์ดชี้คนร้าย' }; + + /** กล่องดูการ์ดหลักฐานแบบขยาย (zoom) — ปิดด้วยปุ่ม ×, คลิกพื้นหลัง, หรือ Esc */ + function openEvidenceCardLightboxPlay(imageUrl, label, rarity) { + if (!imageUrl) return; + var rar = String(rarity || 'common').toLowerCase(); + var glow = EVIDENCE_RARITY_GLOW[rar] || EVIDENCE_RARITY_GLOW.common; + var ov = document.getElementById('evidence-card-lightbox'); + if (!ov) { + ov = document.createElement('div'); + ov.id = 'evidence-card-lightbox'; + ov.style.cssText = 'position:fixed;inset:0;z-index:100001;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(5,8,18,.88);backdrop-filter:blur(4px)'; + ov.innerHTML = + '' + + '
' + + '' + + '
' + + '
'; + document.body.appendChild(ov); + var closeFn = function () { ov.style.display = 'none'; }; + ov.addEventListener('click', function (e) { if (e.target === ov || (e.target.classList && e.target.classList.contains('ecl-close'))) closeFn(); }); + document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && ov.style.display !== 'none') closeFn(); }); + } + var img = ov.querySelector('.ecl-img'); + var cap = ov.querySelector('.ecl-cap'); + if (img) { img.style.boxShadow = '0 0 44px ' + glow + ',0 22px 60px rgba(0,0,0,.6)'; img.src = imageUrl; } + if (cap) cap.textContent = label || ''; + ov.style.display = 'flex'; + } + + function showInSceneEvidenceReveal(res, onDone) { + var done = false; + var finish = function () { if (done) return; done = true; onDone(); }; + var si = res.suspectIndex; + /* การ์ดที่เพิ่งได้รอบนี้ (object: rarity/imageUrl) — fallback ใช้ N ใบท้ายของ owned */ + var awarded = (res.awardedCards && Array.isArray(res.awardedCards)) + ? res.awardedCards.filter(function (c) { return c && c.imageUrl; }) + : []; + if (!awarded.length) { + var owned = (res.myPlayerEvidence && res.myPlayerEvidence[si]) || []; + var freeN = Math.max(0, Number(res.freeEvidenceCount) || 0); + var newN = Math.min(owned.length, 1 + freeN); + awarded = owned.slice(owned.length - newN).filter(function (c) { return c && c.imageUrl; }); + } + if (!awarded.length) { finish(); return; } + var newN = awarded.length; + var ov = document.createElement('div'); + ov.id = 'inscene-evidence-reveal'; + /* scroll container + inner margin:auto → มือถือแนวตั้งที่เนื้อหาสูงเกินจอ เลื่อนถึงปุ่มได้ (เดิม justify center ไม่มี scroll → ปุ่มหลุดล่างจอ) */ + ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(5,8,18,.9);display:flex;overflow-y:auto;-webkit-overflow-scrolling:touch;backdrop-filter:blur(3px);padding:16px 12px;box-sizing:border-box'; + var inner = document.createElement('div'); + inner.style.cssText = 'margin:auto;display:flex;flex-direction:column;align-items:center;gap:clamp(12px,2.4vh,22px);width:100%;max-width:640px'; + var title = document.createElement('div'); + title.style.cssText = 'font:900 clamp(20px,5.5vw,30px)/1.2 Kanit,system-ui,sans-serif;color:#ffe7a3;text-shadow:0 0 18px rgba(255,214,102,.5);text-align:center'; + title.textContent = newN > 1 ? ('คุณได้รับหลักฐาน ' + newN + ' ใบ!') : 'คุณได้รับหลักฐาน!'; + if (res.grade) { + var gradeEl = document.createElement('div'); + gradeEl.style.cssText = 'font:800 16px/1 Kanit,system-ui,sans-serif;color:#bfe3ff;opacity:.9'; + gradeEl.textContent = 'ผลงานทีม: เกรด ' + res.grade; + title.appendChild(document.createElement('br')); + title.appendChild(gradeEl); + } + var freeBonus = Math.max(0, Number(res.freeEvidenceCount) || 0); + if (freeBonus > 0) { + var bonusEl = document.createElement('div'); + bonusEl.style.cssText = 'font:800 15px/1.3 Kanit,system-ui,sans-serif;color:#9ece6a;margin-top:4px'; + bonusEl.textContent = '⭐ การ์ด Free Evidence: ทุกคนรับหลักฐานฟรี +' + freeBonus + ' ใบ'; + title.appendChild(document.createElement('br')); + title.appendChild(bonusEl); + } + var row = document.createElement('div'); + row.style.cssText = 'display:flex;gap:18px;flex-wrap:wrap;justify-content:center;align-items:center;max-width:92vw'; + awarded.forEach(function (c, i) { + var rar = String(c.rarity || 'common').toLowerCase(); + var glow = EVIDENCE_RARITY_GLOW[rar] || EVIDENCE_RARITY_GLOW.common; + var cardWrap = document.createElement('div'); + cardWrap.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:8px;opacity:0;transform:translateY(24px) scale(.92);transition:opacity .45s ease,transform .45s cubic-bezier(.2,.9,.3,1.3)'; + var img = document.createElement('img'); + img.src = c.imageUrl; + img.alt = EVIDENCE_RARITY_LABEL[rar] || 'หลักฐาน'; + img.style.cssText = 'display:block;width:auto;height:auto;max-width:min(72vw,360px);max-height:52vh;border-radius:14px;box-shadow:0 0 36px ' + glow + ',0 18px 50px rgba(0,0,0,.6);cursor:zoom-in'; + img.title = 'แตะเพื่อดูใหญ่'; + img.addEventListener('click', function () { openEvidenceCardLightboxPlay(c.imageUrl, EVIDENCE_RARITY_LABEL[rar] || '', rar); }); + img.onerror = function () { this.onerror = null; this.style.width = '200px'; this.style.height = '300px'; this.style.background = '#1b2240'; this.removeAttribute('src'); }; + var tag = document.createElement('div'); + tag.style.cssText = 'font:800 14px/1 Kanit,system-ui,sans-serif;color:#fff;opacity:.92'; + tag.textContent = EVIDENCE_RARITY_LABEL[rar] || ''; + cardWrap.appendChild(img); + cardWrap.appendChild(tag); + row.appendChild(cardWrap); + setTimeout(function () { cardWrap.style.opacity = '1'; cardWrap.style.transform = 'translateY(0) scale(1)'; }, 120 + i * 160); + }); + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = 'คุณได้หลักฐาน · เก็บเข้าแฟ้ม'; + btn.style.cssText = 'cursor:pointer;border:none;border-radius:999px;padding:clamp(11px,1.8vh,14px) clamp(26px,8vw,40px);font:800 clamp(15px,4.4vw,18px)/1 Kanit,system-ui,sans-serif;color:#0a0e1a;background:linear-gradient(180deg,#ffd666,#f0b429);box-shadow:0 10px 30px rgba(240,180,41,.45);flex:0 0 auto'; + btn.addEventListener('click', function () { + ov.style.transition = 'opacity .25s'; ov.style.opacity = '0'; + setTimeout(finish, 220); + }); + inner.appendChild(title); + inner.appendChild(row); + inner.appendChild(btn); + ov.appendChild(inner); + document.body.appendChild(ov); + /* กันค้าง: ถ้าไม่กดภายใน 12 วิ ให้ไปต่อเอง */ + setTimeout(function () { if (!done) { try { ov.remove(); } catch (e) {} finish(); } }, 12000); + } + + /** จบมินิเกมโหมดสืบสวน → LobbyB; คืน true ถ้า redirect แล้ว */ + function tryFinishDetectiveMinigameAndReturnLobby() { + if (!isDetectiveMinigamePlay()) return false; + finishDetectiveMinigameAndReturnLobby(); + return true; + } + + /** ทดสอบจากเอดิเตอร์: เติมบอทให้ครบจำนวน (รวมผู้เล่นจริง) — ?fillTotal=6 (ค่าเริ่ม 6) */ + const previewFillBots = previewMode && editorEmbedReturn; + let detectiveCaseFillBots = false; + let detectiveBotSlotCount = 0; + function playBotsEnabled() { + return previewFillBots || detectiveCaseFillBots; + } + function playBotTargetHeadcount() { + if (previewFillBots) return previewTargetHeadcount; + if (detectiveCaseFillBots) return countPlayHumans() + detectiveBotSlotCount; + return countPlayHumans(); + } + /** พรีวิวใน iframe เอดิเตอร์: ซูมมุมมองได้ (เดิมไม่มี — zDraw จาก zoom คงที่) */ + const PLAY_EMBED_USER_ZOOM_MIN = 0.5; + const PLAY_EMBED_USER_ZOOM_MAX = 3; + const PLAY_EMBED_ZOOM_STEP_KEY = 1.12; + let playEmbedUserZoomMul = 1; + let lastPlayZDrawForInput = 1.4; + /** คูณ world→พิกเซลบนแคนวาสให้ตรงกับ zDraw ใน draw() (รวม preview+embed zoom) — ใช้ซิงก์ DOM ทับฉาก */ + function playDomSyncZoom() { + const z = lastPlayZDrawForInput; + return Number.isFinite(z) && z > 0 ? z : zoom; + } + const previewTargetHeadcount = Math.min(24, Math.max(1, parseInt(params.get('fillTotal'), 10) || 6)); + const PREVIEW_BOT_PREFIX = '__pv_bot_'; + let previewBotSeq = 0; + /** Stack preview: บอทไม่วาบบนแผนที่ — ใช้ไฮไลต์แถบแข่งแทน */ + let lastStackPreviewActorId = null; + let lastStackPreviewActorUntil = 0; + /** Stack preview: สลับตา Player 1 = มนุษย์, 2–6 = บอท (เฉพาะตาปัจจุบันเล่นได้) */ + const STACK_PREVIEW_TURN_COUNT = 6; + let stackPreviewTurnOrder = null; + let stackPreviewTurnIndex = 0; + let stackPreviewBotThinkUntil = 0; + + /** ลำดับตา MG3 = ผู้เล่นจริงทุกคน + บอท เรียงตาม id แบบคงที่ → ทุกเครื่องได้ลำดับ/ที่นั่งเดียวกัน (shared tower) + (เดิม [human=ตัวเอง seat1 + bots] ต่อเครื่อง → แต่ละเครื่องมองตัวเองเป็นคนเดียว เลยแยกตึกกัน) */ + function rebuildStackPreviewTurnOrder() { + if (!playBotsEnabled() || !mapData || mapData.gameType !== 'stack') { + stackPreviewTurnOrder = null; + return; + } + if (!detectiveCaseFillBots) { + /* editor preview (คนเดียว): คงพฤติกรรมเดิม [human ตัวเอง seat1 + บอท] */ + const bots = [...others.keys()].filter(isPreviewBotId).sort(); + stackPreviewTurnOrder = [{ kind: 'human', seat: 1, id: myId != null ? String(myId) : '__me__' }]; + for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) { + stackPreviewTurnOrder.push({ kind: 'bot', seat: i + 2, id: bots[i] || null, botId: bots[i] || null }); + } + stackPreviewTurnIndex = 0; + stackPreviewBotThinkUntil = 0; + markStackNextDropVisualDirty(); + return; + } + /* เล่นจริง: non-host ไม่สร้าง order เอง — ใช้ของ host จาก stack-state (กัน order ไม่ตรง/client ตกหล่นช่วงโหลด) */ + if (!isMePlayHost()) return; + /* host: ผู้เล่นจริงทุกคน (ตัด banned spectator กัน stall) + บอท เรียง id → ที่นั่งคงที่; preserve คนที่กำลังถึงตา */ + const prevActorId = (stackCurrentTurnEntryPlay() && stackCurrentTurnEntryPlay().id) || null; + const parts = []; + /* dedup คนซ้ำ (ghost reconnect) ก่อนสร้างคิวตา — กัน seat ผี (ตาที่ไม่มีใครวาง = เกมค้าง) + ⚠️ เทียบด้วย playerKey เท่านั้น — **ห้าม dedup ด้วยชื่อ**: 2 คนตั้งชื่อเหมือนกัน (เช่น "MONE") = คนละ key = คนละคน + ต้องได้ seat ทั้งคู่ (เดิม fallback ชื่อ → client ที่ชื่อซ้ำ host ถูกตัด = "ไม่ได้เล่นเลย") */ + const seenHumanKey = new Set(); + const tryAddHuman = (id, ent) => { + const pk = (ent && ent.playerKey) ? String(ent.playerKey) : (String(id) === String(myId) ? (typeof getJdPlayerKey === 'function' ? getJdPlayerKey() : '') : ''); + if (pk) { + if (seenHumanKey.has(pk)) return; /* playerKey เดียวกัน = ghost reconnect → ข้าม */ + seenHumanKey.add(pk); + } + parts.push({ kind: 'human', id: String(id) }); + }; + if (myId != null && !playBannedSpectator) tryAddHuman(myId, me); + others.forEach((o, id) => { + if (isPreviewBotId(id)) { parts.push({ kind: 'bot', id: String(id), botId: String(id) }); return; } + if (o && o.bannedSpectator) return; /* คนถูกแบน = ดูเฉยๆ ไม่เข้าคิววาง */ + tryAddHuman(id, o); + }); + parts.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const capped = parts.slice(0, STACK_PREVIEW_TURN_COUNT); + stackPreviewTurnOrder = capped.map((p, i) => ({ + kind: p.kind, seat: i + 1, id: p.id, botId: p.kind === 'bot' ? p.botId : null, + })); + let ni = -1; + if (prevActorId != null) ni = stackPreviewTurnOrder.findIndex((e) => String(e.id) === String(prevActorId)); + if (ni >= 0) stackPreviewTurnIndex = ni; + else if (stackPreviewTurnIndex >= stackPreviewTurnOrder.length) stackPreviewTurnIndex = 0; + stackPreviewBotThinkUntil = 0; + markStackNextDropVisualDirty(); + broadcastStackStatePlay(); + } + + /** host broadcast ลำดับตา + index ปัจจุบัน → non-host ใช้ตรงกัน (single source of truth) */ + function broadcastStackStatePlay() { + if (!detectiveCaseFillBots || !isMePlayHost() || !socket || myId == null || !stackTurnOrderReadyPlay()) return; + try { + socket.emit('stack-state', { + idx: stackPreviewTurnIndex, + /* theme ต่อที่นั่ง (host เป็นเจ้าของ) → ทุกจอลงสี block ตรงกัน (เดิม non-host คำนวณสีเองจาก playTint → ไม่ตรง 2 จอ) */ + order: stackPreviewTurnOrder.map((e) => ({ kind: e.kind, seat: e.seat, id: e.id, botId: e.botId || null, theme: stackSeatThemeForBroadcastPlay(e.seat) })), + /* สีของ block ที่ "วางแล้ว" ทั้งหอ (host authoritative) → client sync เขียนทับ layer ที่เพี้ยนค้าง (จากจังหวะ dual-host ต้นเกม) */ + lthemes: (stackMini && Array.isArray(stackMini.layers)) ? stackMini.layers.map(function (l) { return (l && l.theme >= 1 && l.theme <= 8) ? l.theme : 0; }) : [], + }); + logStackSeatColors('host-bcast'); + } catch (e) { /* ignore */ } + } + + /** order พร้อมใช้ (มีอย่างน้อย 1 ที่นั่ง) */ + function stackTurnOrderReadyPlay() { + return !!(stackPreviewTurnOrder && stackPreviewTurnOrder.length >= 1); + } + /** (host) คิวตาตรงกับผู้เล่น/บอทปัจจุบันไหม — ใช้ auto-heal late-join (client เข้าทีหลังต้องได้ seat ไม่งั้น "เล่นไม่ได้" + ไม่มีสี) + เทียบรายชื่อคน (me + others ที่ไม่ใช่บอท/ไม่ถูกแบน) + บอท กับ entry ในคิว; ต่างเมื่อไหร่ = ต้อง rebuild */ + let _stackHumansSig = ''; + function stackOrderNeedsRebuildPlay() { + if (!stackTurnOrderReadyPlay() || !Array.isArray(stackPreviewTurnOrder)) return true; + /* เช็คเฉพาะ "คน" (humans ≤ maxPlayers จึงลงคิวครบเสมอ ไม่โดน cap ตัด) — บอทจัดการใน rebuild เอง */ + const cur = []; + if (myId != null && !playBannedSpectator) cur.push(String(myId)); + others.forEach((o, id) => { + if (isPreviewBotId(id)) return; + if (o && o.bannedSpectator) return; + cur.push(String(id)); + }); + cur.sort(); + const sig = cur.join('|'); + if (sig === _stackHumansSig) return false; /* คนไม่เปลี่ยน → ไม่ต้องเช็ค (กัน rebuild ถี่) */ + const ord = []; + stackPreviewTurnOrder.forEach((e) => { if (e && e.kind !== 'bot' && !e.botId && e.id != null) ord.push(String(e.id)); }); + ord.sort(); + const need = (sig !== ord.join('|')); + if (!need) _stackHumansSig = sig; /* ตรงแล้ว → cache กันเช็คซ้ำทุกเฟรม */ + return need; + } + function stackCurrentTurnEntryPlay() { + if (!stackTurnOrderReadyPlay()) return null; + return stackPreviewTurnOrder[stackPreviewTurnIndex % stackPreviewTurnOrder.length] || null; + } + function isMyStackTurn() { + if (!playBotsEnabled()) return true; /* เล่นเดี่ยว → กดได้ */ + if (detectiveCaseFillBots && !stackTurnOrderReadyPlay()) return false; /* เล่นจริง: รอ order จาก host ก่อน */ + if (!stackTurnOrderReadyPlay()) return true; + const cur = stackCurrentTurnEntryPlay(); + if (!cur || cur.kind !== 'human') return false; + if (!detectiveCaseFillBots) return true; /* editor preview: human = previewer คนเดียว */ + return String(cur.id) === String(myId); + } + function isStackCurrentBotTurnPlay() { + const cur = stackCurrentTurnEntryPlay(); + return !!(cur && cur.kind === 'bot'); + } + /** เจ้าของตาที่วาง broadcast ผลการวาง → ทุกเครื่อง apply เหมือนกัน + เลื่อนตาพร้อมกัน */ + function broadcastStackDropPlay(hit, seat, heavy, actorId, isBot) { + if (!detectiveCaseFillBots || !socket || myId == null || !hit) return; /* broadcast เฉพาะเล่นจริง */ + try { + socket.emit('stack-drop', { + placedCx: hit.placedCx, placedW: hit.placedW, miss: !!hit.miss, perfect: !!hit.perfect, + pts: hit.pts || 0, progressDelta: hit.progressDelta || 0, landOffsetTiles: hit.landOffsetTiles || 0, + overlap: hit.overlap, supportRatio: hit.supportRatio, delta: hit.delta || 0, + seat: seat || 1, heavy: !!heavy, actorId: actorId != null ? String(actorId) : '', isBot: !!isBot, + /* สี (theme) ที่ host/เจ้าของตา resolve ตอนวาง → ส่งไปด้วย ให้บล็อกที่วางบนทุกจอใช้สีเดียวกัน (กัน host/client คนละสี) — never-null */ + theme: stackSeatThemeForBroadcastPlay(seat), + }); + } catch (e) { /* ignore */ } + } + + function advanceStackPreviewTurn() { + if (!playBotsEnabled() || !stackTurnOrderReadyPlay()) return; + /* เล่นจริง: index คุมโดย host เท่านั้น (non-host รับจาก stack-state) กันลำดับตาเหลื่อมกัน */ + if (detectiveCaseFillBots && !isMePlayHost()) return; + stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % stackPreviewTurnOrder.length; + stackPreviewBotThinkUntil = 0; + markStackNextDropVisualDirty(); + broadcastStackStatePlay(); + } + + function isStackPreviewHumanTurn() { + if (!playBotsEnabled() || !stackTurnOrderReadyPlay()) return true; + const cur = stackCurrentTurnEntryPlay(); + return !!(cur && cur.kind === 'human'); + } + + function getStackPreviewCurrentSeat() { + if (!stackTurnOrderReadyPlay()) return 1; + const cur = stackCurrentTurnEntryPlay(); + return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1; + } + + function getStackDropActorSeat() { + if (playBotsEnabled() && stackTurnOrderReadyPlay()) { + return getStackPreviewCurrentSeat(); + } + return 1; + } + + function markStackNextDropVisualDirty() { + if (stackMini) stackMini.nextDropVisualDirty = true; + } + + /* #4 MG3 block สีตาม user — Block-Code-1..8 ตรง lobby theme (PLAY_LOBBY_THEME_HEX) + heavy '-2' */ + const STACK_BLOCK_THEME_NAMES = ['1-red', '2-orange', '3-Yellow', '4-Green', '5-Blue', '6-Navy', '7-Violet', '8-Pink']; + const STACK_BLOCK_THEME_NORMAL = STACK_BLOCK_THEME_NAMES.map(function (s) { return BASE + '/img/TowerBlock/Block-Code-' + s + '.png'; }); + const STACK_BLOCK_THEME_HEAVY = STACK_BLOCK_THEME_NAMES.map(function (s) { return BASE + '/img/TowerBlock/Block-Code-' + s + '-2.png'; }); + function stackThemeNormalUrlPlay(theme) { return (theme >= 1 && theme <= 8) ? STACK_BLOCK_THEME_NORMAL[theme - 1] : ''; } + function stackThemeHeavyUrlPlay(theme) { return (theme >= 1 && theme <= 8) ? STACK_BLOCK_THEME_HEAVY[theme - 1] : ''; } + /** theme (1-8) ของผู้เล่นที่ครอบครอง seat นั้นในลำดับตา — ใช้ลงสี block ตามผู้วาง (ไม่แตะ seat/turn logic) */ + function stackSeatColorThemePlay(seat) { + if (!isStack() || !stackTurnOrderReadyPlay()) return null; + const s = Math.floor(Number(seat)) || 0; + const entry = stackPreviewTurnOrder.find(function (e) { return e && e.seat === s; }); + if (!entry) return null; + /* (1) สีที่ host broadcast มาทาง stack-state = SINGLE SOURCE OF TRUTH → client ยึดค่า host เสมอ (สีตรง host ทุกจอ) + เกม MG3 host เป็นเจ้าของลำดับตา/สี; host เองไม่มี entry.theme (คำนวณสดตอน broadcast) → ตกไป (2) authoritative + เหตุที่ต้อง broadcast-first: ถ้า client คำนวณเอง (lobbyThemeIndexSrv/playLobbyBotThemes ของ client) แล้วข้อมูลมาช้า/ไม่ตรง + → สีหลุดจาก host เพี้ยนเรื่อย ๆ. ใช้ค่า host = ไม่มีทางหลุด */ + const bcTheme = parsePlayLobbyThemeIndex(entry.theme); + if (bcTheme) return bcTheme; + /* (2) authoritative ต่อ entity (สำหรับ "host เอง" + ตอน client ยังไม่ได้รับ broadcast): + bot → playLobbyBotThemes[slot] (server) ; human → lobbyThemeIndexSrv (= server lobbyColorThemeIndex) */ + const botId = entry.botId || (entry.kind === 'bot' ? entry.id : null); + if (botId && isPreviewBotId(botId)) { + const slot = parseInt(String(botId).slice(PREVIEW_BOT_PREFIX.length), 10); + const bt = (slot >= 0 && Array.isArray(playLobbyBotThemes)) ? playLobbyBotThemes[slot] : null; + const botIdx = bt ? parsePlayLobbyThemeIndex(bt.themeIndex) : null; + if (botIdx) return botIdx; + } else if (entry.id != null) { + const entH = (myId != null && String(entry.id) === String(myId)) ? me : (others.get(entry.id) || others.get(String(entry.id))); + const humanIdx = entH ? parsePlayLobbyThemeIndex(entH.lobbyThemeIndexSrv) : null; + if (humanIdx) return humanIdx; + } + /* (3) ทางเลือกสุดท้าย: เดาจาก playTint (nearest hex — อาจไม่ตรงข้ามจอ) */ + let ent = null; + if (entry.id != null) ent = (myId != null && String(entry.id) === String(myId)) ? me : (others.get(entry.id) || others.get(String(entry.id))); + if (!ent && entry.botId) ent = others.get(entry.botId) || others.get(String(entry.botId)); + return ent ? playLobbyThemeIndexForEntityPlay(ent) : null; + } + + /** สีที่ host จะ broadcast — "ไม่มีทาง null": resolve ปกติ ไม่ได้ค่อย default ตาม seat + → client (broadcast-first) ยึดค่า host ได้เสมอ ไม่ตกไปคำนวณเอง = สีตรง host ทุกจอแน่นอน */ + function stackSeatThemeForBroadcastPlay(seat) { + const t = stackSeatColorThemePlay(seat); + if (t >= 1 && t <= 8) return t; + const s = Math.floor(Number(seat)) || 1; + return ((s - 1) % 8) + 1; + } + + /** ส่ง log ไป /gamelog (ดูรวมที่หน้าเว็บ) */ + function gamelogPost(cat, ev, detail, who) { + try { + fetch('/gamelog/log.php', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true, + body: JSON.stringify({ cat: cat || 'อื่นๆ', room: (typeof spaceId === 'string' ? spaceId : ''), who: who || '', ev: ev || '', detail: detail || '', src: 'client' }), + }).catch(function () {}); + } catch (e) { /* ignore */ } + } + /** วินิจฉัยสี block MG3 ต่อ seat — เทียบ "สี avatar (playTint)" vs "สี block (resolved)"; ถ้าไม่ตรง → ส่งเข้า /gamelog + เรียกเอง: window.__mg3ColorLog() หรือ auto throttle */ + let _mg3ColorLogAt = 0; + let _mg3SnapAt = 0; + let _mg3LastMismatchKey = ''; + function logStackSeatColors(tag, force) { + try { + if (!isStack() || !stackTurnOrderReadyPlay()) return; + const now = Date.now(); + if (!force && now - _mg3ColorLogAt < 2000) return; + _mg3ColorLogAt = now; + const rows = stackPreviewTurnOrder.map(function (e) { + let ent = null; + if (e.id != null) ent = (myId != null && String(e.id) === String(myId)) ? me : (others.get(e.id) || others.get(String(e.id))); + if (!ent && e.botId) ent = others.get(e.botId) || others.get(String(e.botId)); + const tint = ent && ent.playTint && ent.playTint.body ? ent.playTint.body : ''; + const avatarIdx = tint ? nearestIndexFromHexPlay(tint, PLAY_LOBBY_THEME_HEX) : null; /* สีจริงของ avatar (จาก playTint) */ + const blockIdx = stackSeatColorThemePlay(e.seat); /* สี block ที่จะวาด */ + return { seat: e.seat, kind: e.kind, nick: (ent && ent.nickname) || String(e.id || e.botId || '').slice(0, 6), avatarIdx: avatarIdx, blockIdx: blockIdx, srv: (ent && ent.lobbyThemeIndexSrv) || null, bc: e.theme || null, ok: (avatarIdx && blockIdx) ? (avatarIdx === blockIdx) : null }; + }); + console.log('[mg3-color/' + (tag || '') + '] host=' + isMePlayHost(), JSON.stringify(rows)); + const role = isMePlayHost() ? 'host' : 'client'; + /* (A) snapshot ทุก ~5 วิ จาก "ทั้ง host และ client" → เทียบใน /gamelog ได้: block(=สีที่วาด) av(=สี avatar) srv bc */ + if (now - _mg3SnapAt > 5000) { + _mg3SnapAt = now; + /* รายชื่อ "คน" ใน others (เทียบกับคิว) — หา root "client หายจากคิว" */ + const othH = []; + if (myId != null) othH.push((me && me.nickname ? me.nickname : 'me') + '(self' + (playBannedSpectator ? ',BAN' : '') + ')'); + others.forEach(function (o, id) { + if (isPreviewBotId(id)) return; + othH.push((o && o.nickname ? o.nickname : String(id).slice(0, 5)) + (o && o.bannedSpectator ? '(BAN)' : '')); + }); + const ordSeats = rows.length; + /* block ที่ "วางแล้ว" (frozen layer.theme) — ตัวที่ user เห็นเพี้ยน; เทียบ host vs client ทีละ layer (seat:theme) */ + let lyr = ''; + if (stackMini && Array.isArray(stackMini.layers)) { + const L = stackMini.layers; + lyr = ' || layers(' + L.length + ')=[' + L.slice(-12).map(function (l) { return (l && l.seat != null ? l.seat : '?') + ':' + (l && l.theme != null ? l.theme : 'null'); }).join(',') + ']'; + } + const summary = 'order=' + ordSeats + 'seat othHumans=[' + othH.join(',') + '] :: ' + + rows.map(function (r) { return 's' + r.seat + ':' + (r.nick || '?').slice(0, 5) + ' blk=' + r.blockIdx + ' av=' + r.avatarIdx; }).join(' | ') + lyr; + gamelogPost('MG3', 'color-snap', summary, role); + } + /* (B) mismatch (avatar≠block บนจอเดียวกัน) → ส่งทันที (เปลี่ยนค่อยส่งซ้ำ) */ + const bad = rows.filter(function (r) { return r.ok === false; }); + if (bad.length) { + const key = role + '|' + bad.map(function (r) { return r.seat + ':' + r.avatarIdx + '!=' + r.blockIdx; }).join(','); + if (key !== _mg3LastMismatchKey) { + _mg3LastMismatchKey = key; + const detail = bad.map(function (r) { return r.nick + ' seat' + r.seat + ' avatar#' + r.avatarIdx + ' แต่ block#' + r.blockIdx + ' (srv' + r.srv + '/bc' + r.bc + ')'; }).join(' | '); + gamelogPost('MG3', 'color-mismatch', detail, role); + } + } + } catch (e) { /* ignore */ } + } + window.__mg3ColorLog = function () { logStackSeatColors('manual', true); }; + + function pickStackBlockHeavyVisual(seat) { + const slot = Math.max(0, Math.min(7, (Math.floor(Number(seat)) || 1) - 1)); + let hU = normalizeGauntletAssetUrlForPlay(playStackBlockHeavyUrls[slot] || ''); + /* ถ้าใช้สี theme (Block-Code) — heavy variant '-2' มีครบ ใช้แทน admin slot */ + const _theme = stackSeatColorThemePlay(seat); + if (_theme && stackThemeHeavyUrlPlay(_theme)) hU = stackThemeHeavyUrlPlay(_theme); + /** 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.pendingDropTheme = null; /* ดรอป local/host → ใช้ stackSeatColorThemePlay เอง (remote ตั้งทับทีหลัง) */ + stackMini.widthTiles = stackBlockWidthTilesForPlay(stackMini.initialWidthTiles, !!stackMini.pendingDropHeavy); + stackMini.nextDropVisualDirty = false; + } + + function normalizePlayStackSixUrlsFromTiming(arr) { + const out = ['', '', '', '', '', '', '', '']; /* 8 slot ตาม lobby theme (#4) */ + if (!Array.isArray(arr)) return out; + for (let i = 0; i < 8; i++) { + out[i] = normalizeGauntletAssetUrlForPlay(typeof arr[i] === 'string' ? arr[i] : ''); + if (out[i]) ensureGauntletAssetImage(out[i]); + } + return out; + } + + function resolveStackBlockSpriteRec(seat, heavy, themeOverride) { + const tryHeavy = !!heavy; + /* #4 สี block = theme ของผู้เล่นที่ seat นั้น (Block-Code-1..8) ถ้า resolve ได้ (มี turn order) + — fallback ไป admin slot URLs ถ้าไม่มี theme (เกม stack อื่น/ยังไม่มีลำดับตา) + themeOverride = สีที่ "freeze" ไว้บน layer ตอนวาง → กัน re-resolve เปลี่ยนสีเมื่อ turn order เปลี่ยน (สี block ที่วางแล้วคงที่+ตรงทุกจอ) */ + const theme = (themeOverride >= 1 && themeOverride <= 8) ? themeOverride : stackSeatColorThemePlay(seat); + if (theme) { + const tn = stackThemeNormalUrlPlay(theme); + const th = stackThemeHeavyUrlPlay(theme); + const torder = tryHeavy ? [th, tn] : [tn, th]; + for (let ti = 0; ti < torder.length; ti++) { + if (torder[ti]) return ensureGauntletAssetImage(torder[ti]); + } + } + const slot = Math.max(0, Math.min(7, (Math.floor(Number(seat)) || 1) - 1)); + 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, o.themeOverride); + const img = rec && rec.img; + if (rec && rec.ready && img && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) { + const iw = img.naturalWidth; + const ih = img.naturalHeight; + if (drawW > 0 && drawH > 0) { + const scale = Math.max(drawW / iw, drawH / ih); + const dw = iw * scale; + const dh = ih * scale; + const dx = sx + (drawW - dw) * 0.5; + const dy = sy + (drawH - dh) * 0.5; + const prevSmooth = ctx.imageSmoothingEnabled; + ctx.save(); + ctx.beginPath(); + ctx.rect(sx, sy, drawW, drawH); + ctx.clip(); + try { + ctx.imageSmoothingEnabled = false; + ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh); + } finally { + ctx.imageSmoothingEnabled = prevSmooth; + } + ctx.restore(); + if (o.missOverlay) { + ctx.fillStyle = 'rgba(247, 118, 190, 0.42)'; + ctx.fillRect(sx, sy, drawW, drawH); + } + return true; + } + } + const hue = ((hueFallback % 360) + 360) % 360; + ctx.fillStyle = o.missTint ? 'rgba(247, 118, 190, 0.88)' : ('hsla(' + hue + ', 68%, 56%, 0.9)'); + ctx.fillRect(sx, sy, drawW, drawH); + return false; + } + + if (!spaceId) { window.location.replace(playDesignMainMenuHref()); return; } + + const socket = io({ path: BASE + '/socket.io' }); + window.__jdGameSocket = socket; + const canvas = document.getElementById('game-canvas'); + const ctx = canvas.getContext('2d'); + + /** popup แจ้งเตือน/ผิดพลาด ดีไซน์เอง (แทน alert() ของเบราว์เซอร์ที่หน้าตาไม่เข้าธีม) + opts: { type:'error'|'info', okText, onClose } — onClose เรียกเมื่อกดตกลง/ปิด */ + function showPlayAlert(message, opts) { + opts = opts || {}; + const prev = document.getElementById('play-alert-modal'); + if (prev) prev.remove(); + const overlay = document.createElement('div'); + overlay.id = 'play-alert-modal'; + overlay.className = 'play-alert-modal' + (opts.type === 'error' ? ' play-alert-modal--error' : ''); + const card = document.createElement('div'); + card.className = 'play-alert-card'; + card.setAttribute('role', 'alertdialog'); + card.setAttribute('aria-modal', 'true'); + const backdrop = document.createElement('div'); + backdrop.className = 'play-alert-backdrop'; + const icon = document.createElement('div'); + icon.className = 'play-alert-icon'; + icon.textContent = opts.type === 'error' ? '!' : 'i'; + const msg = document.createElement('div'); + msg.className = 'play-alert-msg'; + msg.textContent = (message == null ? '' : String(message)); + const ok = document.createElement('button'); + ok.type = 'button'; + ok.className = 'play-alert-ok'; + ok.textContent = opts.okText || 'ตกลง'; + card.appendChild(icon); + card.appendChild(msg); + card.appendChild(ok); + overlay.appendChild(backdrop); + overlay.appendChild(card); + document.body.appendChild(overlay); + let closed = false; + const done = function () { + if (closed) return; + closed = true; + overlay.remove(); + document.removeEventListener('keydown', onKey, true); + if (typeof opts.onClose === 'function') { try { opts.onClose(); } catch (e) { /* ignore */ } } + }; + const onKey = function (e) { + if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); done(); } + }; + ok.addEventListener('click', done); + if (!opts.requireButton) backdrop.addEventListener('click', done); + document.addEventListener('keydown', onKey, true); + setTimeout(function () { try { ok.focus(); } catch (e) { /* ignore */ } }, 30); + return { close: done }; + } + window.__jdShowPlayAlert = showPlayAlert; + let mapData = null, tileSize = 32, myId = null; + let stackMini = null; + let stackFall = null; + let stackLastSwingEmit = 0; /* throttle broadcast เฟสแกว่ง MG3 (เจ้าของตา → คนอื่น mirror) */ + let stackLastStateEmit = 0; /* throttle broadcast ลำดับตา+index MG3 (host → non-host adopt) */ + let stackRemoteDropQueue = []; /* คิวดรอปจาก host/คนอื่น (MG3) — ทยอย apply ทีละอันกันทิ้ง */ + /** เศษบล็อกที่พลาด/หล่น → ตกแบบ physics หลุดขอบจอ (visual เท่านั้น ไม่กระทบคะแนน/sync) */ + let stackMissDebris = []; + let _stackDebrisLastT = 0; + /** Tower / Stack — เชือกเหวี่ยงจาก `img/TowerBlock/sling.png` */ + let stackTowerSlingImg = null; + /** Tower mnn93hpi — +คะแนน (`score+10.png` / `score+20.png`) + เอฟเฟกต์ ×2 (`score-light.png`, `scorex2.png`) */ + let stackTowerScorePlus10Img = null; + let stackTowerScorePlus20Img = null; + let stackTowerScoreLightImg = null; + let stackTowerScoreX2Img = null; + /** พลาดแล้วโชว์ heart-1.png กลางเหนือกอง (mock Tower) — heart เต็มใน HUD ใช้ไฟล์เดียวกัน */ + let stackTowerHeartMinusImg = null; + /** HUD มุมขวา: `life-bar.png` + `life-full.png` / `life-hit.png` (mock รูป2) */ + let stackTowerLifeBarImg = null; + let stackTowerLifeHitImg = null; + let stackTowerLifeFullImg = null; + /** Space Shooter (violent_crime): life HUD โหลดจาก /img/ViolentCrime/ แยกจาก TowerBlock ของ Stack */ + let violentCrimeLifeBarImg = null; + let violentCrimeLifeHitImg = null; + let violentCrimeLifeFullImg = null; + /** @type {{ until: number } | null} */ + let stackTowerHeartMinusFx = null; + const STACK_TOWER_HEART_MINUS_FX_MS = 1500; + const STACK_TOWER_PERFECT_GLOW_MS = 1500; + /** ป๊อปอัป +10 / +20 เหนือบล็อก */ + const STACK_TOWER_SCORE_POPUP_MS = 2000; + /** ระยะโชว์ score-light + scorex2 ข้างชั้นบน (คู่กับ +20 / perfect full) */ + const STACK_TOWER_PERFECT_X2_MS = 2200; + /** ทับบล็อกก่อนหน้า + ชั้นใหม่: support เต็มแทบไม่มีเพี้ยน */ + const STACK_TOWER_PERFECT_SUPPORT_MIN = 0.998; + let lastStackTickMs = performance.now(); + let lastStackTowerScrollBgTickMs = performance.now(); + let mapBackgroundImg = null; + /** space_shooter + แมป mnpz6rkp — พื้นหลังเลื่อนแนวตั้ง (ข้อมูลจาก editorBgScroll ในแมป) */ + const PLAY_SCROLL_BG_MAP_ID = 'mnpz6rkp'; + const PLAY_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnpz6rkp/intro.png'; + const PLAY_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnpz6rkp/loop.png'; + let playScrollBgIntroImg = null; + let playScrollBgLoopImg = null; + /** ขอบบนของ viewport ใน strip — เพิ่มค่า = เลื่อนลง strip (เข้า loop ใต้ intro) */ + let playScrollBgPx = 0; + let playScrollBgSpeedPxPerSec = 56; + let playScrollBgOff = false; + /** true = บน→ล่าง: พลิกมุมมองใน viewport (ยังเลื่อน S+ เข้า loop ใต้ intro) */ + let playScrollBgFlowDown = false; + + function playScrollBgMapEligible() { + return !!(mapData && mapData.gameType === 'space_shooter' && (currentPlayMapId() || '').trim() === PLAY_SCROLL_BG_MAP_ID); + } + + function playScrollBgDrawActive() { + if (!playScrollBgMapEligible() || playScrollBgOff) return false; + const a = playScrollBgIntroImg; + const b = playScrollBgLoopImg; + return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth); + } + + function reloadPlayScrollBgFromMap() { + playScrollBgIntroImg = null; + playScrollBgLoopImg = null; + playScrollBgPx = 0; + if (!playScrollBgMapEligible()) return; + const raw = mapData.editorBgScroll && typeof mapData.editorBgScroll === 'object' ? mapData.editorBgScroll : {}; + playScrollBgOff = raw.enabled === false; + playScrollBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 56)); + const dirRaw = String(raw.scrollDirection || raw.direction || 'up').toLowerCase(); + playScrollBgFlowDown = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom'); + if (playScrollBgOff) return; + const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : PLAY_SCROLL_BG_DEFAULT_INTRO; + const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : PLAY_SCROLL_BG_DEFAULT_LOOP; + let pending = 2; + const bump = () => { + pending--; + if (pending <= 0) { + playScrollBgSyncInitialScrollToBottom(); + try { draw(); } catch (e) { /* ignore */ } + } + }; + const im1 = new Image(); + im1.onload = () => { playScrollBgIntroImg = im1; bump(); }; + im1.onerror = () => { bump(); }; + im1.src = introSrc; + const im2 = new Image(); + im2.onload = () => { playScrollBgLoopImg = im2; bump(); }; + im2.onerror = () => { bump(); }; + im2.src = loopSrc; + } + + function playScrollBgSyncInitialScrollToBottom() { + const intro = playScrollBgIntroImg; + if (!canvas || !intro || !intro.complete || !intro.naturalWidth) return; + const cwR = Math.max(1, Math.round(canvas.width)); + const chR = Math.max(1, Math.round(canvas.height)); + const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth)); + /* ล่าง→บน: S = HI−ch ให้ขอบล่าง intro ชิดล่างจอ · บน→ล่าง (พลิก dest): S=0 ถึงได้ขอบล่าง intro ชิดล่างจอ */ + if (playScrollBgFlowDown) { + playScrollBgPx = 0; + } else { + playScrollBgPx = Math.max(0, drawHIntro - chR); + } + } + + function drawPlayScrollBgFullCanvas(cw, ch) { + const intro = playScrollBgIntroImg; + const loop = playScrollBgLoopImg; + if (!intro || !intro.complete || !loop || !loop.complete) return; + const cwR = Math.max(1, Math.round(cw)); + const chR = Math.max(1, Math.round(ch)); + const scaleI = cwR / intro.naturalWidth; + const drawHIntro = Math.round(intro.naturalHeight * scaleI); + const scaleL = cwR / loop.naturalWidth; + const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL)); + const S = playScrollBgPx; + const vp0 = S; + const vp1 = S + chR; + const yLimit = vp1 + drawHLoop * 2; + const flowDown = playScrollBgFlowDown; + + function stripTopToDestY(stripTop, drawH) { + if (!flowDown) return stripTop - S; + return S + chR - (stripTop + drawH); + } + + ctx.save(); + /* BG อวกาศเป็นภาพ illustration (loop 1024w สเกลขึ้น ~1.9x) — เปิด smoothing ไม่ให้แตกเป็นบล็อก (stack tower BG กว้าง ~1920 จึงคงปิดไว้) */ + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.beginPath(); + ctx.rect(0, 0, cwR, chR); + ctx.clip(); + + if (vp0 < 0) { + let k = 1; + if (vp0 < -drawHLoop * 4) { + k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2); + } + for (; k < 50000; k++) { + const y0 = -k * drawHLoop; + if (y0 >= vp1 + drawHLoop) break; + if (y0 + drawHLoop <= vp0) continue; + const dy = Math.round(stripTopToDestY(y0, drawHLoop)); + ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop); + } + } + + if (drawHIntro > vp0 && vp1 > 0) { + const dy = Math.round(stripTopToDestY(0, drawHIntro)); + ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro); + } + + let y = drawHIntro; + if (y < yLimit && vp1 > drawHIntro) { + if (vp0 > drawHIntro) { + const n = Math.floor((vp0 - drawHIntro) / drawHLoop); + y = drawHIntro + Math.max(0, n - 1) * drawHLoop; + } + while (y < yLimit) { + const dy = Math.round(stripTopToDestY(y, drawHLoop)); + ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop); + y += drawHLoop; + } + } + ctx.restore(); + } + + function tickPlayScrollBg(dtSec) { + if (!playScrollBgDrawActive()) return; + playScrollBgPx += (playScrollBgSpeedPxPerSec || 56) * dtSec; + } + + /** Last Light (mno9kb07) — พื้นหลังแนวนอน: start → loop 2→3→4 → finish ครั้งเดียว (ไม่วน) แล้วจบเกม + ซ่อนอุปสรรค (gauntletCrownRunwayBg ในแมป) */ + const GAUNTLET_CROWN_RUNWAY_BG_MAP_ID = 'mno9kb07'; + /** หลังหยุดเลื่อนที่จุด FINISH — รอก่อน latch + โฟลว์สรุป (มิลลิวิ) */ + const GAUNTLET_CROWN_RUNWAY_POST_STOP_MS = 2000; + /** ความเร็วเดินเข้าเส้นชัยตอนจบ (ช่อง/วินาที) — ค่อยๆ เดินแทนกระพริบ snap */ + const GAUNTLET_FINISH_WALK_TILES_PER_SEC = 7; + /** เพดานเวลาเดินเข้าเส้นชัย (กันค้างถ้าใครติด) — ต้อง < server grace (GAUNTLET_FINISH_WALK_MS) */ + const GAUNTLET_FINISH_WALK_MAX_MS = 2600; + 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; + /* G1: scroll ผูกเวลา server (ทุกเครื่องตรงกัน ไม่ขึ้น resolution/frame-rate/host) — scroll = base + speed × (serverNow − anchor) */ + let gauntletCrownRunwayLiveAnchorServerMs = 0; /* เวลาเริ่ม live (server epoch) */ + let gauntletCrownRunwayServerLiveStartMs = 0; /* liveStartMs จาก server (gauntlet-sync) — anchor เดียวกันทุกเครื่อง */ + let gauntletCrownRunwayBaseScrollPx = 0; /* ค่า scroll ตอน snap (resolution-independent เท่ากันทุกเครื่อง) */ + let gauntletCrownRunwayBgFinishLatched = false; + /** ส่งสัญญาณ "ถึงเส้นชัย" ให้ server แล้วหรือยัง (กันส่งซ้ำ) — server จะหยุด obstacle/collision */ + let gauntletFinishPhaseSignaled = false; + /** กำลังเดินค่อยๆ เข้าเส้นชัย (ก่อน freeze) — true ระหว่างทุกคนทยอยเดินมาที่เส้น */ + let gauntletCrownRunwayWalkActive = false; + /** เวลาเริ่มเดินเข้าเส้นชัย (ms) — ใช้เพดานเวลา GAUNTLET_FINISH_WALK_MAX_MS */ + let gauntletCrownRunwayWalkStartMs = 0; + /** >0 = หยุดเลื่อนแล้ว (timestamp ms) รอ POST_STOP ก่อน latch */ + let gauntletCrownRunwayBgStripFreezeSinceMs = 0; + /** คอลัมน์แผนที่ที่ล็อกตอน freeze — แนบตัวนำแทนขอบขวาสูงสุดของแมป */ + let gauntletCrownRunwayFinishAlignLatchedWorldX = null; + /** พรีวิว embed: แสดง GCM ครั้งเดียวเมื่อรันเวย์ครบแนว (ไม่รอเซิร์ฟเวอร์) */ + let gauntletCrownRunwayClientMissionShown = false; + /** พรมแดง mno9kb07 — จำกัดขอบขวาของตัว (ขอบขวา footprint ไม่เกินสัดส่วนนี้ของความกว้างแมปเป็นช่อง) */ + const GAUNTLET_CROWN_HEIST_MAX_X_WORLD_FRAC = 0.8; + const GAUNTLET_CROWN_SCORE_POP_URL = BASE + '/img/gauntlet-assets/score-.png'; + let gauntletCrownScorePenaltyImg = null; + function ensureGauntletCrownScorePenaltyImgPlay() { + if (gauntletCrownScorePenaltyImg) return gauntletCrownScorePenaltyImg; + const im = new Image(); + im.src = GAUNTLET_CROWN_SCORE_POP_URL; + gauntletCrownScorePenaltyImg = im; + return im; + } + let lastGauntletCrownRunwayBgTickMs = 0; + /** จัด offset เลื่อนรันเวย์ให้จุดเกิดตรงกับงานศิลป์ START (รูป 1) — รีเซ็ตตอน howto / โหลดแมปใหม่ */ + let gauntletCrownRunwaySpawnScrollSnapped = false; + + function gauntletCrownRunwayBgMapEligiblePlay() { + return !!(mapData && mapData.gameType === 'gauntlet' && (currentPlayMapId() || '').trim() === GAUNTLET_CROWN_RUNWAY_BG_MAP_ID); + } + + function gauntletCrownRunwayBgNatDims(im) { + if (!im) return null; + if (im.tagName === 'CANVAS' && im.width > 0 && im.height > 0) return { nw: im.width, nh: im.height }; + if (im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) return { nw: im.naturalWidth, nh: im.naturalHeight }; + return null; + } + + function gauntletCrownRunwayBgImageSlotReadyPlay(im) { + return !!gauntletCrownRunwayBgNatDims(im); + } + + function gauntletCrownRunwayBgStripWidthsAtCh(chR) { + const ch0 = Math.max(1, Math.round(chR)); + const wOf = (im) => { + const d = gauntletCrownRunwayBgNatDims(im); + if (!d) return 0; + return Math.max(1, Math.round(d.nw * (ch0 / d.nh))); + }; + return { + w0: wOf(gauntletCrownRunwayBgImgs[0]), + w1: wOf(gauntletCrownRunwayBgImgs[1]), + w2: wOf(gauntletCrownRunwayBgImgs[2]), + w3: wOf(gauntletCrownRunwayBgImgs[3]), + w4: wOf(gauntletCrownRunwayBgImgs[4]), + }; + } + + function gauntletCrownRunwayBgImagesReadyPlay() { + for (let i = 0; i < 5; i++) { + if (!gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) return false; + } + return true; + } + + /** ถ้า URL บนเซิร์ฟยังไม่มีไฟล์ (404) ให้แผ่นเลื่อนได้ — พื้นผิวนุ่ม ไม่ใส่ข้อความใหญ่ (กันเหมือน debug) */ + function ensureGauntletCrownRunwayBgPlaceholdersPlay() { + if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return; + const W = 960; + const H = 540; + const hues = [352, 210, 165, 225, 38]; + for (let i = 0; i < 5; i++) { + if (gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) continue; + const c = document.createElement('canvas'); + c.width = W; + c.height = H; + const g = c.getContext('2d'); + const h0 = hues[i]; + const grd = g.createLinearGradient(0, 0, W, H * 0.9); + grd.addColorStop(0, `hsl(${h0},26%,20%)`); + grd.addColorStop(0.45, `hsl(${h0},18%,14%)`); + grd.addColorStop(1, `hsl(${h0},12%,10%)`); + g.fillStyle = grd; + g.fillRect(0, 0, W, H); + g.strokeStyle = 'rgba(255,255,255,0.06)'; + g.lineWidth = 1; + for (let x = 0; x < W; x += 48) { + g.beginPath(); + g.moveTo(x, 0); + g.lineTo(x + H * 0.04, H); + g.stroke(); + } + const vg = g.createRadialGradient(W * 0.45, H * 0.35, 20, W * 0.5, H * 0.55, W * 0.85); + vg.addColorStop(0, 'rgba(0,0,0,0)'); + vg.addColorStop(1, 'rgba(0,0,0,0.38)'); + g.fillStyle = vg; + g.fillRect(0, 0, W, H); + g.fillStyle = 'rgba(255,255,255,0.1)'; + g.font = '10px system-ui,sans-serif'; + g.textAlign = 'right'; + g.fillText(String(i + 1), W - 10, H - 8); + gauntletCrownRunwayBgImgs[i] = c; + } + } + + function gauntletCrownRunwayBgDrawActivePlay() { + if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return false; + return gauntletCrownRunwayBgImagesReadyPlay(); + } + + function gauntletCrownRunwayFinishLineInArtFracPlay() { + const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + let lineInArt = Number(raw.finishLineInFinishArtFrac); + if (!Number.isFinite(lineInArt)) { + lineInArt = gauntletCrownRunwayBgMapEligiblePlay() ? 0.34 : 0.12; + } + return Math.max(0, Math.min(0.92, lineInArt)); + } + + function gauntletCrownRunwayFinishLineStripXPlay(g) { + if (!g) return null; + return g.finishStart + g.w4Safe * gauntletCrownRunwayFinishLineInArtFracPlay(); + } + + /** โลกแมป X ของจุดอ้างอิงในงานศิลป์ FINISH = stripX − scrollPx (ไม่ขึ้นกับกล้อง) */ + function gauntletCrownRunwayFinishLineMapWorldXPlay() { + const g = gauntletCrownRunwayScrollGeometryPlay(); + if (!g) return null; + const stripX = gauntletCrownRunwayFinishLineStripXPlay(g); + if (stripX == null) return null; + return stripX - gauntletCrownRunwayBgScrollPx; + } + + function gauntletCrownRunwayStripFrozenPlay() { + return gauntletCrownRunwayWalkActive || gauntletCrownRunwayBgStripFreezeSinceMs > 0 || gauntletCrownRunwayBgFinishLatched; + } + + /** จุดอ้างอิงกลางตัวนำ (map world px) — ใช้หยุด FINISH ตรงตัวละคร ไม่ใช่ขอบขวาแมป */ + function gauntletCrownRunwayLeadingEntityAlignWorldXPlay() { + if (!mapData || !isGauntletCrownHeistMapPlay()) return null; + const ts = mapData.tileSize || 32; + const { cw } = getCharacterFootprintWH(mapData); + const mx = getGauntletCrownHeistMaxEntityXPlay(mapData); + /* แนบเส้นชัยกับ "ตัวนำหน้าสุดที่ยังไม่ตาย" (tx มากสุด) → ตัวนำหยุดที่เส้นพอดี ไม่เลยเส้น + (ตอนหยุด เราจะ snap ตัวที่เหลือมายืนที่เส้นเดียวกันด้วย ดู gauntletSnapLivingToFinishPlay) */ + let leadTx = -Infinity; + const consider = (ent) => { + if (!ent || ent.gauntletEliminated) return; + let tx = Number.isFinite(ent.tx) ? ent.tx : Number(ent.x); + if (!Number.isFinite(tx)) return; + leadTx = Math.max(leadTx, tx); + }; + consider(me); + others.forEach((o, id) => { + if (!playBotsEnabled() && isPreviewBotId(id)) return; + consider(o); + }); + if (!Number.isFinite(leadTx) || leadTx === -Infinity) return null; + let alignWorld = (leadTx + cw * 0.5) * ts; + if (mx != null && Number.isFinite(mx)) { + alignWorld = Math.min(alignWorld, (mx + cw * 0.5) * ts); + } + return alignWorld; + } + + /** ตอนถึงเส้นชัย: ดึงตัวที่ยังไม่ตายทุกตัวมายืนที่ "เส้นชัย" — ไม่มีใครเลยเส้น, คนตายไม่ย้าย + เป้าหมาย = ตำแหน่งเส้นชัยที่ latch ไว้ (คงที่) ถ้ายังไม่ latch ใช้ tx ตัวนำสุด + เรียกทุกเฟรมระหว่าง freeze/latched เพื่อกันบอทที่ sim ยังขยับ "ไหล" หลุดจากเส้น */ + function gauntletSnapLivingToFinishPlay() { + if (!mapData || !isGauntletCrownHeistMapPlay()) return; + const ts = mapData.tileSize || 32; + const { cw } = getCharacterFootprintWH(mapData); + let targetTx = null; + if (gauntletCrownRunwayFinishAlignLatchedWorldX != null) { + targetTx = gauntletCrownRunwayFinishAlignLatchedWorldX / ts - cw * 0.5; + } else { + let leadTx = -Infinity; + const consider = (ent) => { + if (!ent || ent.gauntletEliminated) return; + const tx = Number.isFinite(ent.tx) ? ent.tx : Number(ent.x); + if (Number.isFinite(tx)) leadTx = Math.max(leadTx, tx); + }; + consider(me); + others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; consider(o); }); + if (Number.isFinite(leadTx) && leadTx !== -Infinity) targetTx = leadTx; + } + if (targetTx == null || !Number.isFinite(targetTx)) return; + const snap = (ent) => { + if (!ent || ent.gauntletEliminated) return; + if (Number.isFinite(ent.x)) ent.x = targetTx; + if (ent.tx != null) ent.tx = targetTx; + }; + snap(me); + others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; snap(o); }); + } + + /** tx เป้าหมายที่เส้นชัย (คอลัมน์ที่ทุกคนต้องมายืน) — null ถ้าคำนวณไม่ได้ */ + function gauntletFinishTargetTxPlay() { + if (!mapData || !isGauntletCrownHeistMapPlay()) return null; + const ts = mapData.tileSize || 32; + const { cw } = getCharacterFootprintWH(mapData); + if (gauntletCrownRunwayFinishAlignLatchedWorldX != null) { + return gauntletCrownRunwayFinishAlignLatchedWorldX / ts - cw * 0.5; + } + let leadTx = -Infinity; + const consider = (ent) => { + if (!ent || ent.gauntletEliminated) return; + const tx = Number.isFinite(ent.tx) ? ent.tx : Number(ent.x); + if (Number.isFinite(tx)) leadTx = Math.max(leadTx, tx); + }; + consider(me); + others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; consider(o); }); + return (Number.isFinite(leadTx) && leadTx !== -Infinity) ? leadTx : null; + } + + /** เดินทุกคนที่ยังไม่ตาย "ค่อยๆ" เข้าหาเส้นชัยทีละเฟรม (แทน snap กระพริบ) + คืน true เมื่อทุกคนถึงเส้นแล้ว (เริ่ม freeze→จบเกมต่อ) */ + function gauntletWalkLivingToFinishStepPlay(dtSec) { + const targetTx = gauntletFinishTargetTxPlay(); + if (targetTx == null || !Number.isFinite(targetTx)) return true; + const step = Math.max(0.04, GAUNTLET_FINISH_WALK_TILES_PER_SEC * Math.max(0, Number(dtSec) || 0)); + const eps = 0.06; + let allArrived = true; + const walk = (ent) => { + if (!ent || ent.gauntletEliminated) return; + let x = Number.isFinite(ent.x) ? ent.x : (Number.isFinite(ent.tx) ? ent.tx : targetTx); + if (x < targetTx - eps) { + x = Math.min(targetTx, x + step); + ent.direction = 'right'; + ent.isWalking = true; + allArrived = false; + } else { + x = targetTx; /* ไม่ให้ใครเลยเส้นชัย (เลยมา → ดึงกลับมาที่เส้น) */ + ent.isWalking = false; + } + ent.x = x; + ent.tx = x; + }; + walk(me); + others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; walk(o); }); + return allArrived; + } + + /** เริ่มเฟส "เดินเข้าเส้นชัย": ล็อก align → snap scroll → แจ้ง server หยุด obstacle ทันที (กันตายระหว่างเดิน) + จากนั้น tick จะค่อยๆ เดินทุกคนมาที่เส้น แล้วค่อยเข้า freeze→จบเกม */ + function gauntletCrownRunwayBeginWalkToFinishPlay() { + if (gauntletCrownRunwayWalkActive || gauntletCrownRunwayBgStripFreezeSinceMs > 0 || gauntletCrownRunwayBgFinishLatched) return; + if (gauntletCrownRunwayFinishAlignLatchedWorldX == null) { + const leadAlign = gauntletCrownRunwayLeadingEntityAlignWorldXPlay(); + if (leadAlign != null) gauntletCrownRunwayFinishAlignLatchedWorldX = leadAlign; + } + gauntletCrownRunwaySnapScrollToFinishStopPlay(); + gauntletCrownRunwayWalkActive = true; + gauntletCrownRunwayWalkStartMs = Date.now(); + /* แจ้ง server ครั้งเดียว: เข้าโซนเส้นชัย → หยุด obstacle + collision (กันโดนชน/-10 ตอนกำลังเดินเข้าเส้น) + server จะรอ grace (GAUNTLET_FINISH_WALK_MS) ให้เดินครบก่อนค่อยจบ */ + if (!gauntletFinishPhaseSignaled) { + gauntletFinishPhaseSignaled = true; + try { socket.emit('gauntlet-finish-phase'); } catch (_e) { /* ignore */ } + } + try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ } + } + + + /** + * คอลัมน์บนกริดที่เส้น FINISH ต้องแนบ (map world px) + * gauntletCrownRunwayBg.finishAlignMapTileX | finishAlignMapTileFrac — ถ้าไม่ตั้ง ใช้ตำแหน่งตัวนำ + */ + function gauntletCrownRunwayFinishAlignMapWorldXPlay() { + if (!mapData) return null; + if (gauntletCrownRunwayFinishAlignLatchedWorldX != null) { + return gauntletCrownRunwayFinishAlignLatchedWorldX; + } + const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + const ts = mapData.tileSize || 32; + const tileX = Number(raw.finishAlignMapTileX); + if (Number.isFinite(tileX)) return (tileX + 0.5) * ts; + const tileFrac = Number(raw.finishAlignMapTileFrac); + if (Number.isFinite(tileFrac)) { + const w = mapData.width || 20; + return (Math.max(0, Math.min(1, tileFrac)) * w + 0.5) * ts; + } + if (gauntletCrownRunwayBgMapEligiblePlay()) { + return gauntletCrownRunwayLeadingEntityAlignWorldXPlay(); + } + return null; + } + + /** ตำแหน่งเส้น FINISH บนจอ (0=ซ้าย 1=ขวา) จากขอบซ้าย viewport — ใช้หยุดเลื่อน + fade สิ่งกีดขวาง */ + function gauntletCrownRunwayFinishLineScreenFracPlay(g) { + if (!g) return null; + const finishLineWorldX = gauntletCrownRunwayFinishLineStripXPlay(g); + if (finishLineWorldX == null) return null; + return (finishLineWorldX - g.scrollView) / Math.max(1e-6, g.cwWorld); + } + + function gauntletCrownRunwayFinishStopViewportFracPlay() { + const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + const stopHi = gauntletCrownRunwayBgMapEligiblePlay() ? 0.85 : 0.75; + let vp = Number(raw.finishStopViewportFrac); + if (Number.isFinite(vp)) return Math.max(0.08, Math.min(stopHi, vp)); + let legacy = Number(raw.finishStopScreenFrac); + if (Number.isFinite(legacy)) { + legacy = Math.max(0.08, Math.min(0.96, legacy)); + return Math.max(0.12, Math.min(0.62, 0.14 + legacy * 0.42)); + } + /* mno9kb07: หยุดเลื่อนเร็วขึ้น (เส้น FINISH หยุดที่ ~0.55 จากซ้าย — ขอบขวาของแถบ FINISH ใกล้กลางจอ) */ + return gauntletCrownRunwayBgMapEligiblePlay() ? 0.55 : 0.34; + } + + function gauntletCrownRunwayObstacleFadeStartViewportFracPlay() { + const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + let v = Number(raw.obstacleFadeStartViewportFrac); + /* mno9kb07: เริ่มจางอุปสรรคให้เร็วขึ้น (1.0 = ตอนเส้น FINISH ยังอยู่ขอบขวา) เพื่อให้อุปสรรคหายก่อนถึงเส้นชัย */ + if (!Number.isFinite(v)) v = gauntletCrownRunwayBgMapEligiblePlay() ? 1.05 : 0.9; + return Math.max(0.45, Math.min(1.15, v)); + } + + /** 1 = สิ่งกีดขวางเต็ม · 0 = หาย — ค่อยๆ จางเมื่อเส้น FINISH เข้าใกล้จุดหยุดบนจอ */ + function gauntletCrownRunwayObstacleDrawAlphaPlay() { + if (!gauntletCrownRunwayBgDrawActivePlay()) return 1; + if (gauntletCrownRunwayBgFinishLatched) return 0; + const g = gauntletCrownRunwayScrollGeometryPlay(); + if (!g) return 1; + const screenFrac = gauntletCrownRunwayFinishLineScreenFracPlay(g); + if (screenFrac == null) return 1; + const stopAt = gauntletCrownRunwayFinishStopViewportFracPlay(); + const fadeStart = gauntletCrownRunwayObstacleFadeStartViewportFracPlay(); + if (screenFrac >= fadeStart) return 1; + if (screenFrac <= stopAt) return 0; + return (screenFrac - stopAt) / Math.max(1e-6, fadeStart - stopAt); + } + + function gauntletCrownRunwayBgHideObstaclesPlay() { + return gauntletCrownRunwayObstacleDrawAlphaPlay() <= 0.001; + } + + /** Last Light: หยุดแอนิเมชันวิ่งเมื่อหยุดที่ FINISH หรือ latch แล้ว */ + function gauntletCrownRunwayAvatarRunAllowedPlay() { + if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayBgDrawActivePlay()) { + if (gauntletCrownRunwayBgFinishLatched) return false; + if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) return false; + return true; + } + return true; + } + + function drawImageCoverCanvasPlay(ctx, img, cwR, chR) { + const d = gauntletCrownRunwayBgNatDims(img); + if (!d) return; + const iw = d.nw; + const ih = d.nh; + if (!iw || !ih) return; + const scale = Math.max(cwR / iw, chR / ih); + const dw = iw * scale; + const dh = ih * scale; + const dx = (cwR - dw) / 2; + const dy = (chR - dh) / 2; + ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh); + } + + function drawRunwayHSlicePlay(ctx, img, worldX0, segW, scrollLeft, cwR, chR) { + const d0 = gauntletCrownRunwayBgNatDims(img); + if (!d0 || segW <= 0) return; + const natW = d0.nw; + const natH = d0.nh; + const v0 = scrollLeft; + const v1 = scrollLeft + cwR; + const o0 = Math.max(worldX0, v0); + const o1 = Math.min(worldX0 + segW, v1); + if (o1 <= o0) return; + const u0 = ((o0 - worldX0) / segW) * natW; + const u1 = ((o1 - worldX0) / segW) * natW; + const dx = o0 - scrollLeft; + const dw = o1 - o0; + /** ทาบแนวตั้งเล็กน้อยกันช่องว่างย่อย 1px (seam) ระหว่างแถบลูป */ + ctx.drawImage(img, u0, 0, u1 - u0, natH, dx - 0.25, 0, dw + 0.55, chR); + } + + /** วาดแถบรันเวย์ในระบบพิกัด X = ความกว้างมองเห็น (cwR) × สูงแถบ chR · scrollView = จุดเริ่ม viewport ในโลกรันเวย์ */ + function drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView) { + const start = gauntletCrownRunwayBgImgs[0]; + const L2 = gauntletCrownRunwayBgImgs[1]; + const L3 = gauntletCrownRunwayBgImgs[2]; + const L4 = gauntletCrownRunwayBgImgs[3]; + const { w0, w1, w2, w3 } = gauntletCrownRunwayBgStripWidthsAtCh(chR); + const cycle = w1 + w2 + w3; + const S = scrollView; + ctx.imageSmoothingEnabled = true; + ctx.fillStyle = '#1a1b26'; + ctx.fillRect(0, 0, cwR, chR); + if (w0 > 0) drawRunwayHSlicePlay(ctx, start, 0, w0, S, cwR, chR); + if (cycle <= 0) return; + const ws = [w1, w2, w3]; + const ims = [L2, L3, L4]; + const uTotal = Math.max(0, S - w0); + let cycN = Math.floor(uTotal / cycle); + let uRem = uTotal - cycN * cycle; + let si = 0; + while (si < 3 && uRem >= ws[si]) { + uRem -= ws[si]; + si++; + } + if (si >= 3) { + cycN++; + si = 0; + uRem = 0; + } + let acc = 0; + for (let k = 0; k < si; k++) acc += ws[k]; + let curLeft = w0 + cycN * cycle + acc; + let guard = 0; + let idx = si; + while (curLeft < S + cwR && guard++ < 500) { + drawRunwayHSlicePlay(ctx, ims[idx], curLeft, ws[idx], S, cwR, chR); + curLeft += ws[idx]; + idx = (idx + 1) % 3; + } + } + + /** Last Light mno9kb07: แถบเดียว START → 2 → 3 → 4 → FINISH (ไม่วนลูป) */ + function drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView) { + const S = scrollView; + const { w0, w1, w2, w3, w4 } = gauntletCrownRunwayBgStripWidthsAtCh(chR); + ctx.imageSmoothingEnabled = true; + ctx.fillStyle = '#1a1b26'; + ctx.fillRect(0, 0, cwR, chR); + const segs = [ + { im: gauntletCrownRunwayBgImgs[0], w: w0, x0: 0 }, + { im: gauntletCrownRunwayBgImgs[1], w: w1, x0: w0 }, + { im: gauntletCrownRunwayBgImgs[2], w: w2, x0: w0 + w1 }, + { im: gauntletCrownRunwayBgImgs[3], w: w3, x0: w0 + w1 + w2 }, + { im: gauntletCrownRunwayBgImgs[4], w: w4, x0: w0 + w1 + w2 + w3 }, + ]; + for (let i = 0; i < segs.length; i++) { + const g = segs[i]; + if (g.w > 0 && g.im) drawRunwayHSlicePlay(ctx, g.im, g.x0, g.w, S, cwR, chR); + } + } + + /** + * @param {number} canvasW + * @param {number} canvasH + * @param {null|{ worldMinX:number, worldMaxX:number, camX:number, camY:number, zDraw:number, mapWpx:number, mapHpx:number }} worldAlign — ถ้ามี จะคลิป+สเกลให้ตรงกริดแมป (ตัว/สิ่งกีดขวางตรงพื้นที่ตั้ง) + */ + function drawGauntletCrownRunwayBgFullCanvasPlay(canvasW, canvasH, worldAlign) { + gauntletCrownRunwayMaintainFinishScrollPlay(); + const finish = gauntletCrownRunwayBgImgs[4]; + const align = worldAlign && typeof worldAlign === 'object' + && Number.isFinite(worldAlign.worldMinX) + && Number.isFinite(worldAlign.worldMaxX) + && Number.isFinite(worldAlign.camX) + && Number.isFinite(worldAlign.camY) + && Number.isFinite(worldAlign.zDraw) + && Number.isFinite(worldAlign.mapWpx) + && Number.isFinite(worldAlign.mapHpx); + /* หลัง latch ยังวาดแถบรันเวย์ที่ scroll ค้าง — อย่า switch เป็น cover เต็มแผนที่ (ทำให้ FINISH กระโดดซ้าย) */ + /* ใช้ความกว้างมองเห็นแบบ float ให้เท่ากับ canvas.width/zDraw — ห้าม Math.round ไม่งั้นซูมแล้วแถบ runway กว้างไม่ตรง worldToScreen (แผนหลุดจากตัว) */ + const cwR = align + ? Math.max(1e-6, worldAlign.worldMaxX - worldAlign.worldMinX) + : Math.max(1, Math.round(canvasW)); + const chR = align + ? Math.max(1, Math.round(worldAlign.mapHpx)) + : Math.max(1, Math.round(canvasH)); + const scrollView = align + ? (gauntletCrownRunwayBgScrollPx + worldAlign.worldMinX) + : gauntletCrownRunwayBgScrollPx; + if (align) { + const { camX, camY, zDraw, mapWpx, mapHpx, worldMinX } = worldAlign; + ctx.fillStyle = '#1a1b26'; + ctx.fillRect(0, 0, canvasW, canvasH); + ctx.save(); + ctx.setTransform(zDraw, 0, 0, zDraw, canvasW / 2 - camX * zDraw, canvasH / 2 - camY * zDraw); + ctx.beginPath(); + ctx.rect(0, 0, mapWpx, mapHpx); + ctx.clip(); + ctx.translate(worldMinX, 0); + if (gauntletCrownRunwayBgMapEligiblePlay()) { + drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView); + } else { + drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView); + } + ctx.restore(); + return; + } + ctx.save(); + if (gauntletCrownRunwayBgMapEligiblePlay()) { + drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView); + } else { + drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView); + } + ctx.restore(); + } + + /** + * Shared runway strip math (viewport in strip space). null if not drawable. + * Map tuning (mno9kb07): finishStopViewportFrac 0.08–0.75 = เส้น FINISH อยู่ที่สัดส่วนนี้จากซ้ายจอก่อนหยุด (ค่าเริ่ม 0.34) + * finishLineInFinishArtFrac = ตำแหน่งเส้นใน finish.png (ค่าเริ่ม 0.12) + * obstacleFadeStartViewportFrac = เริ่มจางสิ่งกีดขวางเมื่อเส้น FINISH อยู่ที่สัดส่วนนี้บนจอ (ค่าเริ่ม 0.9) + */ + function gauntletCrownRunwayScrollGeometryPlay() { + if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null; + const zGaRaw = computePlayCameraZDrawPlay(); + const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGaRaw); + if (!gCam) return null; + const halfW = canvas.width / (2 * zGaRaw); + const worldMinX = gCam.px - halfW; + const cwWorld = canvas.width / zGaRaw; + const h = mapData.height || 15; + const ts = mapData.tileSize || 32; + const mapHpx = Math.max(1, Math.round(h * ts)); + const { w0, w1, w2, w3, w4 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx); + const w4Safe = Math.max(w4, 1); + const totalStrip = w0 + w1 + w2 + w3 + w4Safe; + if (!(totalStrip > 8)) return null; + const scrollView = gauntletCrownRunwayBgScrollPx + worldMinX; + const right = scrollView + cwWorld; + const finishStart = w0 + w1 + w2 + w3; + const geoMinScroll = finishStart + Math.max(0, cwWorld - w4Safe); + const wideVpPad = cwWorld > w4Safe * 1.02 ? w4Safe * 0.38 : 0; + const minScrollForFinishPass = geoMinScroll + wideVpPad; + return { + scrollView, right, cwWorld, w4Safe, totalStrip, finishStart, minScrollForFinishPass, + }; + } + + /** True when เส้น FINISH ถึงตำแหน่งหยุด (แนบคอลัมน์แผนที่ หรือสัดส่วนจอแบบเดิม) */ + function gauntletCrownRunwayScrollStripStopZoneReachedPlay() { + const g = gauntletCrownRunwayScrollGeometryPlay(); + if (!g) return false; + const alignWorldX = gauntletCrownRunwayFinishAlignMapWorldXPlay(); + if (alignWorldX != null) { + /* world-based: หยุดเมื่อ "เส้นชัย (world X)" ถึงตัวนำ — ไม่ขึ้นกับความละเอียดจอ + (เดิมมี gate g.scrollView >= minScrollForFinishPass ที่ผูก cwWorld = canvas.width/zoom + → จอกว้างต้อง scroll มากกว่าก่อนหยุด = ตัวละครวิ่งเลยเส้นชัยบนจอที่ resolution ต่างกัน) */ + const finishMapX = gauntletCrownRunwayFinishLineMapWorldXPlay(); + if (finishMapX == null) return false; + return finishMapX <= alignWorldX + 1.5; + } + /* fallback (ไม่มีตัวนำให้ align) — ใช้ gate scroll + สัดส่วนจอแบบเดิม */ + if (g.scrollView < g.minScrollForFinishPass - 0.01) return false; + const screenFrac = gauntletCrownRunwayFinishLineScreenFracPlay(g); + if (screenFrac == null) return false; + return screenFrac <= gauntletCrownRunwayFinishStopViewportFracPlay() + 0.008; + } + + /** ค่าตำแหน่ง scroll ที่ทำให้เส้น FINISH หยุดตรงจุด (คืน null ถ้าคำนวณไม่ได้) — ใช้ทั้ง snap และ clamp กันวิ่งเลยเส้นชัย */ + function gauntletCrownRunwayFinishStopScrollPxPlay() { + if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null; + const g = gauntletCrownRunwayScrollGeometryPlay(); + if (!g) return null; + const finishLineStripX = gauntletCrownRunwayFinishLineStripXPlay(g); + if (finishLineStripX == null) return null; + const alignWorldX = gauntletCrownRunwayFinishAlignMapWorldXPlay(); + if (alignWorldX != null) { + return finishLineStripX - alignWorldX; + } + const stopVp = gauntletCrownRunwayFinishStopViewportFracPlay(); + const targetScrollView = finishLineStripX - stopVp * g.cwWorld; + const zGaRaw = computePlayCameraZDrawPlay(); + const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGaRaw); + if (!gCam) return null; + const worldMinX = gCam.px - canvas.width / (2 * zGaRaw); + return targetScrollView - worldMinX; + } + + /** จัด scroll ให้จุดอ้างอิงใน FINISH ตรงคอลัมน์แผนที่ (หรือ finishStopViewportFrac บนจอ) */ + function gauntletCrownRunwaySnapScrollToFinishStopPlay() { + const t = gauntletCrownRunwayFinishStopScrollPxPlay(); + if (t == null) return; + gauntletCrownRunwayBgScrollPx = t; + } + + /** ลำดับการหยุดที่เส้นชัย: ล็อก align (ครั้งเดียว) → เริ่มเฟสเดินค่อยๆ เข้าเส้นชัย (ไม่ snap กระพริบ) + tick จะเดินทุกคนมาที่เส้นจนครบแล้วค่อยเข้า freeze→จบเกม */ + function gauntletCrownRunwayBeginFinishStopPlay() { + gauntletCrownRunwayBeginWalkToFinishPlay(); + try { draw(); } catch (_d) { /* ignore */ } + } + + /** โหมดจอเท่านั้น: กล้องเลื่อนหลัง freeze ทำให้ FINISH หลุด — รีสแนปทุกเฟรม */ + function gauntletCrownRunwayMaintainFinishScrollPlay() { + if (!gauntletCrownRunwayBgStripFreezeSinceMs && !gauntletCrownRunwayBgFinishLatched) return; + if (!gauntletCrownRunwayBgDrawActivePlay()) return; + if (gauntletCrownRunwayFinishAlignMapWorldXPlay() != null) return; + gauntletCrownRunwaySnapScrollToFinishStopPlay(); + } + + function resetGauntletCrownRunwaySpawnScrollSnap() { + gauntletCrownRunwaySpawnScrollSnapped = false; + } + + /** + * จัด gauntletCrownRunwayBgScrollPx ครั้งแรกให้คอลัมน์เกิด (ซ้ายสุด) ตรง ~runwaySpawnAlignFrac ของความกว้างแถบ START (w0) + * ในแมป: gauntletCrownRunwayBg.runwaySpawnAlignFrac 0.02–0.95 (ค่าเริ่ม 0.32) + * Anchor: คอลัมน์ซ้ายสุดระหว่างช่องเกิดในแมปกับตำแหน่งจริงของทุกคนในรอบ (รวม me + บอทพรีวิว) — ถ้า me ถูก resolve ไปคอลัมน์ซ้ายกว่า min ของช่องวาด แต่ runway ยึดแค่ช่องวาด จะเห็นตัวลอยซ้ายของพรมแดง + * Use min(editor spawn column, floor of every alive entity x) so runway START art stays tied to where characters actually stand. + */ + function trySnapGauntletCrownRunwayScrollToSpawnsPlay() { + if (gauntletCrownRunwaySpawnScrollSnapped) return; + if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff || !mapData) return; + if (!gauntletCrownRunwayBgImagesReadyPlay()) return; + const h = mapData.height || 15; + const ts = mapData.tileSize || 32; + const mapHpx = Math.max(1, Math.round(h * ts)); + const { w0 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx); + if (!(w0 > 0)) return; + const slots = collectGauntletSpawnSlotsPlay(mapData); + let minTX = Infinity; + if (slots.length) minTX = Math.min(...slots.map((s) => s.x)); + if (Number.isFinite(me.x)) minTX = Math.min(minTX, Math.floor(me.x)); + others.forEach((o, id) => { + if (!o || o.gauntletEliminated) return; + if (Number.isFinite(o.x)) minTX = Math.min(minTX, Math.floor(o.x)); + }); + if (!Number.isFinite(minTX) || minTX === Infinity) minTX = 1; + const anchorWorldX = (Math.max(0, minTX) + 0.5) * ts; + const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + const frac = Math.max(0.02, Math.min(0.95, Number(raw.runwaySpawnAlignFrac) || 0.32)); + gauntletCrownRunwayBgScrollPx = frac * w0 - anchorWorldX; + /* G1: เก็บ base (ค่า snap นี้ — เท่ากันทุกเครื่อง) + anchor เวลาเริ่ม (server epoch) → scroll คำนวณจากเวลา server */ + gauntletCrownRunwayBaseScrollPx = gauntletCrownRunwayBgScrollPx; + /* anchor = liveStartMs จาก server (gauntlet-sync) — authoritative เดียวกันทุกเครื่อง + สดเสมอ + (เดิมใช้ playMissionLiveBeginsAt ที่ค้างจากเกมก่อน → scroll กระโดด/host-client ไม่ตรง) + fallback: liveBeginsAt ถ้าสด ไม่งั้น serverNow() */ + if (gauntletCrownRunwayServerLiveStartMs > 0) { + gauntletCrownRunwayLiveAnchorServerMs = gauntletCrownRunwayServerLiveStartMs; + } else { + const _lba = (typeof playMissionLiveBeginsAt !== 'undefined') ? Number(playMissionLiveBeginsAt) : 0; + const _nowS = serverNow(); + const _el = _nowS - _lba; + gauntletCrownRunwayLiveAnchorServerMs = (_lba > 0 && _el >= -2000 && _el < 10000) ? _lba : _nowS; + } + gauntletCrownRunwaySpawnScrollSnapped = true; + } + + function reloadGauntletCrownRunwayBgFromMap() { + gauntletCrownRunwayBgImgs = [null, null, null, null, null]; + gauntletCrownRunwayBgScrollPx = 0; + gauntletCrownRunwayLiveAnchorServerMs = 0; /* G1: รีเซ็ต anchor รอบใหม่ */ + gauntletCrownRunwayServerLiveStartMs = 0; + gauntletCrownRunwayBaseScrollPx = 0; + gauntletCrownRunwayBgFinishLatched = false; + gauntletFinishPhaseSignaled = false; + gauntletCrownRunwayWalkActive = false; + gauntletCrownRunwayWalkStartMs = 0; + gauntletCrownRunwayBgStripFreezeSinceMs = 0; + gauntletCrownRunwayFinishAlignLatchedWorldX = null; + gauntletCrownRunwayClientMissionShown = false; + lastGauntletCrownRunwayBgTickMs = 0; + resetGauntletCrownRunwaySpawnScrollSnap(); + if (!gauntletCrownRunwayBgMapEligiblePlay()) return; + const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + gauntletCrownRunwayBgOff = raw.enabled === false; + gauntletCrownRunwayBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 48)); + if (gauntletCrownRunwayBgOff) return; + const srcs = [ + (typeof raw.startImage === 'string' && raw.startImage.length) ? raw.startImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START, + (typeof raw.loopImage2 === 'string' && raw.loopImage2.length) ? raw.loopImage2 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2, + (typeof raw.loopImage3 === 'string' && raw.loopImage3.length) ? raw.loopImage3 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3, + (typeof raw.loopImage4 === 'string' && raw.loopImage4.length) ? raw.loopImage4 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4, + (typeof raw.finishImage === 'string' && raw.finishImage.length) ? raw.finishImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH, + ]; + let pending = 5; + const bump = () => { + pending--; + if (pending <= 0) { + ensureGauntletCrownRunwayBgPlaceholdersPlay(); + try { draw(); } catch (e) { /* ignore */ } + } + }; + for (let i = 0; i < 5; i++) { + const im = new Image(); + const idx = i; + im.onload = () => { + gauntletCrownRunwayBgImgs[idx] = im; + bump(); + }; + im.onerror = () => { bump(); }; + im.src = srcs[i]; + } + } + + function tickGauntletCrownRunwayBgPlay(dtSec) { + if (!gauntletCrownRunwayBgDrawActivePlay()) return; + /* หยุดเลื่อน BG ระหว่างคำถามพิเศษ (special-quiz freeze) — กัน BG/เส้นชัยเดินตอนค้างตอบ */ + if (specialQuizFreeze) return; + /** พรีรัน (howto / countdown) — ห้ามเลื่อนก่อน GO · Mega Virus ใช้ shell เดียวกัน */ + if (usesCrownLobbyShellPlay() && gauntletCrownPregamePhase !== 'live') return; + if (gauntletCrownRunwayBgFinishLatched) { gauntletSnapLivingToFinishPlay(); return; } + + /* เฟส "เดินเข้าเส้นชัย" — ค่อยๆ เดินทุกคนมาที่เส้นแทน snap กระพริบ แล้วค่อยเข้า freeze→จบ */ + if (gauntletCrownRunwayWalkActive) { + const forceDone = (Date.now() - gauntletCrownRunwayWalkStartMs) >= GAUNTLET_FINISH_WALK_MAX_MS; + const arrived = gauntletWalkLivingToFinishStepPlay(dtSec); + if (arrived || forceDone) { + gauntletCrownRunwayWalkActive = false; + gauntletSnapLivingToFinishPlay(); /* จัดเป๊ะที่เส้นชัยครั้งเดียว */ + gauntletCrownRunwayBgStripFreezeSinceMs = Date.now(); + } + try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ } + try { draw(); } catch (_d) { /* ignore */ } + return; + } + + if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) { + gauntletCrownRunwayMaintainFinishScrollPlay(); + gauntletSnapLivingToFinishPlay(); /* ตรึงทุกคนที่ยังไม่ตายไว้ที่เส้นชัยทุกเฟรม กันบอทไหลหลุด */ + if (Date.now() - gauntletCrownRunwayBgStripFreezeSinceMs >= GAUNTLET_CROWN_RUNWAY_POST_STOP_MS) { + gauntletCrownRunwayBgFinishLatched = true; + gauntletCrownRunwayBgStripFreezeSinceMs = 0; + if (playBotsEnabled() && gauntletCrownRunwayBgMapEligiblePlay() && !gauntletCrownRunwayClientMissionShown) { + gauntletCrownRunwayClientMissionShown = true; + window.setTimeout(function () { + try { + gauntletCrownPregamePhase = null; + gauntletCrownHowtoVisible = false; + const hto = document.getElementById('gauntlet-crown-howto-overlay'); + if (hto) hto.classList.add('is-hidden'); + const gcc = document.getElementById('gauntlet-crown-countdown'); + if (gcc) gcc.classList.add('is-hidden'); + if (gauntletCrownCountdownTimer) { + clearTimeout(gauntletCrownCountdownTimer); + gauntletCrownCountdownTimer = null; + } + /* ตามกฎใหม่: เข้าเส้นชัย/รอด ≥1 คน (ไม่ถูกดันตกขอบ) = result-complete · ตายหมด = result-gameover (ไม่มี timeup) */ + const mg2LocalMission = gauntletCrownHeistBuildLocalCrownMissionPlay(); + const mg2LocalWin = missionSurvivorsPlay(mg2LocalMission) >= 1 || missionAnyScorePlay(mg2LocalMission); + beginSimpleResultFlashThenGcmPlay(mg2LocalMission, mg2LocalWin ? 'result-complete.png' : 'result-gameover.png'); + } catch (_e) { /* ignore */ } + try { + draw(); + } catch (_e2) { /* ignore */ } + }, 340); + } + } + return; + } + + if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayScrollStripStopZoneReachedPlay()) { + gauntletCrownRunwayBeginWalkToFinishPlay(); /* เริ่มเดินค่อยๆ เข้าเส้นชัย (แทน snap+freeze ทันที) */ + try { + draw(); + } catch (_d) { /* ignore */ } + return; + } + /* G1: scroll = base + speed × (serverNow − anchor) → ทุกเครื่องเลื่อนตรงกัน ไม่ขึ้น resolution/frame-rate/host + (เดิม scrollPx += speed×dt นับ local จากเวลาเริ่มของตัวเอง → เลื่อนไม่ตรงกัน + host หลุด/resize เพี้ยน) + fallback เป็นนับ local ถ้ายังไม่มี anchor (กันค้าง) */ + const gcrSpeed = gauntletCrownRunwayBgSpeedPxPerSec || 48; + let gcrTarget; + if (gauntletCrownRunwayLiveAnchorServerMs > 0) { + gcrTarget = gauntletCrownRunwayBaseScrollPx + gcrSpeed * Math.max(0, (serverNow() - gauntletCrownRunwayLiveAnchorServerMs)) / 1000; + } else { + gcrTarget = gauntletCrownRunwayBgScrollPx + gcrSpeed * dtSec; + } + /* ถ้า "จะถึง/เลย" จุดหยุด FINISH → หยุดเป๊ะตรงนั้น แล้วเริ่มจบเกมทันที */ + const gcrLiveStopPx = gauntletCrownRunwayFinishStopScrollPxPlay(); + if (gauntletCrownRunwayBgMapEligiblePlay() && gcrLiveStopPx != null + && gcrLiveStopPx >= gauntletCrownRunwayBgScrollPx - 0.01 + && gcrTarget >= gcrLiveStopPx) { + gauntletCrownRunwayBgScrollPx = gcrLiveStopPx; + gauntletCrownRunwayBeginFinishStopPlay(); + return; + } + /* monotonic — เลื่อนหน้าเท่านั้น (กัน clock jitter ดึงถอย) */ + if (gcrTarget > gauntletCrownRunwayBgScrollPx) gauntletCrownRunwayBgScrollPx = gcrTarget; + } + + /** stack + แมป mnn93hpi — พื้นหลัง intro+loop เลื่อนแนวตั้ง (stackTowerBgScroll ในแมป) */ + const STACK_TOWER_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnn93hpi/intro.png'; + const STACK_TOWER_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnn93hpi/loop.png'; + let stackTowerScrollBgIntroImg = null; + let stackTowerScrollBgLoopImg = null; + let stackTowerScrollBgPx = 0; + let stackTowerScrollBgSpeedPxPerSec = 40; + let stackTowerScrollBgOff = false; + let stackTowerScrollBgFlowDown = false; + /** Sraw = px+boost+post ครั้งแรกที่คำนวณ world offset — ใช้หา delta แบบคาบ loop (อย่าใช้ fold(Sraw) ลบตรงๆ จะดันโลกขึ้นหลายร้อย px) */ + let stackTowerScrollBgSrawWorldBaselinePlay = null; + /** ความสูงวาดของไทล์ loop (พิกเซลจอ) — ใช้ขั้นเลื่อนเมื่อ stepScrollPx = 0 */ + let stackTowerScrollBgLoopDrawHPlay = 0; + /** { from, to, t0, dur } — เลื่อนแถบ BG เป็นขั้นตามจำนวนชั้น */ + let stackTowerBgScrollStepAnimPlay = null; + let stackTowerBgScrollPendingStepDeltaPlay = 0; + + function stackTowerScrollBgMapEligible() { + return !!(mapData && mapData.gameType === 'stack' && (currentPlayMapId() || '').trim() === STACK_TOWER_MISSION_MAP_ID); + } + + function stackTowerScrollBgImagesReady() { + if (!stackTowerScrollBgMapEligible()) return false; + const a = stackTowerScrollBgIntroImg; + const b = stackTowerScrollBgLoopImg; + return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth); + } + + /** แถบ intro+loop เต็มจอ — เปิดเฉพาะเมื่อแมปตั้ง stackTowerBgScroll.enabled: true และรูปโหลดครบ (ไม่โหลด strip เมื่อปิด → ใช้รูปแผนที่ backgroundImage) */ + function stackTowerScrollBgDrawActive() { + return stackTowerScrollBgImagesReady() && !stackTowerScrollBgOff; + } + + function reloadStackTowerScrollBgFromMap() { + stackTowerScrollBgIntroImg = null; + stackTowerScrollBgLoopImg = null; + stackTowerScrollBgPx = 0; + stackTowerScrollBgSrawWorldBaselinePlay = null; + stackTowerScrollBgLoopDrawHPlay = 0; + stackTowerBgScrollStepAnimPlay = null; + stackTowerBgScrollPendingStepDeltaPlay = 0; + if (!stackTowerScrollBgMapEligible()) return; + const raw = mapData.stackTowerBgScroll && typeof mapData.stackTowerBgScroll === 'object' ? mapData.stackTowerBgScroll : {}; + stackTowerScrollBgOff = raw.enabled !== true; + { + const spNum = Number(raw.speedPxPerSec); + stackTowerScrollBgSpeedPxPerSec = Number.isFinite(spNum) + ? Math.max(0, Math.min(400, Math.floor(spNum))) + : 0; + } + /* คีย์เวิร์ด down/top = ตัวเลือก "บน→ล่าง" ในเอดิเตอร์ — ใช้สูตรเลื่อนแบบ stripTop−S (ฉากไหลขึ้น) ไม่ใช่ S+chR−… (ไหลลง) เพราะผู้เล่นคาดว่าหอสูง = มองขึ้น */ + const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase(); + const rawIsDownKeyword = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom'); + stackTowerScrollBgFlowDown = !rawIsDownKeyword; + if (stackTowerScrollBgOff) return; + const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : STACK_TOWER_SCROLL_BG_DEFAULT_INTRO; + const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : STACK_TOWER_SCROLL_BG_DEFAULT_LOOP; + let pending = 2; + const bump = () => { + pending--; + if (pending <= 0) { + const intro0 = stackTowerScrollBgIntroImg; + const loop0 = stackTowerScrollBgLoopImg; + if (loop0 && loop0.complete && intro0 && intro0.complete && canvas && canvas.width) { + const cwR0 = Math.max(1, Math.round(canvas.width)); + stackTowerScrollBgLoopDrawHPlay = Math.max(1, Math.round(loop0.naturalHeight * (cwR0 / loop0.naturalWidth))); + } + stackTowerScrollBgSyncInitialScrollToBottom(); + try { draw(); } catch (e) { /* ignore */ } + } + }; + const im1 = new Image(); + im1.onload = () => { stackTowerScrollBgIntroImg = im1; bump(); }; + im1.onerror = () => { bump(); }; + im1.src = introSrc; + const im2 = new Image(); + im2.onload = () => { stackTowerScrollBgLoopImg = im2; bump(); }; + im2.onerror = () => { bump(); }; + im2.src = loopSrc; + } + + function stackTowerScrollBgSyncInitialScrollToBottom() { + const intro = stackTowerScrollBgIntroImg; + if (!canvas || !intro || !intro.complete || !intro.naturalWidth) return; + const cwR = Math.max(1, Math.round(canvas.width)); + const chR = Math.max(1, Math.round(canvas.height)); + const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth)); + if (stackTowerScrollBgFlowDown) { + stackTowerScrollBgPx = 0; + } else { + /* ขอบล่าง intro ชิดขอบล่างแคนวาส — คู่กับ stripTop−S */ + stackTowerScrollBgPx = drawHIntro - chR; + } + } + + /** + * พับค่าเลื่อนแนวตั้ง S (หน่วยเดียวกับ drawStackTowerScrollBgFullCanvas) เข้าช่วงหนึ่งคาบของ loop + * เมื่อ vp0 ≥ drawHIntro — รูปบนจอซ้ำทุก drawHLoop; พับค่า S ก่อนส่งเข้า worldToScreen กัน px โตไม่จำกัดดันโลกหลุดจอ + */ + function foldStackTowerScrollStripViewSForPhase(S, drawHIntro, drawHLoop) { + const dH = Math.max(1, Math.round(Number(drawHLoop) || 1)); + const dI = Math.max(0, Math.round(Number(drawHIntro) || 0)); + const s = Number(S) || 0; + if (s < dI) return s; + return s - Math.floor((s - dI) / dH) * dH; + } + + /** + * offset แนวตั้งจอให้โลกซิงก์กับ draw ที่ใช้ S = fold(Sraw) — ใช้ความต่างเฟส fold(Sraw)−fold(Sraw0) + * เวลาเลื่อนครบคาบ loop ค่า fold วนกลับ → offset ไม่โตไม่จำกัด · ห้ามคืน fold(Sraw) ล้วนเมื่อ Sraw0 ยังอยู่ intro (baseline ไม่เข้า loop ทำให้สูตร f0+mod ไม่ทำงานและเฟสหลุด) + */ + function stackTowerScrollWorldTotalYFromStripPlay(Sraw, drawHIntro, drawHLoop, Sraw0) { + if (Sraw0 == null) return 0; + const dH = Math.max(1, Math.round(Number(drawHLoop) || 1)); + const dI = Math.max(0, Math.round(Number(drawHIntro) || 0)); + const s = Number(Sraw) || 0; + const s0 = Number(Sraw0) || 0; + const fs = foldStackTowerScrollStripViewSForPhase(s, dI, dH); + const fs0 = foldStackTowerScrollStripViewSForPhase(s0, dI, dH); + return fs - fs0; + } + + /** zDrawPan ส่งต่อเพื่อคูณ boost กับ z — ไม่ scale แคนวาสทั้งแผง (เคย scale รอบกลางทำให้จอบางส่วนว่างเมื่อ z ≠ zRef) */ + function drawStackTowerScrollBgFullCanvas(cw, ch, zDrawPan) { + const intro = stackTowerScrollBgIntroImg; + const loop = stackTowerScrollBgLoopImg; + if (!intro || !intro.complete || !loop || !loop.complete) return; + const zPan = Number(zDrawPan) > 0 ? zDrawPan : zoom; + const cwR = Math.max(1, Math.round(cw)); + const chR = Math.max(1, Math.round(ch)); + const scaleI = cwR / intro.naturalWidth; + const drawHIntro = Math.round(intro.naturalHeight * scaleI); + const scaleL = cwR / loop.naturalWidth; + const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL)); + const camFollowScreen = isStackTowerMissionUiMapPlay() ? getStackTowerBgScrollHeightBoostPx() * zPan : 0; + const post50Scroll = isStackTowerMissionUiMapPlay() ? getStackTowerPost50BgScrollExtraPxPlay(chR) : 0; + const Sraw = stackTowerScrollBgPx + camFollowScreen + post50Scroll; + const S = foldStackTowerScrollStripViewSForPhase(Sraw, drawHIntro, drawHLoop); + const vp0 = S; + const vp1 = S + chR; + const yLimit = vp1 + drawHLoop * 96; + const flowDown = stackTowerScrollBgFlowDown; + + function stripTopToDestY(stripTop, drawH) { + if (!flowDown) return stripTop - S; + return S + chR - (stripTop + drawH); + } + + ctx.save(); + ctx.imageSmoothingEnabled = false; + ctx.beginPath(); + ctx.rect(0, 0, cwR, chR); + ctx.clip(); + + if (vp0 < 0) { + let k = 1; + if (vp0 < -drawHLoop * 4) { + k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2); + } + for (; k < 50000; k++) { + const y0 = -k * drawHLoop; + if (y0 >= vp1 + drawHLoop) break; + if (y0 + drawHLoop <= vp0) continue; + const dy = Math.round(stripTopToDestY(y0, drawHLoop)); + ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop); + } + } + + if (drawHIntro > vp0 && vp1 > 0) { + const dy = Math.round(stripTopToDestY(0, drawHIntro)); + ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro); + } + + let y = drawHIntro; + if (y < yLimit && vp1 > drawHIntro) { + if (vp0 > drawHIntro) { + const n = Math.floor((vp0 - drawHIntro) / drawHLoop); + /* ใช้ n ไม่ใช่ n−1 — เดิมทำให้แถบ loop เริ่มสูงกว่า vp0 หนึ่งความสูง จอล่างว่างดำเมื่อ S ใหญ่ */ + y = drawHIntro + n * drawHLoop; + } + while (y < yLimit) { + const dy = Math.round(stripTopToDestY(y, drawHLoop)); + ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop); + y += drawHLoop; + } + } + ctx.restore(); + } + + /** กล้องมองเหนือขอบบนแมป (worldMinY<0) + ใช้รูปแผนที่ — ยัด loop ท้องฟ้าเติมครึ่งบนจอ */ + function drawStackTowerLoopSkyFillAboveMap(ctx, worldMinY, zDraw, cw, ch) { + if (!isStackTowerMissionUiMapPlay() || worldMinY >= 0) return; + const loop = stackTowerScrollBgLoopImg; + if (!loop || !loop.complete || !loop.naturalWidth) return; + const cwR = Math.max(1, Math.round(cw)); + const chR = Math.max(1, Math.round(ch)); + const scaleL = cwR / loop.naturalWidth; + const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL)); + const skyScreenH = Math.min(chR, Math.ceil(-worldMinY * zDraw) + 4); + const stripPx = stackTowerScrollBgOff ? 0 : stackTowerScrollBgPx; + const rawScroll = stripPx + getStackTowerBgScrollHeightBoostPx() * zDraw + + getStackTowerPost50BgScrollExtraPxPlay(chR); + const scroll = ((rawScroll % drawHLoop) + drawHLoop) % drawHLoop; + let y = -scroll; + ctx.save(); + ctx.imageSmoothingEnabled = false; + while (y < skyScreenH + drawHLoop) { + if (y + drawHLoop > 0) { + ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, y, cwR, drawHLoop); + } + y += drawHLoop; + } + ctx.restore(); + } + + function tickStackTowerBgScrollStepAnimPlay() { + const q = stackTowerBgScrollStepAnimPlay; + if (!q) return; + const t = Math.min(1, (performance.now() - q.t0) / Math.max(1, q.dur)); + const e = 1 - Math.pow(1 - t, 3); + stackTowerScrollBgPx = q.from + (q.to - q.from) * e; + if (t >= 1) { + stackTowerScrollBgPx = q.to; + stackTowerBgScrollStepAnimPlay = null; + stackTowerScrollBgSrawWorldBaselinePlay = null; + if (stackTowerBgScrollPendingStepDeltaPlay !== 0) { + const d = stackTowerBgScrollPendingStepDeltaPlay; + stackTowerBgScrollPendingStepDeltaPlay = 0; + startStackTowerBgScrollStepAnimPlay(d); + } + } + } + + function getStackTowerBgScrollStepEveryLayersPlay() { + const raw = mapData && mapData.stackTowerBgScroll; + if (!raw || typeof raw !== 'object') return 0; + const n = Math.floor(Number(raw.stepEveryLayers)); + return Number.isFinite(n) && n > 0 ? Math.min(200, n) : 0; + } + + function getStackTowerBgScrollStepAnimMsPlay() { + const raw = mapData && mapData.stackTowerBgScroll; + if (!raw || typeof raw !== 'object') return 520; + const t = Math.floor(Number(raw.stepAnimMs)); + return Number.isFinite(t) ? Math.max(120, Math.min(4000, t)) : 520; + } + + function getStackTowerBgScrollStepScrollPxPlay() { + const raw = mapData && mapData.stackTowerBgScroll; + if (!raw || typeof raw !== 'object') return 0; + const d = Math.floor(Number(raw.stepScrollPx)); + return Number.isFinite(d) ? Math.max(0, Math.min(8000, d)) : 0; + } + + function startStackTowerBgScrollStepAnimPlay(deltaPx) { + if (!stackTowerScrollBgDrawActive() || !deltaPx) return; + if (stackTowerBgScrollStepAnimPlay) { + stackTowerBgScrollPendingStepDeltaPlay += deltaPx; + return; + } + const dur = getStackTowerBgScrollStepAnimMsPlay(); + stackTowerBgScrollStepAnimPlay = { + from: stackTowerScrollBgPx, + to: stackTowerScrollBgPx + deltaPx, + t0: performance.now(), + dur, + }; + } + + function maybeQueueStackTowerBgScrollStepPlay(layerCountAfter) { + if (!stackTowerScrollBgDrawActive()) return; + const every = getStackTowerBgScrollStepEveryLayersPlay(); + if (every <= 0 || layerCountAfter <= 0 || layerCountAfter % every !== 0) return; + let delta = getStackTowerBgScrollStepScrollPxPlay(); + if (!delta) delta = Math.max(8, stackTowerScrollBgLoopDrawHPlay || 200); + delta = Math.min(8000, Math.max(8, delta)); + startStackTowerBgScrollStepAnimPlay(delta); + } + + function getStackTowerStripReleaseGapWorldPxPlay() { + const raw = mapData && mapData.stackTowerBgScroll; + if (!isStackTowerMissionUiMapPlay() || !raw || typeof raw !== 'object') return null; + const g = Number(raw.releaseGapWorldPx); + if (!Number.isFinite(g) || g < 0) return null; + return Math.min(800, g); + } + + function updateStackTowerSwingYForStripGapPlay() { + if (!stackMini || !isStackTowerMissionUiMapPlay()) return; + const gap = getStackTowerStripReleaseGapWorldPxPlay(); + if (gap == null) return; + const m = stackMini; + const lh = m.layerWorldH || Math.max(14, tileSize * 0.3); + const nLay = m.layers ? m.layers.length : 0; + const topBlockTopY = m.floorWorldY - nLay * lh; + const swingLift = getStackTowerSwingLiftWorldPx(nLay, lh); + m.swingWorldY = topBlockTopY - gap + swingLift; + } + + function tickStackTowerScrollBg(dtSec) { + if (!stackTowerScrollBgMapEligible() || stackTowerScrollBgOff) return; + if (!stackTowerScrollBgImagesReady()) return; + tickStackTowerBgScrollStepAnimPlay(); + if (stackTowerBgScrollStepAnimPlay) return; + const spd = Math.max(0, Number(stackTowerScrollBgSpeedPxPerSec) || 0) * dtSec; + if (spd <= 0) return; + /* !flowDown (stripTop−S): S เพิ่ม → dy ลด → ฉากไหลขึ้น — ห้ามใช้ −= (จะลด S แล้วศิลป์ไหลลง) */ + stackTowerScrollBgPx += spd; + } + + /** + * ค่าลบจาก screen Y ใน worldToScreen — ซิงก์กับ drawStackTowerScrollBgFullCanvas (auto + boost + post-50) + * แถบ BG วาด 1:1 กับแคนวาส — ห้ามใช้ fold(Sraw) เป็นค่าลบตรงๆ (จะดันโลกขึ้นหลายร้อย px เหมือนครึ่งล่างจอว่าง) + */ + function getStackTowerWorldLayerScrollScreenOffsetYPlay(zPan) { + if (!isStackTowerMissionUiMapPlay() || !stackMini) return 0; + const zPn = Number(zPan) > 0 ? zPan : zoom; + const chPx = Math.max(1, Math.round(canvas && canvas.height ? canvas.height : 720)); + const boostScreen = getStackTowerBgScrollHeightBoostPx() * zPn; + const post50Screen = getStackTowerPost50BgScrollExtraPxPlay(chPx); + const intro = stackTowerScrollBgIntroImg; + const loop = stackTowerScrollBgLoopImg; + let totalScreen = boostScreen + post50Screen; + if (stackTowerScrollBgDrawActive() && intro && intro.complete && intro.naturalWidth + && loop && loop.complete && loop.naturalWidth && canvas && canvas.width) { + const cwR = Math.max(1, Math.round(canvas.width)); + const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth)); + const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * (cwR / loop.naturalWidth))); + const SrawDraw = stackTowerScrollBgPx + boostScreen + post50Screen; + if (stackTowerScrollBgSrawWorldBaselinePlay == null) stackTowerScrollBgSrawWorldBaselinePlay = SrawDraw; + const S0 = stackTowerScrollBgSrawWorldBaselinePlay; + let stripY; + if (stackTowerScrollBgFlowDown) { + const Sview = foldStackTowerScrollStripViewSForPhase(SrawDraw, drawHIntro, drawHLoop); + stripY = (boostScreen + post50Screen) * 2 - Sview; + } else { + stripY = stackTowerScrollWorldTotalYFromStripPlay(SrawDraw, drawHIntro, drawHLoop, S0); + } + totalScreen = stripY; + } else if (stackTowerScrollBgDrawActive()) { + const px = stackTowerScrollBgFlowDown ? -stackTowerScrollBgPx : stackTowerScrollBgPx; + totalScreen = px + boostScreen + post50Screen; + } + return totalScreen; + } + + /** โหลดจาก mapData.gridImageLibrary — ดัชนีตรงกับ gridImageCells[y][x] */ + let mapGridImageImgs = []; + let mapGridImageHeldImgs = []; + let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, gauntletEliminated: false, tx: null, ty: null, quizCarryHeld: null, gauntletCrownPenaltyFxUntil: 0 }; + const others = new Map(); + /** ===== MG3 terminal log (เกมจริง) — hacking-terminal ตามสเปก #mg3-terminal-log ===== */ + let mg3TermLog = []; + let mg3TermLineNo = 0; + const MG3_TERM_MAX = 240; + function mg3TermPush(lines) { + const arr = Array.isArray(lines) ? lines : [lines]; + for (let i = 0; i < arr.length; i++) mg3TermLog.push(String(arr[i] == null ? '' : arr[i])); + while (mg3TermLog.length > MG3_TERM_MAX) mg3TermLog.shift(); + } + function mg3TermReset() { mg3TermLog = []; mg3TermLineNo = 0; } + function mg3TermBoot() { + mg3TermLog = []; mg3TermLineNo = 0; + mg3TermPush([ + '[INIT] Booting decryption sequence...', + '[SYS] Locating target server...', + '[SYS] Connection established. Status: SECURE.', + '[HACK] Bypassing "Vault Lockdown" protocol...', + '[HACK] Disabling Cyber Security Check...', + '[LOAD] Injecting Decryption_Core.exe...', + '[READY] Awaiting manual code block alignment...', + '>>> STACKING SEQUENCE START <<<', + ]); + } + function mg3TermOnPlace(perfect, progressPct) { + mg3TermLineNo += 1; + const n = mg3TermLineNo; + if (n >= 100 || (Number.isFinite(Number(progressPct)) && Number(progressPct) >= 99.5)) { + mg3TermPush([ + '>>> Compile Line [100]: SUCCESS. Maximum stack reached.', + '[DATA]: Decryption progress: 100%. Master key generated.', + '[HACK]: "Vault Lockdown" bypassed. Firewall disabled.', + '[SYS]: Evidence files unlocked. Initiating secure download...', + '>>> MISSION ACCOMPLISHED. Disconnecting safely. <<<', + ]); + } else if (perfect) { + mg3TermPush('>>> Compile Line [' + n + ']: PERFECT. Synergy bonus!'); + } else { + mg3TermPush('>>> Compile Line [' + n + ']: SUCCESS. Stack stable.'); + } + } + function mg3TermOnMiss(attemptsRemaining) { + if (attemptsRemaining <= 0) { + mg3TermPush([ + '[CRITICAL]: Packet loss detected. Compile FAILED. (Attempts remaining: 0)', + '[ALERT]: Cyber Security Check triggered! Intruder traced.', + '[SYS]: Initiating "Vault Lockdown" protocol...', + '>>> FATAL ERROR: Connection Terminated. SYSTEM LOCKED. <<<', + ]); + } else { + mg3TermPush('[WARNING]: Packet loss detected. Compile FAILED. (Attempts remaining: ' + attemptsRemaining + '/3)'); + } + } + + /** Stack preview HUD: เทอร์มินัลล็อก (cyber UI) */ + const STACK_PREVIEW_HUD_LOG_MAX = 8; + let stackPreviewHudLog = []; + function stackPreviewPushHudLog(line) { + if (!playBotsEnabled()) return; + stackPreviewHudLog.push(String(line || '').slice(0, 96)); + while (stackPreviewHudLog.length > STACK_PREVIEW_HUD_LOG_MAX) stackPreviewHudLog.shift(); + } + function stackPreviewLogStackDrop(hit, actor) { + if (!playBotsEnabled() || !hit) return; + const bid = actor && actor.botId; + let nm = 'NODE'; + if (actor && actor.human) nm = (me.nickname || 'YOU').toUpperCase().replace(/\s+/g, '_').slice(0, 12); + else if (bid && others.get(bid)) nm = String(others.get(bid).nickname || 'BOT').toUpperCase().replace(/\s+/g, '_').slice(0, 12); + if (hit.miss) stackPreviewPushHudLog(`>>> ${nm}: SECTOR_MISS`); + else if (hit.perfect) stackPreviewPushHudLog(`>>> ${nm}: PERFECT +${hit.pts} SYN`); + else stackPreviewPushHudLog(`>>> ${nm}: LOCK +${hit.pts}`); + } + const keys = {}; + const characterImages = {}; + /** data URL จาก canvas compose สี — กันยิง toDataURL ทุกเฟรมตอน sync HUD */ + const cyberHudScoreAvatarUrlCache = new Map(); + const CYBER_HUD_AV_URL_CACHE_CAP = 56; + function createDefaultAvatarImg() { + const c = document.createElement('canvas'); + c.width = 64; c.height = 64; + const ctx = c.getContext('2d'); + ctx.fillStyle = '#7aa2f7'; + ctx.beginPath(); + ctx.arc(32, 22, 14, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#9ece6a'; + ctx.beginPath(); + ctx.arc(32, 48, 18, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#1a1b26'; + ctx.beginPath(); + ctx.arc(28, 20, 3, 0, Math.PI * 2); + ctx.arc(36, 20, 3, 0, Math.PI * 2); + ctx.fill(); + const img = new Image(); + img.src = c.toDataURL('image/png'); + return img; + } + const defaultAvatarImg = createDefaultAvatarImg(); + const characterAnimations = {}; + const characterIdleAnimations = {}; + const CHARACTER_ANIM_FRAMES = 4; + const CHARACTER_ANIM_FRAME_MS = 200; + + /** จังหวะเดินลูปคงที่ 4 เฟรม — อย่า modulo ด้วยจำนวนเฟรมที่โหลดได้แล้ว (ไม่งั้นเฟรมกลางหาย/ลูปสั้นลง) */ + /** true เมื่ออยู่ในเกมวิ่งหลบ (gauntlet runner) ช่วงเล่นจริง — ใช้เร่งจังหวะขาให้ดูเหมือน "วิ่ง" */ + function gauntletRunnerAnimActivePlay() { + try { + return isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay(); + } catch (e) { return false; } + } + + function walkAnimPhaseIndex(now, isWalking) { + if (!isWalking) return 0; + const t = (typeof now === 'number' ? now : Date.now()); + /* gauntlet วิ่งหลบ: เร่งจังหวะขาให้ "วิ่ง" — 0.40 (80ms/เฟรม) เร็วสุดที่ยังลื่น ไม่กระตุก + (sprite มีแค่ 4 เฟรม ถ้าเร็วกว่านี้ เช่น 0.27 เฟรมจะสลับถี่เกินจนดูสั่น/กระตุก) */ + const frameMs = gauntletRunnerAnimActivePlay() ? CHARACTER_ANIM_FRAME_MS * 0.40 : CHARACTER_ANIM_FRAME_MS; + return Math.floor(t / frameMs) % CHARACTER_ANIM_FRAMES; + } + + /** เลือกเฟรมสูงสุดที่โหลดแล้วและ <= phase (เดิมถอย) */ + function pickLoadedWalkFrameIndex(anim, phase) { + if (!anim || !anim.frames || !anim.frames.length) return -1; + const maxK = Math.min(phase, anim.frames.length - 1, CHARACTER_ANIM_FRAMES - 1); + for (let k = maxK; k >= 0; k--) { + const f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return k; + } + return -1; + } + + /** ตัวละครอัปโหลดจากระบบมักเป็น char- + ตัวเลข (อาจมี suffix) — ไม่มี idle strip / idle layer ครบทุกทิศ */ + function isUploadedCharAssetId(id) { + if (!id) return false; + const s = String(id).trim(); + return /^char-\d/i.test(s); + } + + function useMultiFrameIdleSpriteSheets(id) { + if (!id) return true; + return !isUploadedCharAssetId(id); + } + + function ensureCharacterIdleAnim(id, dir) { + const d = dir || 'down'; + /* char-* ไม่มี *_idle_0.png — ใช้ strip เดิน dir_0..3 เป็น idle ต่อทิศ (มีลูป) แทนรูปเดียวที่มักไม่มี */ + if (!useMultiFrameIdleSpriteSheets(id)) { + const key = String(id) + '_' + d + '_idle'; + let anim = characterIdleAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterIdleAnimations[key] = anim; + const enc = encodeURIComponent(id); + for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + const img = new Image(); + img.src = BASE + '/img/characters/' + enc + '_' + d + '_' + i + '.png'; + anim.frames.push(img); + } + const fb = new Image(); + fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png'; + anim.fallback = fb; + } + return anim; + } + const key = id + '_' + d + '_idle'; + let anim = characterIdleAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterIdleAnimations[key] = anim; + const enc = encodeURIComponent(id); + for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + const img = new Image(); + img.src = BASE + '/img/characters/' + enc + '_' + d + '_idle_' + i + '.png'; + anim.frames.push(img); + } + const fb = new Image(); + fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png'; + anim.fallback = fb; + } + return anim; + } + + /** ลูป idle ขณะยืน — ใช้จังหวะเวลาเหมือนเดิน (CHARACTER_ANIM_FRAME_MS) */ + function idleAnimPhaseIndex(now) { + const t = typeof now === 'number' ? now : Date.now(); + return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES; + } + + function characterIdleSpritesVisible(id, dir) { + if (!id) return false; + const anim = ensureCharacterIdleAnim(id, dir || 'down'); + if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return true; + for (let i = 0; i < anim.frames.length; i++) { + const f = anim.frames[i]; + if (f && f.complete && f.naturalWidth) return true; + } + return false; + } + + /** สุ่มสีตามเลเยอร์เดียวกับ character.html: bodyColor / hairColor / headColor (ลำดับซ้อนเหมือน composeLayeredFrame) */ + const PLAY_LAYER_ORDER = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; + const PLAY_LAYER_TINT_KEY = { bodyColor: 'body', headColor: 'head', hairColor: 'hair' }; + const PLAY_TINT_HEAD = ['#eaa78a', '#fbd5c4', '#fae9e1']; + const PLAY_TINT_HAIR = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe']; + const PLAY_TINT_BODY = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe']; + const playLayerMode = {}; + /** คิว probe ครบ up/down/left/right แล้ว — กันทิศที่ไม่มีไฟล์ layer ไปตก fallback แถบทั้งทั้งที่ทิศอื่นมี */ + const playLayerAllDirsQueued = {}; + /** จาก GET /api/characters — hasLayerFiles สแกนจากชื่อไฟล์จริง (แม่นกว่า probe จาก อย่างเดียว) */ + let playCharLayerApi = { status: 'idle', map: null }; + /** id → Set เลเยอร์ที่มีไฟล์จริง (จาก GET /api/characters) — กันยิง URL เลเยอร์ที่ไม่เคยอัปโหลด → 404 รกคอนโซล */ + const playCharDiskLayersById = Object.create(null); + + function ensurePlayCharLayerListFetch() { + if (playCharLayerApi.status !== 'idle') return; + playCharLayerApi.status = 'loading'; + fetch(BASE + '/api/characters') + .then((r) => r.json()) + .then((list) => { + const map = Object.create(null); + playCharacterIdRoster = []; + if (Array.isArray(list)) { + list.forEach((c) => { + if (!c || !c.id) return; + playCharacterIdRoster.push(String(c.id)); + map[c.id] = !!c.hasLayerFiles; + if (Array.isArray(c.layers) && c.layers.length) { + const st = new Set(); + c.layers.forEach((n) => { + if (typeof n === 'string' && n.length) st.add(n); + }); + if (st.size) playCharDiskLayersById[c.id] = st; + else delete playCharDiskLayersById[c.id]; + } else { + delete playCharDiskLayersById[c.id]; + } + }); + } + playCharLayerApi = { status: 'done', map }; + }) + .catch(() => { + playCharacterIdRoster = []; + playCharLayerApi = { status: 'done', map: Object.create(null) }; + }); + } + + /** @returns {boolean|null} true/false จาก API, null = ยังไม่โหลดหรือไม่มีรายการ id นี้ */ + function playCharLayerFromApi(characterId) { + if (playCharLayerApi.status !== 'done' || !characterId) return null; + if (Object.prototype.hasOwnProperty.call(playCharLayerApi.map, characterId)) { + return playCharLayerApi.map[characterId]; + } + return null; + } + + /** ถ้าเซิร์ฟเวอร์ส่งรายชื่อเลเยอร์ที่มีบนดิสก์ — ไม่โหลดเลเยอร์อื่น (ลด 404) */ + function playCharDiskLayersSet(characterId) { + if (!characterId || playCharLayerApi.status !== 'done') return null; + const s = playCharDiskLayersById[characterId]; + return s && s.size ? s : null; + } + + const playLayerImageCache = new Map(); + const playLayerCompositeCache = new Map(); + function pickRandomPlayTint() { + return { + head: PLAY_TINT_HEAD[Math.floor(Math.random() * PLAY_TINT_HEAD.length)], + hair: PLAY_TINT_HAIR[Math.floor(Math.random() * PLAY_TINT_HAIR.length)], + body: PLAY_TINT_BODY[Math.floor(Math.random() * PLAY_TINT_BODY.length)], + }; + } + + /** FNV-1a 32-bit — แยกดัชนี head/hair/body ได้กระจายกว่า hash*31 เดิม (กันบอท __pv_bot_N สีซ้ำ) */ + function hashStringFnv1a32(str) { + let h = 2166136261 >>> 0; + const s = String(str || ''); + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + return h >>> 0; + } + + function playTintFromPeerId(id) { + const s = String(id || 'x'); + const h0 = hashStringFnv1a32(s); + const h1 = hashStringFnv1a32(s + '\n1hair'); + const h2 = hashStringFnv1a32(s + '\n2body'); + return { + head: PLAY_TINT_HEAD[h0 % PLAY_TINT_HEAD.length], + hair: PLAY_TINT_HAIR[h1 % PLAY_TINT_HAIR.length], + body: PLAY_TINT_BODY[h2 % PLAY_TINT_BODY.length], + }; + } + + const LOBBY_PLAYER_TINT_KEY = 'justiceLobbyPlayerTint'; + /** ธีมสี 8 ช่อง — ตรง room-lobby / customize */ + const PLAY_LOBBY_THEME_HEX = [ + '#d72520', '#ef8508', '#efe237', '#5bb443', + '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe', + ]; + const PLAY_LOBBY_SKIN_HEX = ['#eaa78a', '#fbd5c4', '#fae9e1']; + let playLobbyBotThemes = []; + + function parsePlayLobbyThemeIndex(n) { + const v = Math.floor(Number(n)); + return (v >= 1 && v <= 8) ? v : null; + } + + function parsePlayLobbySkinIndex(n) { + const v = Math.floor(Number(n)); + return (v >= 1 && v <= 3) ? v : null; + } + + function playTintFromLobbyThemeIndices(themeIndex, skinIndex) { + const ti = parsePlayLobbyThemeIndex(themeIndex); + const si = parsePlayLobbySkinIndex(skinIndex); + const bodyHex = ti ? PLAY_LOBBY_THEME_HEX[ti - 1] : ''; + const headHex = si ? PLAY_LOBBY_SKIN_HEX[si - 1] : ''; + if (!bodyHex && !headHex) return null; + return playTintFromLobbyRgb(bodyHex || null, headHex || null); + } + + function playTintFromJoinPeer(peerRow) { + if (!peerRow) return null; + return playTintFromLobbyThemeIndices(peerRow.lobbyColorThemeIndex, peerRow.lobbySkinToneIndex); + } + + function playTintForPreviewBotId(peerId) { + if (!isPreviewBotId(peerId)) return null; + const idx = parseInt(String(peerId).slice(PREVIEW_BOT_PREFIX.length), 10); + if (!Number.isFinite(idx) || idx < 0) return null; + /* ใช้ธีมจาก server (กันสีซ้ำอยู่แล้ว) — ถ้าไม่มี ตกไปธีมแบบ "กระจายตามดัชนี" (ไม่สุ่ม hash ที่ซ้ำได้) + กัน bot สีเดียวกัน: bot N → ธีม ((N % 8)+1), สกิน ((N % 3)+1) */ + /* 1) สีที่ lobby บันทึกไว้ (ให้ตรงกับ lobby เป๊ะ) — ไม่เขียนทับ */ + try { + const savedMap = JSON.parse(sessionStorage.getItem(lobbyBotTintsStorageKeyPlay()) || '{}'); + const saved = savedMap['__lobby_bot_' + idx]; + if (saved && (saved.colorTheme || saved.colorSkin)) { + const st = playTintFromLobbyRgb(saved.colorTheme, saved.colorSkin); + if (st) return st; + } + } catch (e) { /* ignore */ } + /* 2) ไม่มีค่าจาก lobby → ใช้ธีม server / กระจายตามดัชนี แล้วบันทึกไว้ */ + const slot = playLobbyBotThemes[idx] || { themeIndex: (idx % 8) + 1, skinToneIndex: (idx % 3) + 1 }; + const tint = playTintFromLobbyThemeIndices(slot.themeIndex, slot.skinToneIndex); + if (!tint) return null; + try { + const map = JSON.parse(sessionStorage.getItem(lobbyBotTintsStorageKeyPlay()) || '{}'); + map['__lobby_bot_' + idx] = { + colorTheme: PLAY_LOBBY_THEME_HEX[parsePlayLobbyThemeIndex(slot.themeIndex) - 1] || '', + colorSkin: PLAY_LOBBY_SKIN_HEX[parsePlayLobbySkinIndex(slot.skinToneIndex) - 1] || '', + }; + sessionStorage.setItem(lobbyBotTintsStorageKeyPlay(), JSON.stringify(map)); + } catch (e) { /* ignore */ } + return tint; + } + + function lobbyBotTintsStorageKeyPlay() { + const sp = (typeof spaceId === 'string' && spaceId) ? spaceId : (new URLSearchParams(window.location.search).get('space') || 'default'); + return 'justiceLobbyBotTints:' + sp; + } + + function rgbCssToHexPlay(rgb) { + if (!rgb) return ''; + const s = String(rgb).trim(); + if (s.charAt(0) === '#') return s; + const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if (!m) return ''; + return '#' + [m[1], m[2], m[3]].map((n) => { + const v = Math.max(0, Math.min(255, parseInt(n, 10))); + return v.toString(16).padStart(2, '0'); + }).join(''); + } + + function playTintFromLobbyRgb(themeRgb, skinRgb) { + const bodyHex = rgbCssToHexPlay(themeRgb); + const headHex = rgbCssToHexPlay(skinRgb); + if (!headHex && !bodyHex) return null; + return { + head: headHex || '#fbd5c4', + hair: bodyHex || '#fb4941', + body: bodyHex || '#fb4941', + }; + } + + function loadPlayTintFromLobbyPlayerStorage() { + try { + const raw = localStorage.getItem(LOBBY_PLAYER_TINT_KEY); + if (raw) { + const j = JSON.parse(raw); + if (j && typeof j === 'object') { + if (j.head && j.body) { + return { + head: String(j.head), + hair: String(j.hair || j.body), + body: String(j.body), + }; + } + const fromJson = playTintFromLobbyRgb(j.themeRgb, j.skinRgb); + if (fromJson) return fromJson; + } + } + } catch (e) { /* ignore */ } + try { + const theme = localStorage.getItem('lobbyThemeColor') || ''; + const skin = localStorage.getItem('lobbySkinTone') || ''; + return playTintFromLobbyRgb(theme, skin); + } catch (e2) { + return null; + } + } + + /** แปลง hex/rgb → {r,g,b} (กันค่าเพี้ยน) */ + function hexOrRgbToRgbPlay(v) { + const s0 = String(v || '').trim(); + const m = s0.match(/rgba?\(\s*(\d+)\D+(\d+)\D+(\d+)/i); + if (m) return { r: +m[1], g: +m[2], b: +m[3] }; + let s = s0.replace('#', ''); + if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2]; + if (!/^[0-9a-fA-F]{6}$/.test(s)) return null; + const n = parseInt(s, 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; + } + function nearestIndexFromHexPlay(value, palette) { + const asIdx = Math.floor(Number(value)); + if (asIdx >= 1 && asIdx <= palette.length) return asIdx; /* เก็บเป็น index อยู่แล้ว */ + const t = hexOrRgbToRgbPlay(value); + if (!t) return null; + let best = -1; let bestD = Infinity; + for (let i = 0; i < palette.length; i++) { + const c = hexOrRgbToRgbPlay(palette[i]); + if (!c) continue; + const d = (c.r - t.r) * (c.r - t.r) + (c.g - t.g) * (c.g - t.g) + (c.b - t.b) * (c.b - t.b); + if (d < bestD) { bestD = d; best = i; } + } + return best >= 0 ? best + 1 : null; + } + /** theme/skin index ที่ผู้เล่นเลือกไว้ (จาก localStorage) — ส่งให้ server เพื่อกันบอทใช้สีเดียวกับเรา */ + function getMyChosenLobbyColorIndicesPlay() { + let body = ''; + let skin = ''; + try { + const raw = localStorage.getItem(LOBBY_PLAYER_TINT_KEY); + if (raw) { const j = JSON.parse(raw); if (j) { body = j.body || j.themeRgb || ''; skin = j.skinRgb || j.skin || ''; } } + } catch (e) { /* ignore */ } + try { + if (!body) body = localStorage.getItem('lobbyThemeColor') || ''; + if (!skin) skin = localStorage.getItem('lobbySkinTone') || ''; + } catch (e2) { /* ignore */ } + return { + themeIndex: nearestIndexFromHexPlay(body, PLAY_LOBBY_THEME_HEX), + skinIndex: nearestIndexFromHexPlay(skin, PLAY_LOBBY_SKIN_HEX), + }; + } + + function loadPlayTintForBotPeerId(peerId) { + const sid = String(peerId || ''); + let botKey = sid; + if (sid.indexOf(PREVIEW_BOT_PREFIX) === 0) { + botKey = '__lobby_bot_' + sid.slice(PREVIEW_BOT_PREFIX.length); + } + try { + const raw = sessionStorage.getItem(lobbyBotTintsStorageKeyPlay()); + if (!raw) return null; + const map = JSON.parse(raw); + const saved = map && map[botKey]; + if (!saved) return null; + return playTintFromLobbyRgb(saved.colorTheme, saved.colorSkin); + } catch (e) { + return null; + } + } + + /** สีจากล็อบบี้ (ผู้เล่น + บอท) — fallback hash ตาม peer id */ + function resolvePlayTintForPeer(peerId, peerRow) { + const sid = String(peerId || ''); + /* ตัวเราเอง: ยึด "สีที่เลือกไว้" (localStorage) ก่อนทุกอย่าง — กัน peerRow/theme-index จาก server มาทับ (สีกระพริบ) */ + if (myId != null && sid === String(myId)) { + const meChosen = loadPlayTintFromLobbyPlayerStorage(); + if (meChosen) return meChosen; + } + const fromRow = peerRow ? playTintFromJoinPeer(peerRow) : null; + if (fromRow) return fromRow; + const previewTint = playTintForPreviewBotId(sid); + if (previewTint) return previewTint; + if (myId != null && sid === String(myId)) { + const meTint = loadPlayTintFromLobbyPlayerStorage(); + if (meTint) return meTint; + } + if ((sid === '__me' || sid === '__local_me') && me && me.playTint) return me.playTint; + const botTint = loadPlayTintForBotPeerId(sid); + if (botTint) return botTint; + return playTintFromPeerId(sid); + } + + function applyPlayTintFromLobbyIndices(peerId, themeIndex, skinIndex) { + const sid = peerId != null ? String(peerId) : ''; + /* server-assigned theme index (อาจถูก reassign ตอนเลือกสีชนกัน) — เก็บไว้ใช้ลงสี "block MG3" ให้ตรงกันทุกจอ + (เดิม self ใช้สีจาก localStorage แต่ others ใช้ index จาก server → block สีไม่ตรง 2 จอ) */ + const tiNum = parsePlayLobbyThemeIndex(themeIndex); + if (myId != null && sid === String(myId)) { + if (tiNum) me.lobbyThemeIndexSrv = tiNum; + /* ตัวเราเอง: ใช้ "สีจาก server index" (authoritative) เป็นหลัก — ให้สีเรา "ตรงกันทุกจอ" + block MG3 ตรงกับ avatar + เดิม override ด้วย localStorage → จอเราเห็นสีที่เลือก แต่จออื่น+block MG3 ใช้ server index = ไม่ตรง + (เคส server reassign สีตอนเลือกชนกัน → server index คือสีจริง ต้องยึดอันนี้) */ + const tintMe = (tiNum) ? playTintFromLobbyThemeIndices(themeIndex, skinIndex) : null; + if (tintMe) { me.playTint = tintMe; return; } + const meChosen = loadPlayTintFromLobbyPlayerStorage(); + if (meChosen) me.playTint = meChosen; + return; + } + const oEarly = others.get(peerId) || others.get(sid); + if (oEarly && tiNum) oEarly.lobbyThemeIndexSrv = tiNum; + const tint = playTintFromLobbyThemeIndices(themeIndex, skinIndex); + if (!tint) return; + const o = others.get(peerId) || others.get(sid); + if (o) o.playTint = tint; + if (isPreviewBotId(sid)) playTintForPreviewBotId(sid); + } + + function applyPlayLobbyTintSync(data) { + if (!data || typeof data !== 'object') return; + if (Array.isArray(data.lobbyBotThemes)) playLobbyBotThemes = data.lobbyBotThemes; + if (Array.isArray(data.peers)) { + data.peers.forEach(function (p) { + if (!p || p.id == null) return; + applyPlayTintFromLobbyIndices(p.id, p.lobbyColorThemeIndex, p.lobbySkinToneIndex); + }); + } + if (playBotsEnabled()) rebalancePreviewBots(); + try { + preloadPlayOccupantsTints(); + preloadPlayLobbyIdleAvatarsForOccupants(); + } catch (e) { /* ignore */ } + } + + /** วินิจฉัยสีในเกม: รันใน console บนแต่ละเครื่อง → เทียบสีที่ render ของทุกตัว */ + window.__colorDebug = function () { + var rows = []; + rows.push({ who: '*ME*', id: String(myId == null ? '' : myId).slice(0, 6), renderTint: JSON.stringify(me && me.playTint) }); + others.forEach(function (o, id) { + rows.push({ who: isPreviewBotId(id) ? 'BOT' : 'peer', id: String(id).slice(0, 10), name: o.nickname || '', renderTint: JSON.stringify(o.playTint) }); + }); + try { console.table(rows); } catch (e) { console.log(JSON.stringify(rows, null, 2)); } + var lsTheme = '?'; var lsTint = '?'; + try { lsTheme = localStorage.getItem('lobbyThemeColor'); lsTint = localStorage.getItem(LOBBY_PLAYER_TINT_KEY); } catch (e) { /* ignore */ } + console.log('[color-dbg/game] myId=' + myId + ' playLobbyBotThemes=' + JSON.stringify(playLobbyBotThemes) + + ' LS.lobbyThemeColor=' + lsTheme + ' LS.PLAYER_TINT=' + lsTint); + return rows; + }; + + function getPlayTintForMissionAvatar(peerId) { + const sid = peerId != null ? String(peerId) : ''; + if (myId != null && sid === String(myId)) { + return me.playTint || resolvePlayTintForPeer(sid); + } + const o = others.get(peerId) || others.get(sid); + if (o && o.playTint) return o.playTint; + return resolvePlayTintForPeer(sid); + } + + /** สรุปภารกิจ — ผู้เล่นจริงต้องอยู่ใน top N เสมอ */ + function missionEnsureHumanInTopRanked(rows, maxN) { + const cap = Math.max(1, maxN || 5); + if (!Array.isArray(rows) || !rows.length) return []; + const humanId = myId != null ? String(myId) : null; + const humanRow = humanId + ? rows.find((r) => r && r.id != null && String(r.id) === humanId) + : rows.find((r) => r && r.isLocalHuman); + let top = rows.slice(0, cap); + if (!humanRow) return top; + const hid = humanRow.id != null ? String(humanRow.id) : ''; + if (top.some((r) => r && r.id != null && String(r.id) === hid)) return top; + if (top.length >= cap) top = top.slice(0, cap - 1); + top.push(humanRow); + return top; + } + + function hexToRgb01(hex) { + const h = (hex || '').replace('#', '').trim(); + if (h.length !== 6) return [1, 1, 1]; + return [ + parseInt(h.slice(0, 2), 16) / 255, + parseInt(h.slice(2, 4), 16) / 255, + parseInt(h.slice(4, 6), 16) / 255, + ]; + } + + function tintPlayLayerImageData(imageData, tintHex) { + const rgb = hexToRgb01(tintHex); + const tr = rgb[0], tg = rgb[1], tb = rgb[2]; + const d = imageData.data; + for (let i = 0; i < d.length; i += 4) { + if (d[i + 3] < 12) continue; + const r = d[i], g = d[i + 1], b = d[i + 2]; + const L = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + d[i] = Math.min(255, Math.round(tr * 255 * L)); + d[i + 1] = Math.min(255, Math.round(tg * 255 * L)); + d[i + 2] = Math.min(255, Math.round(tb * 255 * L)); + } + } + + function drawPlayTintedLayer(ctx, w, h, img, tintHex) { + if (!tintHex) { + ctx.drawImage(img, 0, 0, w, h); + return; + } + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + const x = c.getContext('2d'); + x.drawImage(img, 0, 0, w, h); + try { + const idata = x.getImageData(0, 0, w, h); + tintPlayLayerImageData(idata, tintHex); + x.putImageData(idata, 0, 0); + } catch (e) { + x.clearRect(0, 0, w, h); + x.drawImage(img, 0, 0, w, h); + } + ctx.drawImage(c, 0, 0, w, h); + } + + function ensurePlayLayerImage(url) { + let img = playLayerImageCache.get(url); + if (!img) { + img = new Image(); + img.src = url; + playLayerImageCache.set(url, img); + } + return img; + } + + /** อุ่น cache เลเยอร์สีของตัวละคร (ทุกทิศ + เดิน 0-3 + idle) ก่อนเข้าเกม กันกระตุกตอนโหลดสีกลางเกม + * ยิงเฉพาะ layer ที่ "มีจริง" (อ่านจาก manifest/disk set) เพื่อกัน 404 รัว — รอ manifest พร้อมก่อน */ + const _playPreloadedChars = new Set(); + const _playPreloadedTintKeys = new Set(); + function preloadPlayCharacterLayers(charId, _tries) { + if (!charId || _playPreloadedChars.has(charId)) return; + try { ensurePlayCharLayerListFetch(); } catch (e) { /* ignore */ } + try { ensurePlayLayerProbesAllDirections(charId); } catch (e) { /* ignore */ } + const diskSet = playCharDiskLayersSet(charId); + if (!diskSet) { + // manifest ยังไม่พร้อม — ลองใหม่อีกครู่ (กันยิง layer ที่ไม่มี = 404) + const t = _tries || 0; + if (t < 8) setTimeout(function () { preloadPlayCharacterLayers(charId, t + 1); }, 450); + return; + } + _playPreloadedChars.add(charId); + const dirs = ['down', 'up', 'left', 'right']; + const warm = function (urls) { if (urls && urls.length) ensurePlayLayerImage(urls[0]); }; + dirs.forEach(function (dir) { + PLAY_LAYER_ORDER.forEach(function (ln) { + if (ln !== 'shadow' && !diskSet.has(ln)) return; // ข้าม layer ที่ไม่มีไฟล์ + if (ln === 'face' && dir === 'up' && isUploadedCharAssetId(charId)) return; // up ไม่มี face + for (let fi = 0; fi < CHARACTER_ANIM_FRAMES; fi++) { + warm(ln === 'shadow' ? shadowUrlCandidates(charId, dir, fi) : layerUrlCandidates(charId, dir, ln, fi)); + } + warm(ln === 'shadow' ? shadowUrlCandidatesIdle(charId, dir, 0) : layerUrlCandidatesIdle(charId, dir, ln, 0)); + }); + }); + } + + /** อุ่น canvas ย้อมสี (head/hair/body) ต่อตัวละคร+สี */ + function preloadPlayTintForAvatar(charId, tint) { + if (!charId || !tint || typeof tint.head !== 'string') return; + const key = [charId, tint.head, tint.hair, tint.body].join('|'); + if (_playPreloadedTintKeys.has(key)) return; + _playPreloadedTintKeys.add(key); + preloadPlayCharacterLayers(charId); + const dirs = ['down', 'up', 'left', 'right']; + const now = Date.now(); + dirs.forEach(function (dir) { + const rawWalk = getCharacterFrame(charId, dir, now, true); + const rawIdle = getCharacterFrame(charId, dir, now, false); + if (rawWalk) getPlayTintedAvatarSource(rawWalk, charId, dir, now, true, tint); + if (rawIdle) getPlayTintedAvatarSource(rawIdle, charId, dir, now + 400, false, tint); + }); + } + + function preloadPlayOccupantsTints() { + const meCid = me.characterId || getPlayCharacterId(); + if (meCid) { + preloadPlayTintForAvatar(meCid, me.playTint || resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')))); + } + others.forEach(function (o, id) { + if (!o || !o.characterId) return; + preloadPlayTintForAvatar(o.characterId, o.playTint || resolvePlayTintForPeer(id)); + }); + } + + /** ลำดับลอง URL: เฟรมปัจจุบันแบบ multi → เฟรม 0 → แบบ single (ไม่มี _0_) เพื่อกันชื่อไฟล์ไม่ตรงกับที่เซิร์ฟเวอร์เขียน */ + function layerUrlCandidates(id, dir, layerName, frameIndex) { + const enc = encodeURIComponent(id); + const base = BASE + '/img/characters/' + enc + '_' + dir; + const out = []; + function add(u) { + if (out.indexOf(u) === -1) out.push(u); + } + add(base + '_' + frameIndex + '_layer_' + layerName + '.png'); + if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png'); + add(base + '_layer_' + layerName + '.png'); + return out; + } + + /** เลเยอร์ idle: id__idle__layer_.png หรือ id__idle_layer_.png */ + function layerUrlCandidatesIdle(id, dir, layerName, frameIndex) { + const enc = encodeURIComponent(id); + const base = BASE + '/img/characters/' + enc + '_' + dir + '_idle'; + const out = []; + function add(u) { + if (out.indexOf(u) === -1) out.push(u); + } + // idle เฟรมเดียว — ชื่อจริงของ char-* คือ *_idle_layer_*.png (ไม่มี frame index) ลองตัวนี้ก่อนกัน 404 รัว + add(base + '_layer_' + layerName + '.png'); + add(base + '_' + frameIndex + '_layer_' + layerName + '.png'); + if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png'); + return out; + } + + function shadowUrlCandidates(id, dir, frameIndex) { + const c = layerUrlCandidates(id, dir, 'shadow', frameIndex); + const def = BASE + '/img/default-shadow-' + dir + '.png'; + if (c.indexOf(def) === -1) c.push(def); + return c; + } + + function shadowUrlCandidatesIdle(id, dir, frameIndex) { + const c = layerUrlCandidatesIdle(id, dir, 'shadow', frameIndex); + const def = BASE + '/img/default-shadow-' + dir + '.png'; + if (c.indexOf(def) === -1) c.push(def); + return c; + } + + /** ลองทีละ URL — อย่า set src พร้อมกันหลายรูป (เดิมทำให้ legacy `id_dir_layer_x` โดน 404 ทุกเลเยอร์ทุกทิศทั้งที่มีแค่ `id_dir_0_layer_x`) */ + function resolvePlayLayerImage(urls) { + for (let i = 0; i < urls.length; i++) { + const img = ensurePlayLayerImage(urls[i]); + if (!img.complete) return { status: 'pending' }; + if (img.naturalWidth > 0) return { status: 'ok', img }; + } + return { status: 'missing' }; + } + + function ensurePlayLayerProbesAllDirections(characterId) { + if (!characterId || playLayerAllDirsQueued[characterId]) return; + playLayerAllDirsQueued[characterId] = true; + ['up', 'down', 'left', 'right'].forEach((d) => ensurePlayLayerProbe(characterId, d)); + } + + function playCharAnyDirectionLayered(characterId) { + if (!characterId) return false; + return ['up', 'down', 'left', 'right'].some((d) => playLayerMode[characterId + '|' + d] === 'layered'); + } + + /** รอเฉพาะตอนยังไม่เจอ layered เลย — ถ้ามีทิศหนึ่ง layered แล้ว ไม่ต้องรอทิศอื่น */ + function playCharLayerDiscoveryPending(characterId) { + if (!characterId) return true; + const api = playCharLayerFromApi(characterId); + if (api === true || api === false) return false; + if (playCharAnyDirectionLayered(characterId)) return false; + return ['up', 'down', 'left', 'right'].some((d) => { + const m = playLayerMode[characterId + '|' + d]; + return m === undefined || m === 'pending'; + }); + } + + function ensurePlayLayerProbe(characterId, dir) { + const key = characterId + '|' + dir; + if (playLayerMode[key] !== undefined && playLayerMode[key] !== 'pending') return; + if (playLayerMode[key] === 'pending') return; + playLayerMode[key] = 'pending'; + const cands = layerUrlCandidates(characterId, dir, 'bodyColor', 0); + let ci = 0; + function tryNextProbe() { + if (playLayerMode[key] !== 'pending') return; + if (ci >= cands.length) { + playLayerMode[key] = 'none'; + return; + } + const url = cands[ci++]; + const img = ensurePlayLayerImage(url); + const fin = () => { + if (playLayerMode[key] !== 'pending') return; + if (img.naturalWidth > 0) { + playLayerMode[key] = 'layered'; + return; + } + tryNextProbe(); + }; + img.onload = fin; + img.onerror = fin; + if (img.complete) fin(); + } + tryNextProbe(); + } + + function getCharacterAnimFrameIndex(id, dir, now, isWalking) { + if (!id) return 0; + const key = id + '_' + dir; + const anim = characterAnimations[key]; + if (!anim) return 0; + const phase = walkAnimPhaseIndex(now, isWalking); + const fi = pickLoadedWalkFrameIndex(anim, phase); + return fi >= 0 ? fi : 0; + } + + function tryComposePlayLayersFromFiles(rawImg, id, dir, frameIndex, tint, opts) { + const useIdle = opts && opts.idle; + if (!rawImg || !rawImg.naturalWidth || !rawImg.naturalHeight) return null; + const w = rawImg.naturalWidth, h = rawImg.naturalHeight; + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + const cctx = c.getContext('2d'); + let anyTintColorLayer = false; + let skippedShadowWhilePending = false; + const diskLayers = playCharDiskLayersSet(id); + /* ยืน (opts.idle): ลอง compose จาก *_idle_*_layer_* เหมือนหน้า character upload — รวม char-* ที่อัปโหลด idle เลเยอร์; ถ้าไม่มีไฟล์จะ missing → ไม่มีเลเยอร์ย้อม → getPlayTintedAvatarSource fallback ไป walk compose */ + const idleLayerStripes = !!useIdle; + const resDir = dir; + const resFi = frameIndex; + for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) { + const layerName = PLAY_LAYER_ORDER[li]; + if (diskLayers && layerName !== 'shadow' && !diskLayers.has(layerName)) continue; + /* char-* หันหลัง (up): ไม่มี face ทิศนี้ — ข้ามกันตาหน้าซ้อนหลังหัว; ซ้าย/ขวา/หน้าใช้ face ตามทิศได้ */ + if (layerName === 'face' && isUploadedCharAssetId(id) && dir === 'up') { + continue; + } + let urls; + if (layerName === 'shadow') { + urls = idleLayerStripes + ? shadowUrlCandidatesIdle(id, dir, frameIndex) + : shadowUrlCandidates(id, resDir, resFi); + } else { + urls = idleLayerStripes + ? layerUrlCandidatesIdle(id, dir, layerName, frameIndex) + : layerUrlCandidates(id, resDir, layerName, resFi); + } + const r = resolvePlayLayerImage(urls); + /* เงาโหลดช้า/404 บ่อย — อย่าให้บล็อกทั้งคอมโพส (ไม่งั้นตกไป fallback แถบแนวตั้งแทนเลเยอร์จริง) */ + if (r.status === 'pending' && layerName === 'shadow') { + skippedShadowWhilePending = true; + continue; + } + if (r.status === 'pending') return null; + if (r.status === 'missing') continue; + const tintHex = PLAY_LAYER_TINT_KEY[layerName] ? tint[PLAY_LAYER_TINT_KEY[layerName]] : null; + if (PLAY_LAYER_TINT_KEY[layerName]) anyTintColorLayer = true; + drawPlayTintedLayer(cctx, w, h, r.img, tintHex); + } + /* ถ้าโหลดได้แต่ไม่มีเลเยอร์สีเลย จะเหลือแต่ stroke → ตัวขาว — ใช้ PNG รวมแทน */ + if (!anyTintColorLayer) return null; + return { canvas: c, skipCache: skippedShadowWhilePending }; + } + + function getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, tint) { + if (!tint || !characterId || !rawImg) return rawImg; + ensurePlayCharLayerListFetch(); + ensurePlayLayerProbesAllDirections(characterId); + const dirn = dir || 'down'; + const frameIndexWalk = getCharacterAnimFrameIndex(characterId, dirn, timeMs, isWalking); + const idlePhase = idleAnimPhaseIndex(typeof timeMs === 'number' ? timeMs : Date.now()); + + if (playCharLayerDiscoveryPending(characterId)) return rawImg; + + const apiFlag = playCharLayerFromApi(characterId); + /* เหมือนเดิม: มีเลเยอร์แยกไฟล์เมื่อ API บอก hasLayerFiles หรือ probe เจอ — gen สีทำบน canvas จาก *_layer_* เท่านั้น */ + const charHasLayerFiles = apiFlag === true + || (apiFlag == null && playCharAnyDirectionLayered(characterId)); + + /* มี PNG idle รวม — ใช้เมื่อไม่มีระบบเลเยอร์สี (ถ้ามีเลเยอร์ต้อง compose ไม่ return raw ขาว) */ + if (!charHasLayerFiles && !isWalking && characterIdleSpritesVisible(characterId, dirn)) { + return rawImg; + } + const cacheKeyWalk = [characterId, dirn, frameIndexWalk, tint.head, tint.hair, tint.body, 'ct3'].join('|'); + + if (charHasLayerFiles) { + if (!isWalking) { + const composeFrame = isUploadedCharAssetId(characterId) ? 0 : idlePhase; + const cacheKeyIdle = [characterId, dirn, composeFrame, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|'); + const hitI = playLayerCompositeCache.get(cacheKeyIdle); + if (hitI) return hitI; + const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, composeFrame, tint, { idle: true }); + if (packI && packI.canvas) { + if (!packI.skipCache) playLayerCompositeCache.set(cacheKeyIdle, packI.canvas); + return packI.canvas; + } + } + const hit = playLayerCompositeCache.get(cacheKeyWalk); + if (hit) return hit; + const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, frameIndexWalk, tint, { idle: false }); + if (pack && pack.canvas) { + if (!pack.skipCache) playLayerCompositeCache.set(cacheKeyWalk, pack.canvas); + return pack.canvas; + } + return rawImg; + } + + /* + * ไม่มีไฟล์เลเยอร์ตาม API/probe — แสดง PNG รวม (ไม่ย้อมทั้งตัว: ไม่เหมือนระบบ gen สีแบบเลเยอร์เดิม) + */ + return rawImg; + } + + function getCharacterImg(id, direction) { + if (!id) return null; + const key = id + '_' + (direction || 'down'); + if (characterImages[key]) return characterImages[key]; + const img = new Image(); + img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + (direction || 'down') + '.png'; + characterImages[key] = img; + return img; + } + + function getCharacterFrame(id, direction, now, isWalking) { + if (!id) return null; + const dir = direction || 'down'; + const t = typeof now === 'number' ? now : Date.now(); + if (!isWalking) { + const idleAnim = ensureCharacterIdleAnim(id, dir); + let idlePhase = idleAnimPhaseIndex(t); + if (isUploadedCharAssetId(id)) idlePhase = 0; + const idleFi = pickLoadedWalkFrameIndex(idleAnim, idlePhase); + if (idleFi >= 0) return idleAnim.frames[idleFi]; + const idfb = idleAnim.fallback; + if (idfb && idfb.complete && idfb.naturalWidth) return idfb; + } + const key = id + '_' + dir; + let anim = characterAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterAnimations[key] = anim; + for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + const img = new Image(); + img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png'; + anim.frames.push(img); + } + anim.fallback = getCharacterImg(id, dir); + } + const phase = walkAnimPhaseIndex(now, isWalking); + const fi = pickLoadedWalkFrameIndex(anim, phase); + if (fi >= 0) return anim.frames[fi]; + const fb = anim.fallback; + if (fb && fb.complete && fb.naturalWidth) return fb; + return null; + } + + function getAvatarImg(characterId, direction, now, isWalking) { + /* 'Chatest' = placeholder ที่ไม่มีไฟล์ sprite จริง → อย่าโหลด (กัน 404 รัว) ใช้ default avatar generated แทน */ + const validId = characterId && characterId !== LEGACY_PLACEHOLDER_CHARACTER_ID; + const img = validId ? getCharacterFrame(characterId, direction, now, isWalking) : null; + if (img) return img; + return defaultAvatarImg; + } + + /** composite idle ลงหน้า — เดียวกับ #room-lobby-profile-avatar ในล็อบบี้ */ + const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:'; + const PLAY_LOBBY_AVATAR_SS_PREFIX = 'justicePlayLobbyAvatar:'; + const playLobbyIdleAvatarCache = new Map(); + + function playLobbyIdleAvatarSrcForCharacter(characterId) { + const id = String(characterId || '').trim(); + if (!id) return ''; + try { + const ss = sessionStorage.getItem(PLAY_LOBBY_AVATAR_SS_PREFIX + id) || ''; + if (ss && ss.indexOf('data:image/') === 0) return ss; + const ls = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id) || ''; + if (ls && ls.indexOf('data:image/') === 0) return ls; + } catch (e) { /* ignore */ } + return ''; + } + + function ensurePlayLobbyIdleAvatarImage(characterId) { + const id = String(characterId || '').trim(); + if (!id) return; + const hit = playLobbyIdleAvatarCache.get(id); + if (hit && hit.complete && hit.naturalWidth) return; + if (playLobbyIdleAvatarCache.get(id + '_loading')) return; + const src = playLobbyIdleAvatarSrcForCharacter(id); + if (!src) return; + const img = new Image(); + img.onload = function () { + playLobbyIdleAvatarCache.set(id, img); + playLobbyIdleAvatarCache.delete(id + '_loading'); + }; + img.onerror = function () { + playLobbyIdleAvatarCache.delete(id + '_loading'); + }; + img.src = src; + playLobbyIdleAvatarCache.set(id + '_loading', img); + } + + function getPlayLobbyIdleAvatarImageSync(characterId) { + const id = String(characterId || '').trim(); + if (!id) return null; + const hit = playLobbyIdleAvatarCache.get(id); + if (hit && hit.complete && hit.naturalWidth) return hit; + ensurePlayLobbyIdleAvatarImage(id); + return null; + } + + /** วาดตัวละคร — ทิศลง + useLobbyIdleComposite: composite เดียวกับล็อบบี้ (เฉพาะผู้เล่นจริง ตอนยืน); ตอนเดินใช้ strip เดิน down_0..3 */ + function resolvePlayDrawCharSource(characterId, direction, now, isWalking, playTint, useLobbyIdleComposite) { + /* placeholder 'Chatest' ไม่มี sprite/เลเยอร์จริง → ปฏิบัติเป็นว่าง (ใช้ default avatar) กัน 404 รัวทุกเส้นทางวาด */ + if (characterId === LEGACY_PLACEHOLDER_CHARACTER_ID) characterId = ''; + const dir = direction || 'down'; + const walk = !!isWalking; + if (dir === 'down' && useLobbyIdleComposite && !walk) { + const lobbyImg = getPlayLobbyIdleAvatarImageSync(characterId); + if (lobbyImg) return lobbyImg; + } + const rawImg = getAvatarImg(characterId, dir, now, walk); + if (!playTint || !characterId) return rawImg; + return getPlayTintedAvatarSource(rawImg, characterId, dir, now, walk, playTint) || rawImg; + } + + function preloadPlayLobbyIdleAvatarsForOccupants() { + const meCid = me.characterId || getPlayCharacterId(); + if (meCid) ensurePlayLobbyIdleAvatarImage(meCid); + others.forEach(function (o) { + if (o && o.characterId) ensurePlayLobbyIdleAvatarImage(o.characterId); + }); + } + + function playHideTopChrome() { + try { + return document.documentElement.classList.contains('play-hide-top-chrome'); + } catch (e) { + return true; + } + } + + function getStoredCharacterId() { + try { + const v = localStorage.getItem('gameCharacterId'); + if (v) return v; + } catch (e) { /* ignore */ } + if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID; + return firstCharacterDefaultResolved || ''; + } + function getPlayCharacterId() { + if (forceDefaultCharacter) { + if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID; + return firstCharacterDefaultResolved || ''; + } + return getStoredCharacterId(); + } + + /** playerKey ของผู้เล่น (ผูกบัญชีเหรียญ/คะแนน) — สร้างถ้ายังไม่มี เหมือน Main-Lobby */ + function getJdPlayerKey() { + try { + let k = (localStorage.getItem('jdPlayerKey') || '').trim(); + if (!k || k.length < 8) { + k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + localStorage.setItem('jdPlayerKey', k); + } + return k; + } catch (e) { + return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + } + } + + /** บอทพรีวิว: เลือกรหัสคนละตัวจาก roster (ไม่ซ้ำผู้เล่นถ้ามีตัวเลือก) */ + function pickPreviewBotCharacterId(botSlotIndex) { + const idx = Math.max(0, Math.floor(Number(botSlotIndex)) || 0); + const roster = playCharacterIdRoster; + const humanId = String(getPlayCharacterId() || ''); + if (!roster.length) return humanId || LEGACY_PLACEHOLDER_CHARACTER_ID; + const alts = roster.filter((id) => id !== humanId); + const pool = alts.length ? alts : roster.slice(); + return pool[idx % pool.length] || humanId || roster[0]; + } + const MOVE_SPEED = 0.15; + /* time-based movement: เดิม step=MOVE_SPEED ต่อเฟรม → จอ 120fps เดินเร็ว 2 เท่า (คน/บอทวิ่งเร็วช้าไม่เท่ากัน) + playDtMul = dt*60 (clamp ≤3 กัน tunneling) คูณเข้ากับ step ทุกที่ → ความเร็วเท่ากันทุก FPS */ + let playDtMul = 1; + let playTickLastTs = 0; + /** Anti-stuck (MG1/MG4): ดันชน "เพื่อน" กี่เฟรมก่อนปลดล็อกเดินผ่าน + ช่วงเวลาที่ผ่านได้ต่อเนื่อง */ + const ANTI_STUCK_PEER_TICKS = 13; + const ANTI_STUCK_WINDOW_MS = 650; + /** 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) * playDtMul; + } + + function isMovementKey(code) { + return ['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].indexOf(code) !== -1; + } + function isChatFocused() { + return false; + } + let zoom = 1.4; + let froggerScore = 0; + let lastFroggerKey = 0; + let gauntletObstacles = []; + /** อินเทอร์โพเลตการวาด obstacle ระหว่างแพ็กเก็ต sync (~220ms) */ + let gauntletObsRenderPrev = []; + let gauntletObsRenderNext = []; + let gauntletObsBlendT0 = 0; + let meGauntletJumpTicks = 0; + /** ค่าที่ใช้วาดการยกตัว (เลอร์ปจาก meGauntletJumpTicks ให้โค้งกระโดดไม่กระตุก) */ + let meGauntletJumpVis = 0; + /** ซิงก์จากเซิร์ฟเวอร์ (gauntlet-sync / GET /api/game-timing) */ + let gauntletRuntimeTickMs = 220; + let gauntletRuntimeJumpTicks = 16; + /** Stack: รอบสวิงต่อวินาที — จาก GET /api/game-timing · ค่าน้อย = สวิงช้า */ + let playStackSwingHz = 0.55; + /** Stack: ความกว้างบล็อก (tile) — null = คำนวณจากโซนลงบนแผนที่ */ + let playStackBlockWidthTiles = null; + /** ภารกิจ Tower (mnn93hpi): จำกัดเวลารอบ (วินาที) — จาก game-timing.json */ + let playStackTowerMissionTimeSec = 90; + /** ภารกิจ Tower: ทีมพลาดได้กี่ครั้งก่อนเกมจบ — จาก game-timing.json */ + let playStackTeamMissesMax = 3; + /** ภารกิจ Tower: ชั้นสำเร็จกี่ชั้น (ฐาน) = Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */ + let playStackTowerProgressBlocks = 50; + /** รูปบล็อก Stack ต่อที่นั่ง 1–6 (game-timing) — ว่าง = วาดสีเดิม */ + let playStackBlockNormalUrls = ['', '', '', '', '', '', '', '']; + let playStackBlockHeavyUrls = ['', '', '', '', '', '', '', '']; + /** 0–100: โอกาสใช้สไปรต์ “ใหญ่” ต่อการปล่อย (ถ้ามี URL ใหญ่ช่องนั้น) */ + let playStackHeavyBlockPercent = 35; + /** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัว (ch×tile) — จาก GET /api/game-timing */ + let playJumpSurviveJumpHeightMult = 1.5; + /** จำกัดเวลารอบภารกิจกระโดดขึ้นแท่น (วินาที) — จาก GET /api/game-timing; 0 = ใช้ค่าเริ่ม 60 ในเกม */ + let playJumpSurviveMissionTimeSec = 0; + /** รูปแพลตฟอร์ม jump_survive ช่อง 1–3 จาก game-timing — url ว่าง = วาด cyan; w/h 0 = ใช้ขนาดไทล์ */ + let playJumpSurvivePlatformTiles = [ + { url: '', w: 0, h: 0 }, + { url: '', w: 0, h: 0 }, + { url: '', w: 0, h: 0 }, + ]; + /** ยิงยานอวกาศ (space_shooter): เวลารอบจาก game-timing; 0 = ใช้ค่าเริ่ม 90 ในเกมถ้าแมปไม่ทับ */ + let playSpaceShooterMissionTimeSec = 0; + /** รูปยานช่อง 1–6 จาก game-timing (spawn slot); ว่าง = วาดยานเวกเตอร์ */ + let playSpaceShooterShipImageUrls = ['', '', '', '', '', '']; + /** อุกาบาต: [0]=ตก, [1..]=แตก — จาก game-timing */ + let playSpaceShooterAsteroidSpriteUrls = []; + let playSpaceShooterAsteroidExplodeFrameMs = 70; + /** ระยะห่างเกิดอุกาบาต (ms) จาก game-timing — แมป spaceShooterAsteroidIntervalMs ≥200 ทับ */ + let playSpaceShooterAsteroidIntervalMs = 1040; + /** ทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (PNG โปร่ง) — ภารกิจ Violent Crime */ + let playSpaceShooterShipDamageOverlayUrls = ['', '', '']; + /** balloon_boss / Mega Virus — จาก game-timing */ + let playBalloonBossMissionTimeSec = 0; + let playBalloonBossBossImageUrl = ''; + let playBalloonBossPlayerBalloonImageUrls = ['', '', '', '', '', '']; + let playBalloonBossPlayerBalloonFallbackUrl = ''; + /** 0 = ไม่จำกัด — จาก game-timing / gauntlet-sync (พรมแดง Gauntlet เท่านั้น) */ + let gauntletRuntimeTimeLimitSec = 0; + /** เวลาสิ้นสุดรอบ (epoch ms) จากเซิร์ฟเวอร์ — null = ไม่จับเวลา */ + let gauntletEndsAtMs = null; + /** Crown (mno9kb07): เซิร์ฟยังไม่ปล่อยรัน — หยุด tick / เวลา */ + let gauntletCrownRunHeldRemote = false; + /** null | 'howto' | 'countdown' | 'live' — พรีรันก่อน GO */ + let gauntletCrownPregamePhase = null; + let gauntletCrownLobbyReadyMap = {}; + /** จำนวนผู้เล่น (มนุษย์) ที่ server คาดว่าจะเข้าเล่นรอบนี้ — host เริ่มได้ต่อเมื่อมาครบ + กด Ready ครบ */ + let missionExpectedActiveCount = 0; + let gauntletCrownCountdownTimer = null; + let lastGauntletJumpKey = 0; + /** รูป lane: หลาย URL สุ่มตาม id คงที่ · laser: แยกบน/ล่าง/เส้น + สี/ความหนา */ + let gauntletLaneImageUrls = []; + let gauntletLaserTopUrl = ''; + let gauntletLaserBottomUrl = ''; + let gauntletLaserLineUrl = ''; + let gauntletLaserFillColor = 'rgba(140,230,255,0.42)'; + let gauntletLaserStrokeColor = 'rgba(255,220,255,0.9)'; + let gauntletLaserLineWidthPx = 2; + const gauntletAssetImageCache = new Map(); + + function encodeSpacesInUrlPath(t) { + const s = String(t || ''); + const q = s.indexOf('?'); + const pathPart = q === -1 ? s : s.slice(0, q); + const query = q === -1 ? '' : s.slice(q); + return pathPart.replace(/ /g, '%20') + query; + } + + function decodeAssetUrlPercentRuns(t) { + let s = String(t || ''); + const q = s.indexOf('?'); + let base = q === -1 ? s : s.slice(0, q); + const query = q === -1 ? '' : s.slice(q); + for (let i = 0; i < 2; i += 1) { + try { + const d = decodeURIComponent(base); + if (d === base) break; + base = d; + } catch (e) { + break; + } + } + return base + query; + } + + function normalizeGameAssetUrlForWebPlay(t) { + let s = String(t || ''); + s = s.replace(/^\/Game\/public\/img\//i, '/Game/img/'); + s = s.replace(/^Game\/public\/img\//i, 'Game/img/'); + return s; + } + + /** แปลง URL จาก Admin/game-timing ให้โหลดได้ (nginx เสิร์ฟจาก /Game/...) */ + function normalizeGauntletAssetUrlForPlay(u) { + if (typeof u !== 'string') return ''; + let raw = normalizeGameAssetUrlForWebPlay(decodeAssetUrlPercentRuns(u.trim())); + const bare0 = raw.split('?')[0].replace(/\/+$/, ''); + if (/^\/Game\/img\/MegaVirus\/Artboard$/i.test(bare0) || /^Game\/img\/MegaVirus\/Artboard$/i.test(bare0)) { + raw = '/Game/img/MegaVirus/Artboard%209.png'; + } + if (!raw) return ''; + if (/^https?:\/\//i.test(raw)) { + const z = raw.replace(/\/+$/, ''); + return z ? encodeSpacesInUrlPath(z) : ''; + } + const qIdx = raw.indexOf('?'); + const base = (qIdx >= 0 ? raw.slice(0, qIdx) : raw).trim().replace(/\/+$/, ''); + const qs = qIdx >= 0 ? raw.slice(qIdx) : ''; + if (!base) return ''; + if (/\.{4,}/.test(base)) return ''; + if (base.startsWith('/')) return encodeSpacesInUrlPath(base + qs); + if (/^Game\//i.test(base)) return encodeSpacesInUrlPath('/' + base.replace(/^\/+/, '') + qs); + return encodeSpacesInUrlPath(base + qs); + } + + function ensureGauntletAssetImage(url) { + const u = normalizeGauntletAssetUrlForPlay(typeof url === 'string' ? url : ''); + if (!u) return null; + let rec = gauntletAssetImageCache.get(u); + if (rec) return rec; + rec = { img: new Image(), ready: false }; + if (/^https?:\/\//i.test(u)) { + try { rec.img.crossOrigin = 'anonymous'; } catch (e) { /* ignore */ } + } + rec.img.onload = function () { rec.ready = true; }; + rec.img.onerror = function () { rec.ready = false; }; + rec.img.src = u; + /** รูปจากแคชบางครั้ง complete ก่อน onload — ต้องเซ็ต ready ทันทีไม่งั้นวาดลูกโป่งไม่ขึ้น */ + if (rec.img.complete && rec.img.naturalWidth > 0) rec.ready = true; + else if (rec.img.complete && rec.img.naturalWidth <= 0) rec.ready = false; + gauntletAssetImageCache.set(u, rec); + return rec; + } + + /** ลูกโป่ง Mega Virus: ถ้ามี URL ต่อที่นั่งแต่โหลดไม่สำเร็จ (404) ให้ใช้ fallback แทน · Per-seat URL wins only when image loads */ + function resolveBalloonBossBalloonSpriteRec(perSeatRaw, fallbackRaw) { + const per = normalizeGauntletAssetUrlForPlay(String(perSeatRaw || '').trim()); + const fb = normalizeGauntletAssetUrlForPlay(String(fallbackRaw || '').trim()); + if (per) { + const rPer = ensureGauntletAssetImage(per); + const img = rPer && rPer.img; + if (img && img.complete && img.naturalWidth > 0) return rPer; + if (img && img.complete && img.naturalWidth <= 0 && fb) return ensureGauntletAssetImage(fb); + return rPer; + } + if (fb) return ensureGauntletAssetImage(fb); + return null; + } + + function pickGauntletLaneImageRec(obsId) { + if (!gauntletLaneImageUrls.length) return null; + const n = gauntletLaneImageUrls.length; + const idx = Math.abs(Number(obsId) | 0) % n; + const u = gauntletLaneImageUrls[idx]; + return u ? ensureGauntletAssetImage(u) : null; + } + + function clampClientLaserColor(s, def) { + const t = String(s || '').trim().slice(0, 100); + if (!t || /[<>"'`]/.test(t)) return def; + return t; + } + + let playQuizPhaseLocal = null; + let playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + let playQuizText = ''; + let playQuizPhaseEndsAt = 0; + let playQuizTimerInterval = null; + /** เกมถูก/ผิด + ภารกิจ mng8a80o: เลขข้อจากเซิร์ฟเวอร์ (1-based) / จำนวนข้อในรอบ — ใช้บรรทัดเล็กเหนือคำถามบนแผนที่ */ + let playQuizQuestionIndex = 0; + let playQuizQuestionTotal = 0; + /** Preview-only: real question pool + phased timer (matches server quiz-settings / map quizQuestions). */ + let previewQuizPool = []; + /** ดัชนีข้อในรอบพรีวิว (0..len-1) — หลังสับแล้วตัดตาม quizRoundQuestionCount เหมือนเซิร์ฟเวอร์ */ + let previewQuizQIndex = 0; + let previewQuizTiming = { readMs: 10000, answerMs: 5000, betweenMs: 3500 }; + let previewQuizStep = 'read'; + let previewQuizCurrent = null; + let playLiveQuizScores = {}; + /** เกมถูก/ผิด (multiplayer): ใครเคยตอบผิดในเซสชันนี้ — ใช้โชว์แสตมป์ในสรุปภารกิจ mng8a80o */ + let playQuizEverWrong = {}; + /** MG1: กันสั่งจบซ้ำเมื่อตรวจพบ "ตายหมด" (รวมบอท ฝั่ง client) */ + let mg1AllDeadEndScheduled = false; + /** MG1: ตรวจว่าทุกคน(เรา+บอท+ผู้เล่นอื่น)ถูกล็อกตอบผิดหมด → จบเกมทันที (เฉพาะโหมดสืบสวน) */ + function checkMg1AllDeadAndEnd() { + if (mg1AllDeadEndScheduled || previewMode) return; + if (!isQuiz() || !isDetectiveMinigamePlay()) return; + const meLocked = !!(playQuizPlayerLocal && playQuizPlayerLocal.cannotTrue && playQuizPlayerLocal.cannotFalse); + if (!meLocked) return; + let anyAlive = false; + others.forEach(function (o, id) { + if (!o || o.bannedSpectator) return; + if (!playBotsEnabled() && isPreviewBotId(id)) return; + if (!(o.quizCannotTrue && o.quizCannotFalse)) anyAlive = true; + }); + if (anyAlive) return; + mg1AllDeadEndScheduled = true; + /* ทุกคนตอบผิดหมด → host ขอให้ server จบเกม → server ส่ง quiz-ended → โชว์ผล/รอกดรับการ์ด (เหมือนเกมอื่น) + เดิม: auto tryFinish = รับผล/การ์ดเองทันที ไม่รอกด */ + setTimeout(function () { + try { + if (isMePlayHost() && socket && socket.connected) socket.emit('quiz-end-request'); + /* non-host: ไม่ต้องทำอะไร รอ server ส่ง quiz-ended มาโชว์ผล */ + } catch (e) { /* ignore */ } + }, 1600); + } + /** ผีตามตัวเมื่อตอบผิด — ใช้เฉพาะแมป mng8a80o (เป็นสัญลักษณ์/บทลงโทษเชิงสนุก) */ + const QUIZ_WRONG_GHOST_URL = BASE + '/img/QUESTION/ghost/ghost-front.png'; + let quizWrongGhostImg = null; + function getQuizWrongGhostImg() { + if (quizWrongGhostImg) return quizWrongGhostImg; + const img = new Image(); + img.decoding = 'async'; + img.src = QUIZ_WRONG_GHOST_URL; + quizWrongGhostImg = img; + return img; + } + /** ===== โซนผี (quizGhostArea): ตอบผิด→วาร์ปมาขังในโซน เดินออกไม่ได้ — gated: ทำงานเฉพาะเมื่อแมปมีการวาดโซนผี ===== */ + function quizGhostAreaGridPlay() { + const g = mapData && mapData.quizGhostArea; + if (!Array.isArray(g)) return null; + for (let y = 0; y < g.length; y++) { if (g[y] && g[y].some((v) => v === 1)) return g; } + return null; + } + function quizMeIsGhostPlay() { + return !!(isQuiz() && playQuizPlayerLocal && (playQuizPlayerLocal.cannotTrue || playQuizPlayerLocal.cannotFalse)); + } + function quizGhostConfineActivePlay() { + return quizMeIsGhostPlay() && !!quizGhostAreaGridPlay(); + } + /** footprint ของผู้เล่นที่ (x,y) อยู่ในโซนผีครบทุกช่องไหม */ + function quizGhostFootprintInsidePlay(x, y) { + const g = quizGhostAreaGridPlay(); + if (!g) return true; + for (const k of quizTilesFootprintPlay(x, y)) { + const p = k.split(','); const tx = +p[0], ty = +p[1]; + if (!g[ty] || g[ty][tx] !== 1) return false; + } + return true; + } + /** วาร์ป me เข้าโซนผี (ช่องที่ footprint ลงได้ ใกล้กลางโซนสุด) — คืน true ถ้ามีโซนผี */ + function warpMeIntoQuizGhostZonePlay() { + const g = quizGhostAreaGridPlay(); + if (!g) return false; + if (quizGhostFootprintInsidePlay(me.x, me.y)) return true; + const xs = [], ys = []; + for (let y = 0; y < g.length; y++) { const row = g[y] || []; for (let x = 0; x < row.length; x++) { if (row[x] === 1) { xs.push(x); ys.push(y); } } } + if (!xs.length) return false; + const cx = Math.round((Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2); + const cy = Math.round((Math.min.apply(null, ys) + Math.max.apply(null, ys)) / 2); + const cand = []; + for (let i = 0; i < xs.length; i++) cand.push([xs[i], ys[i], Math.abs(xs[i] - cx) + Math.abs(ys[i] - cy)]); + cand.sort((a, b) => a[2] - b[2]); + let tx = xs[0], ty = ys[0]; + for (const c of cand) { if (quizGhostFootprintInsidePlay(c[0], c[1])) { tx = c[0]; ty = c[1]; break; } } + me.x = tx; me.y = ty; me.tx = tx; me.ty = ty; + try { if (socket && socket.connected) socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } catch (e) { /* ignore */ } + return true; + } + + /** สุ่มช่องในโซนผีที่ footprint ลงได้ (ใช้คุมบอทผีให้อยู่ในโซน) — คืน {x,y} หรือ null */ + function randomGhostZoneCellPlay() { + const g = quizGhostAreaGridPlay(); + if (!g) return null; + const cells = []; + for (let y = 0; y < g.length; y++) { + const row = g[y] || []; + for (let x = 0; x < row.length; x++) { + if (row[x] === 1 && quizGhostFootprintInsidePlay(x, y)) cells.push({ x: x, y: y }); + } + } + if (!cells.length) { + for (let y = 0; y < g.length; y++) { + const row = g[y] || []; + for (let x = 0; x < row.length; x++) if (row[x] === 1) cells.push({ x: x, y: y }); + } + } + return cells.length ? cells[Math.floor(Math.random() * cells.length)] : null; + } + + /** ตรวจว่า peer/me ตอบผิดในแมป mng8a80o แล้ว → คุมผีแทน avatar */ + function isQuizWrongGhostActiveForEnt(entId) { + if (!isQuizQuestionMissionUiMapPlay()) return false; + const sid = String(entId == null ? '' : entId); + const isMeEnt = (myId != null && sid === String(myId)); + return isMeEnt + ? !!(playQuizPlayerLocal && (playQuizPlayerLocal.cannotTrue || playQuizPlayerLocal.cannotFalse)) + : !!playQuizEverWrong[sid]; + } + + /** วาด "ผี" แทน avatar ทั้งตัวในแมป mng8a80o เมื่อตอบผิด — คุมผีเลย + * args ทุกตัวเป็น "screen pixels" แล้ว (คำนวณจาก worldToScreen ที่ caller) + * - sxCenter = พิกัด X กึ่งกลาง avatar บนจอ (px) + * - syBottom = พิกัด Y เท้า avatar บนจอ (px) + * - heightPx = ความสูง avatar บนจอ (px) ใช้คุมขนาดผีให้พอดี + */ + function drawQuizWrongGhostOverlayPlay(sxCenter, syBottom, heightPx, entId, nameLabel) { + const img = getQuizWrongGhostImg(); + if (!img || !img.complete || !img.naturalWidth) return; + /* ผีสูงเท่า avatar ตามจริง — คงสัดส่วนภาพ */ + const hPx = Math.max(24, Number(heightPx) || 0); + const ratio = img.naturalWidth / Math.max(1, img.naturalHeight); + const wPx = hPx * ratio; + const sid = String(entId == null ? '' : entId); + const tBob = Math.sin(performance.now() / 360 + (sid ? sid.charCodeAt(0) || 0 : 0)) * (hPx * 0.04); + /* วาดให้ "เท้าผี" ตรงกับเท้า avatar */ + const drawX = sxCenter - wPx / 2; + const drawY = syBottom - hPx + tBob; + ctx.save(); + ctx.globalAlpha = 0.98; + ctx.drawImage(img, drawX, drawY, wPx, hPx); + ctx.restore(); + /* ชื่อใต้ผี (ถ้ามี — เพื่อระบุว่าเป็นใคร) */ + if (nameLabel) { + ctx.save(); + ctx.font = '600 12px system-ui, "Segoe UI", "Kanit", sans-serif'; + ctx.textAlign = 'center'; + ctx.fillStyle = 'rgba(255,255,255,0.95)'; + ctx.strokeStyle = 'rgba(0,0,0,0.85)'; + ctx.lineWidth = 3; + const ny = syBottom + 14; + ctx.strokeText(nameLabel, sxCenter, ny); + ctx.fillText(nameLabel, sxCenter, ny); + ctx.restore(); + } + } + /** ช่วยแปลง tile coords → screen pos สำหรับ ghost (ใช้เฉพาะที่ caller ใน render scope) + * รับ helpers ที่เป็น scope-local (worldToScreen, zDraw) เพราะถูกเรียกในรอบ draw เท่านั้น */ + /** row ล่างสุดของโซนผี (cache บน mapData) — ใช้ clamp ภาพผีให้อยู่ในโซน */ + function quizGhostZoneMaxRowPlay() { + if (!mapData) return null; + if (mapData._ghostMaxRow !== undefined) return mapData._ghostMaxRow; + const g = mapData.quizGhostArea; + let maxY = null; + if (Array.isArray(g)) { + for (let y = 0; y < g.length; y++) { + const row = g[y] || []; + for (let x = 0; x < row.length; x++) { if (Number(row[x]) === 1) maxY = y; } + } + } + mapData._ghostMaxRow = maxY; + return maxY; + } + function _quizGhostScreenAnchorPlay(axTile, ayTile, w2s, zDrawIn) { + const md = mapData; + const { cw, ch } = getCharacterFootprintWH(md); + const cxWorld = (axTile + cw * 0.5) * tileSize; + /* anchor เท้าผีตามปกติ (ayTile + ch) แต่ clamp ไม่ให้ต่ำกว่าขอบล่างโซนผี (maxRow + 0.6) + → ภาพผีอยู่ "ในโซนผี" พอดี: ไม่ลอยสูงเกิน (เคส ayTile) และไม่ห้อยลงทับ SAFE/SCAM (เคส ayTile+ch) */ + let bottomTile = ayTile + ch; + const maxRow = quizGhostZoneMaxRowPlay(); + if (maxRow != null) { + const clampBottom = maxRow + 0.6; + if (bottomTile > clampBottom) bottomTile = clampBottom; + } + const cyBottomWorld = bottomTile * tileSize; + const [sx, sy] = w2s(cxWorld, cyBottomWorld); + /* ความสูง = สูง avatar บนจอ (สอดคล้องกับ drawAvatar: boxH = tileSize * zDraw * ch * 1.35) */ + const sizeH = tileSize * zDrawIn * Math.max(ch, 1) * 1.35; + return { sx, sy, sizeH }; + } + /** เกมถูก/ผิด — ตรงกับ server QUIZ_TF_POINTS_PER_CORRECT */ + const QUIZ_TF_POINTS_PER_CORRECT = 10; + const QUIZ_TF_SCORE_PLUS_URL = BASE + '/img/QUESTION/score+.png'; + const QUIZ_TF_SCORE_POPUP_MS = 1700; + /** ขนาดป๊อปอัป score+ เทียบกับ tile — เดิม ~1.35×tile; +100% = 2× → 2.7×tile */ + const QUIZ_TF_SCORE_POPUP_TILE_MULT = 2.7; + const QUIZ_TF_SCORE_POPUP_MIN_BASE_PX = 112; + let quizTfScorePopups = []; + let quizTfScorePlusImg = null; + /** คะแนนต่อคำตอบที่ถูกเมื่อส่งป้ายที่ฮับ/โซนส่ง — quiz_carry เท่านั้น */ + const QUIZ_CARRY_POINTS_PER_CORRECT = 10; + /** ครบเซสชัน (แมปสรุปภารกิจ): โชว์ result-complete / gameover กี่ ms ก่อนแผงสรุปผล */ + const QUIZ_CARRY_SESSION_END_SPLASH_MS = 3000; + /** quiz_carry: หยิบตัวเลือกมาวางโซนกลาง — เล่นพร้อมกัน (ไม่สลับตา) */ + let quizCarryPool = []; + let quizCarryCurrent = null; + /** editor embed + preview: นับ 3–2–1 ก่อนโชว์คำถามและให้เดิน/หยิบได้ */ + let quizCarryEmbedPendingQuestion = null; + let quizCarryEmbedCountdownStartAt = 0; + let quizCarryEmbedCountdownEndAt = 0; + /** editor embed: นับ 3–2–1 รอบสองหลังโชว์คำถาม ก่อนเปิดป้ายตัวเลือกบนแมป */ + let quizCarryEmbedPreOptionCountdownStartAt = 0; + let quizCarryEmbedPreOptionCountdownEndAt = 0; + /** quiz_carry: epoch ให้หยิบตัวเลือกได้ · epoch ปิดรอบตอบ (ตั้งที่ Admin แท็บหยิบมาวาง) */ + let quizCarryOptionRevealAt = 0; + let quizCarryAnswerCloseAt = 0; + /** หมดเวลาตอบรอบนี้แล้ว — โชว์ TIME'S UP ~3s แล้วเรียก quizCarryAfterRoundResolved */ + let quizCarryAnswerTimeupAwaitNext = false; + let quizCarryAnswerRoundTimeupAutoT = null; + let quizCarryAnswerRoundTimeupAdvanceAt = 0; + let quizCarryCarryTimingMs = { carryReadMs: 3000, carryAnswerMs: 5000 }; + /** พรีวิว (รวม editor embed): จำนวนข้อที่เล่นก่อนโอเวอร์เลย์จบ — 0 = ไม่จบอัตโนมัติ */ + let quizCarrySessionLength = 0; + let quizCarryRoundsCompleted = 0; + let quizCarrySessionEnded = false; + /** หลัง timeup บนโต๊ะ (embed): รอ 5s แล้วโชว์ result-complete / result-gameover */ + let quizCarryResultEndTimer = null; + /** หลังโชว์ result-end (embed preview): รอ 5s แล้วกลับ room-lobby — ใช้ร่วม quiz_carry + crown */ + let embedPreviewLobbyReturnTimer = null; + /** ครบเซสชัน (mission summary แมป): รอ splash แล้วเปิด #quiz-carry-mission-overlay */ + let quizCarrySessionCompleteResultToSummaryT = null; + /** embed พรีวิว: รอ Ready / START ของโฮสต์ก่อนนับ 3–2–1 */ + let quizCarryPregameActive = false; + let playHostId = null; + /** ซิงก์จากเซิร์ฟเวอร์: socketId → กด Ready แล้ว */ + let quizCarryLobbyReadyMap = {}; + /** จาก /api/quiz-settings carryMapPanelTheme — ใช้กับ #quiz-map-question-panel เฉพาะ quiz_carry */ + let quizCarryMapPanelTheme = null; + /** จาก /api/quiz-settings quizMapPanelTheme — แผงคำถามบนแผนที่ เกมถูก/ผิด (ไม่ใช่ quiz_carry) */ + let quizMapPanelTheme = null; + /** จาก /api/quiz-settings carryEmbedCountdownTheme — สี/ขนาด overlay นับ 3-2-1 (embed) */ + let quizCarryEmbedCountdownTheme = null; + /** จาก /api/quiz-settings carryChoicePlaqueThemes — ป้าย canvas ต่อช่องตัวเลือก 0..15 (null = ใช้ค่าเริ่มต้นทุกช่อง) */ + let quizCarryChoicePlaqueThemes = null; + /** จาก carryChoicePlaqueMapScale — ขยายป้ายบนแมป + ฟอนต์ (0.85–2.5) */ + let quizCarryPlaqueMapScale = 1.25; + const quizCarryChoiceImageCache = new Map(); + /** สแนปจาก join-space (Node) — ใช้เมื่อ fetch HTTP quiz-settings ไม่ได้หรือไม่มีธีม */ + let quizCarryJoinSettingsSnap = null; + + /** quiz_carry — โซนตัวเลือกบนแมป / ธีมป้ายต่อช่องสูงสุด 16 */ + const QUIZ_CARRY_MAX_OPTION_SLOTS = 16; + + /** jump_survive — กล้องตามตัวในกรอบ + แพลตฟอร์มเลื่อนลง (scroll เพิ่ม world Y ของแพลตฟอร์ม) ไม่ลากทั้งฉาก */ + let jumpSurviveCamCenterX = 0; + let jumpSurviveCamCenterY = 0; + let jumpSurviveEliminated = false; + /** จับเวลาโหมดกระโดดให้รอด (วินาทีบน HUD) */ + let jumpSurviveSessionStartMs = 0; + /* เวลา server (epoch ms) ที่ live เริ่มจริง — ใช้คำนวณ scroll/เวลานับถอยหลังจาก serverNow() ร่วมกัน + ทุกเครื่องเห็นแพลตฟอร์มเลื่อนเท่ากัน + จบพร้อมกัน (เดิมสะสม dt local → เลื่อน/จบไม่ตรงกัน = desync) */ + let jumpSurviveLiveAnchorServerMs = 0; + let jumpSurvivePlatformScrollPx = 0; + let jumpSurviveVy = 0; + let jumpSurviveLastTickMs = 0; + let jumpSurviveJumpQueued = false; + let jumpSurviveOnGround = false; + let jumpSurviveLastEmitT = 0; + /** mnptfts2: howto → countdown → live → ended (คะแนน 100 ผู้รอดสุดท้าย) */ + let jumpSurviveMissionPhase = null; + let jumpSurviveGameEnded = false; + let jumperMissionCountdownTimer = null; + + /** space_shooter — ยิงหิน (จุดเกิดจากกริด P1–P6) */ + let spaceShooterBullets = []; + let spaceShooterAsteroids = []; + /** { x, y, r, fi, acc } — fi = index เฟรมแตก (0 = urls[1]) */ + let spaceShooterAsteroidExplosions = []; + let spaceShooterPopups = []; + let spaceShooterLastTickMs = 0; + let spaceShooterSpawnAccMs = 0; + /* เวลา server ที่ live เริ่ม + ตัวนับลำดับก้อนที่เกิดแล้ว — ใช้ทำ deterministic spawn ให้ทุกเครื่องตรงกัน */ + let spaceShooterLiveAnchorServerMs = 0; + let spaceShooterSpawnCount = 0; + let spaceShooterFireCd = 0; + let spaceShooterSessionStartMs = 0; + let spaceShooterLastMoveEmit = 0; + let spaceShooterGameEnded = false; + let spaceShooterMissionPhase = null; + let spaceShooterMissionCountdownTimer = null; + + /** mng8a80o: howto → countdown → live → ended (สรุปแบบ crown mission) */ + let quizQuestionMissionPhase = null; + let quizQuestionMissionCountdownTimer = null; + let quizQuestionMissionDeferredPhase = null; + /** mng8a80o live: virtual joystick (+x right, +y down) normalized ~[-1,1] */ + let quizQuestionMissionJoyVecX = 0; + let quizQuestionMissionJoyVecY = 0; + let quizQuestionMissionJoyPointerId = null; + /** Stack Tower (mnn93hpi) — flow เดียวกับ crown / quiz mission */ + let stackTowerMissionPhase = null; + let stackTowerMissionCountdownTimer = null; + let stackTowerMissionDeferredPhase = null; + let stackTowerSessionStartAt = 0; + let stackTowerMissionEndedOnce = false; + /** progress ข้ามเกณฑ์ Tower: เริ่มแอนิเมตซูม+เลื่อนแมป — null ยังไม่ข้าม, ตัวเลข = performance.now() ตอนเริ่ม */ + let stackTowerPost50AnimStartMs = null; + /** แฟลชรูปผล Tower (timeup / gameover / complete) ก่อน GCM 5 วิ */ + let stackTowerResultFlashTimer = null; + const STACK_TOWER_RESULT_FLASH_MS = 5000; + + /** รูปอุกาบาตขณะตก: 0 = เต็ม HP (Meteo-1); 1 = โดนยิงแล้วแต่ยังมี HP (Meteo-2 เมื่อมี URL ≥3) */ + function spaceShooterAsteroidLiveSpriteIndexPlay(a) { + const urls = playSpaceShooterAsteroidSpriteUrls; + if (!urls || !urls.length) return 0; + const maxHp = Math.max(1, Math.floor(Number(a && a.maxHp)) || SPACE_SHOOTER_ASTEROID_MAX_HP); + const hp = Math.max(0, Math.floor(Number(a && a.hp)) || maxHp); + if (hp >= maxHp) return 0; + if (urls.length >= 3) return 1; + return 0; + } + + /** เริ่มแอนิเมชันแตกที่เฟรมถัดจาก «โดนแล้ว» เพื่อไม่ซ้ำ Meteo-2 — แสดง Meteo-3 แล้วจบตาม maxFi */ + function spaceShooterAsteroidExplosionStartFiPlay() { + const urls = playSpaceShooterAsteroidSpriteUrls; + if (!urls || urls.length < 2) return 0; + return urls.length >= 3 ? 1 : 0; + } + + function spaceShooterSpawnAsteroidExplosion(worldX, worldY, r) { + const urls = playSpaceShooterAsteroidSpriteUrls; + if (!urls || urls.length < 2) return; + spaceShooterAsteroidExplosions.push({ + x: worldX, + y: worldY, + r: Math.max(6, Number(r) || 14), + fi: spaceShooterAsteroidExplosionStartFiPlay(), + acc: 0, + }); + } + + function spaceShooterTickAsteroidExplosions(dt) { + const urls = playSpaceShooterAsteroidSpriteUrls; + if (!urls || urls.length < 2) { + spaceShooterAsteroidExplosions.length = 0; + return; + } + const fms = Math.max(30, Math.min(500, Number(playSpaceShooterAsteroidExplodeFrameMs) || 70)); + const maxFi = urls.length - 2; + for (let i = spaceShooterAsteroidExplosions.length - 1; i >= 0; i--) { + const ex = spaceShooterAsteroidExplosions[i]; + ex.acc += dt * 1000; + while (ex.acc >= fms) { + ex.acc -= fms; + ex.fi++; + if (ex.fi > maxFi) { + spaceShooterAsteroidExplosions.splice(i, 1); + break; + } + } + } + } + + /** balloon_boss — ลูกโป้งยิงบอส (Mega Virus) */ + let balloonBossPendingShots = []; + let balloonBossPlayerBullets = []; + let balloonBossBossBullets = []; + let balloonBossSessionStartMs = 0; + let balloonBossLastTickMs = 0; + let balloonBossLastMoveEmit = 0; + let balloonBossPlayerFireCd = 0; + /* ปุ่ม SHOOT บนจอ (MG7) กด 1 ครั้ง = ยิง 1 นัด — ตั้ง flag ให้ tick ยิงตามมุมลูกศร เหมือนกด Space */ + let balloonBossFireBtnQueued = false; + let balloonBossGameEnded = false; + let balloonBossHitFx = []; + /** Mega Virus — ป๊อปดาเมจต่อบอส (ข้อความ +2) เมื่อกระสุนโดนบอส */ + let balloonBossScorePopups = []; + let balloonBossBossFireAcc = 0; + /* anchor เวลา server + ตัวนับกระสุนบอส — deterministic boss fire ให้ทุกเครื่องยิงทิศ/เวลาเดียวกัน */ + let balloonBossLiveAnchorServerMs = 0; + let balloonBossBossShotCount = 0; + /* MG7 host-authoritative boss HP: host broadcast ดาเมจรวมเป็นระยะ (balloon-boss-sync) + → non-host แสดง HP บอสตามค่า host เป๊ะ (null = ยังไม่ได้รับ → fallback ผลรวมท้องถิ่น) */ + let balloonBossHostBossDmg = null; + let balloonBossLastSyncEmit = 0; + /** quiz_battle — โดม MCQ กด E (ข้อจาก battleQuizMcq) */ + let quizBattleMcqPool = []; + let quizBattleAnsweredComps = new Set(); + let quizBattleModalCompId = null; + let qbAutoLastComp = null; // โดมล่าสุดที่ trigger อัตโนมัติ (กันเด้งซ้ำขณะยืนค้าง) + /** ประวัติคำตอบต่อโดม (สำหรับ MY RESULT): compId → { qIndex, firstTry(bool), correct(bool), submittedIdx, q } */ + let quizBattleAnswerLog = new Map(); + /** เวลาเริ่มเล่นห้อง quiz_battle (ms) — สำหรับ Total time */ + let quizBattleSessionStartMs = 0; + let quizBattleScoreSubmittedAt = 0; + /** ล็อกตัวเลือกระหว่างโชว์ผลถูก/ผิด (กันกดซ้ำ + กันเด้งซ้ำ) */ + let quizBattleChoiceLocked = false; + /** โชว์ popup Completed แล้วหรือยัง (กันเด้งซ้ำเมื่อตอบครบทุกโดม) */ + let quizBattleCompletedShown = false; + /** ถึงจุดจบแล้ว — ล็อกตำแหน่ง ไม่ให้ echo/restore เด้งกลับ start หรือจุดเดิม */ + let quizBattleFinishLock = false; + /** โดมที่ "ผ่านแล้ว" (ตอบถูกหรือผิดก็ผ่าน) — ใช้ปลดล็อกเส้นทาง + จำว่าเคยตอบ ไม่ต้องตอบซ้ำ */ + let quizBattlePassedComps = new Set(); + /** ซูมที่ผู้เล่นปรับเองในฉาก quiz_battle (ล้อเมาส์ / ปุ่ม +−) */ + let quizBattleUserZoomMul = 1; + const SPACE_SHOOTER_SHIP_COLORS = ['#50fa7b', '#ff79c6', '#ff5555', '#f1fa8c', '#bd93f9', '#8be9fd']; + /** ยานยิงอุกาบาต (MG6) สีตาม lobby theme 1-8 — Rocket-1..8 ตรงกับ PLAY_LOBBY_THEME_HEX */ + const SPACE_SHOOTER_ROCKET_THEME_URLS = [1, 2, 3, 4, 5, 6, 7, 8].map(function (n) { return BASE + '/img/ViolentCrime/Rocket-' + n + '.png'; }); + /** theme index (1-8) ของ entity จากสี body ที่ย้อม (playTint) — ใช้แมป asset สีตามผู้เล่น */ + function playLobbyThemeIndexForEntityPlay(ent) { + const tint = ent && ent.playTint; + const body = (tint && typeof tint.body === 'string') ? tint.body : null; + const idx = body ? nearestIndexFromHexPlay(body, PLAY_LOBBY_THEME_HEX) : null; + return (idx >= 1 && idx <= 8) ? idx : null; + } + + document.getElementById('room-id').textContent = spaceId; + + function hidePlayQuizHud() { + const sb = document.getElementById('play-quiz-scoreboard'); + if (sb) sb.classList.add('is-hidden'); + const fb = document.getElementById('play-quiz-feedback'); + if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; } + } + + function renderPlayQuizScoreboard(scores) { + const wrap = document.getElementById('play-quiz-scoreboard'); + const ul = document.getElementById('play-quiz-scoreboard-list'); + if (!wrap || !ul || !mapData || (!isQuiz() && !isQuizCarry() && !isQuizBattle())) return; + if (!scores || typeof scores !== 'object') return; + wrap.classList.remove('is-hidden'); + ul.textContent = ''; + const merged = { ...scores }; + others.forEach((_, id) => { if (merged[id] == null) merged[id] = 0; }); + if (myId != null && merged[myId] == null) merged[myId] = 0; + const rows = []; + if (myId != null) { + rows.push({ id: myId, nick: me.nickname || 'คุณ', sc: merged[myId] != null ? merged[myId] : 0 }); + } + others.forEach((o, id) => { + rows.push({ id, nick: (o && o.nickname) ? String(o.nickname) : id, sc: merged[id] != null ? merged[id] : 0 }); + }); + rows.sort((a, b) => b.sc - a.sc || a.nick.localeCompare(b.nick, 'th')); + rows.forEach((row) => { + const li = document.createElement('li'); + if (row.id === myId) li.className = 'play-quiz-scoreboard-me'; + const spN = document.createElement('span'); + spN.className = 'play-quiz-scoreboard-name'; + spN.textContent = row.nick; + const spV = document.createElement('span'); + spV.className = 'play-quiz-scoreboard-val'; + spV.textContent = String(row.sc); + li.appendChild(spN); + li.appendChild(spV); + ul.appendChild(li); + }); + if (isQuizCarry() && useCyberPlayHud()) { + wrap.classList.add('is-hidden'); + } + /* แมป mng8a80o: คะแนนอยู่ที่ cyber SCORE ซ้ายแล้ว — ซ่อนกล่อง "คะแนน" มุมขวาไม่ให้ซ้ำกับ mock */ + if (isQuizQuestionMissionUiMapPlay()) { + wrap.classList.add('is-hidden'); + } + /* Quiz Battle: ใช้ HUD ใหม่ (#qb-game-hud) — ซ่อนกล่อง "คะแนน" เดิม */ + if (isQuizBattle()) { + wrap.classList.add('is-hidden'); + } + } + + function initPlayLiveQuizScoresZeros() { + if ((!isQuiz() && !isQuizCarry() && !isQuizBattle()) || myId == null) return; + playLiveQuizScores = {}; + playLiveQuizScores[myId] = 0; + others.forEach((_, id) => { playLiveQuizScores[id] = 0; }); + if (isQuiz()) { playQuizEverWrong = {}; mg1AllDeadEndScheduled = false; } + renderPlayQuizScoreboard(playLiveQuizScores); + } + + function showPlayQuizFeedback(r) { + const el = document.getElementById('play-quiz-feedback'); + if (!el || !r || !r.results || myId == null) return; + let mine = null; + let botRight = 0, botWrong = 0; + for (let i = 0; i < r.results.length; i++) { + const row = r.results[i]; + if (row.id === myId) mine = row; + else if (isPreviewBotId(row.id)) { + if (row.right) botRight++; + else botWrong++; + } + } + if (!mine) return; + el.classList.remove('is-hidden'); + const botExtra = playBotsEnabled() && (botRight + botWrong > 0) + ? ' · บอท ถูก ' + botRight + ' / ผิด ' + botWrong + ' (Bots: ' + botRight + ' right / ' + botWrong + ' wrong)' + : ''; + if (mine.right) { + el.className = 'play-quiz-feedback play-quiz-feedback-ok'; + el.textContent = 'คุณตอบถูก · +' + QUIZ_TF_POINTS_PER_CORRECT + ' · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม' + botExtra; + } else { + el.className = 'play-quiz-feedback play-quiz-feedback-bad'; + el.textContent = (mine.choice == null + ? 'คุณไม่ได้ยืนในโซนตอบ — นับเป็นผิด' + : 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีก') + botExtra; + } + if (typeof window.__playQuizFeedbackT === 'number') clearTimeout(window.__playQuizFeedbackT); + window.__playQuizFeedbackT = setTimeout(() => { el.classList.add('is-hidden'); }, 4200); + } + + function ensureQuizTfScorePlusImgPlay() { + if (quizTfScorePlusImg && quizTfScorePlusImg.src) return; + quizTfScorePlusImg = new Image(); + quizTfScorePlusImg.src = QUIZ_TF_SCORE_PLUS_URL; + quizTfScorePlusImg.onerror = function () { + this.onerror = null; + }; + } + + /** โชว์ score+.png กลางโซนจริง/เท็จที่เป็นคำตอบข้อนี้ (world px) */ + function spawnQuizTrueFalseScorePopupPlay(correctTrue) { + if (!isQuiz() || !mapData) return; + const grid = correctTrue ? mapData.quizTrueArea : mapData.quizFalseArea; + const b = getTileBoundsForOnesGrid(grid); + if (!b) return; + const ts = tileSize || 32; + const wx = ((b.minX + b.maxX + 1) / 2) * ts; + const wy = ((b.minY + b.maxY + 1) / 2) * ts; + const now = Date.now(); + ensureQuizTfScorePlusImgPlay(); + quizTfScorePopups.push({ wx, wy, startMs: now, untilMs: now + QUIZ_TF_SCORE_POPUP_MS }); + } + + function drawQuizTfScorePopupsLayer(ctx, worldToScreen, zDraw, timeMs) { + if (!quizTfScorePopups.length) return; + const img = quizTfScorePlusImg; + if (!img || !img.complete || !img.naturalWidth) return; + const now = typeof timeMs === 'number' ? timeMs : Date.now(); + quizTfScorePopups = quizTfScorePopups.filter((p) => p.untilMs > now); + const baseW = Math.max(QUIZ_TF_SCORE_POPUP_MIN_BASE_PX, (tileSize || 32) * zDraw * QUIZ_TF_SCORE_POPUP_TILE_MULT); + for (let i = 0; i < quizTfScorePopups.length; i++) { + const p = quizTfScorePopups[i]; + const span = Math.max(1, p.untilMs - p.startMs); + const t = Math.max(0, Math.min(1, (now - p.startMs) / span)); + const [sx, sy] = worldToScreen(p.wx, p.wy); + const rise = t * 56 * zDraw; + const fade = t < 0.72 ? 1 : Math.max(0, 1 - (t - 0.72) / 0.28); + const pulse = 1 + Math.sin(t * Math.PI) * 0.1; + const scale = (baseW / img.naturalWidth) * pulse; + const w = img.naturalWidth * scale; + const h = img.naturalHeight * scale; + ctx.save(); + ctx.globalAlpha = fade; + ctx.drawImage(img, sx - w / 2, sy - h / 2 - rise, w, h); + ctx.restore(); + } + } + + function getTileBoundsForOnesGrid(grid) { + if (!grid || !grid.length) return null; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (let yy = 0; yy < grid.length; yy++) { + const row = grid[yy]; + if (!row) continue; + for (let xx = 0; xx < row.length; xx++) { + if (Number(row[xx]) === 1) { + if (xx < minX) minX = xx; + if (yy < minY) minY = yy; + if (xx > maxX) maxX = xx; + if (yy > maxY) maxY = yy; + } + } + } + if (minX === Infinity) return null; + return { minX, minY, maxX, maxY }; + } + + function getQuizQuestionAreaTileBounds(md) { + if (!md || md.gameType !== 'quiz') return null; + return getTileBoundsForOnesGrid(md.quizQuestionArea); + } + + /** quiz_carry: โซนทองพิเศษสำหรับข้อความคำถาม (ถ้าไม่วาด ใช้โซนกลางม่วงแทนใน syncPlayQuizMapPanel) */ + function getQuizCarryQuestionAreaTileBounds(md) { + if (!md || md.gameType !== 'quiz_carry') return null; + return getTileBoundsForOnesGrid(md.quizQuestionArea); + } + + function clearQuizMapPanelThemeInline(panel, textEl) { + if (!panel || !textEl) return; + panel.classList.remove('quiz-map-question-panel--has-kicker'); + panel.classList.remove('quiz-map-question-panel--question-mission-live'); + const qKick = document.getElementById('quiz-map-question-kicker'); + if (qKick) { + qKick.textContent = ''; + qKick.classList.add('is-hidden'); + qKick.setAttribute('aria-hidden', 'true'); + } + panel.style.removeProperty('background-image'); + panel.style.removeProperty('background-size'); + panel.style.removeProperty('background-position'); + delete panel.dataset.qmPlaqueTry; + panel.style.removeProperty('--qmap-bg'); + panel.style.removeProperty('--qmap-border'); + panel.style.removeProperty('--qmap-border-w'); + panel.style.removeProperty('--qmap-shadow'); + panel.style.removeProperty('background'); + panel.style.removeProperty('border'); + panel.style.removeProperty('border-color'); + panel.style.removeProperty('border-width'); + panel.style.removeProperty('border-style'); + panel.style.removeProperty('box-shadow'); + textEl.style.removeProperty('--qmap-text'); + textEl.style.removeProperty('--qmap-text-shadow'); + textEl.style.removeProperty('color'); + textEl.style.removeProperty('text-shadow'); + textEl.style.removeProperty('font-size'); + textEl.style.removeProperty('line-height'); + textEl.style.removeProperty('width'); + textEl.style.removeProperty('box-sizing'); + textEl.style.removeProperty('max-height'); + textEl.style.removeProperty('overflow'); + } + + function clearQuizMapQuestionCarryFeedback() { + if (typeof window.__quizMapCarryFeedbackT === 'number') { + clearTimeout(window.__quizMapCarryFeedbackT); + window.__quizMapCarryFeedbackT = null; + } + const icon = document.getElementById('quiz-map-q-feedback-icon'); + const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap'); + const scoreEl = document.getElementById('quiz-map-q-feedback-score'); + if (icon) { + icon.classList.add('is-hidden'); + icon.removeAttribute('src'); + } + if (scoreEl) scoreEl.classList.remove('quiz-map-q-feedback-score--pop'); + if (scoreWrap) { + scoreWrap.classList.add('is-hidden'); + scoreWrap.setAttribute('aria-hidden', 'true'); + } + } + + /** ไอคอนถูก/ผิดที่ขอบบนแผง + score+10 ตรงกลางใต้ข้อความ (เฉพาะถูก) — ตาม mock quiz_carry */ + function showQuizMapQuestionCarryFeedback(isCorrect) { + if (!isQuizCarry()) return; + const panel = document.getElementById('quiz-map-question-panel'); + const icon = document.getElementById('quiz-map-q-feedback-icon'); + const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap'); + const scoreEl = document.getElementById('quiz-map-q-feedback-score'); + if (!panel || !icon || panel.classList.contains('is-hidden')) return; + clearQuizMapQuestionCarryFeedback(); + icon.src = isCorrect ? QUIZ_CARRY_MAP_FEEDBACK_IMG.correct : QUIZ_CARRY_MAP_FEEDBACK_IMG.incorrect; + icon.classList.remove('is-hidden'); + if (isCorrect && scoreWrap && scoreEl) { + scoreEl.src = QUIZ_CARRY_MAP_FEEDBACK_IMG.scorePlus; + scoreWrap.classList.remove('is-hidden'); + scoreWrap.setAttribute('aria-hidden', 'false'); + scoreEl.classList.remove('quiz-map-q-feedback-score--pop'); + void scoreEl.offsetWidth; + scoreEl.classList.add('quiz-map-q-feedback-score--pop'); + } else if (scoreWrap) { + scoreWrap.classList.add('is-hidden'); + scoreWrap.setAttribute('aria-hidden', 'true'); + } + window.__quizMapCarryFeedbackT = setTimeout(() => { + window.__quizMapCarryFeedbackT = null; + clearQuizMapQuestionCarryFeedback(); + }, QUIZ_MAP_CARRY_FEEDBACK_MS); + } + + /** ค่าเริ่มต้นแผงคำถามบนแผนที่ (ตรงกับ server defaultCarryMapPanelTheme) — ใช้เมื่อยังไม่โหลดจาก API */ + function defaultCarryMapPanelThemePlay() { + return { + panelBg: 'rgba(12, 14, 28, 0.88)', + panelBorder: 'rgba(255, 214, 102, 0.7)', + borderWidthPx: 2, + textColor: '#f1f5f9', + questionFontMinPx: 10, + questionFontMaxPx: 24, + }; + } + + /** ตรงกับ drawQuizCarryChoiceLabels — carryChoicePlaqueMapScale */ + function quizCarryPlaqueMapScaleClampedPlay() { + const sc = Number(quizCarryPlaqueMapScale); + if (!Number.isFinite(sc)) return 1.25; + return Math.max(0.85, Math.min(2.5, sc)); + } + + /** + * ขนาดตัวอักษรแผงคำถาม DOM ให้เท่าป้ายคำตอบบนพื้น: Math.max(10, Math.min(24, tileSize*zoom*0.24*ps)) + * แล้ว clamp ด้วย questionFontMinPx / questionFontMaxPx จากธีม (ถ้าต้องการใหญ่กว่าป้ายได้โดยยกเพดาน max) + */ + function quizMapQuestionFontPxLikeCarryPlaques(th, zoomVal) { + const ts = tileSize * zoomVal; + const ps = quizCarryPlaqueMapScaleClampedPlay(); + const base = Math.max(10, Math.min(24, ts * 0.24 * ps)); + const fb = defaultCarryMapPanelThemePlay(); + let mn = Number(th && th.questionFontMinPx); + let mx = Number(th && th.questionFontMaxPx); + if (!Number.isFinite(mn)) mn = fb.questionFontMinPx; + if (!Number.isFinite(mx)) mx = fb.questionFontMaxPx; + mn = Math.round(Math.max(8, Math.min(40, mn))); + mx = Math.round(Math.max(10, Math.min(72, mx))); + if (mx < mn) { + const s = mn; + mn = mx; + mx = s; + } + return Math.max(mn, Math.min(mx, base)); + } + + /** + * ลด px ทีละ 1 จนเนื้อหาไม่ล้นกล่อง (ไม่ใช้ scrollbar) + * zoom out กล่องเตี้ยมาก: อนุญาตต่ำกว่า questionFontMinPx ของธีมได้ถึง hardMin (≥6) ไม่ให้บรรทัดถูกตัด + * เกมถูก/ผิด: แผงมีคลาส quiz-map-question-panel--true-false จัดข้อความกึ่งกลางแนวตั้งในโซน + */ + function fitQuizMapQuestionFontToPanel(panel, textEl, startPx, minPx) { + const cs = window.getComputedStyle(panel); + const padT = parseFloat(cs.paddingTop) || 0; + const padB = parseFloat(cs.paddingBottom) || 0; + const padL = parseFloat(cs.paddingLeft) || 0; + const padR = parseFloat(cs.paddingRight) || 0; + let kickerReserve = 0; + if (panel.classList.contains('quiz-map-question-panel--question-mission-live') + && panel.classList.contains('quiz-map-question-panel--has-kicker')) { + kickerReserve = 30; + } + const availH = Math.max(12, panel.clientHeight - padT - padB - kickerReserve); + const availW = Math.max(24, panel.clientWidth - padL - padR); + const themeMin = Math.max(8, Math.floor(Number(minPx) || 8)); + const hardMin = Math.min(themeMin, Math.max(6, Math.floor(availH / 7))); + let px = Math.round(Math.min(80, Math.max(hardMin, Number(startPx) || hardMin))); + textEl.style.setProperty('line-height', '1.28', 'important'); + textEl.style.setProperty('width', '100%', 'important'); + textEl.style.setProperty('box-sizing', 'border-box', 'important'); + textEl.style.removeProperty('max-height'); + textEl.style.setProperty('overflow', 'hidden', 'important'); + for (let i = 0; i < 96 && px > hardMin; i++) { + textEl.style.setProperty('font-size', String(px) + 'px', 'important'); + const sh = textEl.scrollHeight; + const sw = textEl.scrollWidth; + if (sh <= availH + 2 && sw <= availW + 2) break; + px -= 1; + } + textEl.style.setProperty('font-size', String(px) + 'px', 'important'); + textEl.style.setProperty('max-height', availH + 'px', 'important'); + } + + /** ธีมจาก quiz-settings / join API — ถ้า JSON ไม่มีฟอนต์ให้ใช้ค่าเริ่มต้น */ + function parseCarryMapPanelThemeObject(raw) { + let t = raw; + if (typeof t === 'string') { + try { + t = JSON.parse(t); + } catch (e) { + t = null; + } + } + if (!t || typeof t !== 'object') return null; + const bw = Number(t.borderWidthPx); + const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2; + const fb = defaultCarryMapPanelThemePlay(); + let qMin = Number(t.questionFontMinPx); + let qMax = Number(t.questionFontMaxPx); + if (!Number.isFinite(qMin)) qMin = fb.questionFontMinPx; + if (!Number.isFinite(qMax)) qMax = fb.questionFontMaxPx; + qMin = Math.round(Math.max(10, Math.min(40, qMin))); + qMax = Math.round(Math.max(14, Math.min(56, qMax))); + if (qMax < qMin) { + const s = qMin; + qMin = qMax; + qMax = s; + } + return { + panelBg: String(t.panelBg || '').trim().slice(0, 120), + panelBorder: String(t.panelBorder || '').trim().slice(0, 120), + borderWidthPx, + textColor: String(t.textColor || '').trim().slice(0, 120), + questionFontMinPx: qMin, + questionFontMaxPx: qMax, + }; + } + + /** + * ชั้นทับจาก carryMapPanelTheme ในไฟล์แมป — ฟอนต์ใส่เฉพาะเมื่อ JSON มี key จริง + * (กันธีมแมปมีแค่สีแล้วไปทับขนาดฟอนต์จาก Admin เป็นค่าเริ่ม 16/24) + */ + function parseCarryMapPanelThemeMapOverlay(raw) { + let t = raw; + if (typeof t === 'string') { + try { + t = JSON.parse(t); + } catch (e) { + t = null; + } + } + if (!t || typeof t !== 'object') return null; + const bw = Number(t.borderWidthPx); + const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2; + const out = { + panelBg: String(t.panelBg || '').trim().slice(0, 120), + panelBorder: String(t.panelBorder || '').trim().slice(0, 120), + borderWidthPx, + textColor: String(t.textColor || '').trim().slice(0, 120), + }; + const hasMin = Object.prototype.hasOwnProperty.call(t, 'questionFontMinPx'); + const hasMax = Object.prototype.hasOwnProperty.call(t, 'questionFontMaxPx'); + if (hasMin || hasMax) { + const fb = defaultCarryMapPanelThemePlay(); + let qMin = hasMin ? Number(t.questionFontMinPx) : fb.questionFontMinPx; + let qMax = hasMax ? Number(t.questionFontMaxPx) : fb.questionFontMaxPx; + if (!Number.isFinite(qMin)) qMin = fb.questionFontMinPx; + if (!Number.isFinite(qMax)) qMax = fb.questionFontMaxPx; + qMin = Math.round(Math.max(10, Math.min(40, qMin))); + qMax = Math.round(Math.max(14, Math.min(56, qMax))); + if (qMax < qMin) { + const s = qMin; + qMin = qMax; + qMax = s; + } + out.questionFontMinPx = qMin; + out.questionFontMaxPx = qMax; + } + return out; + } + + function setQuizCarryMapPanelThemeFromApi(s) { + quizCarryMapPanelTheme = null; + if (!s || typeof s !== 'object') return; + const parsed = parseCarryMapPanelThemeObject(s.carryMapPanelTheme); + if (!parsed) return; + quizCarryMapPanelTheme = parsed; + } + + function setQuizMapPanelThemeFromApi(s) { + quizMapPanelTheme = null; + if (!s || typeof s !== 'object') return; + const parsed = parseCarryMapPanelThemeObject(s.quizMapPanelTheme); + if (!parsed) return; + quizMapPanelTheme = parsed; + } + + function defaultCarryChoicePlaqueThemePlay() { + return { + borderMode: 'neon', + fixedBorder: 'rgba(122, 200, 255, 0.9)', + fillBg: 'rgba(12, 10, 20, 0.88)', + textColor: 'rgba(248, 249, 255, 1)', + borderWidthPx: 2.5, + plaqueImageUrl: '', + }; + } + + function parseCarryChoicePlaqueThemeObject(raw) { + const d = defaultCarryChoicePlaqueThemePlay(); + if (!raw || typeof raw !== 'object') return d; + const safeColor = (x, fb) => { + const t = String(x == null ? '' : x).trim().slice(0, 120); + if (!t || /url\s*\(|expression|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fb; + if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t; + if (/^rgba?\(\s*[^)]+\)$/i.test(t)) return t.replace(/\s+/g, ' ').trim(); + return fb; + }; + const mode = String(raw.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon'; + let bw = Number(raw.borderWidthPx); + if (!Number.isFinite(bw)) bw = d.borderWidthPx; + bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10; + return { + borderMode: mode, + fixedBorder: safeColor(raw.fixedBorder, d.fixedBorder), + fillBg: safeColor(raw.fillBg, d.fillBg), + textColor: safeColor(raw.textColor, d.textColor), + borderWidthPx: bw, + plaqueImageUrl: sanitizeQuizCarryImageUrlClient(raw.plaqueImageUrl), + }; + } + + function buildCarryChoicePlaqueThemesArrayFromApi(s) { + if (!s || typeof s !== 'object') return null; + if (Array.isArray(s.carryChoicePlaqueThemes) && s.carryChoicePlaqueThemes.length) { + const out = []; + for (let i = 0; i < QUIZ_CARRY_MAX_OPTION_SLOTS; i++) { + out.push(parseCarryChoicePlaqueThemeObject(s.carryChoicePlaqueThemes[i])); + } + return out; + } + if (s.carryChoicePlaqueTheme && typeof s.carryChoicePlaqueTheme === 'object') { + const one = parseCarryChoicePlaqueThemeObject(s.carryChoicePlaqueTheme); + return Array.from({ length: QUIZ_CARRY_MAX_OPTION_SLOTS }, () => ({ ...one })); + } + return null; + } + + function getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex) { + const i = Math.max(0, Math.min(QUIZ_CARRY_MAX_OPTION_SLOTS - 1, choiceIndex | 0)); + if (quizCarryChoicePlaqueThemes && quizCarryChoicePlaqueThemes[i]) { + return quizCarryChoicePlaqueThemes[i]; + } + return defaultCarryChoicePlaqueThemePlay(); + } + + function setQuizCarryChoicePlaqueThemeFromApi(s) { + quizCarryChoicePlaqueThemes = buildCarryChoicePlaqueThemesArrayFromApi(s); + } + + function sanitizeQuizCarryImageUrlClient(u) { + const s = String(u == null ? '' : u).trim().slice(0, 512); + if (!s || /[\s<>'"`]/.test(s)) return ''; + if (s.startsWith('/')) { + if (!/^\/[\w\-./?#=&%+]+$/i.test(s)) return ''; + return s; + } + const low = s.toLowerCase(); + if (!low.startsWith('https://') && !low.startsWith('http://')) return ''; + try { + const parsed = new URL(s); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; + return s; + } catch (e) { + return ''; + } + } + + /** crossOrigin=anonymous กับ same-origin บางเซิร์ฟ/nginx ทำให้โหลดรูปล้ม — ใช้เฉพาะข้ามโดเมน */ + function quizCarryApplyImageCrossOrigin(img, rawUrl) { + if (!img) return; + try { + img.removeAttribute('crossorigin'); + const u = sanitizeQuizCarryImageUrlClient(rawUrl); + if (!u || u.startsWith('data:')) return; + let imgOrigin = ''; + if (u.startsWith('http://') || u.startsWith('https://')) { + imgOrigin = new URL(u).origin; + } else if (u.startsWith('/')) { + imgOrigin = window.location.origin; + } + if (imgOrigin && imgOrigin !== window.location.origin) { + img.crossOrigin = 'anonymous'; + } + } catch (e) { /* ignore */ } + } + + function preloadQuizCarryChoiceImages(q) { + if (!q) return; + if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) { + for (let i = 0; i < q.choiceImageUrls.length; i++) { + const raw = sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[i]); + if (!raw) continue; + if (quizCarryChoiceImageCache.has(raw)) continue; + const im = new Image(); + quizCarryApplyImageCrossOrigin(im, raw); + im.onload = () => { try { draw(); } catch (e) { /* ignore */ } }; + quizCarryChoiceImageCache.set(raw, im); + try { + im.src = raw; + } catch (e) { /* ignore */ } + } + } + if (Array.isArray(q.choices)) { + for (let j = 0; j < q.choices.length; j++) { + const raw2 = getQuizCarryPlaqueImageUrlForIndex(q, j); + if (!raw2 || quizCarryChoiceImageCache.has(raw2)) continue; + const im2 = new Image(); + quizCarryApplyImageCrossOrigin(im2, raw2); + im2.onload = () => { try { draw(); } catch (e) { /* ignore */ } }; + quizCarryChoiceImageCache.set(raw2, im2); + try { + im2.src = raw2; + } catch (e) { /* ignore */ } + } + } + } + + function getQuizCarryChoiceImageCached(url) { + const u = sanitizeQuizCarryImageUrlClient(url); + if (!u) return null; + const im = quizCarryChoiceImageCache.get(u); + if (im && im.complete && im.naturalWidth > 0) return im; + return null; + } + + function getQuizCarryChoiceImageUrlForIndex(q, idx) { + if (!q || !Array.isArray(q.choiceImageUrls)) return ''; + const u = q.choiceImageUrls[idx]; + return sanitizeQuizCarryImageUrlClient(u); + } + + /** รูปป้าย: คำถามต่อช่องก่อน แล้วค่อยรูปจากธีมช่อง (carryChoicePlaqueThemes) */ + function getQuizCarryPlaqueImageUrlForIndex(q, idx) { + const perQ = getQuizCarryChoiceImageUrlForIndex(q, idx); + if (perQ) return perQ; + const th = getEffectiveCarryChoicePlaqueThemeForChoice(idx); + const u = th && th.plaqueImageUrl ? sanitizeQuizCarryImageUrlClient(th.plaqueImageUrl) : ''; + return u || ''; + } + + function defaultCarryEmbedCountdownThemePlay() { + return { + overlayBackdrop: 'rgba(6, 8, 14, 0.52)', + innerBg: 'rgba(0, 0, 0, 0.78)', + innerBorder: 'rgba(94, 234, 212, 0.82)', + innerBorderWpx: 1, + innerRadiusPx: 14, + digitColor: '#ffe066', + mapDigitCqmin: 78, + mapDigitCqh: 82, + mapDigitMaxPx: 220, + screenDigitVw: 32, + screenDigitMaxPx: 200, + }; + } + + function parseCarryEmbedCountdownThemeObject(raw) { + const d = defaultCarryEmbedCountdownThemePlay(); + if (!raw || typeof raw !== 'object') return d; + const clampN = (v, lo, hi, def) => { + const n = Number(v); + if (!Number.isFinite(n)) return def; + return Math.max(lo, Math.min(hi, Math.round(n))); + }; + const clipStr = (x, maxLen) => { + const t = String(x == null ? '' : x).trim().slice(0, maxLen); + return t || null; + }; + const safeColor = (x, fb) => { + const t = clipStr(x, 120); + if (!t || /url\s*\(|expression|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fb; + if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t; + if (/^rgba?\(\s*[^)]+\)$/i.test(t)) return t.replace(/\s+/g, ' ').trim(); + return fb; + }; + return { + overlayBackdrop: safeColor(raw.overlayBackdrop, d.overlayBackdrop), + innerBg: safeColor(raw.innerBg, d.innerBg), + innerBorder: safeColor(raw.innerBorder, d.innerBorder), + innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx), + innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx), + digitColor: safeColor(raw.digitColor, d.digitColor), + mapDigitCqmin: clampN(raw.mapDigitCqmin, 35, 100, d.mapDigitCqmin), + mapDigitCqh: clampN(raw.mapDigitCqh, 35, 100, d.mapDigitCqh), + mapDigitMaxPx: clampN(raw.mapDigitMaxPx, 48, 400, d.mapDigitMaxPx), + screenDigitVw: clampN(raw.screenDigitVw, 6, 44, d.screenDigitVw), + screenDigitMaxPx: clampN(raw.screenDigitMaxPx, 48, 220, d.screenDigitMaxPx), + }; + } + + function setQuizCarryEmbedCountdownThemeFromApi(s) { + quizCarryEmbedCountdownTheme = parseCarryEmbedCountdownThemeObject(s && s.carryEmbedCountdownTheme); + applyQuizCarryEmbedCountdownThemeToDom(); + } + + const CARRY_ECD_VAR_KEYS = [ + '--carry-ecd-overlay', + '--carry-ecd-inner-bg', + '--carry-ecd-inner-border', + '--carry-ecd-inner-border-w', + '--carry-ecd-inner-radius', + '--carry-ecd-digit-color', + '--carry-ecd-map-digit-fs', + '--carry-ecd-screen-vw', + '--carry-ecd-screen-max', + ]; + + function clearQuizCarryEmbedCountdownThemeDomVars() { + const root = document.getElementById('quiz-carry-embed-countdown'); + if (!root) return; + CARRY_ECD_VAR_KEYS.forEach((k) => { try { root.style.removeProperty(k); } catch (e) { /* ignore */ } }); + } + + /** ใส่ CSS variables บน #quiz-carry-embed-countdown — อ่านจาก quizCarryEmbedCountdownTheme */ + function applyQuizCarryEmbedCountdownThemeToDom() { + const root = document.getElementById('quiz-carry-embed-countdown'); + const numEl = document.getElementById('quiz-carry-embed-countdown-num'); + if (!root) return; + clearQuizCarryEmbedCountdownThemeDomVars(); + const th = quizCarryEmbedCountdownTheme || defaultCarryEmbedCountdownThemePlay(); + root.style.setProperty('--carry-ecd-overlay', th.overlayBackdrop); + root.style.setProperty('--carry-ecd-inner-bg', th.innerBg); + root.style.setProperty('--carry-ecd-inner-border', th.innerBorder); + root.style.setProperty('--carry-ecd-inner-border-w', `${Math.max(0, Math.min(12, Math.round(Number(th.innerBorderWpx) || 1)))}px`); + root.style.setProperty('--carry-ecd-inner-radius', `${Math.max(0, Math.min(32, Math.round(Number(th.innerRadiusPx) || 12)))}px`); + root.style.setProperty('--carry-ecd-digit-color', th.digitColor); + const cqmin = Math.max(35, Math.min(100, Math.round(Number(th.mapDigitCqmin) || 78))); + const cqh = Math.max(35, Math.min(100, Math.round(Number(th.mapDigitCqh) || 82))); + const mpx = Math.max(48, Math.min(400, Math.round(Number(th.mapDigitMaxPx) || 200))); + root.style.setProperty('--carry-ecd-map-digit-fs', `clamp(14px, min(${cqmin}cqmin, ${cqh}cqh), ${mpx}px)`); + const vw = Math.max(6, Math.min(44, Math.round(Number(th.screenDigitVw) || 28))); + const smx = Math.max(48, Math.min(220, Math.round(Number(th.screenDigitMaxPx) || 132))); + root.style.setProperty('--carry-ecd-screen-vw', `${vw}vw`); + root.style.setProperty('--carry-ecd-screen-max', `${smx}px`); + if (numEl && String(numEl.tagName || '').toUpperCase() !== 'IMG') { + numEl.style.textShadow = '0 0 0.45em currentColor, 0 4px 14px rgba(0,0,0,0.55)'; + } else if (numEl) { + try { numEl.style.removeProperty('text-shadow'); } catch (e) { /* ignore */ } + } + } + + function setCountdown321QuestionAssetGraphic(numEl, n) { + if (!numEl) return; + const d = Math.max(1, Math.min(3, n)); + if (String(numEl.tagName || '').toUpperCase() === 'IMG') { + numEl.src = COUNTDOWN_321_IMG_BASE + d + '.png'; + numEl.alt = String(d); + } else { + numEl.textContent = String(d); + } + } + + /** default ← quiz-settings/API ← แมป (สีจากแมปทับได้; ฟอนต์จากแมปเฉพาะเมื่อแมประบุ key) */ + function getEffectiveCarryMapPanelThemeForApply() { + const d = defaultCarryMapPanelThemePlay(); + const api = quizCarryMapPanelTheme != null && typeof quizCarryMapPanelTheme === 'object' ? quizCarryMapPanelTheme : null; + let merged = api ? { ...d, ...api } : { ...d }; + if (mapData && mapData.carryMapPanelTheme != null) { + const mp = parseCarryMapPanelThemeMapOverlay(mapData.carryMapPanelTheme); + if (mp) merged = { ...merged, ...mp }; + } + return merged; + } + + /** เกมถูก/ผิด: default ← quizMapPanelTheme (API) ← quizMapPanelTheme บนแมป (ถ้ามี) */ + function getEffectiveQuizMapPanelThemeForApply() { + const d = defaultCarryMapPanelThemePlay(); + const api = quizMapPanelTheme != null && typeof quizMapPanelTheme === 'object' ? quizMapPanelTheme : null; + let merged = api ? { ...d, ...api } : { ...d }; + if (mapData && mapData.quizMapPanelTheme != null) { + const mp = parseCarryMapPanelThemeMapOverlay(mapData.quizMapPanelTheme); + if (mp) merged = { ...merged, ...mp }; + } + return merged; + } + + function paintQuizMapPanelTheme(panel, textEl, th) { + if (!panel || !textEl || !th) return; + clearQuizMapPanelThemeInline(panel, textEl); + const bgStr = th.panelBg != null ? String(th.panelBg).trim() : ''; + const brStr = th.panelBorder != null ? String(th.panelBorder).trim() : ''; + const txStr = th.textColor != null ? String(th.textColor).trim() : ''; + const wRaw = Number(th.borderWidthPx); + const w = Number.isFinite(wRaw) ? Math.max(0, Math.min(12, Math.round(wRaw))) : 0; + const bgUse = bgStr || 'transparent'; + const brUse = brStr || 'transparent'; + const txUse = txStr || '#f1f5f9'; + panel.style.setProperty('--qmap-bg', bgUse); + panel.style.setProperty('--qmap-border', brUse); + panel.style.setProperty('--qmap-border-w', String(w) + 'px'); + panel.style.setProperty('--qmap-shadow', 'none'); + textEl.style.setProperty('--qmap-text', txUse); + textEl.style.setProperty('--qmap-text-shadow', 'none'); + /* โหลด play.html แบบแคชเก่า (ไม่มี var) ยังต้องทับได้ — ใช้ !important คู่กับตัวแปร */ + panel.style.setProperty('background', bgUse, 'important'); + panel.style.setProperty('border', String(w) + 'px solid ' + brUse, 'important'); + panel.style.setProperty('box-shadow', 'none', 'important'); + textEl.style.setProperty('color', txUse, 'important'); + textEl.style.setProperty('text-shadow', 'none', 'important'); + } + + function applyQuizCarryMapPanelThemeIfNeeded(panel, textEl) { + if (!panel || !textEl) return; + if (!isQuizCarry()) { + clearQuizMapPanelThemeInline(panel, textEl); + return; + } + paintQuizMapPanelTheme(panel, textEl, getEffectiveCarryMapPanelThemeForApply()); + } + + function applyQuizTrueFalseMapPanelThemeIfNeeded(panel, textEl) { + if (!panel || !textEl) return; + if (!isQuiz() || isQuizCarry()) return; + paintQuizMapPanelTheme(panel, textEl, getEffectiveQuizMapPanelThemeForApply()); + } + + function syncPlayQuizMapPanel() { + const panel = document.getElementById('quiz-map-question-panel'); + const textEl = document.getElementById('quiz-map-question-text'); + if (!panel || !textEl || !mapData) return; + /* เอดิเตอร์ embed: ซ่อนแผงทองเฉพาะ quiz ทั่วไป — แมปภารกิจ mng8a80o ต้องโชว์คำถามในโซนที่วาด (quizQuestionArea) */ + if (previewMode && editorEmbedReturn && !isQuizCarry() && !(isQuiz() && isQuizQuestionMissionUiMapPlay())) { + clearQuizMapQuestionCarryFeedback(); + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + clearQuizMapPanelThemeInline(panel, textEl); + return; + } + if (isQuizBattle()) { + clearQuizMapQuestionCarryFeedback(); + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + clearQuizMapPanelThemeInline(panel, textEl); + return; + } + if (!isQuiz() && !isQuizCarry()) { + clearQuizMapQuestionCarryFeedback(); + return; + } + const bounds = isQuiz() + ? getQuizQuestionAreaTileBounds(mapData) + : (getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData)); + const text = (playQuizText || '').trim(); + if (!bounds || !text) { + clearQuizMapQuestionCarryFeedback(); + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + clearQuizMapPanelThemeInline(panel, textEl); + return; + } + const qmCenter = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null; + const carryCam = !qmCenter && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null; + const camX = qmCenter ? qmCenter.cx : (carryCam ? carryCam.cx : me.x * tileSize); + const camY = qmCenter ? qmCenter.cy : (carryCam ? carryCam.cy : me.y * tileSize); + const zDom = playDomSyncZoom(); + const left = (bounds.minX * tileSize - camX) * zDom + canvas.width / 2; + const top = (bounds.minY * tileSize - camY) * zDom + canvas.height / 2; + const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom; + const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom; + textEl.textContent = text; + panel.style.left = Math.round(left) + 'px'; + panel.style.top = Math.round(top) + 'px'; + panel.style.width = Math.round(Math.max(48, wPx)) + 'px'; + panel.style.height = Math.round(Math.max(40, hPx)) + 'px'; + panel.classList.remove('is-hidden'); + panel.setAttribute('aria-hidden', 'false'); + if (isQuiz() && !isQuizCarry()) { + panel.classList.add('quiz-map-question-panel--true-false'); + } else { + panel.classList.remove('quiz-map-question-panel--true-false'); + } + if (isQuizCarry()) { + applyQuizCarryMapPanelThemeIfNeeded(panel, textEl); + } else if (isQuiz()) { + applyQuizTrueFalseMapPanelThemeIfNeeded(panel, textEl); + } else { + clearQuizMapPanelThemeInline(panel, textEl); + } + const thFont = isQuizCarry() + ? getEffectiveCarryMapPanelThemeForApply() + : getEffectiveQuizMapPanelThemeForApply(); + const startFontPx = quizMapQuestionFontPxLikeCarryPlaques(thFont, zDom); + const mnFont = Math.max(8, Math.round(Number(thFont.questionFontMinPx) || 10)); + /* ไม่โหลด hud-question-plaque.png อัตโนมัติ — บนเซิร์ฟมักไม่มีไฟล์ → 404 ใน Network; ใช้คลาส + CSS แทน (อัปโหลด PNG แล้วค่อยผูกทีหลังได้) */ + if (isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live') { + panel.classList.add('quiz-map-question-panel--question-mission-live'); + } else { + panel.classList.remove('quiz-map-question-panel--question-mission-live'); + if (panel.dataset.qmPlaqueTry) { + panel.style.removeProperty('background-image'); + panel.style.removeProperty('background-size'); + panel.style.removeProperty('background-position'); + delete panel.dataset.qmPlaqueTry; + } + } + panel.classList.toggle( + 'quiz-map-question-panel--tf-mock-qz', + !!(isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live' && isMissionMockHudPlay()), + ); + const kickerEl = document.getElementById('quiz-map-question-kicker'); + const missionLiveQ = isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live'; + const inReadOrAnswer = playQuizPhaseLocal === 'read' || playQuizPhaseLocal === 'answer'; + if (kickerEl) { + let showK = false; + let line = ''; + if (missionLiveQ && inReadOrAnswer) { + const qi = Math.max(0, Number(playQuizQuestionIndex) || 0); + const qt = Math.max(0, Number(playQuizQuestionTotal) || 0); + if (qi > 0 && qt > 0) { + line = '- Quiz ' + qi + ' / ' + qt + ' -'; + showK = true; + } else if (qi > 0) { + line = '- Quiz ' + qi + ' -'; + showK = true; + } + } + if (showK && line) { + kickerEl.textContent = line; + kickerEl.classList.remove('is-hidden'); + kickerEl.setAttribute('aria-hidden', 'false'); + panel.classList.add('quiz-map-question-panel--has-kicker'); + } else { + kickerEl.textContent = ''; + kickerEl.classList.add('is-hidden'); + kickerEl.setAttribute('aria-hidden', 'true'); + panel.classList.remove('quiz-map-question-panel--has-kicker'); + } + } + fitQuizMapQuestionFontToPanel(panel, textEl, startFontPx, mnFont); + } + + /** โซนเดียวกับแผงคำถามบนแผนที่ — ยึด timeup ให้ตรงกล่อง #quiz-map-question-panel (ห้ามคำนวณซ้ำแล้วคลาด) */ + function syncQuizCarryTimeupDeskLayerPosition() { + const layer = document.getElementById('quiz-carry-timeup-desk-layer'); + if (!layer || layer.classList.contains('is-hidden')) return; + const anchor = layer.querySelector('.qc-timeup-desk-anchor') + || layer.querySelector('.qc-timeup-desk-inner') + || (() => { + const img = document.getElementById('quiz-carry-timeup-txt-img'); + return img && img.parentElement && img.parentElement !== layer ? img.parentElement : null; + })(); + if (!anchor || !canvas) return; + const clearToFullStack = () => { + anchor.style.left = '0'; + anchor.style.top = '0'; + anchor.style.width = '100%'; + anchor.style.height = '100%'; + }; + const stack = document.getElementById('play-canvas-stack'); + const panel = document.getElementById('quiz-map-question-panel'); + if (stack && panel && !panel.classList.contains('is-hidden')) { + try { + const sr = stack.getBoundingClientRect(); + const pr = panel.getBoundingClientRect(); + const rw = pr.width; + const rh = pr.height; + if (rw >= 8 && rh >= 8 && sr.width > 0 && sr.height > 0) { + anchor.style.left = Math.round(pr.left - sr.left) + 'px'; + anchor.style.top = Math.round(pr.top - sr.top) + 'px'; + anchor.style.width = Math.round(rw) + 'px'; + anchor.style.height = Math.round(rh) + 'px'; + return; + } + } catch (e) { /* fallback ด้านล่าง */ } + } + const pl = panel && panel.style && String(panel.style.left || '').trim(); + const pt = panel && panel.style && String(panel.style.top || '').trim(); + const pw = panel && panel.style && String(panel.style.width || '').trim(); + const ph = panel && panel.style && String(panel.style.height || '').trim(); + if (pl && pt && pw && ph) { + anchor.style.left = pl; + anchor.style.top = pt; + anchor.style.width = pw; + anchor.style.height = ph; + return; + } + if (!mapData || mapData.gameType !== 'quiz_carry') { + clearToFullStack(); + return; + } + const bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData); + if (!bounds) { + clearToFullStack(); + return; + } + const cc = getQuizCarryMapCameraWorldCenterPxPlay(); + const camX = cc ? cc.cx : me.x * tileSize; + const camY = cc ? cc.cy : me.y * tileSize; + const zDom = playDomSyncZoom(); + const left = (bounds.minX * tileSize - camX) * zDom + canvas.width / 2; + const top = (bounds.minY * tileSize - camY) * zDom + canvas.height / 2; + const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom; + const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom; + anchor.style.left = Math.round(left) + 'px'; + anchor.style.top = Math.round(top) + 'px'; + anchor.style.width = Math.round(Math.max(48, wPx)) + 'px'; + anchor.style.height = Math.round(Math.max(40, hPx)) + 'px'; + } + + function carryEmbedCountdownAnchorResolved() { + if (!mapData || !isQuizCarry()) return 'screen'; + const v = mapData.carryEmbedCountdownAnchor; + if (v === 'grid' || v === 'map') return v; + return 'screen'; + } + + function resetQuizCarryEmbedCountdownLayoutDom() { + const ov = document.getElementById('quiz-carry-embed-countdown'); + const inner = document.getElementById('quiz-carry-embed-countdown-inner'); + if (ov) ov.classList.remove('quiz-carry-embed-countdown--map-anchor'); + if (inner) { + inner.classList.remove('quiz-carry-embed-countdown-inner--map-rect'); + inner.style.position = ''; + inner.style.left = ''; + inner.style.top = ''; + inner.style.width = ''; + inner.style.height = ''; + inner.style.transform = ''; + inner.style.maxWidth = ''; + inner.style.boxSizing = ''; + inner.style.display = ''; + inner.style.margin = ''; + } + } + + /** พรีวิว embed: วางกล่อง 3–2–1 กลางจอ / ชิดโซนทองหรือโซนกลาง / ชิดกริด carryEmbedCountdownArea — ตาม carryEmbedCountdownAnchor */ + function syncQuizCarryEmbedCountdownLayout() { + if (!quizCarryEmbedMissionFlowActive() || !mapData) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + const ov = document.getElementById('quiz-carry-embed-countdown'); + const inner = document.getElementById('quiz-carry-embed-countdown-inner'); + const canvasEl = document.getElementById('game-canvas'); + if (!ov || !inner || !canvasEl) return; + if (ov.classList.contains('is-hidden')) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + const anchor = carryEmbedCountdownAnchorResolved(); + if (anchor === 'screen') { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + let bounds = null; + if (anchor === 'grid') { + bounds = getTileBoundsForOnesGrid(mapData.carryEmbedCountdownArea); + if (!bounds) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + } else { + bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData); + if (!bounds) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + } + const cc = getQuizCarryMapCameraWorldCenterPxPlay(); + const camX = cc ? cc.cx : me.x * tileSize; + const camY = cc ? cc.cy : me.y * tileSize; + const zDom = playDomSyncZoom(); + const l = (bounds.minX * tileSize - camX) * zDom + canvasEl.width / 2; + const t = (bounds.minY * tileSize - camY) * zDom + canvasEl.height / 2; + const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom; + const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom; + const r = canvasEl.getBoundingClientRect(); + const sl = r.left + l; + const st = r.top + t; + ov.classList.add('quiz-carry-embed-countdown--map-anchor'); + inner.classList.add('quiz-carry-embed-countdown-inner--map-rect'); + inner.style.position = 'fixed'; + inner.style.left = Math.round(sl) + 'px'; + inner.style.top = Math.round(st) + 'px'; + inner.style.width = Math.round(Math.max(100, wPx)) + 'px'; + inner.style.height = Math.round(Math.max(72, hPx)) + 'px'; + inner.style.transform = 'none'; + inner.style.boxSizing = 'border-box'; + inner.style.display = 'flex'; + inner.style.flexDirection = 'column'; + inner.style.alignItems = 'center'; + inner.style.justifyContent = 'center'; + inner.style.margin = '0'; + inner.style.maxWidth = 'none'; + } + + /** quiz_carry + embed: คำถามไปที่แผงทองบนแผนที่ / ใน overlay นับถอยหลัง — ไม่ใช้แถบบน */ + function syncQuizCarryEmbedQuestionStrip() { + const wrap = document.getElementById('quiz-carry-embed-q-strip'); + const p = document.getElementById('quiz-carry-embed-q-strip-text'); + if (!wrap || !p) return; + wrap.classList.remove('quiz-carry-embed-q-strip--countdown'); + wrap.classList.add('is-hidden'); + wrap.setAttribute('aria-hidden', 'true'); + } + + function updatePlayQuizTimerDisplay() { + const el = document.getElementById('quiz-game-timer'); + if (!el) return; + if (!playQuizPhaseEndsAt) { + el.textContent = ''; + return; + } + const s = Math.max(0, Math.ceil((playQuizPhaseEndsAt - serverNow()) / 1000)); + el.textContent = s + ' วินาที'; + } + + function clampPreviewMs(n, def, minV, maxV) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(minV, Math.min(maxV, Math.floor(v))); + } + + function clampCarrySessionLen(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(0, Math.min(500, Math.floor(v))); + } + + function buildQuizPoolFromMap(md) { + if (!md || !Array.isArray(md.quizQuestions)) return []; + return md.quizQuestions + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })); + } + + function shufflePreviewQuizQuestionsPlay(arr) { + const a = (arr || []).slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; + } + + /** ตรงกับ server clampQuizRoundQuestionCount — 1–50 ค่าเริ่ม 10 */ + function clampPreviewQuizRoundQuestionCount(settings) { + const v = Number(settings && settings.quizRoundQuestionCount); + if (!Number.isFinite(v)) return 10; + return Math.max(1, Math.min(50, Math.floor(v))); + } + + function finishPreviewQuizSessionPlay() { + previewQuizStep = 'done'; + playQuizPhaseLocal = null; + previewQuizCurrent = null; + playQuizPhaseEndsAt = 0; + if (playQuizTimerInterval) { + clearInterval(playQuizTimerInterval); + playQuizTimerInterval = null; + } + const qov = document.getElementById('quiz-game-overlay'); + if (qov) qov.classList.add('is-hidden'); + const mapPanel = document.getElementById('quiz-map-question-panel'); + if (mapPanel) { + mapPanel.classList.add('is-hidden'); + mapPanel.setAttribute('aria-hidden', 'true'); + } + if (isQuizQuestionMissionUiMapPlay()) { + quizQuestionMissionPhase = 'ended'; + applyQuizQuestionMissionPanelImages(); + const mission = quizQuestionMissionBuildPayload(); + showGauntletCrownMissionOverlay(mission); + return; + } + playQuizText = 'ครบทุกข้อในรอบทดสอบแล้ว'; + if (qov) qov.classList.remove('is-hidden'); + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] จบรอบ'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = playQuizText; + const leg = document.getElementById('quiz-play-legend'); + if (leg) leg.textContent = 'จำนวนข้อต่อรอบใช้ค่าเดียวกับ Admin (quizRoundQuestionCount)'; + } + + function clearPreviewBotAnswerPaths() { + if (!playBotsEnabled()) return; + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + o.botPath = []; + o.botAnswerWander = false; + }); + } + + function resetPreviewBotsQuizState() { + if (!playBotsEnabled()) return; + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + o.quizCannotTrue = false; + o.quizCannotFalse = false; + o.botPath = []; + o.botAnswerWander = false; + }); + } + + function applyPreviewReadPhase(q, poolLen) { + clearPreviewBotAnswerPaths(); + previewQuizStep = 'read'; + playQuizPhaseLocal = 'read'; + previewQuizCurrent = q; + playQuizText = q.text; + playQuizPhaseEndsAt = Date.now() + previewQuizTiming.readMs; + playQuizQuestionIndex = previewQuizQIndex + 1; + playQuizQuestionTotal = Math.max(0, Number(poolLen) || 0); + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) { + phaseEl.textContent = '[ทดสอบ] อ่านคำถาม · ข้อ ' + (previewQuizQIndex + 1) + ' / ' + poolLen; + } + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = playQuizText; + const leg = document.getElementById('quiz-play-legend'); + if (leg) leg.textContent = 'โซนทองบนแผนที่ = ข้อความคำถาม · ฟ้า = จริง · ชมพู = เท็จ'; + } + + function applyPreviewAnswerPhase() { + previewQuizStep = 'answer'; + playQuizPhaseLocal = 'answer'; + playQuizPhaseEndsAt = Date.now() + previewQuizTiming.answerMs; + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] เดินไปโซน ถูก / ผิด'; + const leg = document.getElementById('quiz-play-legend'); + if (leg && previewQuizCurrent) { + leg.textContent = previewQuizCurrent.answerTrue + ? 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «จริง» — โซนสีฟ้า' + : 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «เท็จ» — โซนสีชมพู'; + } + previewBotsPrepareAnswerRound(); + } + + function applyPreviewBetweenPhase() { + clearPreviewBotAnswerPaths(); + previewQuizStep = 'between'; + playQuizPhaseLocal = null; + playQuizText = 'ข้อถัดไปกำลังจะมา…'; + playQuizPhaseEndsAt = Date.now() + previewQuizTiming.betweenMs; + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] พักระหว่างข้อ'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = playQuizText; + const leg = document.getElementById('quiz-play-legend'); + if (leg) leg.textContent = 'รอสักครู่แล้วไปข้อถัดไป (สับแล้วตามจำนวนข้อต่อรอบ)'; + } + + function quizCellOnPlay(grid, x, y) { + return !!(grid && grid[y] && grid[y][x] === 1); + } + + /** ทั้ง footprint ทับโซน จริง/เท็จ — หลังตอบผิดควรถูกดีดออกแล้ว ไม่วาดทับ (กันจุดเล็กค้างกลาง SAFE/SCAM) */ + function playQuizPeerFootprintOverlapsAnswerZone(ax, ay) { + if (!mapData || !isQuiz()) return false; + for (const k of quizTilesFootprintPlay(ax, ay)) { + const parts = k.split(','); + const fx = +parts[0]; + const fy = +parts[1]; + if (quizCellOnPlay(mapData.quizTrueArea, fx, fy) || quizCellOnPlay(mapData.quizFalseArea, fx, fy)) return true; + } + return false; + } + + /** ไม่วาดตัวที่โดนล็อก/เคยตอบผิดแล้วแต่ยังทับโซน (กันจุดเล็กค้าง + desync มัลติเพลย์) */ + function playQuizAvatarHideWhileOverlappingAnswerZone(ent, entId, ax, ay) { + if (!playQuizPeerFootprintOverlapsAnswerZone(ax, ay)) return false; + if (entId === myId || ent === me) { + return !!(playQuizPlayerLocal && playQuizPlayerLocal.cannotTrue && playQuizPlayerLocal.cannotFalse); + } + const locked = !!(ent && ent.quizCannotTrue && ent.quizCannotFalse); + const everWrong = !!playQuizEverWrong[String(entId)]; + return locked || everWrong; + } + + function spawnTileWalkablePlay(md, x, y) { + const w = md.width || 20; + const h = md.height || 15; + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const row = md.objects && md.objects[y]; + if (row && row[x] === 1) return false; + return true; + } + + function spawnFootprintFitsPlay(md, anchorX, anchorY) { + if (!spawnTileWalkablePlay(md, anchorX, anchorY)) return false; + const cellsW = Math.max(1, Math.min(4, Math.floor(Number(md.characterCellsW) || Number(md.characterCells) || 1))); + const cellsH = Math.max(1, Math.min(4, Math.floor(Number(md.characterCellsH) || Number(md.characterCells) || 1))); + const w = md.width || 20; + const h = md.height || 15; + const maxX = Math.min(w, anchorX + cellsW); + const maxY = Math.min(h, anchorY + cellsH); + for (let ty = anchorY; ty < maxY; ty++) { + for (let tx = anchorX; tx < maxX; tx++) { + if (!spawnTileWalkablePlay(md, tx, ty)) return false; + } + } + return true; + } + + /** เหมือน server pickRandomSpawnFromMap — สุ่มด้วย crypto.getRandomValues */ + function pickRandomSpawnFromMapPlay(md) { + const fallback = md.spawn || { x: 1, y: 1 }; + const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1; + const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1; + const grid = md.spawnArea; + if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; + const w = md.width || 20; + const h = md.height || 15; + const pool = []; + for (let y = 0; y < h; y++) { + const row = grid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (Number(row[x]) === 1 && spawnFootprintFitsPlay(md, x, y)) pool.push({ x, y }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const u = new Uint32Array(1); + (typeof crypto !== 'undefined' && crypto.getRandomValues) + ? crypto.getRandomValues(u) + : (u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0)); + const pick = pool[u[0] % pool.length]; + return { x: pick.x, y: pick.y }; + } + + function parseLobbyPlayerSpawnsFromMapPlay(md) { + const w = md.width || 20; + const h = md.height || 15; + const out = [null, null, null, null, null, null]; + const raw = md && md.lobbyPlayerSpawns; + if (!Array.isArray(raw)) return out; + for (let i = 0; i < 6 && i < raw.length; i++) { + const cell = raw[i]; + if (!cell || typeof cell !== 'object') continue; + const x = Math.floor(Number(cell.x)); + const y = Math.floor(Number(cell.y)); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + if (x < 0 || x >= w || y < 0 || y >= h) continue; + out[i] = { x, y }; + } + return out; + } + + /** jump_survive: ช่อง P1–P6 ที่วาดแบบยาน (shooterSpawnSlots) เติมใน slots6 เมื่อ lobbyPlayerSpawns ว่าง */ + function augmentLobbySlotsFromShooterPaintJumpSurvivePlay(md, slots6) { + if (!md || md.gameType !== 'jump_survive' || !Array.isArray(slots6)) return; + const g = md.shooterSpawnSlots; + if (!g) return; + const w = md.width || 20, h = md.height || 15; + for (let slot = 1; slot <= 6; slot++) { + const idx = slot - 1; + if (slots6[idx]) continue; + let found = false; + for (let yy = 0; yy < h && !found; yy++) { + const row = g[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (row[xx] === slot) { + slots6[idx] = { x: xx, y: yy }; + found = true; + break; + } + } + } + } + } + + /** จุดเกิด Quiz Battle — ต้องอยู่บนเส้นทาง (กล่องสไปรต์) ไม่ใช่แค่ snap จาก spawnArea นอกเลน */ + function collectQuizBattleValidSpawnCentersPlay(md) { + const out = []; + if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActive(md)) return out; + const w = md.width || 20, h = md.height || 15; + const g = md.quizBattlePathArea; + const seen = new Set(); + const prevMap = mapData; + mapData = md; + try { + for (let ty = 0; ty < h; ty++) { + for (let tx = 0; tx < w; tx++) { + if (!g[ty] || g[ty][tx] !== 1) continue; + for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) { + const key = nx.toFixed(3) + ',' + ny.toFixed(3); + if (seen.has(key)) continue; + if (!quizBattleSnapTargetValidPlay(nx, ny)) continue; + seen.add(key); + out.push({ tx, ty, x: nx, y: ny }); + } + } + } + } finally { + mapData = prevMap; + } + out.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.x - b.x); + return out; + } + + /** จุดโลกใกล้ pref — ลองกลางช่องก่อน แล้วหาใน spawnArea (ฟ้า) ที่วางตัวได้ */ + function quizBattleNearestValidSpawnWorldPlay(md, prefTx, prefTy) { + const ptx = Math.floor(Number(prefTx)) || 0; + const pty = Math.floor(Number(prefTy)) || 0; + const prevMap = mapData; + mapData = md; + try { + for (const [nx, ny] of [[ptx + 0.5, pty + 0.5], [ptx + 0.01, pty + 0.01]]) { + if (quizBattleSpawnWallClearPlay(md, nx, ny)) return { x: nx, y: ny }; + } + const pool = collectQuizBattleSpawnPoolPlay(md); + if (pool.length) { + let best = pool[0], bestD = Infinity; + for (const c of pool) { + const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty); + if (d < bestD) { bestD = d; best = c; } + } + return { x: best.x, y: best.y }; + } + const valid = collectQuizBattleValidSpawnCentersPlay(md); + if (!valid.length) return { x: ptx + 0.5, y: pty + 0.5 }; + let best = valid[0], bestD = Infinity; + for (const c of valid) { + const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty); + if (d < bestD) { bestD = d; best = c; } + } + return { x: best.x, y: best.y }; + } finally { + mapData = prevMap; + } + } + + /** ช่อง spawnArea (ฟ้า) ที่วางตัวได้ — สุ่มในพื้นที่ฟ้า ไม่บังคับอยู่บนเลนม่วง */ + function collectQuizBattleSpawnPoolPlay(md) { + const out = []; + if (!md) return out; + const prevMap = mapData; + mapData = md; + try { + const w = md.width || 20, h = md.height || 15; + const grid = md.spawnArea; + const seen = new Set(); + const cells = []; + if (grid && Array.isArray(grid)) { + for (let ty = 0; ty < h; ty++) { + const row = grid[ty]; + if (!row) continue; + for (let tx = 0; tx < w; tx++) { + if (Number(row[tx]) === 1) cells.push({ tx, ty }); + } + } + } + if (!cells.length && md.spawn) { + cells.push({ + tx: Math.max(0, Math.min(w - 1, Math.floor(Number(md.spawn.x)) || 1)), + ty: Math.max(0, Math.min(h - 1, Math.floor(Number(md.spawn.y)) || 1)), + }); + } + for (const { tx, ty } of cells) { + for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) { + const key = nx.toFixed(3) + ',' + ny.toFixed(3); + if (seen.has(key)) continue; + if (quizBattlePathModeActive(md)) { + if (!quizBattleSpawnWallClearPlay(md, nx, ny)) continue; + } else if (!spawnFootprintFitsPlay(md, tx, ty)) { + continue; + } + seen.add(key); + out.push({ tx, ty, x: nx, y: ny }); + } + } + if (!out.length && quizBattlePathModeActive(md)) { + return collectQuizBattleValidSpawnCentersPlay(md); + } + } finally { + mapData = prevMap; + } + return out; + } + + function pickQuizBattleRandomSpawnWorldPlay(md) { + const pool = collectQuizBattleSpawnPoolPlay(md); + const fb = md.spawn || { x: 1, y: 1 }; + if (!pool.length) { + return quizBattleNearestValidSpawnWorldPlay(md, fb.x, fb.y); + } + const u = new Uint32Array(1); + if (typeof crypto !== 'undefined' && crypto.getRandomValues) crypto.getRandomValues(u); + else u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0); + const pick = pool[u[0] % pool.length]; + return { x: pick.x, y: pick.y }; + } + + function quizBattleSpawnWorldFromJoinOrderPlay(md, joinOrderIndex) { + if (!md) return { x: 1.5, y: 1.5 }; + const ord = joinOrderIndex | 0; + const mode = md.lobbySpawnMode; + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMapPlay(md); + const j = Math.min(Math.max(0, ord), 5); + const slot = slots[j]; + if (slot) return quizBattleNearestValidSpawnWorldPlay(md, slot.x, slot.y); + return pickQuizBattleRandomSpawnWorldPlay(md); + } + if (mode === 'fixed' && md.spawn) { + const sx = Number(md.spawn.x) || 1, sy = Number(md.spawn.y) || 1; + return quizBattleNearestValidSpawnWorldPlay(md, sx, sy); + } + /* random (ค่าเริ่มต้นใน editor) — สุ่มในพื้นที่ฟ้า ไม่เรียงตาม join order */ + return pickQuizBattleRandomSpawnWorldPlay(md); + } + + function pickQuizBattleSpawnFromMapPlay(md, joinOrderIndex) { + const world = quizBattleSpawnWorldFromJoinOrderPlay(md, joinOrderIndex); + return { x: Math.floor(world.x), y: Math.floor(world.y) }; + } + + /** สอดคล้องกับ server pickSpawnForJoin — ใช้พรีวิวบอท / ทดสอบ */ + function pickSpawnForJoinPlay(md, joinOrderIndex) { + if (!md) return { x: 1, y: 1 }; + if (md.gameType === 'quiz_battle' && quizBattlePathModeActive(md)) { + const qb = pickQuizBattleSpawnFromMapPlay(md, joinOrderIndex); + if (qb) return qb; + } + const mode = md.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMapPlay(md); + const j = Math.min(Math.max(0, ord), 5); + if (mode === 'fixed' && md.spawn) { + const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1; + const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1; + const w = md.width || 20; + const h = md.height || 15; + const x = Math.max(0, Math.min(w - 1, fx)); + const y = Math.max(0, Math.min(h - 1, fy)); + if (spawnFootprintFitsPlay(md, x, y)) return { x, y }; + return pickRandomSpawnFromMapPlay(md); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMapPlay(md); + augmentLobbySlotsFromShooterPaintJumpSurvivePlay(md, slots); + const pick = slots[j]; + if (pick && spawnFootprintFitsPlay(md, pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomSpawnFromMapPlay(md); + } + return pickRandomSpawnFromMapPlay(md); + } + + const GAUNTLET_PREVIEW_MAX = 6; + function gauntletSpawnYsPlay(md, playerCount) { + const h = md.height || 15; + const lo = 1; + const hi = Math.max(lo, h - 2); + const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount)); + if (hi <= lo) return Array.from({ length: n }, () => lo); + const ys = []; + for (let i = 0; i < n; i++) { + ys.push(Math.round(lo + (i * (hi - lo)) / Math.max(1, n - 1))); + } + return ys; + } + function collectGauntletSpawnSlotsFromSpawnAreaPlay(md) { + const grid = md.spawnArea; + if (!grid || !Array.isArray(grid)) return []; + const w = md.width || 20; + const h = md.height || 15; + const slots = []; + for (let y = 0; y < h; y++) { + const row = grid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (Number(row[x]) === 1 && spawnTileWalkablePlay(md, x, y)) slots.push({ x, y }); + } + } + slots.sort((a, b) => a.y - b.y || a.x - b.x); + return slots; + } + function collectGauntletSpawnSlotsPlay(md) { + const explicit = md.gauntletPlayerSpawns; + if (Array.isArray(explicit) && explicit.length > 0) { + const w = md.width || 20; + const h = md.height || 15; + const slots = []; + for (const raw of explicit) { + if (slots.length >= GAUNTLET_PREVIEW_MAX) break; + const x = Math.floor(Number(raw && raw.x)); + const y = Math.floor(Number(raw && raw.y)); + if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= w || y < 0 || y >= h) continue; + if (!spawnTileWalkablePlay(md, x, y)) continue; + slots.push({ x, y }); + } + if (slots.length > 0) return slots; + } + return collectGauntletSpawnSlotsFromSpawnAreaPlay(md); + } + function gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotXFallback) { + const w = md.width || 20; + if (spawnTileWalkablePlay(md, spawnColX, y)) return spawnColX; + if (slotXFallback != null && spawnTileWalkablePlay(md, slotXFallback, y)) return slotXFallback; + for (let d = 0; d < w; d++) { + if (spawnColX + d < w && spawnTileWalkablePlay(md, spawnColX + d, y)) return spawnColX + d; + if (spawnColX - d >= 0 && spawnTileWalkablePlay(md, spawnColX - d, y)) return spawnColX - d; + } + return Math.max(0, Math.min(w - 1, spawnColX)); + } + function gauntletSpawnPositionsPlay(md, playerCount) { + const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount)); + const slots = collectGauntletSpawnSlotsPlay(md); + const fallbackYs = gauntletSpawnYsPlay(md, n); + const spawnColX = slots.length ? Math.min(...slots.map((s) => s.x)) : 1; + const out = []; + for (let i = 0; i < n; i++) { + const y = i < slots.length + ? slots[i].y + : (fallbackYs[i] != null ? fallbackYs[i] : fallbackYs[fallbackYs.length - 1]); + const slotX = i < slots.length ? slots[i].x : null; + const x = gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotX); + out.push({ x, y }); + } + return out; + } + /** + * โหมดทดสอบพรมแดง: จัดตำแหน่งเกิดให้ตรง server (spawnArea หรือกระจาย y) + * @param {boolean} [onlyBots=false] ถ้า true = จัดแค่บอท (หลัง game-start; ไม่ทับ me/คนจริงจาก peersSnap) + */ + function applyGauntletPreviewSpawnLayout(onlyBots) { + if (!mapData || mapData.gameType !== 'gauntlet') return; + const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); + const botIds = [...others.keys()].filter(isPreviewBotId).sort(); + const humanCount = 1 + realIds.length; + const total = Math.min(GAUNTLET_PREVIEW_MAX, humanCount + botIds.length); + const pos = gauntletSpawnPositionsPlay(mapData, total); + if (onlyBots) { + let idx = humanCount; + botIds.forEach((bid) => { + const o = others.get(bid); + if (!o || idx >= pos.length) return; + o.x = pos[idx].x; + o.tx = pos[idx].x; + o.y = pos[idx].y; + o.ty = pos[idx].y; + idx++; + }); + return; + } + let idx = 0; + if (idx < pos.length) { + me.x = pos[idx].x; + me.y = pos[idx].y; + me.tx = me.x; + me.ty = me.y; + idx++; + } + realIds.forEach((rid) => { + const o = others.get(rid); + if (!o || idx >= pos.length) return; + o.x = pos[idx].x; + o.tx = pos[idx].x; + o.y = pos[idx].y; + o.ty = pos[idx].y; + idx++; + }); + botIds.forEach((bid) => { + const o = others.get(bid); + if (!o || idx >= pos.length) return; + o.x = pos[idx].x; + o.tx = pos[idx].x; + o.y = pos[idx].y; + o.ty = pos[idx].y; + idx++; + }); + } + + function isPreviewBotId(id) { + return typeof id === 'string' && id.indexOf(PREVIEW_BOT_PREFIX) === 0; + } + + function countPlayHumans() { + let n = 1; + others.forEach((_, id) => { if (!isPreviewBotId(id)) n++; }); + return n; + } + + /** ป้ายชื่อบอท "บอท N (tier)" — เรียงตาม id (เหมือนทุกเครื่อง) ใช้ o.botTier (host เป็นเจ้าของ ผ่าน fill-bot-state) */ + function relabelPreviewBotsPlay() { + let k = 0; + [...others.keys()].filter(isPreviewBotId).sort().forEach((bid) => { + const o = others.get(bid); + if (!o) return; + const tag = o.botTier === 'sharp' ? '(ฉลาด)' : o.botTier === 'weak' ? '(พลาดบ่อย)' : '(กลาง)'; + o.nickname = 'บอท ' + (++k) + ' ' + tag; + }); + } + + function rebalancePreviewBots() { + if (!playBotsEnabled() || !mapData) return; + const human = countPlayHumans(); + let wantBots = Math.max(0, playBotTargetHeadcount() - human); + wantBots = Math.min(wantBots, Math.max(0, 6 - human)); /* total ≤ 6 เสมอ (กัน over-count เช่น reconnect หลัง bot-takeover) */ + /* Card 3 Ban — บอทที่ถูกแบนรอบนี้ (slot N) ไม่ลงเล่น: ลบทิ้ง + ลดเป้าจำนวนลง 1 + ข้าม slot นั้นตอนสร้าง */ + if (playBannedBotSlot >= 0) { + others.delete(PREVIEW_BOT_PREFIX + playBannedBotSlot); + wantBots = Math.max(0, wantBots - 1); + } + [...others.keys()].filter(isPreviewBotId).forEach((bid) => { + const o = others.get(bid); + if (!o) return; + o.playTint = playTintForPreviewBotId(bid) || resolvePlayTintForPeer(bid); + if (o.botTier == null) { + const tierRoll = Math.random(); + const stackPrev = mapData && mapData.gameType === 'stack'; + o.botTier = stackPrev + ? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak') + : (tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak'); + } + if (o.quizCannotTrue == null) o.quizCannotTrue = false; + if (o.quizCannotFalse == null) o.quizCannotFalse = false; + if (!Array.isArray(o.botPath)) o.botPath = []; + if (o.botAnswerWander == null) o.botAnswerWander = false; + if (o.gauntletJumpTicks == null) o.gauntletJumpTicks = 0; + if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks; + if (o.gauntletScore == null) { + o.gauntletScore = (mapData && mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay()) ? 100 : 0; + } + if (o.gauntletEliminated == null) o.gauntletEliminated = false; + if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) { + const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)]; + o.botWanderDx = wd[0]; + o.botWanderDy = wd[1]; + } + if (typeof o.botWanderNextTurn !== 'number') { + o.botWanderNextTurn = Date.now() + 400 + Math.floor(Math.random() * 900); + } + }); + let botIds = [...others.keys()].filter(isPreviewBotId); + while (botIds.length > wantBots) { + const drop = botIds.pop(); + if (drop) others.delete(drop); + } + botIds = [...others.keys()].filter(isPreviewBotId); + while (botIds.length < wantBots) { + /* หา slot ว่างเล็กสุด (0,1,2,…) — IDX ของ ID ต้องตรงกับ playLobbyBotThemes[idx] เพื่อให้สีตรง lobby */ + let nextSlot = 0; + while (others.has(PREVIEW_BOT_PREFIX + nextSlot) || nextSlot === playBannedBotSlot) nextSlot++; + previewBotSeq = Math.max(previewBotSeq, nextSlot + 1); + const id = PREVIEW_BOT_PREFIX + nextSlot; + const joinIdx = countPlayHumans() + botIds.length; + let x; + let y; + if (mapData.gameType === 'jump_survive') { + const pos = jumpSurviveSpawnWorldFromJoinOrderPlay(mapData, joinIdx); + x = pos.x; + y = pos.y; + } else if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) { + const pos = quizBattleSpawnWorldFromJoinOrderPlay(mapData, joinIdx); + x = pos.x; + y = pos.y; + } else { + const sp = pickSpawnForJoinPlay(mapData, joinIdx); + const jx = (Math.random() - 0.5) * 0.4; + const jy = (Math.random() - 0.5) * 0.4; + x = sp.x + 0.5 + jx; + y = sp.y + 0.5 + jy; + } + const tierRoll = Math.random(); + const stackPrev = mapData && mapData.gameType === 'stack'; + const botTier = stackPrev + ? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak') + : (tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak'); + const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)]; + const crownStart = mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay(); + others.set(id, { + x, y, tx: x, ty: y, + direction: ['down', 'up', 'left', 'right'][Math.floor(Math.random() * 4)], + nickname: 'บอท', + characterId: pickPreviewBotCharacterId(botIds.length), + spawnJoinOrder: joinIdx, + playTint: playTintForPreviewBotId(id) || resolvePlayTintForPeer(id), + botTier, + gauntletJumpTicks: 0, + gauntletJumpVis: 0, + gauntletScore: crownStart ? 100 : 0, + gauntletEliminated: false, + quizCannotTrue: false, + quizCannotFalse: false, + quizCarryHeld: null, + botPath: [], + botAnswerWander: false, + botWanderDx: wd[0], + botWanderDy: wd[1], + botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 1000), + jumpSurviveEliminated: false, + spaceShooterScore: 0, + balloonBossScore: 0, + balloonBossBossDmg: 0, + balloonBossBalloons: mapData && mapData.gameType === 'balloon_boss' ? balloonBossBalloonsStartPlay() : 5, + balloonBossEliminated: false, + botQuizCarryPathfindAfter: performance.now() + previewBotSeq * 75 + Math.floor(Math.random() * 160), + }); + /* preload เลเยอร์สี + tint ของบอททันทีตอนสร้าง — กัน score HUD บน host โชว์ avatar บอท "ไม่ย้อมสี" + (เดิม preloadPlayOccupantsTints รันตอน scene-load ก่อนบอทถูกสร้าง → เลเยอร์ยังไม่โหลด) */ + try { + const bo = others.get(id); + if (bo && bo.characterId) preloadPlayTintForAvatar(bo.characterId, bo.playTint || resolvePlayTintForPeer(id)); + } catch (ePB) { /* ignore */ } + botIds.push(id); + } + relabelPreviewBotsPlay(); + [...others.keys()].filter(isPreviewBotId).sort().forEach((bid, botIdx) => { + const o = others.get(bid); + if (o) o.characterId = pickPreviewBotCharacterId(botIdx); + }); + if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) { + [...others.keys()].filter(isPreviewBotId).forEach((bid) => { + const o = others.get(bid); + if (!o) return; + const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y); + o.x = s.x; + o.y = s.y; + o.tx = s.x; + o.ty = s.y; + }); + } + if (mapData.gameType === 'quiz_carry') { + const t0 = performance.now(); + [...others.keys()].filter(isPreviewBotId).forEach((bid, idx) => { + const o = others.get(bid); + if (!o) return; + o.botQuizCarryPathfindAfter = t0 + idx * 90 + Math.floor(Math.random() * 140); + }); + } + if (mapData.gameType === 'gauntlet') { + applyGauntletPreviewSpawnLayout(false); + emitGauntletPreviewRowsToServer(); + } + if (mapData.gameType === 'stack') { + applyStackPreviewSpawnLayout(); + rebuildStackPreviewTurnOrder(); + } + if (mapData.gameType === 'jump_survive') { + applyJumpSurvivePreviewSpawnLayout(false); + } + if (mapData.gameType === 'space_shooter') { + applySpaceShooterSpawnLayoutPlay(); + } + if (mapData.gameType === 'balloon_boss') { + applyBalloonBossSpawnLayoutPlay(); + fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((t) => { if (t) applyGauntletTimingFromServer(t); }) + .catch(() => {}); + } + if (quizCarryPregameActive && mapData && mapData.gameType === 'quiz_carry') updateQuizCarryPregameHud(); + try { + preloadPlayOccupantsTints(); + preloadPlayLobbyIdleAvatarsForOccupants(); + } catch (e) { /* ignore */ } + } + + function pickRandomWalkableCellInAnswerGrid(grid, o) { + if (!grid || !mapData) return null; + const w = mapData.width || 20, h = mapData.height || 15; + const pool = []; + for (let y = 0; y < h; y++) { + const row = grid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (row[x] !== 1) continue; + if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, NaN, NaN, o)) pool.push({ x, y }); + } + } + if (!pool.length) return null; + return pool[Math.floor(Math.random() * pool.length)]; + } + + /** ช่วงตอบ: บอทสุ่มเดินไปโซนถูก/ผิด/เดินมั่ว ตามระดับ (ฉลาด/กลาง/พลาดบ่อย) */ + function previewBotsPrepareAnswerRound() { + if (!playBotsEnabled() || !mapData || !isQuiz() || !previewQuizCurrent) return; + if (detectiveCaseFillBots) return; /* เกมจริง: server ตัดสินคำตอบบอทเอง */ + const correctTrue = !!previewQuizCurrent.answerTrue; + const qt = mapData.quizTrueArea; + const qf = mapData.quizFalseArea; + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + o.botPath = []; + o.botAnswerWander = false; + const tier = o.botTier || 'avg'; + let pCorrect = 0.52; + if (tier === 'sharp') pCorrect = 0.84; + else if (tier === 'weak') pCorrect = 0.22; + if (Math.random() < 0.14) { + o.botAnswerWander = true; + return; + } + const wantsCorrect = Math.random() < pCorrect; + const targetTrue = wantsCorrect ? correctTrue : !correctTrue; + const zoneGrid = targetTrue ? qt : qf; + const dest = pickRandomWalkableCellInAnswerGrid(zoneGrid, o); + if (!dest) { + o.botAnswerWander = true; + return; + } + const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o); + if (!path || path.length <= 1) { + o.botAnswerWander = true; + return; + } + o.botPath = path.slice(1); + }); + } + + /** คัดลอกตรรกะจาก server — ดีดออกนอกโซนจริง/เท็จ (โหมดทดสอบ preview บน play.html) */ + function findNearestOutsideQuizAnswerZonesPlay(md, sx, sy) { + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + const inAnswer = (x, y) => quizCellOnPlay(tGrid, x, y) || quizCellOnPlay(fGrid, x, y); + const walkable = (x, y) => { + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const row = md.objects && md.objects[y]; + return row && row[x] !== 1; + }; + const fx = Math.floor(Number(sx)); + const fy = Math.floor(Number(sy)); + if (!walkable(fx, fy)) { + const sp = md.spawn || { x: 1, y: 1 }; + return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; + } + if (!inAnswer(fx, fy)) return { x: sx, y: sy }; + const q = [[fx, fy]]; + const seen = new Set([`${fx},${fy}`]); + const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; + while (q.length) { + const [cx, cy] = q.shift(); + for (let di = 0; di < dirs.length; di++) { + const nx = cx + dirs[di][0]; + const ny = cy + dirs[di][1]; + const k = `${nx},${ny}`; + if (seen.has(k)) continue; + if (!walkable(nx, ny)) continue; + seen.add(k); + if (!inAnswer(nx, ny)) { + return { x: nx + 0.5, y: ny + 0.5 }; + } + q.push([nx, ny]); + } + } + const sp = md.spawn || { x: 1, y: 1 }; + return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; + } + + function quizResolveChoiceFromStanding(ox, oy, correctTrue) { + const tx = Math.floor(ox); + const ty = Math.floor(oy); + const qt = mapData.quizTrueArea; + const qf = mapData.quizFalseArea; + const inT = quizCellOnPlay(qt, tx, ty); + const inF = quizCellOnPlay(qf, tx, ty); + let choice = null; + if (inT && !inF) choice = true; + else if (inF && !inT) choice = false; + else if (inT && inF) choice = true; + const right = choice !== null && choice === correctTrue; + return { choice, right }; + } + + /** ลำดับจุดเกิดเดียวกับ pickSpawnForJoinPlay — ห้ามสุ่มทั้ง spawnArea หลังตอบผิด (จะหลุดจาก P1–P6) */ + function quizPreviewJoinOrderForRespawn(actorId) { + if (actorId === myId) { + const jm = Number(me && me.spawnJoinOrder); + return Number.isFinite(jm) ? Math.max(0, Math.floor(jm)) : 0; + } + const o = others.get(actorId); + if (o && typeof o.spawnJoinOrder === 'number' && Number.isFinite(o.spawnJoinOrder)) { + return Math.max(0, Math.floor(o.spawnJoinOrder)); + } + if (!isPreviewBotId(actorId)) { + const othersReal = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); + const idx = othersReal.indexOf(actorId); + return idx >= 0 ? 1 + idx : 1; + } + const bots = [...others.keys()].filter(isPreviewBotId).sort(); + const bi = bots.indexOf(actorId); + if (bi < 0) return 0; + return countPlayHumans() + bi; + } + + /** เฉลยรอบทดสอบบนเครื่อง — ให้พฤติกรรมใกล้เคียง server (ผิด = ล็อกโซน + ดีดออก) */ + function resolvePreviewQuizRound() { + if (!previewMode || !mapData || !isQuiz() || !previewQuizCurrent || myId == null) return; + const correctTrue = !!previewQuizCurrent.answerTrue; + const results = []; + const mine = quizResolveChoiceFromStanding(me.x, me.y, correctTrue); + let right = mine.right; + let choice = mine.choice; + if (mine.right) { + playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + QUIZ_TF_POINTS_PER_CORRECT; + } else { + playQuizPlayerLocal.cannotTrue = true; + playQuizPlayerLocal.cannotFalse = true; + if (!warpMeIntoQuizGhostZonePlay()) { + const ordMe = quizPreviewJoinOrderForRespawn(myId); + const sp = pickSpawnForJoinPlay(mapData, ordMe); + const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5); + me.x = pos.x; + me.y = pos.y; + me.tx = me.x; + me.ty = me.y; + socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); + } + } + results.push({ + id: myId, + right, + choice, + score: playLiveQuizScores[myId] != null ? playLiveQuizScores[myId] : 0, + }); + if (playBotsEnabled()) { + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + const br = quizResolveChoiceFromStanding(o.x, o.y, correctTrue); + o.botPath = []; + o.botAnswerWander = false; + if (br.right) { + playLiveQuizScores[id] = (playLiveQuizScores[id] || 0) + QUIZ_TF_POINTS_PER_CORRECT; + } else { + o.quizCannotTrue = true; + o.quizCannotFalse = true; + const ordB = quizPreviewJoinOrderForRespawn(id); + const sp = pickSpawnForJoinPlay(mapData, ordB); + const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5); + o.x = pos.x + (Math.random() - 0.5) * 0.35; + o.y = pos.y + (Math.random() - 0.5) * 0.35; + o.tx = o.x; + o.ty = o.y; + } + results.push({ + id, + right: br.right, + choice: br.choice, + score: playLiveQuizScores[id] != null ? playLiveQuizScores[id] : 0, + }); + }); + } + const r = { results, scores: { ...playLiveQuizScores } }; + results.forEach(function (row) { + if (!row || row.id == null) return; + if (!row.right) playQuizEverWrong[String(row.id)] = true; + }); + renderPlayQuizScoreboard(playLiveQuizScores); + if (results.some(function (row) { return row && row.right; })) { + spawnQuizTrueFalseScorePopupPlay(correctTrue); + } + showPlayQuizFeedback(r); + } + + function advancePreviewQuizIfDue() { + if (isQuizQuestionMissionUiMapPlay() && isQuizQuestionMissionPregameBlockingPlay()) return; + if (previewQuizStep === 'done') return; + if (!previewMode || !isQuiz() || !playQuizPhaseEndsAt || Date.now() < playQuizPhaseEndsAt) return; + const pool = previewQuizPool; + if (!pool || !pool.length) return; + if (previewQuizStep === 'read') { + applyPreviewAnswerPhase(); + return; + } + if (previewQuizStep === 'answer') { + resolvePreviewQuizRound(); + applyPreviewBetweenPhase(); + return; + } + if (previewQuizStep === 'between') { + previewQuizQIndex += 1; + if (previewQuizQIndex >= pool.length) { + finishPreviewQuizSessionPlay(); + return; + } + const q = pool[previewQuizQIndex]; + if (q) applyPreviewReadPhase(q, pool.length); + } + } + + async function loadPreviewQuizAndStart() { + resetPreviewBotsQuizState(); + playQuizEverWrong = {}; + let settings = null; + try { + const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' }); + if (r.ok) settings = await r.json(); + } catch (e) { /* use map fallback */ } + let pool = []; + if (settings && Array.isArray(settings.questions) && settings.questions.length) { + pool = settings.questions + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })); + } + if (!pool.length && mapData) pool = buildQuizPoolFromMap(mapData); + const cap = clampPreviewQuizRoundQuestionCount(settings); + const shuffled = shufflePreviewQuizQuestionsPlay(pool); + previewQuizPool = shuffled.slice(0, Math.min(cap, shuffled.length)); + previewQuizQIndex = 0; + const dRead = 10000; + const dAns = 5000; + const dBet = 3500; + previewQuizTiming = { + readMs: clampPreviewMs(settings && settings.readMs, dRead, 1000, 300000), + answerMs: clampPreviewMs(settings && settings.answerMs, dAns, 1000, 300000), + betweenMs: clampPreviewMs(settings && settings.betweenMs, dBet, 0, 300000), + }; + previewQuizCurrent = null; + if (!pool.length) { + playQuizPhaseLocal = 'read'; + previewQuizStep = 'read'; + playQuizText = 'ยังไม่มีคำถามในชุด — ตั้งคำถามใน Admin → คำถามเกม หรือในแมป (quizQuestions)'; + playQuizPhaseEndsAt = 0; + playQuizQuestionIndex = 0; + playQuizQuestionTotal = 0; + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] ไม่มีคำถามในระบบ'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = playQuizText; + const leg = document.getElementById('quiz-play-legend'); + if (leg) leg.textContent = 'โซนสีบนแผนที่ยังใช้ดูเลย์เอาต์ได้ตามปกติ'; + initPlayLiveQuizScoresZeros(); + startPlayQuizTimer(); + return; + } + const session = previewQuizPool; + const q = session[0]; + if (q) applyPreviewReadPhase(q, session.length); + initPlayLiveQuizScoresZeros(); + startPlayQuizTimer(); + } + + function startPlayQuizTimer() { + if (playQuizTimerInterval) clearInterval(playQuizTimerInterval); + playQuizTimerInterval = setInterval(() => { + updatePlayQuizTimerDisplay(); + advancePreviewQuizIfDue(); + }, 200); + updatePlayQuizTimerDisplay(); + } + + function teardownPlayQuizUi() { + if (playQuizTimerInterval) { + clearInterval(playQuizTimerInterval); + playQuizTimerInterval = null; + } + const ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.add('is-hidden'); + const panel = document.getElementById('quiz-map-question-panel'); + const mapQText = document.getElementById('quiz-map-question-text'); + if (panel) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + clearQuizMapPanelThemeInline(panel, mapQText); + } + playQuizPhaseLocal = null; + playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + playQuizText = ''; + playQuizPhaseEndsAt = 0; + playQuizQuestionIndex = 0; + playQuizQuestionTotal = 0; + previewQuizPool = []; + previewQuizQIndex = 0; + previewQuizCurrent = null; + previewQuizStep = 'read'; + playLiveQuizScores = {}; + playQuizEverWrong = {}; + quizTfScorePopups = []; + playPath = []; + hidePlayQuizHud(); + quizCarryJoinSettingsSnap = null; + quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT; + quizCarryMapPanelTheme = null; + quizMapPanelTheme = null; + quizCarryChoicePlaqueThemes = null; + resetQuizCarryPlayState(); + resetQuizBattlePlayState(); + quizQuestionMissionPhase = null; + quizQuestionMissionDeferredPhase = null; + if (quizQuestionMissionCountdownTimer) { + clearTimeout(quizQuestionMissionCountdownTimer); + quizQuestionMissionCountdownTimer = null; + } + const gcmTeardown = document.getElementById('gauntlet-crown-mission-overlay'); + if (gcmTeardown) gcmTeardown.classList.add('is-hidden'); + cancelEmbedPreviewLobbyReturnTimer(); + const grabBtnTeardown = document.getElementById('quiz-carry-grab-btn'); + if (grabBtnTeardown) { + grabBtnTeardown.classList.add('is-hidden'); + grabBtnTeardown.classList.remove('quiz-carry-grab-btn--active'); + grabBtnTeardown.classList.remove('quiz-carry-grab-btn--place'); + grabBtnTeardown.style.pointerEvents = ''; + const im = grabBtnTeardown.querySelector('img'); + if (im) im.src = '/Game/img/quiz-carry/btn-grab.png'; + } + const gchJumpTeardown = document.getElementById('gauntlet-crown-jump-btn'); + if (gchJumpTeardown) { + gchJumpTeardown.classList.add('is-hidden'); + gchJumpTeardown.setAttribute('aria-hidden', 'true'); + gchJumpTeardown.style.pointerEvents = ''; + } + const stackDropTeardown = document.getElementById('stack-tower-drop-btn'); + if (stackDropTeardown) { + stackDropTeardown.classList.add('is-hidden'); + stackDropTeardown.setAttribute('aria-hidden', 'true'); + stackDropTeardown.style.pointerEvents = ''; + stackDropTeardown.classList.remove('stack-tower-drop-btn--active'); + } + hideStackTowerResultFlashDomOnlyPlay(); + } + + function setupPlayQuizUi() { + if (playQuizTimerInterval) { + clearInterval(playQuizTimerInterval); + playQuizTimerInterval = null; + } + const ov = document.getElementById('quiz-game-overlay'); + if (ov) { + const hideBottomQuizBar = (previewMode && editorEmbedReturn && !isQuizCarry()) + || (isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live'); + if (hideBottomQuizBar) ov.classList.add('is-hidden'); + else ov.classList.remove('is-hidden'); + } + playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + const leg = document.getElementById('quiz-play-legend'); + if (previewMode) { + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] กำลังโหลดคำถาม…'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = 'ดึงชุดคำถามจาก /api/quiz-settings (หรือจากแมป)…'; + if (leg) leg.textContent = ''; + if (isQuizQuestionMissionUiMapPlay()) { + if (phaseEl) phaseEl.textContent = '[ทดสอบ] รอกด READY เพื่อเริ่มภารกิจ'; + if (qEl) qEl.textContent = 'โหมดภารกิจ — หน้าปก HOWTO จาก /img/QUESTION/'; + if (leg) leg.textContent = ''; + } else { + loadPreviewQuizAndStart(); + } + } else { + playQuizPhaseLocal = null; + playQuizText = 'รอคำถามจากโฮสต์…'; + playQuizPhaseEndsAt = 0; + playQuizQuestionIndex = 0; + playQuizQuestionTotal = 0; + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = 'แมพตอบคำถาม'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = 'เข้าร่วมห้องแล้ว — เมื่อโฮสต์เริ่มเกม ข้อความและเวลาจะอัปเดตที่นี่ (เล่นจริงแนะนำผ่าน room-lobby)'; + if (leg) leg.textContent = 'โซนสีบนแผนที่คือจุดตอบเหมือนใน Lobby'; + const tEl = document.getElementById('quiz-game-timer'); + if (tEl) tEl.textContent = ''; + } + if (isQuiz() && !isQuizCarry()) { + (async () => { + try { + let s = {}; + const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' }); + if (r.ok) { + const raw = await r.text(); + const trimmed = (raw || '').trim(); + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + try { + s = JSON.parse(trimmed); + } catch (e) { /* ignore */ } + } + } + s = await mergeQuizCarrySettingsFromDisk(s && typeof s === 'object' ? s : {}); + setQuizMapPanelThemeFromApi(s); + try { + syncPlayQuizMapPanel(); + } catch (e2) { /* ignore */ } + } catch (e) { /* ignore */ } + })(); + } + } + + /** อัปเดตแถบคำถาม quiz จากเซิร์ฟเวอร์ — แยกไว้ให้ภารกิจ mng8a80o เรียกหลังจบ countdown */ + function applyQuizPhaseFromServer(p) { + if (!p || !mapData || !isQuiz()) return; + if (p.text) playQuizText = p.text; + playQuizPhaseLocal = p.phase; + playQuizPhaseEndsAt = p.endsAt || 0; + playQuizQuestionIndex = Math.max(0, Number(p.questionIndex) || 0); + playQuizQuestionTotal = Math.max(0, Number(p.questionTotal) || 0); + const phaseEl = document.getElementById('quiz-game-phase-label'); + const qEl = document.getElementById('quiz-game-question'); + if (phaseEl) { + phaseEl.textContent = p.phase === 'read' + ? ('อ่านคำถาม ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || '')) + : ('เดินไปโซน ถูก / ผิด — ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || '')); + } + if (qEl) qEl.textContent = playQuizText || ''; + const ov = document.getElementById('quiz-game-overlay'); + if (isQuizQuestionMissionHudActivePlay()) { + if (ov) ov.classList.add('is-hidden'); + } else if (ov) { + ov.classList.remove('is-hidden'); + } + if (playQuizTimerInterval) clearInterval(playQuizTimerInterval); + playQuizTimerInterval = setInterval(updatePlayQuizTimerDisplay, 200); + updatePlayQuizTimerDisplay(); + if (!previewMode && isQuiz() && !Object.keys(playLiveQuizScores).length) initPlayLiveQuizScoresZeros(); + if (!previewMode && isQuiz() && p.phase === 'read' && Number(p.questionIndex) === 1) playQuizEverWrong = {}; + try { + syncPlayQuizMapPanel(); + } catch (eQm) { /* ignore */ } + } + + function isFrogger() { return mapData && mapData.gameType === 'frogger'; } + function isGauntlet() { return mapData && mapData.gameType === 'gauntlet'; } + function isGauntletFaceRightMapMno9kb07() { + if (!isGauntlet()) return false; + /** ใช้ currentPlayMapId (session → mapData.id → ?map=) ให้สอดคล้อง runway / timing — กัน HUD crown หลุดค้างเป็นแผงเก่าแนวตั้งทับ TIME */ + return (currentPlayMapId() || '').trim() === GAUNTLET_FACE_RIGHT_MAP_ID; + } + /** Crown heist rules + UI (เดียวกับหันขวา — ฉาก mno9kb07) */ + function isGauntletCrownHeistMapPlay() { + return isGauntletFaceRightMapMno9kb07(); + } + function isMegaVirusMissionShellMapPlay() { + return !!(mapData && mapData.gameType === 'balloon_boss' && currentPlayMapId() === BALLOON_BOSS_MISSION_MAP_ID); + } + /** Crown gauntlet หรือ Mega Virus — lobby HOWTO / Ready / นับถอยหลัง / held run */ + function usesCrownLobbyShellPlay() { + return isGauntletCrownHeistMapPlay() || isMegaVirusMissionShellMapPlay(); + } + /** Last Light (mno9kb07) embed — ค่าเริ่มต้นเมื่อโหลดแมป (ปรับซูมได้ด้วยล้อ / [ ]) */ + const PLAY_EMBED_LAST_LIGHT_DEFAULT_ZOOM_MUL = 1.49; + /** Stack Tower (mnn93hpi) ใน embed พรีวิว: ซูมคงที่ ×1 — ล้อ/ปุ่มไม่ปรับได้ */ + const PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL = 1; + function isStackTowerEmbedZoomLockedPlay() { + return !!(previewMode && editorEmbedReturn && mapData && isStackTowerMissionUiMapPlay()); + } + function applyPlayEmbedZoomForCurrentMapPlay() { + if (isStackTowerEmbedZoomLockedPlay()) { + playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL; + } else if (previewMode && editorEmbedReturn && mapData && isGauntletCrownHeistMapPlay()) { + playEmbedUserZoomMul = PLAY_EMBED_LAST_LIGHT_DEFAULT_ZOOM_MUL; + } + } + + /** smoothstep 0..1 */ + function stackTowerPost50SmoothstepPlay(t) { + const x = Math.max(0, Math.min(1, t)); + return x * x * (3 - 2 * x); + } + + /** Tower live + progress ≥เกณฑ์: 0→1 ระหว่าง STACK_TOWER_POST50_ANIM_MS — ใช้เฉพาะเมื่อเปิดกล้องตามหอ (ไม่งั้นฉากธรรมดาไม่เลื่อน/ซูมหลังเกณฑ์) */ + function getStackTowerPost50AnimEasePlay() { + if (!getStackTowerCameraFollowPlayConfig().enabled) { + stackTowerPost50AnimStartMs = null; + return 0; + } + if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'live' || !stackMini) return 0; + const p = Number(stackMini.progressPct) || 0; + if (p < STACK_TOWER_POST50_PROGRESS_THRESH) { + stackTowerPost50AnimStartMs = null; + return 0; + } + if (stackTowerPost50AnimStartMs == null) stackTowerPost50AnimStartMs = performance.now(); + const u = Math.min(1, Math.max(0, (performance.now() - stackTowerPost50AnimStartMs) / STACK_TOWER_POST50_ANIM_MS)); + return stackTowerPost50SmoothstepPlay(u); + } + + function getStackTowerPost50ZMulPlay() { + const e = getStackTowerPost50AnimEasePlay(); + if (e <= 0) return 1; + return 1 + (STACK_TOWER_POST50_ZOOM_MUL - 1) * e; + } + + function getStackTowerPost50BgScrollExtraPxPlay(ch) { + const e = getStackTowerPost50AnimEasePlay(); + if (e <= 0) return 0; + const chR = Math.max(1, Number(ch) || 720); + return STACK_TOWER_POST50_MAP_SHIFT_SCREEN_FRAC * chR * e; + } + + /** + * Same zDraw chain as draw() (zoom, special maps, Last Light embed lock, preview embed mul). + * Runway end checks must not rely on lastPlayZDrawForInput alone — it can lag map/canvas or default 1.4 vs real zoom. + */ + function computePlayCameraZDrawPlay() { + if (!canvas || !mapData) return zoom; + const w = mapData.width || 20; + const h = mapData.height || 15; + let zDraw = zoom; + if (isSpaceShooter() || isBalloonBoss()) { + const mwPx = w * tileSize; + const mhPx = h * tileSize; + zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96; + /* MG7 (balloon_boss) — ขยายให้เต็มจอกว่า fit ทั้งแมป: ซูมพอดีกับพื้นที่ผู้เล่น+บอส (crop ขอบบน-ล่างที่ว่าง) + แมป 20×15 (aspect 1.33) บนจอ 16:9 ถ้า fit ทั้งแมปจะเหลือขอบดำซ้าย-ขวาเยอะ */ + if (isBalloonBoss()) { + const win = getBalloonBossVisibleHalfTilesPlay(mapData); + if (win) { + const needWpx = win.halfW * 2 * tileSize; + const needHpx = win.halfH * 2 * tileSize; + const zContent = Math.min(canvas.width / needWpx, canvas.height / needHpx); + if (Number.isFinite(zContent) && zContent > zDraw) zDraw = zContent; + } + } + } else if (isQuizQuestionMissionHudActivePlay()) { + const qmc = getQuizQuestionMissionMapCenterWorldPxPlay(); + if (qmc) { + /* ซูมออกเล็กน้อยให้เห็นพื้นหลัง/โซน SAFE-SCAM ครบแบบ mock (0.96 เคย crop ขอบล่างบนจอแคบ) */ + zDraw = Math.min(canvas.width / qmc.mwPx, canvas.height / qmc.mhPx) * 0.90; + } + } else if (isQuizCarry()) { + const mwPx = w * tileSize; + const mhPx = h * tileSize; + zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96; + } else if (isQuizBattle()) { + zDraw *= quizBattleUserZoomMul; + } else if (isGauntletCrownHeistMapPlay() && !(previewMode && editorEmbedReturn)) { + /* #1 map fit: gauntlet เกมจริง — fit "ความสูงแมป (ลู่ทั้งหมด)" ให้พอดีจอ resolution-adaptive + (เดิมใช้ zoom ดิบ ไม่ปรับตาม resolution = ไม่พอดี) · fit สูงอย่างเดียว เพราะ runway เลื่อนแนวนอน */ + const mhPx = h * tileSize; + const fitZ = (canvas.height / mhPx) * 0.96; + if (Number.isFinite(fitZ) && fitZ > 0) zDraw = fitZ; + } + if (isStackTowerEmbedZoomLockedPlay()) { + playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL; + } + if (previewMode && editorEmbedReturn && mapData && !isQuizQuestionMissionHudActivePlay()) { + zDraw *= playEmbedUserZoomMul; + } + if (isStackTowerMissionUiMapPlay()) { + zDraw *= STACK_TOWER_FIXED_RENDER_Z_MUL; + zDraw *= getStackTowerPost50ZMulPlay(); + } + return zDraw; + } + let gauntletCrownHowtoVisible = false; + function isGauntletCrownPregameBlockingPlay() { + if (!usesCrownLobbyShellPlay()) return false; + if (gauntletCrownRunHeldRemote) return true; + if (gauntletCrownPregamePhase != null && gauntletCrownPregamePhase !== 'live') return true; + return false; + } + + /** + * Last Light (crown heist): center camera on the whole party bbox instead of only `me` + * — reduces empty void on the side when you fall behind and keeps bots + you in frame together when possible. + */ + function getGauntletCrownHeistGroupCameraCenterPxPlay(tileSizeRef, canvasW, canvasH, zDrawVal) { + if (!isGauntletCrownHeistMapPlay() || !mapData) return null; + const md = mapData; + const { cw, ch } = getCharacterFootprintWH(md); + const ww = md.width || 20; + const hh = md.height || 15; + const mapWpx = ww * tileSizeRef; + const mapHpx = hh * tileSizeRef; + const halfW = canvasW / (2 * zDrawVal); + const halfH = canvasH / (2 * zDrawVal); + const ents = []; + if (!me.gauntletEliminated && Number.isFinite(me.x) && Number.isFinite(me.y)) { + ents.push({ cx: (me.x + cw * 0.5) * tileSizeRef, cy: (me.y + ch * 0.5) * tileSizeRef }); + } + others.forEach((o, id) => { + if (!o || o.gauntletEliminated) return; + if (!playBotsEnabled() && isPreviewBotId(id)) return; + if (!Number.isFinite(o.x) || !Number.isFinite(o.y)) return; + ents.push({ cx: (o.x + cw * 0.5) * tileSizeRef, cy: (o.y + ch * 0.5) * tileSizeRef }); + }); + if (!ents.length) return null; + let minCx = Infinity; + let maxCx = -Infinity; + let sumCy = 0; + for (let i = 0; i < ents.length; i++) { + const e = ents[i]; + minCx = Math.min(minCx, e.cx); + maxCx = Math.max(maxCx, e.cx); + sumCy += e.cy; + } + let px = (minCx + maxCx) * 0.5; + /* แกน Y ตามผู้เล่นเครื่องตัวเอง (ไม่เฉลี่ยทั้งกลุ่ม) → เลนของเราคงที่ใต้กล้อง, host/คนอื่นอยู่เลนของเขาเอง + (เดิมเฉลี่ย Y ทั้งกลุ่ม → ทุกคนถูกเลื่อนจากเลนจริง ทำให้ host ดูมาซ้อนเลนของ client) ; ตายแล้ว → fallback เฉลี่ยกลุ่ม */ + let py = sumCy / ents.length; + if (!me.gauntletEliminated && Number.isFinite(me.y)) { + py = (me.y + ch * 0.5) * tileSizeRef; + } + const minCamX = halfW; + const maxCamX = Math.max(minCamX, mapWpx - halfW); + const minCamY = halfH; + const maxCamY = Math.max(minCamY, mapHpx - halfH); + const viewW = halfW * 2; + const viewH = halfH * 2; + /* + * ถ้ามุมมองกว้าง/สูงกว่าแมป: อย่าใช้ clamp แบบ minCamX (= กล้องชิดซ้ายโลก) — จะดูเหมือนแมปติดมุมซ้ายบน (เด่นใน embed / ซูมออก) + * ให้จัดกลางแมปแทน — ภารกิจคำถาม mng8a80o ช่วง live ใช้กล้องกลางแมปเหมือนกัน (ดู isQuizQuestionMissionHudActivePlay + draw) + */ + if (viewW >= mapWpx) px = mapWpx * 0.5; + else px = Math.max(minCamX, Math.min(maxCamX, px)); + if (viewH >= mapHpx) py = mapHpx * 0.5; + else py = Math.max(minCamY, Math.min(maxCamY, py)); + return { px, py }; + } + + /** Preview bots only — full tail-off loss needs server support for real humans */ + const GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX = 40; + function maybeEliminateGauntletPreviewBotsTailOffCameraPlay(camX, camY, zDrawVal) { + if (!playBotsEnabled() || !isMePlayHost() || !isGauntletCrownHeistMapPlay() || !mapData) return; + if (gauntletCrownPregamePhase !== 'live') return; + const halfW = canvas.width / (2 * zDrawVal); + const worldMinX = camX - halfW; + const gate = worldMinX - GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX; + const { cw } = getCharacterFootprintWH(mapData); + const ts = mapData.tileSize || 32; + others.forEach((o, id) => { + if (!isPreviewBotId(id) || !o || o.gauntletEliminated) return; + const rightWorld = (Number(o.x) + cw) * ts; + if (rightWorld < gate) o.gauntletEliminated = true; + }); + } + + function isStack() { return mapData && mapData.gameType === 'stack'; } + function isJumpSurvive() { return mapData && mapData.gameType === 'jump_survive'; } + + function currentPlayMapId() { + const q = (playSessionMapId || '').trim(); + if (q) return q; + if (mapData && mapData.id != null && String(mapData.id).trim() !== '') return String(mapData.id).trim(); + return (playMapIdFromQuery || '').trim(); + } + + function isQuizQuestionMissionUiMapPlay() { + return isQuiz() && currentPlayMapId() === QUIZ_QUESTION_MISSION_MAP_ID; + } + + function isQuizQuestionMissionPregameBlockingPlay() { + if (!isQuizQuestionMissionUiMapPlay()) return false; + return quizQuestionMissionPhase != null && quizQuestionMissionPhase !== 'live' && quizQuestionMissionPhase !== 'ended'; + } + + function isStackTowerMissionUiMapPlay() { + return isStack() && currentPlayMapId() === STACK_TOWER_MISSION_MAP_ID; + } + + function getStackTowerBlockWorldScalePlay() { + return isStackTowerMissionUiMapPlay() ? STACK_TOWER_BLOCK_WORLD_SCALE : 1; + } + + function isStackTowerMissionPregameBlockingPlay() { + if (!isStackTowerMissionUiMapPlay()) return false; + return stackTowerMissionPhase != null && stackTowerMissionPhase !== 'live' && stackTowerMissionPhase !== 'ended'; + } + + function isStackTowerMissionHudActivePlay() { + return isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live'; + } + + /** mnptfts2 Jumper — ใช้ cyber HUD แบบภารกิจคำถาม (TIME plaque + SCORE แถว QM + กรอบโปรไฟล์) */ + function isJumpSurviveMissionHudActivePlay() { + return isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'live'; + } + + /** Space Shooter mnpz6rkp — ช่วงเล่นจริง: HUD เดียวกับ Stack mission (คลาส question-mission + TIME plaque) */ + function isSpaceShooterMissionHudActivePlay() { + return isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'live'; + } + + /** Mega Virus mnq1eml7 (MG7) — ช่วงเล่นจริง: ใช้ HUD/score-row แบบเดียวกับ MG6 (qm-mock) + กันคะแนน/ชื่อใช้ layout เก่าที่ text ทับ/ซ่อน */ + function isBalloonBossMissionHudActivePlay() { + return isMegaVirusMissionShellMapPlay() && balloonBossSessionStartMs > 0 && !balloonBossGameEnded; + } + + function stackTowerAssetUrl(file) { + return BASE + '/img/TowerBlock/' + String(file || '').replace(/^\/+/, ''); + } + + function ensureStackTowerSlingImagePlay() { + if (!stackTowerSlingImg) { + stackTowerSlingImg = new Image(); + stackTowerSlingImg.decoding = 'async'; + stackTowerSlingImg.src = stackTowerAssetUrl('sling.png'); + } + const im = stackTowerSlingImg; + return im && im.complete && im.naturalWidth > 0 ? im : null; + } + + function ensureStackTowerScorePopupImagesPlay() { + if (!stackTowerScorePlus10Img) { + stackTowerScorePlus10Img = new Image(); + stackTowerScorePlus10Img.decoding = 'async'; + stackTowerScorePlus10Img.src = stackTowerAssetUrl('score+10.png'); + } + if (!stackTowerScorePlus20Img) { + stackTowerScorePlus20Img = new Image(); + stackTowerScorePlus20Img.decoding = 'async'; + stackTowerScorePlus20Img.src = stackTowerAssetUrl('score+20.png'); + } + return { p10: stackTowerScorePlus10Img, p20: stackTowerScorePlus20Img }; + } + + function ensureStackTowerPerfectFxImagesPlay() { + if (!stackTowerScoreLightImg) { + stackTowerScoreLightImg = new Image(); + stackTowerScoreLightImg.decoding = 'async'; + stackTowerScoreLightImg.src = stackTowerAssetUrl('score-light.png'); + } + if (!stackTowerScoreX2Img) { + stackTowerScoreX2Img = new Image(); + stackTowerScoreX2Img.decoding = 'async'; + stackTowerScoreX2Img.src = stackTowerAssetUrl('scorex2.png'); + } + return { light: stackTowerScoreLightImg, x2: stackTowerScoreX2Img }; + } + + /** score-light หลัง scorex2 — วาดเป็นคู่ข้างชั้นบน (mock รูป2) */ + function drawStackTowerPerfectX2ClusterPlay(ctx, lightImg, x2Img, sx, sy, drawW, drawH, nowT, fxUntil) { + if (!x2Img || !x2Img.complete || !x2Img.naturalWidth || drawW <= 0 || drawH <= 0) return; + const iw = x2Img.naturalWidth; + const ih = x2Img.naturalHeight; + const targetH = Math.max(drawH * 0.92, Math.min(drawH * 1.35, 72)); + const scale = targetH / ih; + const dw = iw * scale; + const dh = ih * scale; + const cx = sx + drawW + drawW * 0.06; + const cy = sy + drawH * 0.5; + const life = fxUntil != null ? Math.max(0, Math.min(1, (fxUntil - nowT) / STACK_TOWER_PERFECT_X2_MS)) : 1; + const pulse = 0.4 + 0.48 * Math.sin((1 - life) * Math.PI); + if (lightImg && lightImg.complete && lightImg.naturalWidth) { + const lp = 2.7; + const lw = dw * lp; + const lh = dh * lp; + const lnx = lightImg.naturalWidth; + const lny = lightImg.naturalHeight; + /* จัดแสงให้ตรงกลางไอคอน ×2 (ไอคอนวาดเยื้องซ้าย cx - dw*0.48 → กลางไอคอน ≈ cx + dw*0.02) */ + const iconCx = cx + dw * 0.02; + ctx.save(); + ctx.globalAlpha = Math.min(0.98, pulse + 0.34); + ctx.globalCompositeOperation = 'screen'; + try { + ctx.drawImage(lightImg, 0, 0, lnx, lny, iconCx - lw * 0.5, cy - lh * 0.5, lw, lh); + } catch (e) { /* ignore */ } + ctx.restore(); + } + ctx.save(); + ctx.globalAlpha = 0.98; + ctx.globalCompositeOperation = 'source-over'; + try { + ctx.drawImage(x2Img, 0, 0, iw, ih, cx - dw * 0.48, cy - dh * 0.5, dw, dh); + } catch (e) { /* ignore */ } + ctx.restore(); + } + + function drawStackTowerScorePopupsPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY) { + if (!m.scorePopups || !m.scorePopups.length) return; + const now = performance.now(); + const dur = STACK_TOWER_SCORE_POPUP_MS; + const imgs = ensureStackTowerScorePopupImagesPlay(); + m.scorePopups = m.scorePopups.filter((p) => p.until > now); + const hidPop = getStackTowerVisualHiddenLayerCountPlay(); + for (let pi = 0; pi < m.scorePopups.length; pi++) { + const p = m.scorePopups[pi]; + const li = Math.floor(Number(p.layerIndex)) || 0; + if (li < 0 || li >= m.layers.length) continue; + if (li < hidPop) continue; + const L = m.layers[li]; + const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize * getStackTowerBlockWorldScalePlay(); + const cxW = L.cx * tileSize; + const yb = floorY - (li + 1) * layerWorldH; + const xl = cxW - lw / 2; + const [sx, sy] = worldToScreen(xl, yb); + const drawW = lw * zoom; + const drawH = layerWorldH * zoom; + const t0 = p.until - dur; + const t = Math.max(0, Math.min(1, (now - t0) / dur)); + const alpha = 0.2 + 0.78 * Math.sin(Math.PI * t); + const rise = Math.sin((t * Math.PI) / 2) * 20 * zoom; + const im = p.kind === '20' ? imgs.p20 : imgs.p10; + const targetW = Math.min(drawW * 0.92, Math.max(52, 64 * zoom)); + let iw = targetW; + let ih = targetW * 0.38; + if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) { + ih = (targetW * im.naturalHeight) / im.naturalWidth; + } + const cx = sx + drawW / 2; + const cy = sy - Math.max(8, 6 * zoom) - rise - ih * 0.5; + ctx.save(); + ctx.globalAlpha = Math.min(0.98, Math.max(0.08, alpha)); + if (im && im.complete && im.naturalWidth > 0) { + try { + ctx.drawImage(im, 0, 0, im.naturalWidth, im.naturalHeight, cx - iw / 2, cy - ih / 2, iw, ih); + } catch (e) { /* ignore */ } + } else { + ctx.font = 'bold ' + Math.round(Math.max(12, 15 * zoom)) + 'px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = p.kind === '20' ? '#7af8ff' : '#b4f9cd'; + ctx.fillText(p.kind === '20' ? '+20' : '+10', cx, cy); + } + ctx.restore(); + } + } + + /** สร้างเศษบล็อกตกจากบล็อกที่พลาด (ใช้ตำแหน่ง/หน้าตาเดิมของ stackFall) */ + function spawnStackMissDebrisPlay(sf) { + if (!sf || !stackMini) return; + const lh = stackMini.layerWorldH || Math.max(14, tileSize * 0.3); + const cxW = sf.xLeft1 + sf.tw / 2; + const landOff = sf.hit && sf.hit.landOffsetTiles ? sf.hit.landOffsetTiles : 0; + const dir = landOff >= 0 ? 1 : -1; + stackMissDebris.push({ + cxW, + topY: sf.y1, + w: sf.tw, + h: lh, + vx: dir * (tileSize * (1.4 + Math.random() * 0.9)), + vy: -tileSize * (0.4 + Math.random() * 0.5), + rot: sf.tiltMax || 0, + vrot: dir * (2.0 + Math.random() * 1.8), + seat: sf.actorSeat, + heavy: !!sf.actorHeavy, + hue: (stackMini.layers.length * 47) % 360, + }); + while (stackMissDebris.length > 6) stackMissDebris.shift(); + _stackDebrisLastT = 0; + } + /** อัปเดต+วาดเศษบล็อกที่ตก (gravity + หมุน + ลอยออกข้าง) จนหลุดขอบจอล่าง */ + function drawStackMissDebrisPlay(ctx, worldToScreen, zoom, floorWorldY) { + if (!stackMissDebris.length) return; + const now = performance.now(); + let dt = _stackDebrisLastT ? (now - _stackDebrisLastT) / 1000 : 0.016; + _stackDebrisLastT = now; + if (dt > 0.05) dt = 0.05; + const g = tileSize * 34; + const chCv = canvas && canvas.height ? canvas.height : 720; + const offLimit = floorWorldY + chCv / Math.max(0.2, zoom) + tileSize * 5; + for (let i = stackMissDebris.length - 1; i >= 0; i--) { + const d = stackMissDebris[i]; + d.vy += g * dt; + d.topY += d.vy * dt; + d.cxW += d.vx * dt; + d.rot += d.vrot * dt; + if (d.topY > offLimit) { stackMissDebris.splice(i, 1); continue; } + const sp = worldToScreen(d.cxW - d.w / 2, d.topY); + const drawW = d.w * zoom; + const drawH = d.h * zoom; + ctx.save(); + ctx.translate(sp[0] + drawW / 2, sp[1] + drawH / 2); + ctx.rotate(d.rot); + drawStackBlockSpriteOrHue(ctx, -drawW / 2, -drawH / 2, drawW, drawH, d.seat, d.heavy, d.hue, { missTint: true, missOverlay: true }); + ctx.restore(); + } + } + + function ensureStackTowerHeartMinusImgPlay() { + if (!stackTowerHeartMinusImg) { + stackTowerHeartMinusImg = new Image(); + stackTowerHeartMinusImg.decoding = 'async'; + stackTowerHeartMinusImg.src = stackTowerAssetUrl('heart-1.png'); + } + const im = stackTowerHeartMinusImg; + return im && im.complete && im.naturalWidth > 0 ? im : null; + } + + function ensureStackTowerLifeHudImagesPlay() { + if (!stackTowerLifeBarImg) { + stackTowerLifeBarImg = new Image(); + stackTowerLifeBarImg.decoding = 'async'; + stackTowerLifeBarImg.src = stackTowerAssetUrl('life-bar.png'); + } + if (!stackTowerLifeHitImg) { + stackTowerLifeHitImg = new Image(); + stackTowerLifeHitImg.decoding = 'async'; + stackTowerLifeHitImg.src = stackTowerAssetUrl('life-hit.png'); + } + if (!stackTowerLifeFullImg) { + stackTowerLifeFullImg = new Image(); + stackTowerLifeFullImg.decoding = 'async'; + stackTowerLifeFullImg.src = stackTowerAssetUrl('life-full.png'); + } + } + + /** Space Shooter (mnpz6rkp): โหลด life HUD จาก /img/ViolentCrime/ แล้วคืน image set ให้ drawStackTowerLifeIntegrityBarPlay */ + function ensureViolentCrimeLifeHudImagesPlay() { + if (!violentCrimeLifeBarImg) { + violentCrimeLifeBarImg = new Image(); + violentCrimeLifeBarImg.decoding = 'async'; + violentCrimeLifeBarImg.src = violentCrimeAssetUrl('life-bar.png'); + } + if (!violentCrimeLifeHitImg) { + violentCrimeLifeHitImg = new Image(); + violentCrimeLifeHitImg.decoding = 'async'; + violentCrimeLifeHitImg.src = violentCrimeAssetUrl('life-hit.png'); + } + if (!violentCrimeLifeFullImg) { + violentCrimeLifeFullImg = new Image(); + violentCrimeLifeFullImg.decoding = 'async'; + violentCrimeLifeFullImg.src = violentCrimeAssetUrl('life-full.png'); + } + return { bar: violentCrimeLifeBarImg, full: violentCrimeLifeFullImg, hit: violentCrimeLifeHitImg }; + } + + /** + * สัดส่วนสูงของ life-bar.png ที่เป็นข้อความ "SYSTEM INTEGRITY" (ที่เหลือ = แถวหัวใจแยกวาดเอง ไม่ทับขอบ `[` `]` ใน PNG) + */ + const STACK_TOWER_LIFE_BAR_TITLE_FRAC = 0.38; + + /** + * วาดหัวข้อจาก life-bar.png (ครอปบน) + แถวหัวใจ life-full / life-hit กึ่งกลาง — คืนความสูงที่ใช้จริง + */ + function drawStackTowerLifeIntegrityBarPlay(ctx, destX, destY, maxW, maxH, lifeSlots, livesRem, opts) { + const imgs = (opts && opts.images) + ? opts.images + : (ensureStackTowerLifeHudImagesPlay(), { bar: stackTowerLifeBarImg, full: stackTowerLifeFullImg, hit: stackTowerLifeHitImg }); + const bar = imgs.bar; + const fullIm = (imgs.full && imgs.full.complete && imgs.full.naturalWidth > 0) + ? imgs.full + : ensureStackTowerHeartMinusImgPlay(); + const hitIm = imgs.hit && imgs.hit.complete && imgs.hit.naturalWidth > 0 + ? imgs.hit + : null; + const slots = Math.max(1, Math.min(20, Math.floor(lifeSlots) || 1)); + const lives = Math.max(0, Math.min(slots, Math.floor(livesRem) || 0)); + + if (!bar || !bar.complete || !bar.naturalWidth || maxW <= 4 || maxH <= 4) { + ctx.save(); + ctx.fillStyle = '#bb9af7'; + ctx.font = '600 9px ui-sans-serif, system-ui, sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + let heartStr = ''; + for (let i = 0; i < slots; i++) heartStr += i < lives ? '♥ ' : '♡ '; + ctx.fillText('SYSTEM INTEGRITY ' + heartStr.trim(), destX, destY + maxH * 0.5); + ctx.restore(); + return Math.min(maxH, 28); + } + + const bw0 = bar.naturalWidth; + const bh0 = bar.naturalHeight; + const titleSrcH = Math.max(2, Math.floor(bh0 * STACK_TOWER_LIFE_BAR_TITLE_FRAC)); + const scale = Math.min(maxW / bw0, 1.45); + const bw = bw0 * scale; + const titleDh = titleSrcH * scale; + const gap = 3; + const bx = destX; + const by0 = destY + Math.max(0, (maxH - titleDh - gap - Math.min(40, maxH * 0.45)) * 0.35); + const heartsTop = by0 + titleDh + gap; + const roomHearts = Math.max(10, destY + maxH - heartsTop - 2); + const heartsH = Math.max(18, Math.min(roomHearts, 40, Math.floor(maxH * 0.5))); + + ctx.save(); + try { + ctx.drawImage(bar, 0, 0, bw0, titleSrcH, bx, by0, bw, titleDh); + } catch (e) { /* ignore */ } + + const cy = heartsTop + heartsH * 0.5; + const bracketW = Math.min(14, Math.max(9, bw * 0.07)); + ctx.fillStyle = 'rgba(94, 239, 255, 0.88)'; + ctx.font = '600 ' + Math.round(Math.min(17, heartsH * 0.55)) + 'px ui-monospace, "Cascadia Mono", Consolas, monospace'; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'left'; + ctx.shadowColor = 'rgba(0, 234, 255, 0.45)'; + ctx.shadowBlur = 4; + ctx.fillText('[', bx + 1, cy); + ctx.textAlign = 'right'; + ctx.fillText(']', bx + bw - 1, cy); + ctx.shadowBlur = 0; + + const ix = bx + bracketW + 4; + const iwInner = Math.max(24, bw - (bracketW + 4) * 2); + const slotW = iwInner / slots; + const iconMax = Math.min(heartsH * 0.78, slotW * 0.72, 34); + + for (let i = 0; i < slots; i++) { + const cx = ix + slotW * (i + 0.5); + const useFull = i < lives; + const im = useFull ? fullIm : hitIm; + if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) { + const nw = im.naturalWidth; + const nh = im.naturalHeight; + let ih = iconMax; + let iw = (ih * nw) / nh; + const capW = slotW * 0.82; + const capH = heartsH * 0.88; + if (iw > capW) { + iw = capW; + ih = (iw * nh) / nw; + } + if (ih > capH) { + ih = capH; + iw = (ih * nw) / nh; + } + if (iw < 6 && nh > 0) { + iw = 6; + ih = (iw * nh) / nw; + if (ih > capH) { + ih = capH; + iw = (ih * nw) / nh; + } + } + try { + ctx.drawImage(im, 0, 0, nw, nh, cx - iw / 2, cy - ih / 2, iw, ih); + } catch (e2) { /* ignore */ } + } else { + ctx.fillStyle = useFull ? '#9ece6a' : '#565f89'; + ctx.font = 'bold ' + Math.round(Math.max(10, iconMax * 0.72)) + 'px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(useFull ? '♥' : '♡', cx, cy); + } + } + ctx.restore(); + const usedH = heartsTop + heartsH - by0 + 2; + return Math.min(maxH, Math.max(titleDh, usedH)); + } + + function triggerStackTowerHeartMinusFxPlay() { + if (!isStackTowerMissionHudActivePlay()) return; + stackTowerHeartMinusFx = { until: performance.now() + STACK_TOWER_HEART_MINUS_FX_MS }; + } + + function drawStackTowerHeartMinusFxPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY, nLay) { + if (!stackTowerHeartMinusFx) return; + const nt = performance.now(); + if (nt >= stackTowerHeartMinusFx.until) { + stackTowerHeartMinusFx = null; + return; + } + const dur = STACK_TOWER_HEART_MINUS_FX_MS; + const t0 = stackTowerHeartMinusFx.until - dur; + const u = Math.max(0, Math.min(1, (nt - t0) / dur)); + const alpha = 1 - u * u; + const worldCx = m.topCenterX * tileSize; + const stackTopY = floorY - nLay * layerWorldH; + const worldCy = stackTopY - tileSize * 1.55 - u * tileSize * 0.5; + const [sx, sy] = worldToScreen(worldCx, worldCy); + const im = ensureStackTowerHeartMinusImgPlay(); + const maxW = Math.max(72, Math.min(240, 6.8 * tileSize * zoom)); + let dw = maxW; + let dh = maxW * 0.52; + if (im && im.naturalWidth > 0 && im.naturalHeight > 0) { + dh = (maxW * im.naturalHeight) / im.naturalWidth; + } + ctx.save(); + ctx.globalAlpha = Math.max(0, Math.min(0.98, alpha)); + if (im) { + ctx.drawImage(im, sx - dw / 2, sy - dh / 2, dw, dh); + } else { + ctx.fillStyle = '#f7768e'; + ctx.font = 'bold ' + Math.round(Math.max(14, 18 * zoom)) + 'px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('♥ −1', sx, sy); + } + ctx.restore(); + } + + /** Glow สี่เหลี่ยมสีเหลือง — ขอบเขตเท่ากล่องบล็อก (drawW×drawH) ไม่ล้ำออกนอกสไปรต์ */ + function drawStackTowerPerfectYellowGlowBehindBlockPlay(ctx, sx, sy, drawW, drawH, nowT, untilT) { + if (drawW <= 0 || drawH <= 0) return; + const life = Math.max(0, Math.min(1, (untilT - nowT) / STACK_TOWER_PERFECT_GLOW_MS)); + const pulse = 0.38 + 0.48 * Math.sin((1 - life) * Math.PI); + ctx.save(); + ctx.globalCompositeOperation = 'source-over'; + /* เติมเหลืองทับบล็อกให้เด่น */ + ctx.globalAlpha = Math.min(0.6, 0.3 + pulse * 0.32); + ctx.fillStyle = 'rgba(255, 224, 60, 0.55)'; + ctx.fillRect(sx, sy, drawW, drawH); + /* กรอบเหลืองหนา "รอบ" บล็อก (เลยขอบออกมา) + เรืองแสงออกนอก → เห็นชัด */ + const pad = Math.max(3, Math.min(12, drawW * 0.05)); + const sw = Math.max(3, Math.min(9, drawW * 0.055)); + ctx.shadowColor = 'rgba(255, 196, 24, 0.98)'; + ctx.shadowBlur = Math.max(10, Math.min(30, 14 + pulse * 16)); + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.globalAlpha = Math.min(1, 0.62 + pulse * 0.38); + ctx.strokeStyle = 'rgba(255, 238, 110, 1)'; + ctx.lineWidth = sw; + ctx.strokeRect(sx - pad + sw * 0.5, sy - pad + sw * 0.5, drawW + pad * 2 - sw, drawH + pad * 2 - sw); + /* เส้นในขาวอมเหลือง เพิ่มความคม */ + ctx.shadowBlur = 0; + ctx.shadowColor = 'transparent'; + ctx.globalAlpha = Math.min(0.95, 0.55 + pulse * 0.4); + ctx.strokeStyle = 'rgba(255, 252, 214, 0.96)'; + ctx.lineWidth = Math.max(1.5, sw * 0.42); + ctx.strokeRect(sx - pad + sw, sy - pad + sw, drawW + pad * 2 - sw * 2, drawH + pad * 2 - sw * 2); + ctx.restore(); + } + + /** + * เชือกจากจุดหนีบบน (จอ) ลงจุดยึดบล็อก — รูป sling แนวตั้ง (แกน Y = สาย) ยืดตามความยาว + */ + function drawStackSlingSegmentPlay(ctx, sx0, sy0, sx1, sy1, zoom) { + const dx = sx1 - sx0; + const dy = sy1 - sy0; + const len = Math.hypot(dx, dy); + if (len < 1.5) return; + const sling = ensureStackTowerSlingImagePlay(); + if (sling) { + const nw = sling.naturalWidth; + const nh = sling.naturalHeight; + const ropeW = Math.max(3, Math.min(40, nw * Math.min(1.1, zoom * 0.11) + 2)); + const ang = Math.atan2(dy, dx); + ctx.save(); + ctx.translate(sx0, sy0); + ctx.rotate(ang - Math.PI / 2); + ctx.globalAlpha = 0.96; + try { + ctx.drawImage(sling, -ropeW / 2, 0, ropeW, len); + } catch (e) { /* ignore */ } + ctx.globalAlpha = 1; + ctx.restore(); + } else { + ctx.strokeStyle = 'rgba(90, 88, 86, 0.82)'; + ctx.lineWidth = Math.max(2.2, zoom * 1.4); + ctx.beginPath(); + ctx.moveTo(sx0, sy0); + ctx.lineTo(sx1, sy1); + ctx.stroke(); + ctx.lineWidth = 1; + } + } + + const GCHOWTO_STACK_TOWER_CLASS = 'gauntlet-crown-howto-overlay--stack-tower'; + + function setGauntletCrownHowtoStackTowerArtMaskPlay(on) { + const hov = document.getElementById('gauntlet-crown-howto-overlay'); + if (!hov) return; + hov.classList.toggle(GCHOWTO_STACK_TOWER_CLASS, !!on); + } + + function hideStackTowerHowtoRulesDom() { + /* กล่องกติกา Stack Tower ถูกถอนออกจาก DOM — เก็บชื่อฟังก์ชันเพื่อไม่ให้จุดเรียกพัง */ + } + + function teardownStackTowerMissionUiPlay() { + stackTowerMissionPhase = null; + stackTowerMissionDeferredPhase = null; + stackTowerSessionStartAt = 0; + stackTowerMissionEndedOnce = false; + stackTowerPost50AnimStartMs = null; + hideStackTowerHowtoRulesDom(); + setGauntletCrownHowtoStackTowerArtMaskPlay(false); + if (stackTowerMissionCountdownTimer) { + clearTimeout(stackTowerMissionCountdownTimer); + stackTowerMissionCountdownTimer = null; + } + hideStackTowerResultFlashDomOnlyPlay(); + } + + function hideStackTowerResultFlashDomOnlyPlay() { + if (stackTowerResultFlashTimer) { + clearTimeout(stackTowerResultFlashTimer); + stackTowerResultFlashTimer = null; + } + const ov = document.getElementById('stack-tower-result-flash'); + const img = document.getElementById('stack-tower-result-flash-img'); + if (ov) { + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + if (img) { + img.onerror = null; + img.removeAttribute('src'); + } + } + + /** กันภาพผลโชว์ "ซ้ำ" — บางเกมจบ 2 path (เช่น MG2 local finish + server gauntlet-ended) ยิง flash 2 รอบผลต่างกัน + → ตัวที่ 2 ถูกบล็อก (เก็บตัวแรกไว้). reset เมื่อเริ่มมินิเกมรอบใหม่ (resetMissionResultFlashGuardPlay) */ + let _missionResultFlashAt = 0; + function missionResultFlashGuardPlay() { + const now = Date.now(); + if (now - _missionResultFlashAt < 9500) return false; /* เพิ่งโชว์ไป → บล็อกตัวซ้ำ */ + _missionResultFlashAt = now; + return true; + } + function resetMissionResultFlashGuardPlay() { _missionResultFlashAt = 0; } + + /** มีใครได้คะแนน ≥1 ไหม (จาก mission.ranked) — ตัดสิน complete/gameover (MG1 quiz / MG4 quiz_carry) */ + function missionAnyScorePlay(mission) { + const r = (mission && Array.isArray(mission.ranked)) ? mission.ranked : []; + return r.some(function (x) { return x && (Number(x.baseScore) || Number(x.finalScore) || Number(x.score) || 0) > 0; }); + } + /** จำนวนผู้รอด (ไม่ eliminated) — ตัดสิน complete/gameover (MG2 เข้าเส้นชัย / MG5 / MG6) */ + function missionSurvivorsPlay(mission) { + if (mission && typeof mission.survivorCount === 'number') return mission.survivorCount; + const r = (mission && Array.isArray(mission.ranked)) ? mission.ranked : []; + return r.filter(function (x) { return x && !x.eliminated; }).length; + } + /** จำนวนผู้เข้าร่วมทั้งหมด (คน+บอท) */ + function missionParticipantsPlay(mission) { + if (mission && typeof mission.participantCount === 'number' && mission.participantCount > 0) return mission.participantCount; + return (mission && Array.isArray(mission.ranked)) ? mission.ranked.length : 0; + } + /** "รอดทั้งหมด" — ทุกคนรอด ไม่มีใคร eliminated (MG5/MG6) */ + function missionAllSurvivedPlay(mission) { + const total = missionParticipantsPlay(mission); + return total > 0 && missionSurvivorsPlay(mission) >= total; + } + /** แฟลชรูปผลรูปเดียว (5s) แล้วเปิด GCM — เกมที่เดิมไม่มีแฟลช (MG1/MG2). asset ตาม skin ผ่าน gcmMissionMockAssetUrl */ + function beginSimpleResultFlashThenGcmPlay(mission, file) { + if (!missionResultFlashGuardPlay()) return; /* กันโชว์ซ้ำ (MG2: local finish + server gauntlet-ended ยิงทั้งคู่) */ + hideStackTowerResultFlashDomOnlyPlay(); + const ov = document.getElementById('stack-tower-result-flash'); + const imgEl = document.getElementById('stack-tower-result-flash-img'); + if (!mission || !ov || !imgEl) { showGauntletCrownMissionOverlay(mission); return; } + const finishToGcm = function () { hideStackTowerResultFlashDomOnlyPlay(); showGauntletCrownMissionOverlay(mission); }; + imgEl.onerror = function () { imgEl.onerror = null; finishToGcm(); }; + imgEl.src = gcmMissionMockAssetUrl(mission, file); + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + stackTowerResultFlashTimer = setTimeout(function () { stackTowerResultFlashTimer = null; finishToGcm(); }, STACK_TOWER_RESULT_FLASH_MS); + } + + /** โชว์รูปผล Tower จาก TowerBlock 5 วิ แล้วเปิด GCM สรุปเดิม */ + function beginStackTowerResultFlashThenGcm(mission) { + if (!missionResultFlashGuardPlay()) return; /* กันโชว์ผลซ้ำ */ + 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: มีคนรอด ≥1 = complete · ไม่รอดเลย = gameover (เกรดไม่เกี่ยว — ตามกฎใหม่) */ + function jumpSurviveMissionOutcomeImageFile(mission) { + /* รอดทั้งหมด = complete · มีคนตาย/ตายหมด = gameover (ตามกฎใหม่) */ + return missionAllSurvivedPlay(mission) ? 'result-complete.png' : 'result-gameover.png'; + } + + /** + * Jumper ภารกิจ: ใช้ #stack-tower-result-flash เหมือน Tower — time_up = timeup แล้ว outcome; + * all_dead = ข้าม timeup แล้ว outcome อย่างเดียว แล้วค่อย GCM + */ + function beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, endKind) { + if (!missionResultFlashGuardPlay()) return; /* กันโชว์ผลซ้ำ */ + 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 (!missionResultFlashGuardPlay()) return; /* กันโชว์ผลซ้ำ */ + 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(); + /* หมดเวลา: รอดทั้งหมด = complete · มีคนตาย = gameover (ตามกฎใหม่) */ + showOneFlash(missionAllSurvivedPlay(mission) ? 'result-complete.png' : 'result-gameover.png', finishToGcm); + }); + } else { + showOneFlash('result-gameover.png', finishToGcm); /* all_dead */ + } + } + + /** + * Mega Virus (balloon_boss shell): แฟลชรูปเดียวแล้วค่อย GCM + * — แพ้ (ทุกคนตาย / หมดเวลา) = result-gameover.png (TowerBlock เหมือนภารกิจอื่น — รูป2 flow) + * — ชนะ = end-victory-2.png ใน MegaVirus + */ + function beginBalloonBossMegaVirusResultFlashSequenceThenGcm(mission, endReason) { + if (!missionResultFlashGuardPlay()) return; /* กันโชว์ผลซ้ำ */ + 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 finishToGcm = function () { + hideStackTowerResultFlashDomOnlyPlay(); + showGauntletCrownMissionOverlay(mission); + }; + const showOneFlash = function (url, onDone) { + imgEl.onerror = function () { imgEl.onerror = null; onDone(); }; + imgEl.src = url; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + stackTowerResultFlashTimer = setTimeout(function () { stackTowerResultFlashTimer = null; onDone(); }, STACK_TOWER_RESULT_FLASH_MS); + }; + if (endReason === 'victory') { + /* ฆ่าบอส HP=0 → end-victory-2.png (MegaVirus) */ + showOneFlash(megaVirusAssetUrl('end-victory-2.png'), finishToGcm); + } else if (endReason === 'time') { + /* หมดเวลา บอสยังไม่ตาย → result-timeup → result-gameover (MegaVirus ไม่มี timeup ใช้ TowerBlock) */ + showOneFlash(stackTowerAssetUrl('result-timeup.png'), function () { + hideStackTowerResultFlashDomOnlyPlay(); + showOneFlash(stackTowerAssetUrl('result-gameover.png'), finishToGcm); + }); + } else { + /* ตายหมด → result-gameover */ + showOneFlash(stackTowerAssetUrl('result-gameover.png'), finishToGcm); + } + } + + function stackTowerMissionTimeLimitSecPlay() { + const t = Number(playStackTowerMissionTimeSec); + if (Number.isFinite(t) && t > 0) return Math.max(10, Math.min(7200, Math.floor(t))); + return 90; + } + + function stackTowerProgressBlocksPlay() { + const n = Number(playStackTowerProgressBlocks); + if (Number.isFinite(n) && n >= 1) return Math.max(1, Math.min(500, Math.floor(n))); + return 50; + } + + function stackTowerDecryptPctPlay() { + if (!stackMini) return 0; + if (isStackTowerMissionUiMapPlay()) { + const p = Number(stackMini.progressPct); + const x = Math.min(100, Math.max(0, Number.isFinite(p) ? p : 0)); + if (x >= 99.999) return 100; + return Math.floor(x); + } + const teamScore = stackMini.score || 0; + return Math.min(100, Math.floor((stackMini.layers.length || 0) * 11 + Math.min(40, teamScore * 3))); + } + + function stackTowerMissionElapsedSecPlay() { + if (stackTowerSessionStartAt <= 0) return 0; + return (performance.now() - stackTowerSessionStartAt) / 1000; + } + + function applyStackTowerMissionPanelImages() { + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + howtoBg.src = stackTowerAssetUrl('popup-Howto.png'); + howtoBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; + }; + } + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg) { + resBg.src = stackTowerAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + ensureStackTowerSlingImagePlay(); + } + + function showStackTowerMissionHowtoOverlay() { + if (!isStackTowerMissionUiMapPlay()) return; + hideConflictingOverlaysForGauntletCrown(); + applyStackTowerMissionPanelImages(); + if (stackTowerMissionCountdownTimer) { + clearTimeout(stackTowerMissionCountdownTimer); + stackTowerMissionCountdownTimer = null; + } + const cd = document.getElementById('gauntlet-crown-countdown'); + if (cd) cd.classList.add('is-hidden'); + stackTowerMissionPhase = 'howto'; + const ov = document.getElementById('gauntlet-crown-howto-overlay'); + if (!ov) return; + gauntletCrownHowtoVisible = true; + ov.classList.remove('is-hidden'); + setGauntletCrownHowtoStackTowerArtMaskPlay(true); + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) st.classList.remove('gch-status--jumper'); + const btn = document.getElementById('btn-gch-ready'); + const humans = quizCarryPregameHumanIds(); + const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); + const soleParticipant = totPlayers === 1; + if (soleParticipant) { + if (st) { + st.textContent = ''; + st.classList.add('is-hidden'); + } + if (btn) { + const canGo = isMePlayHost() || humans.length === 1; + btn.classList.remove('is-start-phase'); + btn.classList.toggle('is-read-only', !canGo); + btn.disabled = !canGo; + btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host'; + btn.setAttribute('aria-pressed', 'false'); + } + } else { + if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); + gauntletCrownSyncGuestReadyIfNeeded(); + updateStackTowerMissionHowtoHud(); + } + } + + /** Stack Tower mnn93hpi — Ready Status / ปุ่ม แบบ quiz mission (mng8a80o) */ + function updateStackTowerMissionHowtoHud() { + if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'howto') return; + const st = document.getElementById('gauntlet-crown-howto-status'); + const btn = document.getElementById('btn-gch-ready'); + if (!st || !btn) return; + applyGauntletCrownReadyButtonPlay(st, btn); + } + + /** Jump Survival mnptfts2 — Ready Status / ปุ่ม เดียวกับ Stack Tower */ + function updateJumpSurviveMissionHowtoHud() { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'howto') return; + const st = document.getElementById('gauntlet-crown-howto-status'); + const btn = document.getElementById('btn-gch-ready'); + if (!st || !btn) return; + applyGauntletCrownReadyButtonPlay(st, btn); + } + + /** Space Shooter mnpz6rkp — Ready Status / ปุ่ม เดียวกับ Jumper */ + function updateSpaceShooterMissionHowtoHud() { + if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'howto') return; + const st = document.getElementById('gauntlet-crown-howto-status'); + const btn = document.getElementById('btn-gch-ready'); + if (!st || !btn) return; + applyGauntletCrownReadyButtonPlay(st, btn); + } + + function beginStackTowerMissionCountdownThenRun() { + if (!isStackTowerMissionUiMapPlay()) return; + if (stackTowerMissionCountdownTimer) { + clearTimeout(stackTowerMissionCountdownTimer); + stackTowerMissionCountdownTimer = null; + } + stackTowerMissionPhase = 'countdown'; + const howto = document.getElementById('gauntlet-crown-howto-overlay'); + if (howto) { + howto.classList.add('is-hidden'); + howto.classList.remove(GCHOWTO_STACK_TOWER_CLASS); + } + gauntletCrownHowtoVisible = false; + hideReadyAvatarsFloat(); /* ซ่อนแถวหน้าตัวละคร ready ตอนเริ่มเกม (กันค้างบนจอระหว่างเล่น) */ + hideStackTowerHowtoRulesDom(); + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) { + st.classList.add('is-hidden'); + st.classList.remove('gch-status--jumper'); + } + const cd = document.getElementById('gauntlet-crown-countdown'); + const numEl = document.getElementById('gauntlet-crown-countdown-num'); + const runFinish = function () { + if (cd) cd.classList.add('is-hidden'); + if (stackTowerMissionCountdownTimer) { + clearTimeout(stackTowerMissionCountdownTimer); + stackTowerMissionCountdownTimer = null; + } + stackTowerMissionPhase = 'live'; + stackTowerMissionEndedOnce = false; + stackTowerSessionStartAt = performance.now(); + lastStackTickMs = performance.now(); + resetStackMinigameState(); + }; + if (!cd || !numEl) { + runFinish(); + return; + } + cd.classList.remove('is-hidden'); + syncGauntletCrownCountdownMg2ModePlay(cd); + runAlignedCountdownPlay( + function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, + runFinish, + function (id) { stackTowerMissionCountdownTimer = id; } + ); + } + + function stackTowerMissionBuildPayload(endOpts) { + const outcome = endOpts && endOpts.outcome ? String(endOpts.outcome) : 'decrypt_complete'; + const rows = []; + const teamPts = Math.max(0, Number(stackMini && stackMini.score) || 0); + function pushEnt(id, nickname, characterId, score) { + rows.push({ + id: id, + nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id), + characterId: characterId ?? null, + baseScore: Math.max(0, Number(score) || 0), + eliminated: false, + hadQuizWrong: false, + rankBonus: 0, + finalScore: Math.max(0, Number(score) || 0), + }); + } + if (playBotsEnabled() && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) { + if (myId != null) { + pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), me.stackPreviewHumanPts || 0); + } + stackPreviewTurnOrder.forEach(function (entry) { + if (!entry) return; + if (entry.kind === 'bot' && entry.botId) { + 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 (entry.kind === 'human' && entry.id != null && String(entry.id) !== String(myId)) { + /* คนจริงคนอื่น — ใส่ในสรุปด้วย ใช้คะแนนเฉพาะตัว (เดิมมีแต่ me+bots) */ + const oh = others.get(entry.id) || others.get(String(entry.id)); + pushEnt(String(entry.id), oh ? (oh.nickname || String(entry.id).slice(0, 6)) : String(entry.id).slice(0, 6), oh ? oh.characterId : null, oh ? (oh.stackPreviewHumanPts || 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, 6); + 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, 6); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + const bs = Math.max(0, Number(row.baseScore) || 0); + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: bs, + eliminated: false, + hadQuizWrong: false, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: bs, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n2 = ranked.length || 1; + const averageScore = Math.floor(totalSum / n2); + let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; + if (totalSum <= 0 && baseRows.length) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'stack_tower', + survivorCount: baseRows.length, + participantCount: baseRows.length, + stackTowerOutcome: mission.stackTowerOutcome, + }; + } + + function stackTowerMissionMaybeEndPlay() { + if (!isStackTowerMissionUiMapPlay()) return; + if (stackTowerMissionPhase !== 'live') return; + if (stackTowerMissionEndedOnce) return; + const pct = stackTowerDecryptPctPlay(); + const elapsed = stackTowerMissionElapsedSecPlay(); + const timeLim = stackTowerMissionTimeLimitSecPlay(); + const missesOut = stackMini && stackMini.teamMissesLeft != null && stackMini.teamMissesLeft <= 0; + const timeOut = elapsed >= timeLim; + if (pct < 100 && !timeOut && !missesOut) return; + let outcome = 'time_up'; + if (pct >= 100) outcome = 'decrypt_complete'; + else if (missesOut) outcome = 'misses_exhausted'; + /* เล่นจริง: host เป็นคนตัดสินจบ + broadcast → ทุกเครื่องจบพร้อมกัน (เดิมต่างคนต่างจับเวลา local → จบไม่พร้อม) */ + if (detectiveCaseFillBots && !isMePlayHost()) return; /* non-host รอ stack-over จาก host */ + if (detectiveCaseFillBots && socket && myId != null) { + try { socket.emit('stack-over', { outcome: outcome }); } catch (e) { /* ignore */ } + } + finishStackTowerMissionLocalPlay(outcome); + } + + /** จบรอบ MG3 จริง (host เรียกเอง / non-host เรียกตอนรับ stack-over) */ + function finishStackTowerMissionLocalPlay(outcome) { + if (stackTowerMissionEndedOnce || !isStackTowerMissionUiMapPlay()) return; + stackTowerMissionEndedOnce = true; + stackTowerMissionPhase = 'ended'; + applyStackTowerMissionPanelImages(); + beginStackTowerResultFlashThenGcm(stackTowerMissionBuildPayload({ outcome: outcome || 'time_up' })); + } + + function isJumpSurviveMissionUiMapPlay() { + return isJumpSurvive() && currentPlayMapId() === JUMP_SURVIVE_MISSION_MAP_ID; + } + + function isJumpSurviveMissionPregameBlockingPlay() { + if (!isJumpSurviveMissionUiMapPlay()) return false; + return jumpSurviveMissionPhase != null && jumpSurviveMissionPhase !== 'live' && jumpSurviveMissionPhase !== 'ended'; + } + + function jumperAssetUrl(file) { + return BASE + '/img/Jumper/' + file; + } + + function hideConflictingOverlaysForJumpSurviveMission() { + hideConflictingOverlaysForGauntletCrown(); + } + + function jumpSurviveTimeLimitSecForMission() { + const m = Number(mapData && mapData.jumpSurviveTimeSec); + if (Number.isFinite(m) && m > 0 && m < 7200) return Math.floor(m); + const j = Number(playJumpSurviveMissionTimeSec); + if (Number.isFinite(j) && j > 0 && j < 7200) return Math.floor(j); + return 60; + } + + function jumpSurviveRemainingSecMission() { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveSessionStartMs <= 0) return null; + const lim = jumpSurviveTimeLimitSecForMission(); + if (!(lim > 0)) return null; + /* นับเวลาจาก anchor server (serverNow) → จบพร้อมกันทุกเครื่อง; fallback local ถ้ายังไม่มี anchor */ + const elapsed = jumpSurviveLiveAnchorServerMs > 0 + ? (serverNow() - jumpSurviveLiveAnchorServerMs) / 1000 + : (performance.now() - jumpSurviveSessionStartMs) / 1000; + return Math.max(0, Math.ceil(lim - elapsed)); + } + + function applyJumpSurviveJumperPanelImages() { + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + howtoBg.src = jumperAssetUrl('popup-Howto.png'); + howtoBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; + }; + } + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg) { + resBg.src = jumperAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + } + + function showJumpSurviveMissionHowtoOverlay() { + if (!isJumpSurviveMissionUiMapPlay()) return; + hideConflictingOverlaysForJumpSurviveMission(); + applyJumpSurviveJumperPanelImages(); + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + const cd = document.getElementById('gauntlet-crown-countdown'); + if (cd) cd.classList.add('is-hidden'); + jumpSurviveMissionPhase = 'howto'; + const ov = document.getElementById('gauntlet-crown-howto-overlay'); + if (!ov) return; + gauntletCrownHowtoVisible = true; + ov.classList.remove('is-hidden'); + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) st.classList.remove('gch-status--jumper'); + const btn = document.getElementById('btn-gch-ready'); + const humans = quizCarryPregameHumanIds(); + const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); + const soleParticipant = totPlayers === 1; + if (soleParticipant) { + if (st) { + st.textContent = ''; + st.classList.add('is-hidden'); + } + if (btn) { + const canGo = humans.length === 1 || isMePlayHost(); + btn.classList.remove('is-start-phase'); + btn.classList.toggle('is-read-only', !canGo); + btn.disabled = !canGo; + btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host'; + btn.setAttribute('aria-pressed', 'false'); + } + } else { + if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); + gauntletCrownSyncGuestReadyIfNeeded(); + updateJumpSurviveMissionHowtoHud(); + } + } + + function beginJumpSurviveMissionCountdownThenRun() { + if (!isJumpSurviveMissionUiMapPlay()) return; + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + jumpSurviveMissionPhase = 'countdown'; + const howto = document.getElementById('gauntlet-crown-howto-overlay'); + if (howto) howto.classList.add('is-hidden'); + gauntletCrownHowtoVisible = false; + hideReadyAvatarsFloat(); /* ซ่อนแถวหน้าตัวละคร ready ตอนเริ่มเกม (กันค้างบนจอระหว่างเล่น) */ + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) { + st.classList.add('is-hidden'); + st.classList.remove('gch-status--jumper'); + } + const cd = document.getElementById('gauntlet-crown-countdown'); + const numEl = document.getElementById('gauntlet-crown-countdown-num'); + const runFinish = function () { + if (cd) cd.classList.add('is-hidden'); + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + jumpSurviveMissionPhase = 'live'; + jumpSurviveGameEnded = false; + jumpSurviveEliminated = false; + /* anchor เวลาเริ่ม live = liveBeginsAt ของ server (ถ้ามี) → scroll/timer ของทุกเครื่องอ้างอิงจุดเดียวกัน */ + jumpSurviveLiveAnchorServerMs = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : serverNow(); + jumpSurvivePlatformScrollPx = 0; + applyJumpSurvivePreviewSpawnLayout(false); + }; + if (!cd || !numEl) { + runFinish(); + return; + } + cd.classList.remove('is-hidden'); + syncGauntletCrownCountdownMg2ModePlay(cd); + runAlignedCountdownPlay( + function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, + runFinish, + function (id) { jumperMissionCountdownTimer = id; } + ); + } + + /** Jumper ภารกิจ mnptfts2: เกรดจากจำนวนผู้รอด — 6=A … 3=D; น้อยกว่า 2 = F; พอดี 2 คน = E */ + function jumpSurviveGradeFromSurvivorCount(survivors) { + const s = Math.max(0, Math.floor(Number(survivors) || 0)); + if (s >= 6) return 'A'; + if (s === 5) return 'B'; + if (s === 4) return 'C'; + if (s === 3) return 'D'; + if (s === 2) return 'E'; + return 'F'; + } + + function jumpSurviveRollRewardCardForGrade(grade) { + if (grade === 'F') return null; + const r = Math.random(); + if (grade === 'A' || grade === 'B') { + if (r < 0.35) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.7) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'C' || grade === 'D') { + if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'E') { + if (r < 0.12) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + + function jumpSurviveMissionTotalParticipants() { + let n = myId != null ? 1 : 0; + if (others && typeof others.size === 'number') n += others.size; + return n; + } + + function jumpSurviveMissionCountAlive() { + let n = 0; + if (myId != null && !jumpSurviveEliminated) n++; + if (others && typeof others.forEach === 'function') { + others.forEach(function (o) { + if (o && !o.jumpSurviveEliminated) n++; + }); + } + return n; + } + + /** จบทันทีเมื่อไม่มีผู้รอด — เหลือคนสุดท้ายยังเล่นต่อจนหมดเวลา (ไม่จบเร็วแบบ “ชนะขาดลอย”) */ + function jumpSurviveMissionMaybeEarlyFinish() { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return; + const alive = jumpSurviveMissionCountAlive(); + if (alive === 0) { + endJumpSurviveMissionRound('all_dead'); + } + } + + function jumpSurviveBuildMissionPayload() { + const rows = []; + const pushEnt = function (id, nickname, characterId, eliminated) { + const alive = !eliminated; + const baseScore = alive ? 100 : 0; + rows.push({ + id: id, + nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id), + characterId: characterId ?? null, + baseScore: baseScore, + eliminated: !!eliminated, + rankBonus: 0, + finalScore: baseScore, + }); + }; + if (myId != null) { + pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), jumpSurviveEliminated); + } + others.forEach(function (o, id) { + if (!o) return; + pushEnt(id, o.nickname, o.characterId, !!o.jumpSurviveEliminated); + }); + rows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + let survivorCount = 0; + for (let si = 0; si < rows.length; si++) { + if (rows[si] && !rows[si].eliminated) survivorCount++; + } + const participantCount = rows.length; + const grade = jumpSurviveGradeFromSurvivorCount(survivorCount); + const top = missionEnsureHumanInTopRanked(rows, 5); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: row.baseScore, + eliminated: row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: row.baseScore, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + const rewardCard = jumpSurviveRollRewardCardForGrade(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'jumper', + survivorCount: survivorCount, + participantCount: participantCount, + }; + } + + function jumpSurviveMergePreviewBotsMission(mission) { + if (!mission || mission.uiSkin !== 'jumper' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission; + const seen = new Set(); + (mission.ranked || []).forEach(function (r) { + if (r && r.id != null) seen.add(String(r.id)); + }); + const baseRows = (mission.ranked || []).map(function (r) { + return { + id: r.id, + nickname: r.nickname, + characterId: r.characterId, + baseScore: Math.max(0, Number(r.baseScore) || 0), + eliminated: !!r.eliminated, + rankBonus: 0, + }; + }); + others.forEach(function (o, id) { + if (!o || !isPreviewBotId(id)) return; + const sid = String(id); + if (seen.has(sid)) return; + seen.add(sid); + baseRows.push({ + id: id, + nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid, + characterId: o.characterId ?? null, + baseScore: o.jumpSurviveEliminated ? 0 : 100, + eliminated: !!o.jumpSurviveEliminated, + rankBonus: 0, + }); + }); + baseRows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + let survivorCount = 0; + for (let si = 0; si < baseRows.length; si++) { + if (baseRows[si] && !baseRows[si].eliminated) survivorCount++; + } + const participantCount = baseRows.length; + const grade = jumpSurviveGradeFromSurvivorCount(survivorCount); + const top = baseRows.slice(0, 6); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + const bs = Math.max(0, Number(row.baseScore) || 0); + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: bs, + eliminated: !!row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: bs, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: jumpSurviveRollRewardCardForGrade(grade), + totalParts: totalParts, + uiSkin: 'jumper', + survivorCount: survivorCount, + participantCount: participantCount, + }; + } + + function endJumpSurviveMissionRound(endKind) { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveGameEnded) return; + jumpSurviveGameEnded = true; + jumpSurviveMissionPhase = 'ended'; + applyJumpSurviveJumperPanelImages(); + const mission = jumpSurviveBuildMissionPayload(); + const kind = endKind === 'all_dead' ? 'all_dead' : 'time_up'; + beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, kind); + } + function isSpaceShooter() { return mapData && mapData.gameType === 'space_shooter'; } + + function isSpaceShooterMissionUiMapPlay() { + return isSpaceShooter() && currentPlayMapId() === SPACE_SHOOTER_MISSION_MAP_ID; + } + + function isSpaceShooterMissionPregameBlockingPlay() { + if (!isSpaceShooterMissionUiMapPlay()) return false; + return spaceShooterMissionPhase != null && spaceShooterMissionPhase !== 'live' && spaceShooterMissionPhase !== 'ended'; + } + + function violentCrimeAssetUrl(file) { + return BASE + '/img/ViolentCrime/' + String(file || '').replace(/^\//, ''); + } + + function megaVirusAssetUrl(file) { + return BASE + '/img/MegaVirus/' + String(file || '').replace(/^\//, ''); + } + + function applyGauntletCrownHeistMissionPanelImages() { + const howtoOv = document.getElementById('gauntlet-crown-howto-overlay'); + if (howtoOv) { + const tfHowto = isGauntletCrownHeistMapPlay(); + howtoOv.classList.toggle('gch-quiz-tf-mock', tfHowto); + howtoOv.classList.toggle('gch-mg2-mock', tfHowto); + if (tfHowto) { + ensureQuizTfScaleResizeListener(); + updateGchTfMockScale(howtoOv); + } else { + howtoOv.style.removeProperty('--gch-tf-scale'); + } + } + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + howtoBg.src = missionMockHudAssetUrl('popup-Howto.png'); + howtoBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; + }; + } + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg && isGauntletCrownHeistMapPlay()) { + resBg.src = missionMockHudAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + } + + function applyMegaVirusMissionPanelImages() { + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + howtoBg.src = megaVirusAssetUrl('popup-Howto.png'); + howtoBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; + }; + } + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg) { + resBg.src = megaVirusAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + } + + function applySpaceShooterMissionPanelImages() { + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + howtoBg.src = violentCrimeAssetUrl('popup-Howto.png'); + howtoBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; + }; + } + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg) { + resBg.src = violentCrimeAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + } + + function showSpaceShooterMissionHowtoOverlay() { + if (!isSpaceShooterMissionUiMapPlay()) return; + hideConflictingOverlaysForGauntletCrown(); + applySpaceShooterMissionPanelImages(); + if (spaceShooterMissionCountdownTimer) { + clearTimeout(spaceShooterMissionCountdownTimer); + spaceShooterMissionCountdownTimer = null; + } + const cd = document.getElementById('gauntlet-crown-countdown'); + if (cd) cd.classList.add('is-hidden'); + spaceShooterMissionPhase = 'howto'; + const ov = document.getElementById('gauntlet-crown-howto-overlay'); + if (!ov) return; + gauntletCrownHowtoVisible = true; + ov.classList.remove('is-hidden'); + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) st.classList.remove('gch-status--jumper'); + const btn = document.getElementById('btn-gch-ready'); + const humans = quizCarryPregameHumanIds(); + const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); + const soleParticipant = totPlayers === 1; + if (soleParticipant) { + if (st) { + st.textContent = ''; + st.classList.add('is-hidden'); + } + if (btn) { + const canGo = humans.length === 1 || isMePlayHost(); + btn.classList.remove('is-start-phase'); + btn.classList.toggle('is-read-only', !canGo); + btn.disabled = !canGo; + btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host'; + btn.setAttribute('aria-pressed', 'false'); + } + } else { + if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); + gauntletCrownSyncGuestReadyIfNeeded(); + updateSpaceShooterMissionHowtoHud(); + } + spaceShooterResetMissionShipStateForAllPlay(); + } + + function beginSpaceShooterMissionCountdownThenRun() { + if (!isSpaceShooterMissionUiMapPlay()) return; + if (spaceShooterMissionCountdownTimer) { + clearTimeout(spaceShooterMissionCountdownTimer); + spaceShooterMissionCountdownTimer = null; + } + spaceShooterMissionPhase = 'countdown'; + const howto = document.getElementById('gauntlet-crown-howto-overlay'); + if (howto) howto.classList.add('is-hidden'); + gauntletCrownHowtoVisible = false; + hideReadyAvatarsFloat(); /* ซ่อนแถวหน้าตัวละคร ready ตอนเริ่มเกม (กันค้างบนจอระหว่างเล่น) */ + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) { + st.classList.add('is-hidden'); + st.classList.remove('gch-status--jumper'); + } + const cd = document.getElementById('gauntlet-crown-countdown'); + const numEl = document.getElementById('gauntlet-crown-countdown-num'); + const runFinish = function () { + if (cd) cd.classList.add('is-hidden'); + if (spaceShooterMissionCountdownTimer) { + clearTimeout(spaceShooterMissionCountdownTimer); + spaceShooterMissionCountdownTimer = null; + } + spaceShooterMissionPhase = 'live'; + spaceShooterGameEnded = false; + spaceShooterBullets = []; + spaceShooterAsteroids = []; + spaceShooterAsteroidExplosions = []; + spaceShooterPopups = []; + spaceShooterLastTickMs = performance.now(); + spaceShooterSpawnAccMs = 0; + spaceShooterSpawnCount = 0; + /* anchor = liveBeginsAt ของ server (ทุกเครื่องได้ค่าเดียวกัน) → spawn/timer อ้างอิงจุดเดียวกัน */ + spaceShooterLiveAnchorServerMs = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : serverNow(); + spaceShooterFireCd = 0; + spaceShooterSessionStartMs = performance.now(); + spaceShooterLastMoveEmit = 0; + if (myId != null) me.spaceShooterScore = 0; + others.forEach(function (o) { + if (o) o.spaceShooterScore = 0; + }); + spaceShooterResetMissionShipStateForAllPlay(); + if (mapData) applySpaceShooterSpawnLayoutPlay(); + }; + if (!cd || !numEl) { + runFinish(); + return; + } + cd.classList.remove('is-hidden'); + syncGauntletCrownCountdownMg2ModePlay(cd); + runAlignedCountdownPlay( + function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, + runFinish, + function (id) { spaceShooterMissionCountdownTimer = id; } + ); + } + + function spaceShooterBuildMissionPayload() { + const rows = []; + function pushEnt(id, nickname, characterId, score, eliminated) { + rows.push({ + id: id, + nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id), + characterId: characterId ?? null, + baseScore: Math.max(0, Number(score) || 0), + eliminated: !!eliminated, + rankBonus: 0, + finalScore: Math.max(0, Number(score) || 0), + }); + } + if (myId != null) { + pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), me.spaceShooterScore, !!me.spaceShooterEliminated); + } + others.forEach(function (o, id) { + if (!o) return; + pushEnt(id, o.nickname, o.characterId, o.spaceShooterScore, !!o.spaceShooterEliminated); + }); + rows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + const top = rows.slice(0, 6); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + /* โบนัสอยู่รอดจนจบเกม = +20 (เฉพาะคนไม่ตาย) — รวมใน finalScore เหมือน rankBonus ของ MG2 */ + const surviveBonus = row.eliminated ? 0 : 20; + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: row.baseScore, + eliminated: !!row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: surviveBonus, + finalScore: row.baseScore + surviveBonus, + }; + }); + const totalParts = ranked.map(function (r) { return r.finalScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + const survivorCount = rows.filter(function (r) { return !r.eliminated; }).length; + let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; + if (survivorCount <= 0) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'violent_crime', + survivorCount: survivorCount, + participantCount: rows.length, + }; + } + + function spaceShooterMergePreviewBotsMission(mission) { + if (!mission || mission.uiSkin !== 'violent_crime' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission; + const seen = new Set(); + (mission.ranked || []).forEach(function (r) { + if (r && r.id != null) seen.add(String(r.id)); + }); + const baseRows = (mission.ranked || []).map(function (r) { + return { + id: r.id, + nickname: r.nickname, + characterId: r.characterId, + baseScore: Math.max(0, Number(r.baseScore) || 0), + eliminated: !!r.eliminated, + rankBonus: 0, + finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0), + }; + }); + others.forEach(function (o, id) { + if (!o || !isPreviewBotId(id)) return; + const sid = String(id); + if (seen.has(sid)) return; + seen.add(sid); + baseRows.push({ + id: id, + nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid, + characterId: o.characterId ?? null, + baseScore: Math.max(0, Number(o.spaceShooterScore) || 0), + eliminated: !!o.spaceShooterEliminated, + rankBonus: 0, + finalScore: Math.max(0, Number(o.spaceShooterScore) || 0), + }); + }); + baseRows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + const top = baseRows.slice(0, 6); + 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, 6); + /* โบนัสจบเกม: ฆ่าบอส(+100)→ทุกคนที่ร่วมเล่น · รอดชีวิต(+10)→เฉพาะคนไม่ตาย (รวมใน finalScore, clamp ≥0) */ + const endRBonus = mission && mission.balloonBossEndReason; + const killBonusAll = endRBonus === 'victory' ? 100 : 0; + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + const bs = Math.max(0, Number(row.baseScore) || 0); + const bonus = killBonusAll + (row.eliminated ? 0 : 10); + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: bs, + eliminated: !!row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: bonus, + finalScore: Math.max(0, bs + bonus), + }; + }); + const totalParts = ranked.map(function (r) { return r.finalScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n2 = ranked.length || 1; + const averageScore = Math.floor(totalSum / n2); + const maxHp = balloonBossMaxHpPlay(); + let bossDmgSum = Math.max(0, me.balloonBossBossDmg | 0); + others.forEach(function (o) { + if (o) bossDmgSum += Math.max(0, o.balloonBossBossDmg | 0); + }); + const progress = Math.min(1, bossDmgSum / Math.max(1, maxHp)); + const score100 = Math.min(100, Math.floor(progress * 100)); + let grade = 'C'; + const endR = mission && mission.balloonBossEndReason; + if (endR === 'victory' || progress >= 1) grade = 'A'; + else if (endR === 'all_dead') grade = 'F'; + else if (score100 >= 50) grade = 'B'; + else if (totalSum <= 0) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'mega_virus', + survivorCount: baseRows.filter(function (r) { return !r.eliminated; }).length, + participantCount: baseRows.length, + balloonBossEndReason: mission.balloonBossEndReason, + }; + } + + function balloonBossBuildMissionPayloadPlay(reason) { + const rows = []; + if (myId != null) { + rows.push({ + id: myId, + nickname: ((me.nickname || nick || 'คุณ').trim() || 'คุณ'), + characterId: me.characterId ?? null, + baseScore: Math.max(0, me.balloonBossScore | 0), + eliminated: !!me.balloonBossEliminated, + }); + } + others.forEach((o, id) => { + rows.push({ + id: id, + nickname: (o && o.nickname) ? String(o.nickname).trim() : id, + characterId: o && o.characterId != null ? o.characterId : null, + baseScore: Math.max(0, o.balloonBossScore | 0), + eliminated: !!(o && o.balloonBossEliminated), + }); + }); + rows.sort((a, b) => b.baseScore - a.baseScore + || String(a.nickname).localeCompare(String(b.nickname), 'th') + || String(a.id).localeCompare(String(b.id))); + const top = rows.slice(0, 6); + /* โบนัสจบเกม: ฆ่าบอส(+100)→ทุกคนที่ร่วมเล่น · รอดชีวิต(+10)→เฉพาะคนไม่ตาย (รวมใน finalScore, clamp ≥0) */ + const killBonusAll = reason === 'victory' ? 100 : 0; + const ranked = top.map((row, idx) => { + const pos = idx + 1; + const bs = Math.max(0, Number(row.baseScore) || 0); + const bonus = killBonusAll + (row.eliminated ? 0 : 10); + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: bs, + eliminated: !!row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: bonus, + finalScore: Math.max(0, bs + bonus), + }; + }); + const totalParts = ranked.map((r) => r.finalScore); + const totalSum = totalParts.reduce((s, n) => s + n, 0); + const n2 = ranked.length || 1; + const averageScore = Math.floor(totalSum / n2); + const maxHp = balloonBossMaxHpPlay(); + let bossDmgSum = Math.max(0, me.balloonBossBossDmg | 0); + others.forEach((o) => { + if (o) bossDmgSum += Math.max(0, o.balloonBossBossDmg | 0); + }); + const progress = Math.min(1, bossDmgSum / Math.max(1, maxHp)); + const score100 = Math.min(100, Math.floor(progress * 100)); + let grade = 'C'; + if (reason === 'victory' || progress >= 1) grade = 'A'; + else if (reason === 'all_dead') grade = 'F'; + else if (score100 >= 50) grade = 'B'; + else if (totalSum <= 0) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'mega_virus', + survivorCount: rows.filter((r) => !r.eliminated).length, + participantCount: rows.length, + balloonBossEndReason: reason, + }; + } + + /* host เป็นผู้ตัดสินจบ MG6 + แนบคะแนน/สถานะสุดท้าย → ทุกเครื่องจบพร้อมกันและอันดับตรงกัน */ + function triggerSpaceShooterOverPlay(kind) { + if (spaceShooterGameEnded) return; + const k = (kind === 'all_dead') ? 'all_dead' : 'time_up'; + try { + if (socket && myId != null && isMePlayHost()) { + const scores = buildStableShipParticipantRefsPlay().map((e) => ({ + id: e.id, + score: Math.max(0, (e.ref && (e.ref.spaceShooterScore | 0)) || 0), + hits: Math.max(0, Number(e.ref && e.ref.spaceShooterHits) || 0), + eliminated: !!(e.ref && e.ref.spaceShooterEliminated), + })); + socket.emit('space-shooter-over', { kind: k, scores: scores }); + } + } catch (e) { /* ignore */ } + endSpaceShooterMissionRound(k); + } + + function applySpaceShooterOverScoresPlay(scores) { + if (!Array.isArray(scores)) return; + scores.forEach((s) => { + if (!s || s.id == null) return; + const sc = Math.max(0, Number(s.score) || 0); + const hits = Math.max(0, Number(s.hits) || 0); + const ent = (myId != null && String(s.id) === String(myId)) ? me : (others.get(String(s.id)) || others.get(s.id)); + if (!ent) return; + ent.spaceShooterScore = sc; + ent.spaceShooterHits = hits; + if (s.eliminated) ent.spaceShooterEliminated = true; + }); + } + + function endSpaceShooterMissionRound(endKind) { + if (!isSpaceShooterMissionUiMapPlay() || spaceShooterGameEnded) return; + const kind = endKind === 'all_dead' ? 'all_dead' : 'time_up'; + spaceShooterGameEnded = true; + spaceShooterMissionPhase = 'ended'; + applySpaceShooterMissionPanelImages(); + spaceShooterBullets = []; + spaceShooterAsteroids = []; + spaceShooterAsteroidExplosions = []; + spaceShooterSpawnAccMs = 0; + spaceShooterPopups = []; + const mission = spaceShooterBuildMissionPayload(); + mission.spaceShooterEndKind = kind; + beginSpaceShooterMissionResultFlashSequenceThenGcm(mission, kind); + } + + function questionMissionAssetUrl(file) { + return BASE + '/img/QUESTION/' + String(file || '').replace(/^\//, ''); + } + + /** HUD กลางจอ (เวลา / แผ่นคำถาม) — ชื่อไฟล์คู่กับ public/img/QUESTION บนดิสก์ → URL /Game/img/QUESTION */ + function questionMissionHudAssetUrl(file) { + if (isMissionMockHudPlay()) return missionMockHudAssetUrl(file); + return questionMissionAssetUrl(file); + } + + /** สกิน HUD แบบ mock Minigame 1–7 (fix 1920×1080 + score-bar / profile) */ + function getMissionMockHudSkinPlay() { + if (isQuizQuestionMissionUiMapPlay()) return 'question_mission'; + if (isStackTowerMissionUiMapPlay()) return 'stack_tower'; + if (isJumpSurviveMissionUiMapPlay()) return 'jumper'; + if (isSpaceShooterMissionUiMapPlay()) return 'violent_crime'; + if (isMegaVirusMissionShellMapPlay()) return 'mega_virus'; + if (isQuizCarry() && quizCarryEmbedMissionFlowActive()) return 'quiz_carry'; + if (isGauntletCrownHeistMapPlay()) return 'gauntlet_crown'; + return ''; + } + + function isMissionMockHudPlay() { + return !!getMissionMockHudSkinPlay(); + } + + function missionMockHudAssetUrl(file) { + const skin = getMissionMockHudSkinPlay(); + const f = String(file || '').replace(/^\//, ''); + if (skin === 'jumper') return jumperAssetUrl(f); + if (skin === 'violent_crime') return violentCrimeAssetUrl(f); + if (skin === 'mega_virus') return megaVirusAssetUrl(f); + if (skin === 'stack_tower') return stackTowerAssetUrl(f); + if (skin === 'quiz_carry') return BASE + '/img/quiz-carry/' + f; + if (skin === 'gauntlet_crown') return BASE + '/img/gauntlet-assets/' + f; + return questionMissionAssetUrl(f); + } + + function applyMissionMockHudCssVarsPlay(root) { + if (!root) return; + if (!isMissionMockHudPlay()) { + root.style.removeProperty('--mmh-score-bar'); + root.style.removeProperty('--mmh-score-label'); + root.style.removeProperty('--mmh-score-avatar'); + root.style.removeProperty('--mmh-mic-on'); + root.style.removeProperty('--mmh-btn-ready'); + root.style.removeProperty('--mmh-btn-start'); + delete root.dataset.missionMockSkin; + return; + } + const skin = getMissionMockHudSkinPlay(); + root.dataset.missionMockSkin = skin; + if (skin === 'gauntlet_crown') { + root.style.removeProperty('--mmh-score-bar'); + root.style.setProperty('--mmh-score-label', "url('" + missionMockHudAssetUrl('score.png') + "')"); + const crownHead = document.getElementById('play-cyber-crown-score-head'); + const crownImg = document.getElementById('play-cyber-crown-score-img'); + if (crownHead && crownImg) { + const scoreSrc = missionMockHudAssetUrl('score.png'); + crownHead.classList.remove('is-hidden'); + crownHead.setAttribute('aria-hidden', 'false'); + if (crownImg.getAttribute('src') !== scoreSrc) crownImg.setAttribute('src', scoreSrc); + crownImg.alt = 'SCORE :'; + crownImg.classList.add('mg2-score-label'); + } + } else { + root.style.removeProperty('--mmh-score-label'); + root.style.setProperty('--mmh-score-bar', "url('" + missionMockHudAssetUrl('score-bar.png') + "?v=3')"); + } + root.style.setProperty('--mmh-score-avatar', "url('" + missionMockHudAssetUrl('score-avartar.png') + "')"); + root.style.setProperty('--mmh-mic-on', "url('" + missionMockHudAssetUrl('btn-mic_on.png') + "')"); + root.style.setProperty('--mmh-btn-ready', "url('" + missionMockHudAssetUrl('btn-ready.png') + "')"); + root.style.setProperty('--mmh-btn-start', "url('" + missionMockHudAssetUrl('btn-start.png') + "')"); + const micImg = document.getElementById('play-cyber-mic-img'); + if (micImg) { + micImg.classList.remove('is-hidden'); + micImg.setAttribute('aria-hidden', 'false'); + const micSrc = missionMockHudAssetUrl('btn-mic_on.png'); + if (micImg.getAttribute('src') !== micSrc) micImg.setAttribute('src', micSrc); + } + const frameOverlay = document.getElementById('play-cyber-profile-frame-overlay'); + if (frameOverlay) { + const frameSrc = missionMockHudAssetUrl('Avartar.png'); + if (frameOverlay.getAttribute('src') !== frameSrc) frameOverlay.setAttribute('src', frameSrc); + } + } + + function isQuizQuestionMissionHudActivePlay() { + return isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live'; + } + + /** ภารกิจคำถาม mng8a80o ช่วง live: กล้องกลางแมป (world px) — ไม่ตาม me */ + function getQuizQuestionMissionMapCenterWorldPxPlay() { + if (!mapData || !isQuiz()) return null; + const w = mapData.width || 20; + const h = mapData.height || 15; + const ts = tileSize; + const mwPx = w * ts; + const mhPx = h * ts; + return { cx: mwPx * 0.5, cy: mhPx * 0.5, mwPx, mhPx }; + } + + function applyQuizQuestionMissionPanelImages() { + /* ติดคลาส scope ให้ overlay howto (popup HOW TO PLAY) ใช้สไตล์ mockup เฉพาะ mng8a80o */ + const howtoOv = document.getElementById('gauntlet-crown-howto-overlay'); + if (howtoOv) { + const tfHowto = isQuizQuestionMissionUiMapPlay(); + howtoOv.classList.toggle('gch-quiz-tf-mock', tfHowto); + if (tfHowto) { + ensureQuizTfScaleResizeListener(); + updateGchTfMockScale(howtoOv); + } else { + howtoOv.style.removeProperty('--gch-tf-scale'); + } + } + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + /* MG1 ใช้ popup-Howtominigame1.png (รูป SAFE/SCAM) — popup-Howto.png เดิมเป็นดีไซน์ MG4 ใส่ผิดที่ */ + howtoBg.src = questionMissionAssetUrl('popup-Howtominigame1.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; + hideReadyAvatarsFloat(); /* ซ่อนแถวหน้าตัวละคร ready ตอนเริ่มเกม (กันค้างบนจอระหว่างเล่น) */ + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) { + st.classList.add('is-hidden'); + st.classList.remove('gch-status--jumper'); + } + const cd = document.getElementById('gauntlet-crown-countdown'); + const numEl = document.getElementById('gauntlet-crown-countdown-num'); + const runFinish = function () { + if (cd) cd.classList.add('is-hidden'); + if (quizQuestionMissionCountdownTimer) { + clearTimeout(quizQuestionMissionCountdownTimer); + quizQuestionMissionCountdownTimer = null; + } + quizQuestionMissionPhase = 'live'; + playEmbedUserZoomMul = 1; + const qov = document.getElementById('quiz-game-overlay'); + if (qov) { + if (isQuizQuestionMissionUiMapPlay()) qov.classList.add('is-hidden'); + else if (previewMode && editorEmbedReturn) qov.classList.add('is-hidden'); + else qov.classList.remove('is-hidden'); + } + const dp = quizQuestionMissionDeferredPhase; + quizQuestionMissionDeferredPhase = null; + if (dp) applyQuizPhaseFromServer(dp); + if (previewMode && isQuiz()) loadPreviewQuizAndStart(); + }; + if (!cd || !numEl) { + runFinish(); + return; + } + cd.classList.remove('is-hidden'); + syncGauntletCrownCountdownMg2ModePlay(cd); + runAlignedCountdownPlay( + function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, + runFinish, + function (id) { quizQuestionMissionCountdownTimer = id; } + ); + } + + function quizQuestionMissionBuildPayload() { + const rows = []; + function pushEnt(id, nickname, characterId, score) { + const sid = String(id); + rows.push({ + id: id, + nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id), + characterId: characterId ?? null, + baseScore: Math.max(0, Number(score) || 0), + eliminated: false, + hadQuizWrong: !!playQuizEverWrong[sid], + rankBonus: 0, + finalScore: Math.max(0, Number(score) || 0), + }); + } + if (myId != null) { + pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), playLiveQuizScores[myId]); + } + others.forEach(function (o, id) { + if (!o) return; + pushEnt(id, o.nickname, o.characterId, playLiveQuizScores[id]); + }); + rows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + const top = rows.slice(0, 6); + 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'; + /* เกรด F (= โชว์ Lose stamp) เฉพาะ "ไม่มีใครได้คะแนนเลย" — ดูจาก playLiveQuizScores เต็ม (ครบทุกคน) + ไม่ใช่ totalSum ของ ranked (me+others) ที่อาจหายคนตอบถูกที่ไม่อยู่ใน others → ขัดกับ flash complete */ + const mg1FullScoreSum = Object.keys(playLiveQuizScores).reduce(function (s, k) { return s + Math.max(0, playLiveQuizScores[k] | 0); }, 0); + if (mg1FullScoreSum <= 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, 6); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + const bs = Math.max(0, Number(row.baseScore) || 0); + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: bs, + eliminated: false, + hadQuizWrong: !!row.hadQuizWrong, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: bs, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n2 = ranked.length || 1; + const averageScore = Math.floor(totalSum / n2); + let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; + if (totalSum <= 0 && baseRows.length) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'question_mission', + survivorCount: baseRows.length, + participantCount: baseRows.length, + }; + } + + function isBalloonBoss() { return mapData && mapData.gameType === 'balloon_boss'; } + function isQuizCarry() { return mapData && mapData.gameType === 'quiz_carry'; } + + /** พรีวิว editor + ภารกิจสืบสวน mnorwqx1 — flow เต็ม: HOWTO/READY, นับ 3-2-1, ครบ carrySessionLength, สรุปภารกิจ */ + function quizCarryEmbedMissionFlowActive() { + if (!isQuizCarry()) return false; + if (previewMode && editorEmbedReturn) return true; + return !!(isDetectiveMinigamePlay() && quizCarryUseMissionSummaryOverlay()); + } + /** quiz_carry: จุดกลางแมป (world px) — กล้องไม่ตามตัวละคร; ใช้ร่วม draw + overlay DOM */ + function getQuizCarryMapCameraWorldCenterPxPlay() { + if (!mapData || mapData.gameType !== 'quiz_carry') return null; + const ww = mapData.width || 20, hh = mapData.height || 15; + const ts = tileSize; + return { cx: ww * ts * 0.5, cy: hh * ts * 0.5 }; + } + function isQuizBattle() { return mapData && mapData.gameType === 'quiz_battle'; } + + function quizCarryNeonColorForChoice(choiceIndex) { + const i = Math.max(0, choiceIndex | 0); + const h = (i * 47 + 100) % 360; + return 'hsl(' + h + ', 72%, 62%)'; + } + + function quizCarryMinimapOptionFillCss(ov) { + const th = getEffectiveCarryChoicePlaqueThemeForChoice((Number(ov) | 0) - 1); + if (th.borderMode === 'fixed' && th.fixedBorder) { + const m = /^rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*(?:,\s*([0-9.]+)\s*)?\)$/i.exec(String(th.fixedBorder).trim()); + if (m) { + const a0 = m[4] != null && m[4] !== '' ? Number(m[4]) : 1; + const aa = (Number.isFinite(a0) ? Math.max(0, Math.min(1, a0)) : 1) * 0.32; + const t = Math.round(aa * 1000) / 1000; + return 'rgba(' + m[1] + ',' + m[2] + ',' + m[3] + ',' + t + ')'; + } + } + const i = Math.max(0, ov - 1); + const h = (i * 47 + 100) % 360; + return 'hsla(' + h + ', 58%, 52%, 0.32)'; + } + + function normalizeQuizCarryQuestionFromAny(q) { + if (!q) return null; + let text = String(q.text || q.question || q.prompt || q.title || '').trim(); + if (Array.isArray(q.choices) && q.choices.length >= 2) { + const choices = q.choices.map((c) => String(c)); + let ci = Number(q.correctIndex); + if (!Number.isFinite(ci) || ci < 0 || ci >= choices.length) ci = 0; + if (!text) text = 'เลือกคำตอบที่ถูกต้อง — หยิบตัวเลือกไปวางโซนส่งคำตอบ'; + let chSlot = Number(q.countdownHighlightSlot); + if (!Number.isFinite(chSlot) || chSlot < 1 || chSlot > 16) chSlot = null; + else chSlot = Math.floor(chSlot); + let choiceImageUrls = null; + if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) { + const urls = choices.map((_, idx) => sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[idx])); + if (urls.some((u) => u)) choiceImageUrls = urls; + } + return { text, choices, correctIndex: ci, countdownHighlightSlot: chSlot, choiceImageUrls }; + } + if (!text) return null; + const t = !!q.answerTrue; + return { text, choices: ['จริง', 'เท็จ'], correctIndex: t ? 0 : 1, countdownHighlightSlot: null }; + } + + function buildQuizCarryPoolFromMap(md) { + if (!md || !Array.isArray(md.quizQuestions)) return []; + const out = []; + for (let i = 0; i < md.quizQuestions.length; i++) { + const n = normalizeQuizCarryQuestionFromAny(md.quizQuestions[i]); + if (n) out.push(n); + } + return out; + } + + function getQuizCarryHubTileBounds(md) { + if (!md || md.gameType !== 'quiz_carry') return null; + const grid = md.quizCarryHubArea; + if (!grid || !grid.length) return null; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (let yy = 0; yy < grid.length; yy++) { + const row = grid[yy]; + if (!row) continue; + for (let xx = 0; xx < row.length; xx++) { + if (row[xx] === 1) { + if (xx < minX) minX = xx; + if (yy < minY) minY = yy; + if (xx > maxX) maxX = xx; + if (yy > maxY) maxY = yy; + } + } + } + if (minX === Infinity) return null; + return { minX, minY, maxX, maxY }; + } + + /** จุดกลางโซนตัวเลือกหยิบมาวาง (ค่า grid 1..N = ลำดับ choices) เพื่อวาดข้อความบนแผนที่ */ + function getQuizCarryOptionClusterBounds(md, slot1toN) { + const g = md && md.quizCarryOptionArea; + if (!g || !g.length) return null; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + let n = 0, sumX = 0, sumY = 0; + for (let yy = 0; yy < g.length; yy++) { + const row = g[yy]; + if (!row) continue; + for (let xx = 0; xx < row.length; xx++) { + if (Number(row[xx]) === slot1toN) { + if (xx < minX) minX = xx; + if (yy < minY) minY = yy; + if (xx > maxX) maxX = xx; + if (yy > maxY) maxY = yy; + sumX += xx + 0.5; + sumY += yy + 0.5; + n++; + } + } + } + if (!n || minX === Infinity) return null; + return { + minX, minY, maxX, maxY, + cx: sumX / n, + cy: sumY / n, + }; + } + + function canvasWordWrapLines(ctx, text, maxWidth) { + const raw = String(text || '').trim(); + if (!raw) return []; + const words = raw.split(/\s+/); + const lines = []; + let cur = words[0]; + for (let i = 1; i < words.length; i++) { + const w = words[i]; + const test = cur + ' ' + w; + if (ctx.measureText(test).width <= maxWidth) cur = test; + else { + lines.push(cur); + cur = w; + } + } + lines.push(cur); + return lines; + } + + /** ตัดบรรทัดระดับตัวอักษรให้แต่ละบรรทัดพอดีกว้าง (รองรับคำไทยยาวไม่มีช่องว่าง) — คืนทุกบรรทัด */ + function quizCarryWrapLinesFit(ctx, text, maxWidth) { + const raw = String(text || '').trim(); + if (!raw) return []; + const lines = []; + let cur = ''; + for (const chr of raw) { + if (chr === '\n') { lines.push(cur); cur = ''; continue; } + const test = cur + chr; + if (cur === '' || ctx.measureText(test).width <= maxWidth) cur = test; + else { lines.push(cur); cur = chr; } + } + if (cur) lines.push(cur); + return lines; + } + + function quizCarryOptionHeldByAnyone(optionIdx) { + if (optionIdx == null || optionIdx < 0) return false; + if (me.quizCarryHeld === optionIdx) return true; + for (const o of others.values()) { + if (o && o.quizCarryHeld === optionIdx) return true; + } + return false; + } + + /** สไปรต์ทับช่องโซนกลาง (ม่วง) อย่างน้อย 1 ช่อง */ + function spriteOverlapsQuizCarryHubArea(md, sp) { + if (!md || md.gameType !== 'quiz_carry' || !sp) return false; + const hub = md.quizCarryHubArea; + if (!hub || !hub.length) return false; + const mw = md.width || 20, mh = md.height || 15; + const x0 = sp.x | 0, y0 = sp.y | 0, ww = sp.w | 0, hh = sp.h | 0; + for (let yy = y0; yy < y0 + hh; yy++) { + if (yy < 0 || yy >= mh) continue; + const row = hub[yy]; + if (!row) continue; + for (let xx = x0; xx < x0 + ww; xx++) { + if (xx < 0 || xx >= mw) continue; + if (row[xx] === 1) return true; + } + } + return false; + } + + /** ตัวเลือกหนึ่งข้อ — มีคนถือป้ายอยู่ได้แค่คนเดียว (หยิบซ้ำไม่ได้) */ + function pickQuizCarryTargetOptionIndexAvoidingTaken(preferredIdx) { + const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0; + if (n < 1) return null; + let p = preferredIdx | 0; + if (p < 0 || p >= n) p = 0; + if (!quizCarryOptionHeldByAnyone(p)) return p; + const avail = []; + for (let i = 0; i < n; i++) { + if (!quizCarryOptionHeldByAnyone(i)) avail.push(i); + } + if (!avail.length) return null; + return avail[Math.floor(Math.random() * avail.length)]; + } + + /** + * วาดป้ายมุมมน + ขอบเรือง + ข้อความ (รูปแบบเดียวกันทั้งแมปและติดตัว) — สีจาก carryChoicePlaqueThemes[choiceIndex] · รูปจาก plaqueExtra.imageUrl + * @param tether ถ้ามี วาดเส้นขาวจาก (x0,y0) ถึง (x1,y1) ก่อนป้าย + * @param plaqueExtra optional { imageUrl } + */ + function drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, tether, plaqueExtra) { + const th = getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex); + const neon = th.borderMode === 'fixed' ? th.fixedBorder : quizCarryNeonColorForChoice(choiceIndex); + const fillCol = th.fillBg || 'rgba(12, 10, 20, 0.88)'; + const textCol = th.textColor || '#f8f9ff'; + const baseLw = Math.max(0.5, Math.min(8, Number(th.borderWidthPx))); + const imgUrl = plaqueExtra && plaqueExtra.imageUrl ? String(plaqueExtra.imageUrl) : ''; + const elImg = plaqueExtra && plaqueExtra.imageElement; + const imgFromEl = (elImg && elImg.complete && elImg.naturalWidth > 0) ? elImg : null; + const img = !imgFromEl && imgUrl ? getQuizCarryChoiceImageCached(imgUrl) : null; + const drawPlaqueImg = imgFromEl || ((img && img.complete && img.naturalWidth > 0) ? img : null); + const rr = Math.max(6, Math.min(12, Math.min(w, h) * 0.2)); + const simplePreviewPlaque = previewMode && editorEmbedReturn; + function pathBoard() { + ctx.beginPath(); + if (typeof ctx.roundRect === 'function') ctx.roundRect(signX, signY, w, h, rr); + else ctx.rect(signX, signY, w, h); + } + if (!simplePreviewPlaque) { + for (let g = 4; g >= 1; g--) { + ctx.strokeStyle = neon; + ctx.globalAlpha = 0.07 + g * 0.06; + ctx.lineWidth = baseLw + g * 3.2; + pathBoard(); + ctx.stroke(); + } + } + ctx.globalAlpha = 1; + ctx.fillStyle = fillCol; + pathBoard(); + ctx.fill(); + if (drawPlaqueImg) { + ctx.save(); + pathBoard(); + ctx.clip(); + const padImg = Math.max(2, padY * 0.65); + const ix = signX + padImg; + const iy = signY + padImg; + const iw = Math.max(0, w - padImg * 2); + const ih = Math.max(0, h - padImg * 2); + if (iw > 0 && ih > 0) { + const nw = drawPlaqueImg.naturalWidth; + const nh = drawPlaqueImg.naturalHeight; + const ir = nw / nh; + let dw; + let dh; + if (iw / ih > ir) { + dh = ih; + dw = dh * ir; + } else { + dw = iw; + dh = dw / ir; + } + const dx = ix + (iw - dw) / 2; + const dy = iy + (ih - dh) / 2; + ctx.drawImage(drawPlaqueImg, 0, 0, nw, nh, dx, dy, dw, dh); + } + ctx.restore(); + } + ctx.strokeStyle = neon; + if (simplePreviewPlaque) { + ctx.lineWidth = Math.max(1, baseLw * 0.85); + ctx.shadowBlur = 0; + pathBoard(); + ctx.stroke(); + } else { + ctx.lineWidth = baseLw; + ctx.shadowColor = neon; + ctx.shadowBlur = 14; + pathBoard(); + ctx.stroke(); + ctx.shadowBlur = 8; + pathBoard(); + ctx.stroke(); + ctx.shadowBlur = 0; + } + const attachX = signX + w / 2; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = textCol; + const lh = lineH || 12; + if (!simplePreviewPlaque && lines && lines.length) { + ctx.shadowColor = 'rgba(0,0,0,0.55)'; + ctx.shadowBlur = 3; + } + if (lines && lines.length) { + const n = lines.length; + const textBlockH = n * lh; + const y0 = signY + Math.max(padY, (h - textBlockH) / 2); + const firstLineCenterY = y0 + lh / 2; + for (let li = 0; li < n; li++) { + ctx.fillText(lines[li], attachX, firstLineCenterY + li * lh); + } + } + ctx.shadowBlur = 0; + if (tether && Number.isFinite(tether.x0) && Number.isFinite(tether.y0)) { + ctx.strokeStyle = 'rgba(255,255,255,0.95)'; + ctx.lineWidth = 1.5; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.shadowBlur = 0; + ctx.beginPath(); + ctx.moveTo(tether.x0, tether.y0); + ctx.lineTo(tether.x1, tether.y1); + ctx.stroke(); + } + } + + /** + * กล่องป้ายบนจอ (signX, signY, w, h) ให้ตรงกับ drawQuizCarryChoiceLabels สำหรับช่อง idx 0-based + */ + function computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, idx0Based) { + if (!isQuizCarry() || !mapData || !quizCarryCurrent) return null; + const q = quizCarryCurrent; + const choices = q.choices; + if (!choices || !choices.length || idx0Based < 0 || idx0Based >= choices.length) return null; + const i = idx0Based; + const b = getQuizCarryOptionClusterBounds(mapData, i + 1); + if (!b) return null; + const ts = tileSize * zoom; + const ps = quizCarryPlaqueMapScaleClampedPlay(); + const choiceText = String(choices[i] || '').trim(); + const imgUrl = getQuizCarryPlaqueImageUrlForIndex(q, i); + const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i); + const plaqueExtra = imgUrl ? { imageUrl: imgUrl } + : (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {}); + if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) return null; + const tileSpanX = (b.maxX - b.minX + 1); + const tileSpanY = (b.maxY - b.minY + 1); + const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps); + const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps); + const wx = b.cx * tileSize; + const wy = b.cy * tileSize; + const [sx, sy] = worldToScreen(wx, wy); + ctx.save(); + let signX; + let signY; + let w; + let h; + if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) { + const minW = minPlaqueW; + const minH = minPlaqueH; + signX = sx - minW / 2; + signY = sy - minH / 2; + w = minW; + h = minH; + ctx.restore(); + return { signX, signY, w, h }; + } + const line = choiceText; + const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps)); + ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; + let maxW = Math.max(56, tileSpanX * ts - 14); + const lineH = fontPx * 1.2; + let textLines = canvasWordWrapLines(ctx, line, maxW); + if (!textLines.length) textLines = [line]; + if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) { + const fp2 = Math.max(9, fontPx * 0.85); + ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; + const lh2 = fp2 * 1.2; + maxW = Math.max(48, maxW - 4); + textLines = canvasWordWrapLines(ctx, line, maxW); + if (textLines.length * lh2 > tileSpanY * ts - 8) { + textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2))); + } + for (let ti = 0; ti < textLines.length; ti++) { + let s = textLines[ti]; + if (ctx.measureText(s).width <= maxW) continue; + while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); + textLines[ti] = s + '…'; + } + const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40); + const padX = 10; + const padY = 8; + w = Math.ceil(maxLineW + padX * 2); + h = Math.ceil(textLines.length * lh2 + padY * 2); + if (imgUrl || plaqueExtra.imageElement) { + w = Math.max(w, minPlaqueW); + h = Math.max(h, minPlaqueH); + } + signX = sx - w / 2; + signY = sy - h / 2; + ctx.restore(); + return { signX, signY, w, h }; + } + for (let ti = 0; ti < textLines.length; ti++) { + let s = textLines[ti]; + if (ctx.measureText(s).width <= maxW) continue; + while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); + textLines[ti] = s + '…'; + } + let maxLineW = 0; + for (let ti = 0; ti < textLines.length; ti++) { + const lw = ctx.measureText(textLines[ti]).width; + if (lw > maxLineW) maxLineW = lw; + } + const padX = 10; + const padY = 8; + w = Math.ceil(Math.max(maxLineW, 52) + padX * 2); + h = Math.ceil(textLines.length * lineH + padY * 2); + if (imgUrl || plaqueExtra.imageElement) { + w = Math.max(w, minPlaqueW); + h = Math.max(h, minPlaqueH); + } + signX = sx - w / 2; + signY = sy - h / 2; + ctx.restore(); + return { signX, signY, w, h }; + } + + /** + * ขอบเรืองรอบช่องตัวเลือก (carryEmbedCountdownHighlight + carryEmbedCountdownHighlightColor) + * ขนาดกรอบเท่าป้ายบนแมป (เดียวกับ drawQuizCarryChoiceLabels) ไม่ใช่ทั้ง cluster กริด + */ + function drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zoom, timeMs) { + if (!isQuizCarry() || !mapData) return; + if (mapData.carryEmbedCountdownHighlight === false) return; + const optIdx = getQuizCarryLocalGrabbableOptionIndex(); + if (optIdx == null) return; + const pr = computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, optIdx); + if (!pr || pr.w < 4 || pr.h < 4) return; + const rx = pr.signX; + const ry = pr.signY; + const rw = pr.w; + const rh = pr.h; + const rr = Math.max(6, Math.min(16, Math.min(rw, rh) * 0.14)); + const colRaw = mapData.carryEmbedCountdownHighlightColor; + const strokeBase = typeof colRaw === 'string' && /^#[0-9a-fA-F]{6}$/.test(colRaw.trim()) ? colRaw.trim() : '#ffb44a'; + const pulse = 0.55 + 0.45 * Math.sin((timeMs || 0) / 180); + ctx.save(); + function pathRr() { + ctx.beginPath(); + if (typeof ctx.roundRect === 'function') ctx.roundRect(rx, ry, rw, rh, rr); + else ctx.rect(rx, ry, rw, rh); + } + pathRr(); + ctx.fillStyle = strokeBase; + ctx.globalAlpha = 0.12 * pulse; + ctx.fill(); + for (let g = 6; g >= 1; g--) { + ctx.strokeStyle = strokeBase; + ctx.globalAlpha = (0.07 + g * 0.055) * pulse; + ctx.lineWidth = 2.5 + g * 4; + pathRr(); + ctx.stroke(); + } + ctx.globalAlpha = 0.98; + ctx.strokeStyle = strokeBase; + ctx.lineWidth = 4; + ctx.shadowColor = strokeBase; + ctx.shadowBlur = 22 * pulse; + pathRr(); + ctx.stroke(); + ctx.shadowBlur = 12 * pulse; + pathRr(); + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.globalAlpha = 1; + ctx.restore(); + } + + function drawQuizCarryChoiceLabels(ctx, worldToScreen, zoom) { + if (!isQuizCarry() || !mapData || !quizCarryCurrent) return; + if (!quizCarryOptionsPickableNow()) return; + const choices = quizCarryCurrent.choices; + if (!choices || !choices.length) return; + const maxSlots = choices.length; + const ts = tileSize * zoom; + const ps = quizCarryPlaqueMapScaleClampedPlay(); + ctx.save(); + for (let i = 0; i < Math.min(choices.length, maxSlots); i++) { + if (quizCarryOptionHeldByAnyone(i)) continue; + const b = getQuizCarryOptionClusterBounds(mapData, i + 1); + if (!b) continue; + const choiceText = String(choices[i] || '').trim(); + const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, i); + const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i); + const plaqueExtra = imgUrl ? { imageUrl: imgUrl } + : (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {}); + if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) continue; + const tileSpanX = (b.maxX - b.minX + 1); + const tileSpanY = (b.maxY - b.minY + 1); + const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps); + const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps); + if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) { + const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps)); + const lineH = fontPx * 1.2; + const padY = 8; + const minW = minPlaqueW; + const minH = minPlaqueH; + const wx = b.cx * tileSize; + const wy = b.cy * tileSize; + const [sx, sy] = worldToScreen(wx, wy); + drawQuizCarryNeonPlaque(ctx, sx - minW / 2, sy - minH / 2, minW, minH, [], lineH, padY, i, null, plaqueExtra); + continue; + } + const line = choiceText; + let fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps)); + let maxW = Math.max(96, tileSpanX * ts * 1.5 - 14); + ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; + /* คำตอบไทยมักเป็น "คำเดียวยาว" (ไม่มีช่องว่าง → wrap ไม่ได้) → ย่อฟอนต์ให้พอดีกว้าง + ปล่อยป้ายกว้างได้ กันโดนตัด "…" */ + while (fontPx > 8 && ctx.measureText(line).width > maxW) { + fontPx -= 1; + ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; + } + const lineH = fontPx * 1.2; + let textLines = canvasWordWrapLines(ctx, line, maxW); + if (!textLines.length) textLines = [line]; + if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) { + const fp2 = Math.max(9, fontPx * 0.85); + ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; + const lh2 = fp2 * 1.2; + maxW = Math.max(48, maxW - 4); + textLines = canvasWordWrapLines(ctx, line, maxW); + if (textLines.length * lh2 > tileSpanY * ts - 8) { + textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2))); + } + for (let ti = 0; ti < textLines.length; ti++) { + let s = textLines[ti]; + if (ctx.measureText(s).width <= maxW) continue; + while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); + textLines[ti] = s + '…'; + } + const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40); + const padX = 10; + const padY = 8; + let w = Math.ceil(maxLineW + padX * 2); + let h = Math.ceil(textLines.length * lh2 + padY * 2); + if (imgUrl || plaqueExtra.imageElement) { + w = Math.max(w, minPlaqueW); + h = Math.max(h, minPlaqueH); + } + const wx = b.cx * tileSize; + const wy = b.cy * tileSize; + const [sx, sy] = worldToScreen(wx, wy); + const signX = sx - w / 2; + const signY = sy - h / 2; + drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lh2, padY, i, null, plaqueExtra); + continue; + } + for (let ti = 0; ti < textLines.length; ti++) { + let s = textLines[ti]; + if (ctx.measureText(s).width <= maxW) continue; + while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); + textLines[ti] = s + '…'; + } + let maxLineW = 0; + for (let ti = 0; ti < textLines.length; ti++) { + const lw = ctx.measureText(textLines[ti]).width; + if (lw > maxLineW) maxLineW = lw; + } + const padX = 10; + const padY = 8; + let w = Math.ceil(Math.max(maxLineW, 52) + padX * 2); + let h = Math.ceil(textLines.length * lineH + padY * 2); + if (imgUrl || plaqueExtra.imageElement) { + w = Math.max(w, minPlaqueW); + h = Math.max(h, minPlaqueH); + } + const wx = b.cx * tileSize; + const wy = b.cy * tileSize; + const [sx, sy] = worldToScreen(wx, wy); + const signX = sx - w / 2; + const signY = sy - h / 2; + drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lineH, padY, i, null, plaqueExtra); + } + ctx.restore(); + } + + function quizCarryHubCellPlay(md, tx, ty) { + const g = md && md.quizCarryHubArea; + return !!(g && g[ty] && g[ty][tx] === 1); + } + + function quizCarryInteractiveCellPlay(md, tx, ty) { + const g = md && md.interactive; + return !!(g && g[ty] && g[ty][tx] === 1); + } + + function quizCarryMapHasAnswerInteractive(md) { + if (!md || !md.interactive || !md.interactive.length) return false; + const h = md.height || 15, w = md.width || 20; + for (let y = 0; y < h; y++) { + const row = md.interactive[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (row[x] === 1) return true; + } + } + return false; + } + + /** @returns {number|null} index ใน choices จากช่องหมายเลข 1..N บนแมป */ + function quizCarryOptionIndexAtPlay(md, tx, ty) { + const g = md && md.quizCarryOptionArea; + if (!g || !g[ty]) return null; + const v = g[ty][tx]; + if (v < 1 || v > QUIZ_CARRY_MAX_OPTION_SLOTS) return null; + const idx = v - 1; + const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0; + if (n < 1 || idx >= n) return null; + return idx; + } + + /** ร่างตัวละครทับช่องโซนกลาง (ม่วง) — ใช้บล็อกการเดิน */ + function quizCarryFootprintOverlapsHub(px, py) { + if (!mapData || !isQuizCarry()) return false; + const md = mapData; + for (const k of quizTilesFootprintPlay(px, py)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (quizCarryHubCellPlay(md, tx, ty)) return true; + } + return false; + } + + /** ยืนข้างนอกแต่ชิดโซนกลาง — ใช้ให้กด F ส่งคำตอบได้โดยไม่ต้องย่ำบนม่วง */ + function quizCarryFootprintAdjacentToHub(md, px, py) { + if (!md) return false; + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + for (const k of quizTilesFootprintPlay(px, py)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (quizCarryHubCellPlay(md, tx, ty)) continue; + for (let di = 0; di < dirs.length; di++) { + const nx = tx + dirs[di][0], ny = ty + dirs[di][1]; + if (quizCarryHubCellPlay(md, nx, ny)) return true; + } + } + return false; + } + + function quizCarryCanSubmitAtHub(md, px, py) { + if (!md) return false; + return quizCarryFootprintOverlapsHub(px, py) || quizCarryFootprintAdjacentToHub(md, px, py); + } + + function quizCarryFootprintOverlapsInteractive(md, px, py) { + if (!md) return false; + for (const k of quizTilesFootprintPlay(px, py)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (quizCarryInteractiveCellPlay(md, tx, ty)) return true; + } + return false; + } + + function quizCarryFootprintAdjacentToInteractive(md, px, py) { + if (!md) return false; + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + for (const k of quizTilesFootprintPlay(px, py)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (quizCarryInteractiveCellPlay(md, tx, ty)) continue; + for (let di = 0; di < dirs.length; di++) { + const nx = tx + dirs[di][0], ny = ty + dirs[di][1]; + if (quizCarryInteractiveCellPlay(md, nx, ny)) return true; + } + } + return false; + } + + function quizCarryCanSubmitAtInteractiveZone(md, px, py) { + if (!md) return false; + return quizCarryFootprintOverlapsInteractive(md, px, py) || quizCarryFootprintAdjacentToInteractive(md, px, py); + } + + /** ส่งคำตอบ: ถ้ามีโซน interactive (เขียว) ในแมป ใช้โซนนั้น — ไม่มี fallback ชิดโซนกลาง */ + function quizCarryCanSubmitAnswerAt(md, px, py) { + if (!md) return false; + if (quizCarryMapHasAnswerInteractive(md)) { + return quizCarryCanSubmitAtInteractiveZone(md, px, py); + } + return quizCarryCanSubmitAtHub(md, px, py); + } + + /** ข้อความใน HUD / โซนคำถาม — ไม่รวมตัวเลือก (แสดงบนแผนที่ตามจุดสี) */ + function formatQuizCarryQuestionHud(q) { + if (!q) return ''; + return String(q.text || q.question || q.prompt || q.title || '').trim(); + } + + function quizCarryRestoreEmbedPhaseLabel() { + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (!phaseEl || !mapData) return; + phaseEl.textContent = quizCarryMapHasAnswerInteractive(mapData) + ? 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งที่โซน interactive (เขียว) — เล่นพร้อมกันได้' + : 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งเมื่อชิดโซนกลาง (ม่วงเป็นกำแพง) — เล่นพร้อมกันได้'; + } + + function isQuizCarryEmbedCountdownBlockingMovement() { + if (!quizCarryEmbedMissionFlowActive()) return false; + const now = Date.now(); + return (quizCarryEmbedCountdownEndAt > now) || (quizCarryEmbedPreOptionCountdownEndAt > now); + } + + function isQuizCarryEmbedLobbyBlockingMovement() { + return !!(quizCarryEmbedMissionFlowActive() && quizCarryPregameActive); + } + + /* ผู้ถูกแบนรอบนี้ (Card 3 Ban) เข้ามาดูเฉยๆ ไม่ต้อง Ready → ไม่นับใน gate เริ่มเกม + (ไม่งั้น host จะค้าง N/N+1 เพราะ banned ไม่เคย ready) */ + function isQuizCarryPregameBannedId(id) { + const o = others.get(id) || others.get(String(id)); + return !!(o && o.bannedSpectator); + } + + function quizCarryPregameHumanIds() { + const set = new Set(); + if (myId != null && !isPreviewBotId(myId) && !playBannedSpectator) set.add(String(myId)); + /* นับจาก readyMap ของ server (authoritative + ตัด banned แล้ว) + ตัวเราเท่านั้น + → host/client นับ "จำนวนผู้เล่น" ตรงกันเสมอ (เดิมวน others ซึ่ง membership ต่างกันชั่วคราว → ready ไม่ตรง) + การรอให้ client เข้าครบใช้ expectedActive (gauntletCrownPregameExpectedHumans) แทน */ + [quizCarryLobbyReadyMap, gauntletCrownLobbyReadyMap].forEach((rm) => { + if (rm && typeof rm === 'object') { + Object.keys(rm).forEach((id) => { + if (id && !isPreviewBotId(id) && !isQuizCarryPregameBannedId(id)) set.add(String(id)); + }); + } + }); + return [...set]; + } + + function quizCarryPregameBotCount() { + let n = 0; + others.forEach((_, id) => { + if (isPreviewBotId(id)) n++; + }); + return n; + } + + function quizCarryPregameReadyNumerator() { + let n = quizCarryPregameBotCount(); + quizCarryPregameHumanIds().forEach((id) => { + if (quizCarryLobbyReadyMap[id]) n++; + }); + return n; + } + + function quizCarryPregameTotalPlayers() { + return quizCarryPregameHumanIds().length + quizCarryPregameBotCount(); + } + + function isMePlayHost() { + if (myId == null) return false; + if (playHostId == null) return true; /* fallback (load-bearing): host พึ่งตอน null — อย่าเปลี่ยน (เคยทำ MG3 ready ค้าง 4/5 + MG2 spawn ทับ) */ + return String(playHostId) === String(myId); + } + + /* บอทเติมช่อง (fill-bot) ใน 7 มินิเกมต้องเดิน/เล่น/คะแนนตรงกันทุก client: + host เป็นผู้จำลอง bot ทั้งหมด (AI/ฟิสิกส์/คะแนน) แล้ว broadcast state — client อื่นปิด local sim แล้ว render ตาม + (เดิม: ทุกเครื่องสุ่ม Math.random() แยกกัน → บอทตัวเดียวกันอยู่คนละที่/คนละคะแนนบนแต่ละจอ) */ + function fbNum(v) { return (typeof v === 'number' && Number.isFinite(v)) ? Math.round(v * 100) / 100 : undefined; } + function broadcastFillBotState() { + if (!playBotsEnabled() || !isMePlayHost() || !mapData) return; + if (isQuiz() && detectiveCaseFillBots) return; /* quiz เกมจริง: server เป็นเจ้าของบอท (quiz-bot-move) → ห้าม host ส่งทับ = บอทวาป/ผีหลุด area */ + const arr = []; + others.forEach((o, id) => { + if (!isPreviewBotId(id) || !o) return; + const row = { id: id, x: fbNum(o.x), y: fbNum(o.y), direction: o.direction || 'down', walking: !!o.botIsWalking }; + if (o.characterId != null) row.cid = o.characterId; /* ให้ client สร้างบอทที่ขาดได้ → ชุดบอท host-authoritative */ + if (o.botTier != null) row.tier = o.botTier; /* tier (ฉลาด/กลาง/พลาดบ่อย) — host เป็นเจ้าของ ให้ label/พฤติกรรมตรงกันทุกเครื่อง (เดิมสุ่ม local → ป้ายไม่ตรง) */ + if (o.gauntletScore != null) row.gScore = o.gauntletScore; + if (o.gauntletEliminated != null) row.gElim = !!o.gauntletEliminated; + if (o.gauntletJumpTicks != null) row.gJump = o.gauntletJumpTicks; + if (o.gauntletJumpVis != null) row.gJumpVis = fbNum(o.gauntletJumpVis); + if (o.jumpSurviveEliminated != null) row.jsElim = !!o.jumpSurviveEliminated; + if (o.spaceShooterCx != null) row.ssCx = fbNum(o.spaceShooterCx); + if (o.spaceShooterCy != null) row.ssCy = fbNum(o.spaceShooterCy); + if (o.spaceShooterScore != null) row.ssScore = o.spaceShooterScore; + if (o.spaceShooterEliminated != null) row.ssElim = !!o.spaceShooterEliminated; + if (o.balloonBossCx != null) row.bbCx = fbNum(o.balloonBossCx); + if (o.balloonBossCy != null) row.bbCy = fbNum(o.balloonBossCy); + if (o.balloonBossScore != null) row.bbScore = o.balloonBossScore; + if (o.balloonBossBossDmg != null) row.bbDmg = o.balloonBossBossDmg; + if (o.balloonBossBalloons != null) row.bbBalloons = o.balloonBossBalloons; + if (o.balloonBossEliminated != null) row.bbElim = !!o.balloonBossEliminated; + if (o.quizCarryHeld !== undefined) row.qcHeld = o.quizCarryHeld; + /* MG1 quiz: สถานะล็อก (ตอบผิด=ผี) + คะแนนบอท → host เป็นเจ้าของ ส่งให้ทุกเครื่องตรงกัน + (เดิมไม่ส่งเลย → non-host เห็นบอทล็อก/respawn/คะแนนคนละแบบ) */ + if (isQuiz()) { + if (o.quizCannotTrue != null) row.qct = !!o.quizCannotTrue; + if (o.quizCannotFalse != null) row.qcf = !!o.quizCannotFalse; + } + /* คะแนนบอท MG1 (quiz) + MG4 (quiz_carry) → host เป็นเจ้าของ ส่งให้ทุกเครื่องตรงกัน + (เดิม MG4 ไม่ส่ง → non-host sim บอทเอง ด้วย RNG ต่างกัน = คะแนนบอทไม่ตรง 2 จอ) */ + if (isQuiz() || isQuizCarry()) { + const qsc = playLiveQuizScores[id]; + if (qsc != null) row.qsc = Math.max(0, qsc | 0); + } + /* สีบอท (host เป็นเจ้าของ) — แนบไปด้วยเพื่อให้ทุก client แสดงสีตรงกัน + (เดิมแต่ละเครื่องคำนวณสีเองจาก theme/sessionStorage → สีบอทไม่ตรงกันเมื่อ sync ธีมมาช้า) */ + if (o.playTint && o.playTint.body) { + row.tint = { head: o.playTint.head, hair: o.playTint.hair, body: o.playTint.body }; + } + arr.push(row); + }); + /* รายชื่อ "คนจริง" ที่ host เห็น (= myId(host) + others ที่ไม่ใช่บอท) — ให้ non-host force ลบผีที่ host ไม่มี + (humans ไม่ถูก sync แบบบอท → ผี reconnect ค้างบน client; id-based ไม่ชนชื่อ default) */ + const humanRoster = []; + if (myId != null) humanRoster.push(String(myId)); + others.forEach((o, id) => { if (!isPreviewBotId(id) && o) humanRoster.push(String(id)); }); + if (arr.length) socket.emit('fill-bot-state', { bots: arr, humans: humanRoster }); + } + setInterval(broadcastFillBotState, 80); /* ~12.5Hz — self-gate ถ้าไม่ใช่ host หรือไม่มีบอท */ + + /* ===== Server-time sync + เวลาเริ่มเล่นจริงร่วมกัน (liveBeginsAt) ===== + แก้ปัญหา "เวลาในเกมไม่เท่ากัน เริ่ม/จบไม่พร้อมกัน": เดิมแต่ละเครื่องนับ 3-2-1 เองด้วย setTimeout + และจับเวลารอบด้วย performance.now() ของตัวเอง → GO และจุดจบไม่ตรงกัน + แก้: sync นาฬิกากับ server (serverNow) แล้วทุกคนนับไปหา "liveBeginsAt" (server epoch) จุดเดียวกัน + เมื่อ GO พร้อมกัน → นาฬิการอบ (performance.now ตั้งแต่ GO) ก็ sync กันเองอัตโนมัติ */ + let playClockOffset = 0; /* serverTime - clientTime (ms) */ + let playClockBestRtt = null; + let playClockSyncInterval = null; + function serverNow() { return Date.now() + playClockOffset; } + function syncPlayClock(round) { + round = round || 0; + if (!socket || !socket.connected) return; + /* เริ่ม burst ใหม่ทุกครั้ง → รีเซ็ต best RTT เพื่อ "re-establish" offset ใหม่ทั้งหมด + ไม่งั้น best RTT ที่ค้างไว้จะทำให้ re-sync ครั้งหลังถูกข้าม แก้ clock drift ไม่ได้ + (เวลาในเกม MG6/MG7 จะเพี้ยนสะสมเพราะ serverNow ของแต่ละเครื่องไม่ตรงกัน) */ + if (round === 0) playClockBestRtt = null; + const t0 = Date.now(); + socket.emit('time-sync', (res) => { + const t1 = Date.now(); + if (res && Number.isFinite(res.t)) { + const rtt = t1 - t0; + if (playClockBestRtt == null || rtt <= playClockBestRtt) { + playClockBestRtt = rtt; + playClockOffset = res.t + rtt / 2 - t1; /* ชดเชยครึ่งหนึ่งของ round-trip latency */ + } + } + if (round < 5) setTimeout(function () { syncPlayClock(round + 1); }, 300); + }); + } + /* re-sync นาฬิกาเป็นระยะ → กัน clock drift ระหว่างเล่นยาว ๆ ทำให้ serverNow ทุกเครื่องตรงกันเสมอ + (ฐานเวลาเดียวกัน → timer/boss/aim/spawn ของทุก minigame sync กัน) */ + function startPeriodicClockSync() { + if (playClockSyncInterval) return; + playClockSyncInterval = setInterval(function () { + try { if (socket && socket.connected) syncPlayClock(0); } catch (_e) { /* ignore */ } + }, 12000); + } + + /* ─────────────────────────────────────────────────────────────────────── + ตัวบอกสถานะเน็ต (เพิ่มล้วน — ไม่แตะ game logic): ป้าย "เน็ตหน่วง"/"กำลังเชื่อมต่อใหม่" + แสดงมุมขวาบน. วัด RTT เองด้วย time-sync (server มี handler อยู่แล้ว ไม่ต้องแก้ server) + ───────────────────────────────────────────────────────────────────────── */ + const NET_RTT_WARN_MS = 320; /* เกินนี้ = เน็ตหน่วง (เหลือง) */ + const NET_RTT_BAD_MS = 700; /* เกินนี้ = แย่มาก (แดง) */ + let netStatusEl = null; + let netLastRtt = null; + let netReconnecting = false; + let netPingTimer = null; + let netOkHideTimer = null; + function ensureNetStatusEl() { + if (netStatusEl) return netStatusEl; + const css = document.createElement('style'); + css.textContent = '' + + '#play-net-status{position:fixed;top:10px;right:12px;z-index:9400;display:none;align-items:center;gap:7px;' + + 'padding:7px 13px;border-radius:11px;font-family:inherit;font-weight:700;font-size:14px;line-height:1;' + + 'color:#fff;box-shadow:0 4px 16px rgba(0,0,0,.42);pointer-events:none;backdrop-filter:blur(2px);' + + 'transition:opacity .25s;letter-spacing:.2px;}' + + '#play-net-status.is-warn{background:linear-gradient(135deg,#c9831a,#e0a52b);}' + + '#play-net-status.is-bad{background:linear-gradient(135deg,#b3231d,#d84038);}' + + '#play-net-status.is-ok{background:linear-gradient(135deg,#1b9e4b,#13c06a);}' + + '#play-net-status .pns-dot{width:9px;height:9px;border-radius:50%;background:#fff;opacity:.92;}' + + '#play-net-status.is-bad .pns-spin{width:13px;height:13px;border:2px solid rgba(255,255,255,.4);' + + 'border-top-color:#fff;border-radius:50%;animation:pnsSpin .8s linear infinite;}' + + '@keyframes pnsSpin{to{transform:rotate(360deg);}}'; + document.head.appendChild(css); + const el = document.createElement('div'); + el.id = 'play-net-status'; + el.innerHTML = ''; + document.body.appendChild(el); + netStatusEl = el; + return el; + } + function setNetStatus(kind, text, withSpin) { + const el = ensureNetStatusEl(); + if (netOkHideTimer) { clearTimeout(netOkHideTimer); netOkHideTimer = null; } + if (kind === 'hidden') { el.style.display = 'none'; return; } + el.className = 'is-' + kind; + const icon = el.querySelector('.pns-icon'); + if (icon) icon.innerHTML = withSpin ? '' : ''; + const t = el.querySelector('.pns-text'); + if (t) t.textContent = text; + el.style.display = 'flex'; + el.style.opacity = '1'; + if (kind === 'ok') { + netOkHideTimer = setTimeout(function () { el.style.opacity = '0'; setTimeout(function () { if (netStatusEl) netStatusEl.style.display = 'none'; }, 280); }, 1600); + } + } + function refreshNetStatus() { + if (netReconnecting || (socket && socket.connected === false)) { + setNetStatus('bad', 'กำลังเชื่อมต่อใหม่…', true); + return; + } + if (netLastRtt != null && netLastRtt >= NET_RTT_BAD_MS) { + setNetStatus('bad', 'เน็ตหน่วงมาก (' + Math.round(netLastRtt) + ' ms)', false); + } else if (netLastRtt != null && netLastRtt >= NET_RTT_WARN_MS) { + setNetStatus('warn', 'เน็ตหน่วง (' + Math.round(netLastRtt) + ' ms)', false); + } else { + setNetStatus('hidden'); + } + } + function netPingOnce() { + if (!socket || !socket.connected) return; + const t0 = Date.now(); + let done = false; + try { + socket.emit('time-sync', function () { + if (done) return; done = true; + netLastRtt = Date.now() - t0; + refreshNetStatus(); + }); + } catch (_e) { /* ignore */ } + setTimeout(function () { if (!done) { done = true; netLastRtt = Math.max(netLastRtt || 0, NET_RTT_BAD_MS); refreshNetStatus(); } }, 2500); + } + function setupNetStatusIndicator() { + if (netPingTimer) return; + try { + socket.on('disconnect', function () { netReconnecting = true; refreshNetStatus(); }); + socket.on('connect', function () { + if (netReconnecting) { netReconnecting = false; setNetStatus('ok', 'เชื่อมต่อแล้ว', false); } + else { netReconnecting = false; } + netPingOnce(); + }); + if (socket.io && socket.io.on) { + socket.io.on('reconnect_attempt', function () { netReconnecting = true; refreshNetStatus(); }); + socket.io.on('reconnect', function () { netReconnecting = false; setNetStatus('ok', 'เชื่อมต่อแล้ว', false); }); + } + } catch (_e) { /* ignore */ } + netPingTimer = setInterval(netPingOnce, 4000); + netPingOnce(); + } + + /* #20 แสดงเลข version มุมขวาล่าง — อ่านจาก ?v= ของ - +
v —

Justice — Test Case / Bug Listรายการบัก + ผลตรวจ QA · จัดหมวด · srv1361159.hstgr.cloud/testcase

+
diff --git a/www/html/testcase/test.html b/www/html/testcase/test.html new file mode 100644 index 0000000..6f03c56 --- /dev/null +++ b/www/html/testcase/test.html @@ -0,0 +1,242 @@ + + + +Justice — Test Console (เทสมินิเกม + Disconnect) + + + +
+ +

Justice — Test Console + เครื่องมือเทสมินิเกม + ระบบ Disconnect แบบกดปุ่มเดียว · ใช้ flow เดียวกับเกมจริง (สร้างห้อง /Game/api/spaces) +

+ +
+ ← กลับ Bug List + + Test Mode (debug) + +
+ +
+ มินิเกมเริ่มได้ 2 ทาง — เลือกให้ตรงกับสิ่งที่จะเทส:
+ • โหมด A พรีวิว = เปิดมินิเกมเดี่ยวพร้อมบอททันที (บอทจำลองฝั่ง host) → เหมาะเช็ค ภาพ / texture / gameplay / UI เร็วๆ ทุกเกมใช้ได้
+ • โหมด B ห้องจริง LobbyA = flow จริง host→case→มินิเกม (บอทจาก server) → เป็นทางเดียวที่เทส Server-Authority + Disconnect/Takeover ได้จริง +
+ + +

โหมด A · พรีวิวมินิเกม (เช็คภาพ/gameplay) บอทฝั่ง host

+
+ + +

โหมด B · เทส Server-Authority + Disconnect (ห้องจริง) บอทฝั่ง server

+
+
+ + + เปิดหลายแท็บ = จำลองหลายคน (ทดสอบ disconnect) +
+
+ + + คน + บอท ≤ 6 (LobbyA cap) +
+
+ + +
+
+ + +
    +
  1. กด สร้างห้อง → เปิดแท็บ LobbyA ตามจำนวนผู้เล่น (อนุญาต popup ถ้าเบราว์เซอร์ถาม)
  2. +
  3. ในแท็บ host เดินตัวละครไป โซนเริ่มเกมสีส้ม → เกมจะสุ่ม case แล้วเข้ามินิเกม (บอทมาจาก server)
  4. +
  5. ระหว่างเล่นมินิเกม ปิดแท็บผู้เล่น 1 แท็บ เพื่อจำลอง disconnect → สังเกตว่าตัวนั้นกลายเป็น "(บอท)" เล่นต่อโดย server ไม่ค้าง/ไม่หาย
  6. +
  7. เปิดแท็บนั้นใหม่ (ลิงก์เดิม) ภายในเวลาที่กำหนด → ผู้เล่นควร กลับเข้ามาแทนบอท ตำแหน่ง/คะแนนเดิม
  8. +
+
+ + +

เช็กลิสต์ที่ต้องผ่านทุกเกม (Server-Authority + Disconnect)

+
+
    +
  • บอทเดินจาก server: ปิดแท็บ host แล้วบอทยัง เคลื่อนไหว/ทำคะแนนต่อ (ไม่ใช่ host จำลอง)
  • +
  • Host หลุด ≠ เกมหยุด: host ออกกลางเกม เกมยังดำเนินต่อสำหรับคนอื่น
  • +
  • Takeover: คนหลุด → กลายเป็นบอทชื่อ (บอท) สี/ตำแหน่งเดิม ไม่หายวับ
  • +
  • Reconnect: เปิดลิงก์เดิมกลับมา → แทนที่บอท takeover (ไม่เกิดตัวซ้ำ)
  • +
  • คะแนน/เกรด: สรุปผลรวมบอท ตรงกันทุกจอ (server เป็นแหล่งเดียว)
  • +
  • ไม่กระทบกันข้ามเกม: เล่นเกมอื่นต่อในห้องเดิม สถานะเกมก่อนหน้าไม่รั่ว
  • +
+
+ +
+ สถานะ: MG1 quiz · MG2 gauntlet = server-auth แล้ว · + MG3–MG7 = กำลังทยอยย้าย · เครื่องมือนี้ไม่แก้ logic เกม (เปิดห้องผ่าน API จริงเท่านั้น) +
+ +
+ +