From a474f0e7363b37994fac01419e6d6951f91705ea Mon Sep 17 00:00:00 2001 From: giteaadmin Date: Sun, 21 Jun 2026 09:46:01 +0000 Subject: [PATCH] fixed game synce --- www/html/Admin/private/store.json | 49 ++++- www/html/Game/public/js/play.js | 335 +++++++++++++++++++++++------- www/html/Game/public/play.html | 2 +- www/html/Game/server.js | 43 +++- 4 files changed, 344 insertions(+), 85 deletions(-) diff --git a/www/html/Admin/private/store.json b/www/html/Admin/private/store.json index d7e46d4..ce7bf4d 100644 --- a/www/html/Admin/private/store.json +++ b/www/html/Admin/private/store.json @@ -111,20 +111,21 @@ "providerUserId": "p_1777015659667_xqfjh247bgp", "notes": "auto: player-coins", "blocked": false, - "coins": 330, + "coins": 348, "createdAt": "2026-04-24T07:28:02+00:00", - "updatedAt": "2026-06-20T17:54:37+00:00", - "score": 330, + "updatedAt": "2026-06-21T09:15:13+00:00", + "score": 348, "scoreByCase": { "8": 220, "5": 30, "7": 60, "2": 10, - "9": 10 + "9": 10, + "10": 18 }, "lbName": "MONE", "achievements": { - "d1_minigame_solver": 2 + "d1_minigame_solver": 4 }, "lobbyColorThemeIndex": 6, "lobbySkinToneIndex": 1 @@ -535,19 +536,24 @@ { "id": "80f31f73da0328dbf24e0206", "email": "", - "displayName": "Guest", + "displayName": "ผู้เล่น", "loginType": "guest", "providerUserId": "p_1781862203558_jhynnz90qrg", "notes": "auto: player-coins", "blocked": false, - "coins": 0, + "coins": 18, "createdAt": "2026-06-19T09:43:23+00:00", - "updatedAt": "2026-06-20T17:27:35+00:00", + "updatedAt": "2026-06-21T09:15:13+00:00", "lobbyColorThemeIndex": 6, "lobbySkinToneIndex": 1, "achievements": { - "d1_minigame_solver": 1 - } + "d1_minigame_solver": 2 + }, + "score": 18, + "scoreByCase": { + "10": 18 + }, + "lbName": "ผู้เล่น" }, { "id": "fbb262e6c2aa47bd97aef554", @@ -562,6 +568,29 @@ "updatedAt": "2026-06-20T06:58:53+00:00", "lobbyColorThemeIndex": 7, "lobbySkinToneIndex": 1 + }, + { + "id": "8f0741532038e900e759c5ef", + "email": "", + "displayName": "MONE", + "loginType": "guest", + "providerUserId": "p_1782016990238_0u37bv7pxlxs", + "notes": "auto: player-coins", + "blocked": false, + "coins": 20, + "createdAt": "2026-06-21T04:43:11+00:00", + "updatedAt": "2026-06-21T05:01:35+00:00", + "lobbyColorThemeIndex": 8, + "lobbySkinToneIndex": 1, + "score": 20, + "scoreByCase": { + "13": 10, + "10": 10 + }, + "lbName": "ผู้เล่น", + "achievements": { + "d1_minigame_solver": 2 + } } ] } \ No newline at end of file diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index 6a5dec5..b5cebef 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -145,6 +145,22 @@ const SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL = 48; const SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP = 11; + /* PRNG แบบ seed ได้ (mulberry32) — ใช้สุ่มสิ่งแวดล้อมมินิเกมแบบ deterministic + seed เดียวกัน (อิง liveBeginsAt ของ server) → ทุกเครื่องได้ลำดับสุ่มเดียวกัน = อุกาบาต/บอลลูนชุดเดียวกัน */ + function makeSeededRng(seed) { + let t = (seed >>> 0) || 1; + return function () { + t = (t + 0x6D2B79F5) >>> 0; + let r = Math.imul(t ^ (t >>> 15), 1 | t); + r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; + return ((r ^ (r >>> 14)) >>> 0) / 4294967296; + }; + } + /* seed ต่อ index ของ spawn — รวม anchor (liveBeginsAt) กับ index ให้ก้อนแต่ละลำดับสุ่มต่างกันแต่ตรงกันทุกเครื่อง */ + function envSpawnSeed(anchorMs, index) { + return (((anchorMs >>> 0) ^ Math.imul((index | 0) + 1, 2654435761)) >>> 0); + } + function spaceShooterAsteroidRadiusFromHpPlay(hp) { const cap = SPACE_SHOOTER_ASTEROID_MAX_HP; const h = Math.max(0, Math.min(cap, Math.floor(Number(hp)) || 0)); @@ -3428,6 +3444,9 @@ 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; @@ -3447,6 +3466,9 @@ 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; @@ -3541,6 +3563,9 @@ /** Mega Virus — ป๊อปดาเมจต่อบอส (ข้อความ +2) เมื่อกระสุนโดนบอส */ let balloonBossScorePopups = []; let balloonBossBossFireAcc = 0; + /* anchor เวลา server + ตัวนับกระสุนบอส — deterministic boss fire ให้ทุกเครื่องยิงทิศ/เวลาเดียวกัน */ + let balloonBossLiveAnchorServerMs = 0; + let balloonBossBossShotCount = 0; /** quiz_battle — โดม MCQ กด E (ข้อจาก battleQuizMcq) */ let quizBattleMcqPool = []; let quizBattleAnsweredComps = new Set(); @@ -7092,7 +7117,10 @@ if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveSessionStartMs <= 0) return null; const lim = jumpSurviveTimeLimitSecForMission(); if (!(lim > 0)) return null; - const elapsed = (performance.now() - jumpSurviveSessionStartMs) / 1000; + /* นับเวลาจาก 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)); } @@ -7182,6 +7210,9 @@ jumpSurviveMissionPhase = 'live'; jumpSurviveGameEnded = false; jumpSurviveEliminated = false; + /* anchor เวลาเริ่ม live = liveBeginsAt ของ server (ถ้ามี) → scroll/timer ของทุกเครื่องอ้างอิงจุดเดียวกัน */ + jumpSurviveLiveAnchorServerMs = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : serverNow(); + jumpSurvivePlatformScrollPx = 0; applyJumpSurvivePreviewSpawnLayout(false); }; if (!cd || !numEl) { @@ -7564,6 +7595,9 @@ 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; @@ -8967,12 +9001,19 @@ } function quizCarryPregameHumanIds() { - const ids = []; - if (myId != null && !isPreviewBotId(myId)) ids.push(String(myId)); + const set = new Set(); + if (myId != null && !isPreviewBotId(myId)) set.add(String(myId)); others.forEach((_, id) => { - if (!isPreviewBotId(id)) ids.push(String(id)); + if (!isPreviewBotId(id)) set.add(String(id)); }); - return ids; + /* รวม id จาก readyMap ของ server ด้วย — กันกรณี host เข้า pregame ก่อน client reconnect (others ยังว่าง) + ทำให้ host นับ/รอ client ครบจริง ไม่เริ่มคนเดียว และเห็นจำนวนผู้เล่นถูกต้อง */ + if (quizCarryLobbyReadyMap && typeof quizCarryLobbyReadyMap === 'object') { + Object.keys(quizCarryLobbyReadyMap).forEach((id) => { + if (id && !isPreviewBotId(id)) set.add(String(id)); + }); + } + return [...set]; } function quizCarryPregameBotCount() { @@ -9126,14 +9167,16 @@ primaryImg.alt = humansReady ? 'START' : 'READY'; } if (primary) { + /* READY phase: ทุกคน (รวม client) กดสลับ Ready ของตัวเองได้ | START phase: เฉพาะ host กดเริ่ม */ + const canPress = humansReady ? isMePlayHost() : true; primary.classList.toggle('is-start-phase', humansReady); - primary.classList.toggle('is-read-only', !isMePlayHost()); - primary.classList.toggle('is-pressed', isMePlayHost() && meReady && !humansReady); - primary.disabled = !isMePlayHost(); + primary.classList.toggle('is-read-only', !canPress); + primary.classList.toggle('is-pressed', meReady && !humansReady); + primary.disabled = !canPress; primary.setAttribute('aria-pressed', humansReady ? 'false' : (meReady ? 'true' : 'false')); - primary.title = isMePlayHost() - ? (humansReady ? 'START' : (meReady ? 'ยกเลิก READY' : 'READY')) - : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); + primary.title = humansReady + ? (isMePlayHost() ? 'START' : 'รอโฮสต์กด START') + : (meReady ? 'ยกเลิก READY' : 'READY'); } } @@ -9143,8 +9186,8 @@ quizCarryLobbyReadyMap = {}; quizCarryPregameHumanIds().forEach((id) => { quizCarryLobbyReadyMap[id] = false; }); showQuizCarryPregameOverlay(); + /* ขอสถานะ ready ปัจจุบันจาก server (authoritative) — ทุกคนรวม host ต้องกด READY เองตาม flow */ if (socket && socket.connected) socket.emit('quiz-carry-lobby-sync-request'); - quizCarrySyncGuestReadyIfNeeded(); updateQuizCarryPregameHud(); } @@ -9361,6 +9404,43 @@ syncQuizCarryEmbedQuestionStrip(); } + /* networked mission (quiz_carry detective): เฉพาะ host เลือกคำถาม + broadcast ให้คนอื่น + กันแต่ละเครื่องสุ่มคนละข้อ (เดิม client สุ่ม Math.random() เอง → host/client เห็นคนละคำถาม) */ + function quizCarryIsNetworkedMission() { + return quizCarryEmbedMissionFlowActive() && socket && socket.connected; + } + + /* ตั้งคำถามถัดไป (embed/detective flow) จาก q ที่ "เลือกแล้ว" — ใช้ได้ทั้ง host (หลังสุ่ม) และ client (รับ broadcast) */ + function applyQuizCarryEmbedNextQuestion(q) { + if (!q) return; + quizCarryEmbedPendingQuestion = q; + preloadQuizCarryChoiceImages(q); + quizCarryEmbedCountdownStartAt = Date.now(); + quizCarryEmbedCountdownEndAt = quizCarryEmbedCountdownStartAt + 3000; + quizCarryEmbedPreOptionCountdownStartAt = 0; + quizCarryEmbedPreOptionCountdownEndAt = 0; + quizCarryCurrent = null; + playQuizText = ''; + clearQuizMapQuestionCarryFeedback(); + quizCarryOptionRevealAt = 0; + quizCarryAnswerCloseAt = 0; + me.quizCarryHeld = null; + others.forEach((o) => { + if (!o) return; + o.quizCarryHeld = null; + o.botPath = []; + o.botPathStuckTicks = 0; + /* ต้องใช้ performance.now() ช่วงกับ stepQuizCarryPreviewBots — ห้ามใช้ Date.now() epoch ไม่งั้นบอทค้าง pathfind ตลอด */ + o.botQuizCarryPathfindAfter = 0; + }); + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = '…'; + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = 'รอเริ่มคำถาม — นับถอยหลัง 3 · 2 · 1'; + tickQuizCarryEmbedCountdown(); + syncQuizCarryEmbedQuestionStrip(); + } + function quizCarryPickNextQuestion() { if (quizCarrySessionEnded) return; if (quizCarryPregameActive) return; @@ -9377,39 +9457,20 @@ playQuizText = 'ไม่มีคำถาม — ใส่ quizQuestions ในแมป (choices + correctIndex) หรือตั้งใน Admin'; return; } + /* client ที่ไม่ใช่ host ในห้องจริง: ไม่สุ่มเอง รอ event 'quiz-carry-question' จาก host */ + if (quizCarryIsNetworkedMission() && !isMePlayHost()) return; let q; + let qIdx = 0; let guard = 0; do { - q = quizCarryPool[Math.floor(Math.random() * quizCarryPool.length)]; + qIdx = Math.floor(Math.random() * quizCarryPool.length); + q = quizCarryPool[qIdx]; guard++; } while (guard < 12 && quizCarryPool.length > 1 && quizCarryCurrent && q && q.text === quizCarryCurrent.text); if (quizCarryEmbedMissionFlowActive()) { - quizCarryEmbedPendingQuestion = q; - preloadQuizCarryChoiceImages(q); - quizCarryEmbedCountdownStartAt = Date.now(); - quizCarryEmbedCountdownEndAt = quizCarryEmbedCountdownStartAt + 3000; - quizCarryEmbedPreOptionCountdownStartAt = 0; - quizCarryEmbedPreOptionCountdownEndAt = 0; - quizCarryCurrent = null; - playQuizText = ''; - clearQuizMapQuestionCarryFeedback(); - quizCarryOptionRevealAt = 0; - quizCarryAnswerCloseAt = 0; - me.quizCarryHeld = null; - others.forEach((o) => { - if (!o) return; - o.quizCarryHeld = null; - o.botPath = []; - o.botPathStuckTicks = 0; - /* ต้องใช้ performance.now() ช่วงกับ stepQuizCarryPreviewBots — ห้ามใช้ Date.now() epoch ไม่งั้นบอทค้าง pathfind ตลอด */ - o.botQuizCarryPathfindAfter = 0; - }); - const qEl = document.getElementById('quiz-game-question'); - if (qEl) qEl.textContent = '…'; - const phaseEl = document.getElementById('quiz-game-phase-label'); - if (phaseEl) phaseEl.textContent = 'รอเริ่มคำถาม — นับถอยหลัง 3 · 2 · 1'; - tickQuizCarryEmbedCountdown(); - syncQuizCarryEmbedQuestionStrip(); + /* host แจ้ง index คำถามให้ทุกคนใช้ข้อเดียวกัน */ + if (socket && socket.connected) socket.emit('quiz-carry-question', { idx: qIdx }); + applyQuizCarryEmbedNextQuestion(q); return; } quizCarryCurrent = q; @@ -9433,6 +9494,9 @@ finishDetectiveMinigameAndReturnLobby(); return; } + /* ห้องจริง: เฉพาะ host คุมการเลื่อนรอบ/จบเซสชัน แล้ว broadcast — client อื่นรอ event ไม่เลื่อนเอง + (กันแต่ละเครื่องเลื่อนรอบคนละจังหวะ/คนละคำถาม) */ + if (quizCarryIsNetworkedMission() && !isMePlayHost()) return; if (!quizCarryEmbedMissionFlowActive() && !previewMode) { quizCarryPickNextQuestion(); return; @@ -9440,6 +9504,7 @@ quizCarryRoundsCompleted++; const lim = quizCarrySessionLength; if (lim > 0 && quizCarryRoundsCompleted >= lim) { + if (socket && socket.connected) socket.emit('quiz-carry-session-complete', {}); showQuizCarrySessionCompleteOverlay(); return; } @@ -12536,6 +12601,9 @@ function applyMegaBalloonLiveStartPlay() { balloonBossSessionStartMs = performance.now(); balloonBossLastTickMs = performance.now(); + /* anchor = liveBeginsAt server → boss fire + timer อ้างอิงจุดเดียวกันทุกเครื่อง */ + balloonBossLiveAnchorServerMs = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : serverNow(); + balloonBossBossShotCount = 0; balloonBossGameEnded = false; balloonBossPendingShots = []; balloonBossPlayerBullets = []; @@ -15506,7 +15574,10 @@ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return null; const lim = spaceShooterTimeLimitSecPlay(); if (lim <= 0) return null; - const elapsed = (performance.now() - spaceShooterSessionStartMs) / 1000; + /* นับเวลาจาก anchor server → จบพร้อมกันทุกเครื่อง; fallback local ถ้ายังไม่มี anchor (preview) */ + const elapsed = spaceShooterLiveAnchorServerMs > 0 + ? (serverNow() - spaceShooterLiveAnchorServerMs) / 1000 + : (performance.now() - spaceShooterSessionStartMs) / 1000; return Math.max(0, Math.ceil(lim - elapsed)); } @@ -15654,19 +15725,46 @@ } const interval = spaceShooterAsteroidSpawnIntervalMsPlay(); - spaceShooterSpawnAccMs += dt * 1000; - while (spaceShooterSpawnAccMs >= interval) { - spaceShooterSpawnAccMs -= interval; - const maxHp = 1 + Math.floor(Math.random() * SPACE_SHOOTER_ASTEROID_MAX_HP); - const hp0 = maxHp; - spaceShooterAsteroids.push({ - x: 36 + Math.random() * (mw - 72), - y: -45 - Math.random() * 90, - r: spaceShooterAsteroidRadiusFromHpPlay(hp0), - vy: 58 + Math.random() * 62, - maxHp: maxHp, - hp: hp0, - }); + if (spaceShooterLiveAnchorServerMs > 0) { + /* deterministic spawn: ก้อนลำดับ k เกิดที่เวลา anchor + k*interval ด้วยค่าสุ่มจาก seed(anchor,k) + → ทุกเครื่องเกิดก้อนชุดเดียวกัน ตำแหน่ง/HP/ความเร็วเท่ากัน ที่เวลา server เดียวกัน */ + const targetCount = Math.floor((serverNow() - spaceShooterLiveAnchorServerMs) / interval) + 1; + let guard = 0; + while (spaceShooterSpawnCount < targetCount && guard < 200) { + guard++; + const k = spaceShooterSpawnCount++; + const rng = makeSeededRng(envSpawnSeed(spaceShooterLiveAnchorServerMs, k)); + const maxHp = 1 + Math.floor(rng() * SPACE_SHOOTER_ASTEROID_MAX_HP); + const ax = 36 + rng() * (mw - 72); + const baseY = -45 - rng() * 90; + const avy = 58 + rng() * 62; + const spawnTimeMs = spaceShooterLiveAnchorServerMs + k * interval; + const elapsedSec = Math.max(0, (serverNow() - spawnTimeMs) / 1000); + spaceShooterAsteroids.push({ + id: k, + x: ax, + y: baseY + avy * elapsedSec, + r: spaceShooterAsteroidRadiusFromHpPlay(maxHp), + vy: avy, + maxHp: maxHp, + hp: maxHp, + }); + } + } else { + spaceShooterSpawnAccMs += dt * 1000; + while (spaceShooterSpawnAccMs >= interval) { + spaceShooterSpawnAccMs -= interval; + const maxHp = 1 + Math.floor(Math.random() * SPACE_SHOOTER_ASTEROID_MAX_HP); + const hp0 = maxHp; + spaceShooterAsteroids.push({ + x: 36 + Math.random() * (mw - 72), + y: -45 - Math.random() * 90, + r: spaceShooterAsteroidRadiusFromHpPlay(hp0), + vy: 58 + Math.random() * 62, + maxHp: maxHp, + hp: hp0, + }); + } } others.forEach((o, id) => { @@ -16177,8 +16275,11 @@ const lim = balloonBossTimeLimitSecPlay(); if (lim <= 0) return null; if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) return lim; - if (!balloonBossSessionStartMs) return lim; - const elapsed = (performance.now() - balloonBossSessionStartMs) / 1000; + if (!balloonBossSessionStartMs && !(balloonBossLiveAnchorServerMs > 0)) return lim; + /* นับเวลาจาก anchor server → จบพร้อมกันทุกเครื่อง; fallback local ถ้ายังไม่มี anchor */ + const elapsed = balloonBossLiveAnchorServerMs > 0 + ? (serverNow() - balloonBossLiveAnchorServerMs) / 1000 + : (performance.now() - balloonBossSessionStartMs) / 1000; return Math.max(0, Math.ceil(lim - elapsed)); } @@ -16436,18 +16537,40 @@ } const bossFireEvery = 1.42; - balloonBossBossFireAcc += dt; - while (balloonBossBossFireAcc >= bossFireEvery) { - balloonBossBossFireAcc -= bossFireEvery; - if (aliveRefs.length) { - const ang = Math.random() * Math.PI * 2; - const spd = 88 + Math.random() * 72; + if (balloonBossLiveAnchorServerMs > 0) { + /* deterministic boss fire: กระสุนลำดับ k ยิงที่ anchor + k*bossFireEvery ด้วยค่าสุ่มจาก seed(anchor,k) + → ทุกเครื่องเห็นแพตเทิร์นกระสุนบอสเดียวกัน (เดิม Math.random แยกกัน = คนละลาย) */ + const fireIntervalMs = bossFireEvery * 1000; + const targetShots = Math.floor((serverNow() - balloonBossLiveAnchorServerMs) / fireIntervalMs) + 1; + let bguard = 0; + while (balloonBossBossShotCount < targetShots && bguard < 200) { + bguard++; + const k = balloonBossBossShotCount++; + if (!aliveRefs.length) continue; + const rng = makeSeededRng(envSpawnSeed(balloonBossLiveAnchorServerMs, k)); + const ang = rng() * Math.PI * 2; + const spd = 88 + rng() * 72; + const gy = 48 + rng() * 56; balloonBossBossBullets.push({ x: bossC.cx, y: bossC.cy, vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd, - gy: 48 + Math.random() * 56, + gy: gy, }); } + } else { + balloonBossBossFireAcc += dt; + while (balloonBossBossFireAcc >= bossFireEvery) { + balloonBossBossFireAcc -= bossFireEvery; + if (aliveRefs.length) { + const ang = Math.random() * Math.PI * 2; + const spd = 88 + Math.random() * 72; + balloonBossBossBullets.push({ + x: bossC.cx, y: bossC.cy, + vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd, + gy: 48 + Math.random() * 56, + }); + } + } } function applyBalloonBossHitToEntity(ent) { @@ -17827,8 +17950,14 @@ let zJumpSurviveWorld = zoom; if (previewMode && editorEmbedReturn && mapData) zJumpSurviveWorld *= playEmbedUserZoomMul; const halfViewH = canvas.height / (2 * zJumpSurviveWorld); - const rise = (Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42) * dt; - jumpSurvivePlatformScrollPx += rise; + const risePerSec = Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42; + if (jumpSurviveLiveAnchorServerMs > 0) { + /* scroll = ฟังก์ชันของเวลา server (ไม่สะสม dt local) → ทุกเครื่องเลื่อนเท่ากันที่เวลาเดียวกัน */ + const scrollSec = Math.max(0, (serverNow() - jumpSurviveLiveAnchorServerMs) / 1000); + jumpSurvivePlatformScrollPx = scrollSec * risePerSec; + } else { + jumpSurvivePlatformScrollPx += risePerSec * dt; + } const camTgt = jumpSurviveCameraTargetPx(md); const ck = Math.min(1, dt * 7); @@ -18714,8 +18843,35 @@ socket.on('quiz-carry-lobby-sync', (d) => { if (!d || typeof d.readyMap !== 'object') return; quizCarryLobbyReadyMap = { ...d.readyMap }; + /* upsert others ที่ขาดหายจาก peers snapshot ที่ server แนบมา + กัน host ไม่เห็นตัวละคร client เมื่อ user-joined/plist มาไม่ครบช่วงเปลี่ยนหน้า lobbyB→play */ + if (Array.isArray(d.peers)) { + d.peers.forEach((p) => { + if (!p || p.id == null || isPreviewBotId(p.id)) return; + if (myId != null && String(p.id) === String(myId)) return; + if (others.has(p.id) || others.has(String(p.id))) return; + const px = Number(p.x); + const py = Number(p.y); + const x = Number.isFinite(px) ? px : 1; + const y = Number.isFinite(py) ? py : 1; + const joP = Number(p.spawnJoinOrder); + others.set(p.id, { + x, y, tx: x, ty: y, + direction: p.direction || 'down', + nickname: p.nickname, + characterId: p.characterId ?? null, + spawnJoinOrder: Number.isFinite(joP) ? Math.max(0, Math.floor(joP)) : 0, + playTint: playTintFromJoinPeer(p) || playTintForPreviewBotId(p.id) || resolvePlayTintForPeer(p.id, p), + gauntletJumpTicks: 0, gauntletJumpVis: 0, gauntletScore: 0, + jumpSurviveEliminated: false, spaceShooterScore: 0, + balloonBossScore: 0, balloonBossBossDmg: 0, + balloonBossBalloons: balloonBossBalloonsStartPlay(), + balloonBossEliminated: false, quizCarryHeld: null, + }); + if (p.characterId) preloadPlayCharacterLayers(p.characterId); + }); + } if (quizCarryPregameActive && isQuizCarry()) { - quizCarrySyncGuestReadyIfNeeded(); updateQuizCarryPregameHud(); } }); @@ -18724,6 +18880,18 @@ if (!quizCarryEmbedMissionFlowActive()) return; endQuizCarryEmbedPregameAndStart(); }); + /* host เลือกคำถาม → client อื่นใช้ข้อเดียวกัน (กันสุ่มไม่ตรงกัน) */ + socket.on('quiz-carry-question', (d) => { + if (!d || isMePlayHost() || !isQuizCarry() || !quizCarryEmbedMissionFlowActive()) return; + const idx = Number(d.idx); + if (!Number.isInteger(idx) || idx < 0 || idx >= quizCarryPool.length) return; + applyQuizCarryEmbedNextQuestion(quizCarryPool[idx]); + }); + /* host จบเซสชัน → client อื่นโชว์สรุปพร้อมกัน */ + socket.on('quiz-carry-session-complete', () => { + if (isMePlayHost() || !isQuizCarry() || !quizCarryEmbedMissionFlowActive()) return; + showQuizCarrySessionCompleteOverlay(); + }); socket.on('gauntlet-crown-lobby-sync', (d) => { if (!d || typeof d.readyMap !== 'object') return; gauntletCrownLobbyReadyMap = { ...d.readyMap }; @@ -18888,7 +19056,9 @@ } else { o.tx = data.x; o.ty = data.y; - if (mapData && isQuiz()) { + /* quiz + quiz_carry: ผู้เล่นยืน/ขยับเป็นช่องบนแมปคงที่ → สแนปตำแหน่งทันที (ไม่พึ่ง lerp อย่างเดียว) + กัน host ค้าง/หายจากจอเมื่อ move มาห่างหรือมาไม่ต่อเนื่อง */ + if (mapData && (isQuiz() || isQuizCarry())) { const px = Number(data.x); const py = Number(data.y); if (Number.isFinite(px)) o.x = px; @@ -19319,6 +19489,28 @@ o.gauntletEliminated = false; }); } + /* quiz_carry: sync ตำแหน่งจุดเกิดของผู้เล่นจริงระยะไกลจาก peersSnap ตอนเข้าเกม + เดิม client apply peersSnap เฉพาะ gauntlet → quiz_carry ผู้เล่นยืนนิ่งตอบคำถาม ไม่มี move ส่ง + → others ค้างที่ตำแหน่งเก่า (lobby) นอกแมป quiz_carry = host หายจากจอ client */ + if (mapData.gameType === 'quiz_carry' && ev.peersSnap && Array.isArray(ev.peersSnap)) { + ev.peersSnap.forEach((p) => { + if (p.id == null) return; + const px = Number(p.x); + const py = Number(p.y); + if (myId != null && String(p.id) === String(myId)) { + if (Number.isFinite(px)) { me.x = px; me.tx = px; } + if (Number.isFinite(py)) { me.y = py; me.ty = py; } + if (p.direction) me.direction = p.direction; + } else { + const o = others.get(p.id) ?? others.get(String(p.id)); + if (o) { + if (Number.isFinite(px)) { o.x = px; o.tx = px; } + if (Number.isFinite(py)) { o.y = py; o.ty = py; } + if (p.direction) o.direction = p.direction; + } + } + }); + } if (mapData.gameType === 'gauntlet') { if (ev.gauntletEndsAt != null) { const ge = Number(ev.gauntletEndsAt); @@ -23696,12 +23888,14 @@ if (!primary) return; primary.addEventListener('click', () => { if (!quizCarryPregameActive || myId == null) return; - if (!isMePlayHost()) return; const humans = quizCarryPregameHumanIds(); const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]); if (humansReady) { - /* ผู้เล่นจริงคนเดียว + พรีวิว: ไม่ต้องรอ ACK เซิร์ฟ (ห้องที่ map บนเซิร์ฟยังไม่ใช่ quiz_carry จะเคยล้มเหลว) */ - if (humans.length === 1 && isMePlayHost()) { + /* ทุกคนพร้อมแล้ว → เฉพาะ host กด START */ + if (!isMePlayHost()) return; + /* เฉพาะ offline/พรีวิวที่ไม่ได้ต่อเซิร์ฟเวอร์เท่านั้นที่เริ่มได้เลย — ห้องจริงต้องผ่าน server + (กัน host เริ่มคนเดียวทิ้ง client ตอน others ยังว่าง) */ + if ((!socket || !socket.connected) && humans.length === 1) { endQuizCarryEmbedPregameAndStart(); return; } @@ -23710,11 +23904,12 @@ }); return; } + /* READY phase: ทุกคน (รวม client) กดสลับสถานะ Ready ของตัวเองได้ */ const sid = String(myId); const next = !quizCarryLobbyReadyMap[sid]; quizCarryLobbyReadyMap[sid] = next; updateQuizCarryPregameHud(); - socket.emit('quiz-carry-lobby-ready', { ready: next }); + if (socket && socket.connected) socket.emit('quiz-carry-lobby-ready', { ready: next }); }); })(); window.addEventListener('beforeunload', () => { try { saveQuizBattleProgress(); } catch (e) { /* ignore */ } socket.emit('leave-space'); }); diff --git a/www/html/Game/public/play.html b/www/html/Game/public/play.html index 2e8ace6..53bbfaf 100644 --- a/www/html/Game/public/play.html +++ b/www/html/Game/public/play.html @@ -5205,7 +5205,7 @@ - +
v —