diff --git a/www/html/Admin/private/store.json b/www/html/Admin/private/store.json index ce7bf4d..70991f9 100644 --- a/www/html/Admin/private/store.json +++ b/www/html/Admin/private/store.json @@ -394,14 +394,14 @@ { "id": "0bf4718eeec4434c080d6057", "email": "", - "displayName": "Guest", + "displayName": "MONE", "loginType": "guest", "providerUserId": "p_1781851275186_szcmp5pu3s", "notes": "auto: player-coins", "blocked": false, - "coins": 10, + "coins": 158, "createdAt": "2026-06-19T06:41:15+00:00", - "updatedAt": "2026-06-19T07:00:09+00:00", + "updatedAt": "2026-06-21T17:18:58+00:00", "daily": { "anchorMs": 1781802000000, "claimedDays": [ @@ -413,7 +413,20 @@ false, false ], - "lockUntilMs": 1781888400000 + "lockUntilMs": 0 + }, + "score": 138, + "scoreByCase": { + "10": 20, + "13": 36, + "12": 54, + "4": 28 + }, + "lbName": "MONE", + "achievements": { + "d1_minigame_solver": 13, + "a1_first_deduction": 1, + "a5_truth_hunter": 1 } }, { @@ -541,33 +554,44 @@ "providerUserId": "p_1781862203558_jhynnz90qrg", "notes": "auto: player-coins", "blocked": false, - "coins": 18, + "coins": 182, "createdAt": "2026-06-19T09:43:23+00:00", - "updatedAt": "2026-06-21T09:15:13+00:00", + "updatedAt": "2026-06-22T07:38:20+00:00", "lobbyColorThemeIndex": 6, "lobbySkinToneIndex": 1, "achievements": { - "d1_minigame_solver": 2 + "d1_minigame_solver": 17 }, - "score": 18, + "score": 162, "scoreByCase": { - "10": 18 + "10": 44, + "13": 38, + "12": 54, + "4": 26 }, "lbName": "ผู้เล่น" }, { "id": "fbb262e6c2aa47bd97aef554", "email": "", - "displayName": "Guest", + "displayName": "MONE", "loginType": "guest", "providerUserId": "p_1781938677448_8j2wnr5ha1b", "notes": "auto: player-coins", "blocked": false, - "coins": 0, + "coins": 28, "createdAt": "2026-06-20T06:57:57+00:00", - "updatedAt": "2026-06-20T06:58:53+00:00", + "updatedAt": "2026-06-22T07:38:20+00:00", "lobbyColorThemeIndex": 7, - "lobbySkinToneIndex": 1 + "lobbySkinToneIndex": 1, + "achievements": { + "d1_minigame_solver": 3 + }, + "lbName": "MONE", + "score": 18, + "scoreByCase": { + "13": 18 + } }, { "id": "8f0741532038e900e759c5ef", diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index b5cebef..966d080 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -253,13 +253,30 @@ } } + const wasElim = !!me.spaceShooterEliminated; if (myId != null) tryHit(me, me.spaceShooterCx, me.spaceShooterCy); - others.forEach(function (o) { + /* คิดการชนเฉพาะยานของเรา (เจ้าของตัดสินเอง) + บอท (host ตัดสิน) เท่านั้น + ผู้เล่นมนุษย์คนอื่นรอสถานะ eliminated/hits จาก move ของเจ้าตัว — กัน "ตายไม่พร้อมกัน" + (เดิมทุกเครื่องคิดชนให้ทุกยานด้วยตำแหน่งดาวเคราะห์ local ที่ต่างกัน → ตัดสินไม่ตรงกัน) */ + others.forEach(function (o, id) { if (!o) return; - tryHit(o, o.spaceShooterCx, o.spaceShooterCy); + 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()) { - endSpaceShooterMissionRound('all_dead'); + /* host ตัดสิน all_dead + broadcast คะแนน; non-host รอรับ space-shooter-over */ + if (isMePlayHost()) triggerSpaceShooterOverPlay('all_dead'); } } @@ -3092,6 +3109,8 @@ /** 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: แยกบน/ล่าง/เส้น + สี/ความหนา */ @@ -5308,6 +5327,12 @@ 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); } let k = 0; @@ -6806,19 +6831,7 @@ const st = document.getElementById('gauntlet-crown-howto-status'); const btn = document.getElementById('btn-gch-ready'); if (!st || !btn) return; - const humans = quizCarryPregameHumanIds(); - const tot = Math.max(1, quizCarryPregameTotalPlayers()); - const num = gauntletCrownPregameReadyNumerator(); - st.classList.remove('is-hidden'); - st.textContent = 'Ready Status : ' + num + '/' + tot; - const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); - btn.classList.toggle('is-start-phase', humansReady); - btn.classList.toggle('is-read-only', !isMePlayHost()); - btn.disabled = !isMePlayHost(); - btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false')); - btn.title = isMePlayHost() - ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY')) - : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); + applyGauntletCrownReadyButtonPlay(st, btn); } /** Jump Survival mnptfts2 — Ready Status / ปุ่ม เดียวกับ Stack Tower */ @@ -6827,19 +6840,7 @@ const st = document.getElementById('gauntlet-crown-howto-status'); const btn = document.getElementById('btn-gch-ready'); if (!st || !btn) return; - const humans = quizCarryPregameHumanIds(); - const tot = Math.max(1, quizCarryPregameTotalPlayers()); - const num = gauntletCrownPregameReadyNumerator(); - st.classList.remove('is-hidden'); - st.textContent = 'Ready Status : ' + num + '/' + tot; - const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); - btn.classList.toggle('is-start-phase', humansReady); - btn.classList.toggle('is-read-only', !isMePlayHost()); - btn.disabled = !isMePlayHost(); - btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false')); - btn.title = isMePlayHost() - ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY')) - : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); + applyGauntletCrownReadyButtonPlay(st, btn); } /** Space Shooter mnpz6rkp — Ready Status / ปุ่ม เดียวกับ Jumper */ @@ -6848,19 +6849,7 @@ const st = document.getElementById('gauntlet-crown-howto-status'); const btn = document.getElementById('btn-gch-ready'); if (!st || !btn) return; - const humans = quizCarryPregameHumanIds(); - const tot = Math.max(1, quizCarryPregameTotalPlayers()); - const num = gauntletCrownPregameReadyNumerator(); - st.classList.remove('is-hidden'); - st.textContent = 'Ready Status : ' + num + '/' + tot; - const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); - btn.classList.toggle('is-start-phase', humansReady); - btn.classList.toggle('is-read-only', !isMePlayHost()); - btn.disabled = !isMePlayHost(); - btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false')); - btn.title = isMePlayHost() - ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY')) - : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); + applyGauntletCrownReadyButtonPlay(st, btn); } function beginStackTowerMissionCountdownThenRun() { @@ -7917,6 +7906,38 @@ }; } + /* 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'; @@ -9000,19 +9021,26 @@ 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)) set.add(String(myId)); - others.forEach((_, id) => { - if (!isPreviewBotId(id)) set.add(String(id)); + 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)); + }); + } }); - /* รวม 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]; } @@ -9052,6 +9080,7 @@ 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.gauntletScore != null) row.gScore = o.gauntletScore; if (o.gauntletEliminated != null) row.gElim = !!o.gauntletEliminated; if (o.gauntletJumpTicks != null) row.gJump = o.gauntletJumpTicks; @@ -9068,6 +9097,11 @@ if (o.balloonBossBalloons != null) row.bbBalloons = o.balloonBossBalloons; if (o.balloonBossEliminated != null) row.bbElim = !!o.balloonBossEliminated; if (o.quizCarryHeld !== undefined) row.qcHeld = o.quizCarryHeld; + /* สีบอท (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); }); if (arr.length) socket.emit('fill-bot-state', { bots: arr }); @@ -9081,10 +9115,15 @@ เมื่อ 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(); @@ -9098,6 +9137,14 @@ if (round < 5) setTimeout(function () { syncPlayClock(round + 1); }, 300); }); } + /* re-sync นาฬิกาเป็นระยะ → กัน clock drift ระหว่างเล่นยาว ๆ ทำให้ serverNow ทุกเครื่องตรงกันเสมอ + (ฐานเวลาเดียวกัน → timer/boss/aim/spawn ของทุก minigame sync กัน) */ + function startPeriodicClockSync() { + if (playClockSyncInterval) return; + playClockSyncInterval = setInterval(function () { + try { if (socket && socket.connected) syncPlayClock(0); } catch (_e) { /* ignore */ } + }, 12000); + } let playMissionLiveBeginsAt = 0; /* server epoch ms ที่เกมเริ่มจริงพร้อมกันทุกคน (0 = ยังไม่รู้) */ function setPlayMissionLiveBeginsAt(v) { @@ -9137,12 +9184,15 @@ updateQuizCarryPregameHud(); } - function quizCarrySyncGuestReadyIfNeeded() { - if (!quizCarryPregameActive || myId == null || isMePlayHost()) return; - const sid = String(myId); - if (quizCarryLobbyReadyMap[sid]) return; - quizCarryLobbyReadyMap[sid] = true; - if (socket && socket.connected) socket.emit('quiz-carry-lobby-ready', { ready: true }); + /* เดิม guest auto-ready → ตอนนี้ทุกคน (client + host) ต้องกด Ready เองก่อน host จึง Start ได้ → no-op */ + function quizCarrySyncGuestReadyIfNeeded() {} + + /** MG4: true เมื่อผู้เล่นมาครบตามที่คาด + กด Ready ครบทุกคน (host ถึงจะ START ได้) */ + function quizCarryAllReadyPlay() { + const humans = quizCarryPregameHumanIds(); + if (humans.length === 0) return false; + if (humans.length < gauntletCrownPregameExpectedHumans()) return false; + return humans.every((id) => !!quizCarryLobbyReadyMap[id]); } function updateQuizCarryPregameHud() { @@ -9153,7 +9203,7 @@ const num = quizCarryPregameReadyNumerator(); const tot = Math.max(1, quizCarryPregameTotalPlayers()); const humans = quizCarryPregameHumanIds(); - const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]); + const humansReady = quizCarryAllReadyPlay(); const meReady = !!(myId && quizCarryLobbyReadyMap[String(myId)]); if (st) { /* ตาม design 01-howto / 01-howto-host: โฮสต์เห็น "กำลังรอ..ผู้เล่นกด Ready" / ผู้เล่นเห็น "Ready Status" */ @@ -12484,17 +12534,58 @@ return n; } - function gauntletCrownSyncGuestReadyIfNeeded() { - const inQuizQuestionHowto = isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto'; - const inStackTowerHowto = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto'; - const inJumpSurviveHowto = isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto'; - const inSpaceShooterHowto = isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto'; - if (gauntletCrownPregamePhase !== 'howto' && !inQuizQuestionHowto && !inStackTowerHowto && !inJumpSurviveHowto && !inSpaceShooterHowto) return; - if (myId == null || isMePlayHost()) return; - const sid = String(myId); - if (gauntletCrownLobbyReadyMap[sid]) return; - gauntletCrownLobbyReadyMap[sid] = true; - if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: true }); + /* เดิม: guest auto-ready ทันทีที่เข้า howto → host เห็นพร้อมเลย กด start ได้ก่อน client ตั้งตัว + ตอนนี้: ทุกคน (client + host) ต้องกด Ready เองด้วยมือ ก่อน host จึงกด Start ได้ → no-op */ + function gauntletCrownSyncGuestReadyIfNeeded() {} + + /** จำนวนผู้เล่น (มนุษย์) ที่ต้องเข้าครบก่อนเริ่ม — ใช้ค่าจาก server (expectedActive) ถ้ามี + กัน host เริ่มก่อน client โหลด/กด Ready ครบ → เกม sync + จบพร้อมกัน */ + function gauntletCrownPregameExpectedHumans() { + const here = quizCarryPregameHumanIds().length; + const exp = (missionExpectedActiveCount > 0) ? missionExpectedActiveCount : here; + return Math.max(exp, here); + } + + /** true ก็ต่อเมื่อ ผู้เล่นมนุษย์มาครบตามที่คาด + ทุกคนกด Ready แล้ว (host ถึงจะ Start ได้) */ + function gauntletCrownAllReadyPlay() { + const humans = quizCarryPregameHumanIds(); + if (humans.length === 0) return false; + if (humans.length < gauntletCrownPregameExpectedHumans()) return false; + return humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); + } + + /** อัปเดต Ready Status + ปุ่ม READY/START ของ minigame ตระกูล crown-lobby (MG1,2,3,5,6,7) + - ทุกคนกด READY เองได้ + - แสดง n/expected (รอ client ที่ยังไม่เข้า) + - START โผล่เฉพาะตอนมาครบ+พร้อมครบ และกดได้เฉพาะ host */ + function applyGauntletCrownReadyButtonPlay(st, btn) { + if (!btn) return false; + const tot = Math.max(1, quizCarryPregameTotalPlayers(), gauntletCrownPregameExpectedHumans() + quizCarryPregameBotCount()); + const num = gauntletCrownPregameReadyNumerator(); + if (st) { + st.classList.remove('is-hidden'); + st.textContent = 'Ready Status : ' + num + '/' + tot; + } + const allReady = gauntletCrownAllReadyPlay(); + const meReady = !!(myId && gauntletCrownLobbyReadyMap[String(myId)]); + const locked = allReady && !isMePlayHost(); + btn.classList.toggle('is-start-phase', allReady); + if (gauntletCrownHowtoUsesMg2LayoutPlay()) { + const btnImg = document.getElementById('btn-gch-ready-mg2-img'); + if (btnImg) { + btnImg.src = allReady + ? (missionMockHudAssetUrl('btn-start.png') || (BASE + '/img/gauntlet-assets/btn-start.png')) + : (missionMockHudAssetUrl('btn-ready.png') || (BASE + '/img/gauntlet-assets/btn-ready.png')); + btnImg.alt = allReady ? 'START' : 'READY'; + } + } + btn.classList.toggle('is-read-only', locked); + btn.disabled = locked; + btn.setAttribute('aria-pressed', (!allReady && meReady) ? 'true' : 'false'); + btn.title = allReady + ? (isMePlayHost() ? 'START' : 'รอโฮสต์เริ่ม') + : (meReady ? 'ยกเลิก READY' : 'READY'); + return allReady; } /** Minigame-2 mno9kb07 — HOW TO PLAY ใช้ layout mock 01-howto (canvas = mg-bg) */ @@ -12539,49 +12630,14 @@ const st = document.getElementById('gauntlet-crown-howto-status'); const btn = document.getElementById('btn-gch-ready'); if (!st || !btn) return; - const humans = quizCarryPregameHumanIds(); - const tot = Math.max(1, quizCarryPregameTotalPlayers()); - const num = gauntletCrownPregameReadyNumerator(); - st.classList.remove('is-hidden'); - st.textContent = 'Ready Status : ' + num + '/' + tot; - const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); - btn.classList.toggle('is-start-phase', humansReady); - btn.classList.toggle('is-read-only', !isMePlayHost()); - btn.disabled = !isMePlayHost(); - btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false')); - btn.title = isMePlayHost() - ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY')) - : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); + applyGauntletCrownReadyButtonPlay(st, btn); } function updateGauntletCrownHowtoHud() { const st = getGauntletCrownHowtoStatusEl(); const btn = getGauntletCrownHowtoBtnEl(); if (!btn || !usesCrownLobbyShellPlay() || gauntletCrownPregamePhase !== 'howto') return; - const humans = quizCarryPregameHumanIds(); - const tot = Math.max(1, quizCarryPregameTotalPlayers()); - const num = gauntletCrownPregameReadyNumerator(); - if (st) { - st.classList.remove('is-hidden'); - st.textContent = 'Ready Status : ' + num + '/' + tot; - } - const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); - btn.classList.toggle('is-start-phase', humansReady); - if (gauntletCrownHowtoUsesMg2LayoutPlay()) { - const btnImg = document.getElementById('btn-gch-ready-mg2-img'); - if (btnImg) { - btnImg.src = humansReady - ? (missionMockHudAssetUrl('btn-start.png') || (BASE + '/img/gauntlet-assets/btn-start.png')) - : (missionMockHudAssetUrl('btn-ready.png') || (BASE + '/img/gauntlet-assets/btn-ready.png')); - btnImg.alt = humansReady ? 'START' : 'READY'; - } - } - btn.classList.toggle('is-read-only', !isMePlayHost()); - btn.disabled = !isMePlayHost(); - btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false')); - btn.title = isMePlayHost() - ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY')) - : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); + applyGauntletCrownReadyButtonPlay(st, btn); } function beginGauntletCrownCountdownThenRun() { @@ -13079,6 +13135,11 @@ try { if (typeof spaceShooterSessionStartMs === 'number' && spaceShooterSessionStartMs > 0) spaceShooterSessionStartMs += deltaMs; } catch (e) {} try { if (typeof stackTowerSessionStartAt === 'number' && stackTowerSessionStartAt > 0) stackTowerSessionStartAt += deltaMs; } catch (e) {} try { if (typeof balloonBossSessionStartMs === 'number' && balloonBossSessionStartMs > 0) balloonBossSessionStartMs += deltaMs; } catch (e) {} + /* anchor server (serverNow-based) ของ MG5/6/7 ต้องเลื่อนด้วย — ไม่งั้นระหว่าง freeze บอส/ลูกศร/timer/spawn + ยังเดินตาม serverNow และหลัง freeze จะคำนวณ backlog ยิงกระสุนรัวออกมา (กระสุนกระจาย) */ + try { if (typeof jumpSurviveLiveAnchorServerMs === 'number' && jumpSurviveLiveAnchorServerMs > 0) jumpSurviveLiveAnchorServerMs += deltaMs; } catch (e) {} + try { if (typeof spaceShooterLiveAnchorServerMs === 'number' && spaceShooterLiveAnchorServerMs > 0) spaceShooterLiveAnchorServerMs += deltaMs; } catch (e) {} + try { if (typeof balloonBossLiveAnchorServerMs === 'number' && balloonBossLiveAnchorServerMs > 0) balloonBossLiveAnchorServerMs += deltaMs; } catch (e) {} } /** เรียกทุกเฟรมระหว่าง freeze — ดันเวลาไปข้างหน้าเท่าที่ผ่านไปจริง ทำให้ countdown หยุดนิ่ง */ @@ -15510,22 +15571,27 @@ return null; } - function buildSpaceShooterParticipantRefsPlay() { + /** ลำดับผู้เล่นที่ "เหมือนกันทุกเครื่อง" สำหรับเกมยาน (MG6 space_shooter / MG7 balloon_boss) + สำคัญ: ห้ามเอา me ขึ้นก่อน — เดิมทุกเครื่องดันตัวเองเป็น slot แรก ทำให้ + - สี/รูปยาน (slot) ของผู้เล่นคนเดียวกันไม่ตรงกันข้ามจอ + - spawn ถูก map คนละ slot → ตำแหน่งเริ่มไม่ตรงกัน/กองทับกัน + แก้: เรียงตาม id เหมือนกันทุกเครื่อง (มนุษย์รวมตัวเองก่อน เรียง id แล้วบอท) */ + function buildStableShipParticipantRefsPlay() { + const refById = new Map(); + if (me && myId != null) refById.set(String(myId), me); + others.forEach((o, id) => { if (o) refById.set(String(id), o); }); + const humanIds = [...refById.keys()].filter((id) => !isPreviewBotId(id)).sort(); + const botIds = [...refById.keys()].filter((id) => isPreviewBotId(id)).sort(); const refs = []; - if (me) refs.push({ id: myId, ref: me }); - const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); - humanIds.forEach((id) => { - const o = others.get(id); - if (o) refs.push({ id, ref: o }); - }); - const botIds = [...others.keys()].filter(isPreviewBotId).sort(); - botIds.forEach((id) => { - const o = others.get(id); - if (o) refs.push({ id, ref: o }); - }); + humanIds.forEach((id) => refs.push({ id, ref: refById.get(id) })); + botIds.forEach((id) => refs.push({ id, ref: refById.get(id) })); return refs.filter((r) => r.ref); } + function buildSpaceShooterParticipantRefsPlay() { + return buildStableShipParticipantRefsPlay(); + } + function applySpaceShooterSpawnLayoutPlay() { if (!mapData || mapData.gameType !== 'space_shooter') return; normalizeShooterSpawnSlotsInPlay(mapData); @@ -15693,7 +15759,13 @@ o.spaceShooterBotFireCd -= dt; if (o.spaceShooterBotFireCd <= 0 && nearest && nd < ts * 5) { o.spaceShooterBotFireCd = o.botTier === 'sharp' ? 0.22 : o.botTier === 'weak' ? 0.48 : 0.34; - spaceShooterBullets.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 16, vy: -500, ownerId: bid }); + const bX = o.spaceShooterCx; + const bY = o.spaceShooterCy - 16; + spaceShooterBullets.push({ x: bX, y: bY, vy: -500, ownerId: bid }); + /* host จำลองบอท → broadcast กระสุนบอทให้ client อื่นเห็น */ + try { + if (socket && myId != null) socket.emit('space-shooter-shot', { x: bX, y: bY, vy: -500, ownerId: bid, t0: serverNow() }); + } catch (eB) { /* ignore */ } } }); } @@ -15720,8 +15792,14 @@ } const remSec = spaceShooterRemainingSecPlay(); if (remSec != null && remSec <= 0) { - endSpaceShooterTimeUp(); - return; + /* จบแบบ host-authoritative (mission): host emit คะแนนสุดท้าย, non-host รอรับ; fallback ถ้า host หลุด */ + if (isSpaceShooterMissionUiMapPlay()) { + if (isMePlayHost()) { triggerSpaceShooterOverPlay('time_up'); return; } + if (remSec <= -2) { endSpaceShooterMissionRound('time_up'); return; } + } else { + endSpaceShooterTimeUp(); + return; + } } const interval = spaceShooterAsteroidSpawnIntervalMsPlay(); @@ -15769,8 +15847,9 @@ others.forEach((o, id) => { if (isPreviewBotId(id)) return; - if (o.tx != null) o.x += (o.tx - o.x) * 0.28; - if (o.ty != null) o.y += (o.ty - o.y) * 0.28; + /* lerp แรงขึ้น (0.45) → ยานผู้เล่นคนอื่นตามตำแหน่งจริงทันขึ้น ลดอาการ "ไม่ตรง/ยืด" */ + if (o.tx != null) o.x += (o.tx - o.x) * 0.45; + if (o.ty != null) o.y += (o.ty - o.y) * 0.45; o.spaceShooterCx = (o.x + cw * 0.5) * ts; o.spaceShooterCy = clampSpaceShooterWorldCy((o.y + ch * 0.92) * ts, mh); }); @@ -15800,7 +15879,13 @@ const autoFireOk = canPilotMe && !isChatFocused(); if (autoFireOk && spaceShooterFireCd <= 0 && me.spaceShooterCy != null) { spaceShooterFireCd = 0.21; - spaceShooterBullets.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 20, vy: -580, ownerId: myId }); + const shotX = me.spaceShooterCx; + const shotY = me.spaceShooterCy - 20; + spaceShooterBullets.push({ x: shotX, y: shotY, vy: -580, ownerId: myId }); + /* broadcast กระสุนให้ทุกคนเห็นตรงกัน */ + try { + if (socket && myId != null) socket.emit('space-shooter-shot', { x: shotX, y: shotY, vy: -580, ownerId: myId, t0: serverNow() }); + } catch (eS) { /* ignore */ } } stepSpaceShooterPreviewBots(dt); @@ -15833,15 +15918,17 @@ a.hp = Math.max(0, Math.floor(Number(a.hp) || mh) - 1); spaceShooterBullets.splice(bi, 1); const add = 5; + const ownerEnt = (b.ownerId === myId) ? me : others.get(b.ownerId); + /* popup เด้ง +5 ให้เห็นทุกเครื่อง (visual) */ + if (ownerEnt && ownerEnt.spaceShooterCx != null) { + spaceShooterPopups.push({ x: ownerEnt.spaceShooterCx, y: ownerEnt.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 }); + } + /* score: เจ้าของเท่านั้นเป็นคนบวก (มนุษย์=ตัวเอง broadcast ผ่าน move, บอท=host broadcast ผ่าน fill-bot-state) + เครื่องอื่นห้ามบวกเอง — เดิม non-owner บวกจาก asteroid ที่ต่างกันต่อเครื่อง → คะแนนสุดท้ายไม่ตรง */ if (b.ownerId === myId) { me.spaceShooterScore = Math.max(0, (me.spaceShooterScore || 0) + add); - spaceShooterPopups.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 }); - } else { - const o = others.get(b.ownerId); - if (o) { - o.spaceShooterScore = Math.max(0, (o.spaceShooterScore || 0) + add); - spaceShooterPopups.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 }); - } + } else if (isPreviewBotId(b.ownerId) && isMePlayHost() && ownerEnt) { + ownerEnt.spaceShooterScore = Math.max(0, (ownerEnt.spaceShooterScore || 0) + add); } if (a.hp <= 0) { spaceShooterSpawnAsteroidExplosion(a.x, a.y, a.r); @@ -15860,11 +15947,13 @@ me.direction = 'up'; me.isWalking = vx !== 0 || vy !== 0; const tEmit = Date.now(); - if (tEmit - spaceShooterLastMoveEmit > 90 && socket && myId != null) { + if (tEmit - spaceShooterLastMoveEmit > 60 && socket && myId != null) { spaceShooterLastMoveEmit = tEmit; socket.emit('move', { x: me.x, y: me.y, direction: me.direction, spaceShooterScore: Math.max(0, me.spaceShooterScore | 0), + spaceShooterHits: Math.max(0, Number(me.spaceShooterHits) || 0), + spaceShooterEliminated: !!me.spaceShooterEliminated, }); } } @@ -15960,10 +16049,13 @@ const ent = entry.ref; if (ent.spaceShooterCx == null || ent.spaceShooterCy == null) return; const [sx, sy] = worldToScreen(ent.spaceShooterCx, ent.spaceShooterCy); - const col = SPACE_SHOOTER_SHIP_COLORS[idx % SPACE_SHOOTER_SHIP_COLORS.length]; const elimShip = !!(isSpaceShooterMissionUiMapPlay() && ent.spaceShooterEliminated); if (elimShip) return; - const slot = Math.max(1, Math.min(6, Number(ent.spaceShooterSlot) || ((idx % 6) + 1))); + /* slot จาก stable idx (refs = buildStableShipParticipantRefsPlay เรียงตาม id เหมือนกันทุกเครื่อง) + → สี/รูปยานตรงกันทุกเครื่องและ auto-correct เมื่อ set ผู้เล่น/บอทครบ + (เดิมใช้ ent.spaceShooterSlot ที่ freeze ไว้ → เพี้ยนถ้า others ยังไม่ครบตอน layout) */ + const slot = (idx % 6) + 1; + const col = SPACE_SHOOTER_SHIP_COLORS[(slot - 1) % SPACE_SHOOTER_SHIP_COLORS.length]; const shipUrl = (playSpaceShooterShipImageUrls[slot - 1] || '').trim(); const shipRec = shipUrl ? ensureGauntletAssetImage(shipUrl) : null; const shipImg = shipRec && shipRec.ready && shipRec.img && shipRec.img.naturalWidth > 0 ? shipRec.img : null; @@ -16106,14 +16198,34 @@ * จุดกลางบอสระหว่างเล่น — ลอยแบบลูกโป่งจากผลรวม sine (deterministic ต่อเวลาในรอบ) * เพื่อให้ทุก client ได้ตำแหน่งใกล้เคียงกัน (ไม่ใช้ state สุ่มต่อเครื่อง) */ + /* เฟสเริ่มต้นของลูกศรวงกลมต่อ id — hash จาก id (เหมือนกันทุกเครื่อง) */ + function balloonBossAimPhaseForIdPlay(id) { + const s = String(id == null ? '' : id); + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; + return (h % 10000) / 10000 * Math.PI * 2; + } + + /* มุมลูกศรหมุนแบบ deterministic จากเวลา server + เฟสต่อ id → "วงๆ" หมุนตรงกันทุกเครื่อง + (เดิมสุ่ม init + บวก dt ต่อเครื่อง → คนละมุม) ความเร็ว 1.02 rad/s */ + function balloonBossAutoAimRadPlay(id) { + const tMs = balloonBossLiveAnchorServerMs > 0 ? (serverNow() - balloonBossLiveAnchorServerMs) : serverNow(); + const a = (tMs * 0.00102) + balloonBossAimPhaseForIdPlay(id); + return a % (Math.PI * 2); + } + function getBalloonBossBossLiveCenterPlay(md, ts) { if (!md || md.gameType !== 'balloon_boss') return getBalloonBossBossWorldCenterPlay(md, ts); const base = getBalloonBossBossWorldCenterPlay(md, ts); const w = md.width || 20, h = md.height || 15; const mw = w * ts, mh = h * ts; - const tSec = balloonBossSessionStartMs > 0 - ? (performance.now() - balloonBossSessionStartMs) / 1000 - : performance.now() / 1000; + /* ผูกเวลาบอสกับ anchor server (เหมือน timer/กระสุนบอส) → ตำแหน่งบอสตรงกันทุกเครื่อง + (เดิมใช้ balloonBossSessionStartMs = performance.now() local → คนละ baseline = บอสคนละที่) */ + const tSec = balloonBossLiveAnchorServerMs > 0 + ? (serverNow() - balloonBossLiveAnchorServerMs) / 1000 + : (balloonBossSessionStartMs > 0 + ? (performance.now() - balloonBossSessionStartMs) / 1000 + : performance.now() / 1000); const ax = Math.min(mw * 0.19, 240); const ay = Math.min(mh * 0.17, 200); const cx = base.cx + Math.sin(tSec * 0.33 + 0.75) * ax + Math.sin(tSec * 0.69 + 0.15) * (ax * 0.4); @@ -16213,19 +16325,7 @@ } function buildBalloonBossParticipantRefsPlay() { - const refs = []; - if (me) refs.push({ id: myId, ref: me }); - const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); - humanIds.forEach((id) => { - const o = others.get(id); - if (o) refs.push({ id, ref: o }); - }); - const botIds = [...others.keys()].filter(isPreviewBotId).sort(); - botIds.forEach((id) => { - const o = others.get(id); - if (o) refs.push({ id, ref: o }); - }); - return refs.filter((r) => r.ref); + return buildStableShipParticipantRefsPlay(); } function balloonBossBalloonsStartPlay() { @@ -16352,9 +16452,7 @@ ent.tx = ent.x; ent.ty = ent.y; ent.direction = 'down'; - if (typeof ent.balloonBossAimRad !== 'number' || !Number.isFinite(ent.balloonBossAimRad)) { - ent.balloonBossAimRad = Math.random() * Math.PI * 2; - } + ent.balloonBossAimRad = balloonBossAutoAimRadPlay(entry.id); }); } @@ -16410,6 +16508,21 @@ } } + /* host เป็นผู้ตัดสินจบเกม MG7 แล้ว broadcast → ทุกเครื่องจบพร้อมกัน (เดิมต่างคนต่างตัดสินจาก state ที่สะสมไม่พร้อมกัน → game over ไม่เท่ากัน) */ + function triggerBalloonBossOverPlay(reason) { + if (balloonBossGameEnded) return; + try { + if (socket && myId != null && isMePlayHost()) { + /* แนบตารางคะแนนสุดท้าย (host authoritative) → ทุกเครื่องโชว์อันดับ/คะแนนตรงกัน */ + const scores = buildStableShipParticipantRefsPlay().map((e) => ({ + id: e.id, score: Math.max(0, (e.ref && (e.ref.balloonBossScore | 0)) || 0), + })); + socket.emit('balloon-boss-over', { reason: reason, scores: scores }); + } + } catch (e) { /* ignore */ } + endBalloonBossGame(reason); + } + function stepBalloonBossPreviewBots(dt, mw, mh, bossCx, bossCy, ts) { if (!playBotsEnabled() || !isMePlayHost() || !mapData || mapData.gameType !== 'balloon_boss') return; [...others.keys()].filter(isPreviewBotId).forEach((bid) => { @@ -16439,14 +16552,22 @@ const bvx = Math.cos(aim) * bspd; const bvy = Math.sin(aim) * bspd; /** ยิงจากปลายลูกศรเหมือนผู้เล่น */ + const botSx = o.balloonBossCx + Math.cos(aim) * 26; + const botSy = o.balloonBossCy - 10.5 + Math.sin(aim) * 26; balloonBossPendingShots.push({ releaseAt: performance.now(), - sx: o.balloonBossCx + Math.cos(aim) * 26, - sy: o.balloonBossCy - 10.5 + Math.sin(aim) * 26, + sx: botSx, + sy: botSy, vx: bvx, vy: bvy, ownerId: bid, }); + /* host เป็นผู้จำลองบอท → broadcast กระสุนบอทให้ client อื่นเห็นด้วย */ + try { + if (socket && myId != null) { + socket.emit('balloon-boss-shot', { sx: botSx, sy: botSy, vx: bvx, vy: bvy, ownerId: bid, t0: serverNow() }); + } + } catch (eB) { /* ignore */ } /* สเปก MG7: บอทยิง 1 ครั้ง = −5 คะแนน เหมือนผู้เล่น (ต่ำสุด 0) */ o.balloonBossScore = Math.max(0, (o.balloonBossScore | 0) - 5); } @@ -16496,23 +16617,31 @@ return; } const remSec = balloonBossRemainingSecPlay(); - if (remSec != null && remSec <= 0) { - if (teamBossDmg >= maxHp) { endBalloonBossGame('victory'); return; } - /* หมดเวลาแต่บอสยังไม่ตาย → ลูกโป่งของคนที่ยังเหลือ "แตกหมด" ตายทั้งหมด (แพ้) */ - buildBalloonBossParticipantRefsPlay().forEach((e) => { - if (e.ref && !e.ref.balloonBossEliminated) { e.ref.balloonBossBalloons = 0; e.ref.balloonBossEliminated = true; } - }); - try { if (socket && myId != null) socket.emit('move', { x: me.x, y: me.y, direction: me.direction, balloonBossBalloons: 0, balloonBossEliminated: true }); } catch (eT) { /* ignore */ } - endBalloonBossGame('all_dead'); - return; - } - if (teamBossDmg >= maxHp) { - endBalloonBossGame('victory'); - return; - } const allRefsEarly = buildBalloonBossParticipantRefsPlay(); - if (allRefsEarly.length > 0 && allRefsEarly.every((e) => e.ref && e.ref.balloonBossEliminated)) { - endBalloonBossGame('all_dead'); + /* จบเกมแบบ host-authoritative: host ตัดสิน + broadcast, ทุกเครื่องจบพร้อมกัน + (เดิมแต่ละเครื่องตัดสินเองจาก teamBossDmg/elimination ที่สะสมไม่พร้อมกัน → game over ไม่เท่ากัน) */ + if (isMePlayHost()) { + if (remSec != null && remSec <= 0) { + if (teamBossDmg >= maxHp) { triggerBalloonBossOverPlay('victory'); return; } + /* หมดเวลาแต่บอสยังไม่ตาย → ลูกโป่งของคนที่ยังเหลือ "แตกหมด" ตายทั้งหมด (แพ้) */ + allRefsEarly.forEach((e) => { + if (e.ref && !e.ref.balloonBossEliminated) { e.ref.balloonBossBalloons = 0; e.ref.balloonBossEliminated = true; } + }); + try { if (socket && myId != null) socket.emit('move', { x: me.x, y: me.y, direction: me.direction, balloonBossBalloons: 0, balloonBossEliminated: true }); } catch (eT) { /* ignore */ } + triggerBalloonBossOverPlay('all_dead'); + return; + } + if (teamBossDmg >= maxHp) { + triggerBalloonBossOverPlay('victory'); + return; + } + if (allRefsEarly.length > 0 && allRefsEarly.every((e) => e.ref && e.ref.balloonBossEliminated)) { + triggerBalloonBossOverPlay('all_dead'); + return; + } + } else if (remSec != null && remSec <= -2) { + /* non-host fallback: เผื่อ host หลุด — เลยเวลามา 2s แล้วยังไม่ได้รับสัญญาณจบ ให้ปิดเกมเอง */ + endBalloonBossGame(teamBossDmg >= maxHp ? 'victory' : 'all_dead'); return; } @@ -16668,6 +16797,8 @@ }); const addScore = 10; const addBossDmg = 2; + /* score/dmg: เจ้าของเท่านั้นเป็นคนบวก (มนุษย์=ตัวเอง broadcast ผ่าน move, บอท=host broadcast ผ่าน fill-bot-state) + เครื่องอื่นห้ามบวกเอง — เดิม non-owner บวกจากกระสุนที่ถึงคนละจังหวะ → คะแนน/HP สุดท้ายไม่ตรง */ if (b.ownerId === myId) { me.balloonBossScore = Math.max(0, (me.balloonBossScore | 0) + addScore); me.balloonBossBossDmg = Math.max(0, (me.balloonBossBossDmg | 0) + addBossDmg); @@ -16681,7 +16812,7 @@ balloonBossEliminated: !!me.balloonBossEliminated, }); } - } else { + } else if (isPreviewBotId(b.ownerId) && isMePlayHost()) { const o = others.get(b.ownerId); if (o) { o.balloonBossScore = Math.max(0, (o.balloonBossScore | 0) + addScore); @@ -16735,10 +16866,7 @@ aliveRefs.forEach((e) => { const r = e.ref; if (!r || r.balloonBossEliminated) return; - if (typeof r.balloonBossAimRad !== 'number' || !Number.isFinite(r.balloonBossAimRad)) { - r.balloonBossAimRad = Math.random() * Math.PI * 2; - } - r.balloonBossAimRad += 1.02 * dt; + r.balloonBossAimRad = balloonBossAutoAimRadPlay(e.id); }); balloonBossPlayerFireCd -= dt; @@ -16756,14 +16884,22 @@ const vx = Math.cos(aim) * bspd; const vy = Math.sin(aim) * bspd; /** ยิงออกจาก "ปลายลูกศร" ไม่ใช่กลางตัว — วงอยู่เหนือตัว ~10.5px (world) รัศมีปลายหาง ~26px */ + const shotSx = me.balloonBossCx + Math.cos(aim) * 26; + const shotSy = me.balloonBossCy - 10.5 + Math.sin(aim) * 26; balloonBossPendingShots.push({ releaseAt: now + delayMs, - sx: me.balloonBossCx + Math.cos(aim) * 26, - sy: me.balloonBossCy - 10.5 + Math.sin(aim) * 26, + sx: shotSx, + sy: shotSy, vx, vy, ownerId: myId, }); + /* broadcast กระสุนให้ทุกคนเห็นตรงกัน (host เห็นยังไง ทุกคนเห็นแบบนั้น) */ + try { + if (socket && myId != null) { + socket.emit('balloon-boss-shot', { sx: shotSx, sy: shotSy, vx, vy, ownerId: myId, t0: serverNow() }); + } + } catch (eS) { /* ignore */ } /* สเปก MG7: ยิง 1 ครั้ง = −5 คะแนน (ต่ำสุด 0 ไม่ติดลบ) */ me.balloonBossScore = Math.max(0, (me.balloonBossScore | 0) - 5); } @@ -16794,6 +16930,9 @@ const hpLeft = Math.max(0, maxHp - dmg); const bossR = Math.max(36, ts * 1.15); const refs = buildBalloonBossParticipantRefsPlay(); + /* slot รูปลูกโป่งคำนวณสดจาก stable idx (เหมือนสูตรใน spawn layout) → ตรงกันทุกเครื่อง + ไม่อิง ent.balloonBossSkinSlot ที่ freeze ไว้ (เพี้ยนถ้า set ยังไม่ครบตอน layout) */ + const bbSlotCycle = getBalloonBossOccupiedSlotNumbersPlay(mapData); const leaderId = (() => { let best = null, bs = -1; refs.forEach((e) => { @@ -17125,16 +17264,12 @@ const [sx, sy] = worldToScreen(ent.balloonBossCx, ent.balloonBossCy); const col = BALLOON_BOSS_PLAYER_COLORS[idx % BALLOON_BOSS_PLAYER_COLORS.length]; const z = zDraw; - const skinSlot = (typeof ent.balloonBossSkinSlot === 'number' && ent.balloonBossSkinSlot >= 1 && ent.balloonBossSkinSlot <= 6) - ? ent.balloonBossSkinSlot - : ((idx % 6) + 1); + const skinSlot = bbSlotCycle.length ? bbSlotCycle[idx % bbSlotCycle.length] : ((idx % 6) + 1); const ringR = 25 * z; /** จุดกลางฟองบนจอ — ยกจากจุด world เล็กน้อยให้ตัวอยู่กลางวง ลูกโป่งชิดขอบบน (ตาม mock) */ const bubbleCy = sy - ringR * 0.42; /** มุมลูกศร Artboard 9 — หมุนอัตโนมัติ; Mega Virus ยิงตามมุมนี้ ณ ตอนกด Space */ - const angRing = (typeof ent.balloonBossAimRad === 'number' && Number.isFinite(ent.balloonBossAimRad)) - ? ent.balloonBossAimRad - : ((timeMs * 0.0019 + idx * 1.73) % (Math.PI * 2)); + const angRing = balloonBossAutoAimRadPlay(entry.id); /** หันหน้าเข้ากล้อง (mock) — ไม่ใช้ทิศออกจากบอสในเลเยอร์นี้ */ const faceDir = 'down'; const slotIdxRing = Math.max(0, Math.min(5, skinSlot - 1)); @@ -18378,7 +18513,7 @@ } others.clear(); plist.forEach(p => { - if (p.id !== myId) { + if (p.id !== myId && p.id !== socket.id) { const pos = peerXYFromJoin(p, mapData.spawn); let px = pos.x, py = pos.y; if (isQuizCarry()) { @@ -18411,6 +18546,7 @@ })(), balloonBossEliminated: !!p.balloonBossEliminated, quizCarryHeld: null, + bannedSpectator: !!p.bannedSpectator, }); } }); @@ -18691,6 +18827,7 @@ } myId = socket.id; try { syncPlayClock(); } catch (_sc) { /* ignore */ } + try { startPeriodicClockSync(); } catch (_sci) { /* ignore */ } if (res && res.missionLiveBeginsAt != null) setPlayMissionLiveBeginsAt(res.missionLiveBeginsAt); if (playBannedSpectator) setupBannedSpectatorUi(); if (specialQuizForceDebug) { @@ -18799,6 +18936,8 @@ socket.on('user-joined', (data) => { if (!data || isPreviewBotId(data.id)) return; + if (myId != null && String(data.id) === String(myId)) return; + if (socket && socket.id && String(data.id) === String(socket.id)) return; const x = Number(data.x); const y = Number(data.y); const px = Number.isFinite(x) ? x : 1; @@ -18824,6 +18963,7 @@ })(), balloonBossEliminated: !!data.balloonBossEliminated, quizCarryHeld: null, + bannedSpectator: !!data.bannedSpectator, }); if (playBotsEnabled()) rebalancePreviewBots(); if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud(); @@ -18840,37 +18980,58 @@ if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud(); if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud(); }); + /* upsert others ที่ขาดหายจาก peers snapshot ที่ server แนบมากับ lobby-sync + กัน host/client ไม่เห็นตัวละคร (และนับจำนวนผู้เล่นไม่เท่ากัน) เมื่อ plist/user-joined มาไม่ครบ + ช่วงเปลี่ยนหน้า lobbyB → play (ใช้ทั้ง quiz_carry และ crown family: MG1/2/3/5/6/7) */ + function upsertOthersFromPeersSnapPlay(peers) { + if (!Array.isArray(peers)) return false; + let changed = false; + peers.forEach((p) => { + if (!p || p.id == null || isPreviewBotId(p.id)) return; + if (myId != null && String(p.id) === String(myId)) return; + const existing = others.get(p.id) || others.get(String(p.id)); + if (existing) { + /* entry มีอยู่แล้ว — อัปเดต flag สำคัญ (โดยเฉพาะ bannedSpectator) ให้ตรง server + กัน host นับผู้ถูกแบนเป็นผู้เล่นที่ต้องรอ Ready → humansReady ไม่ครบ → เริ่มเกมไม่ได้ */ + const nb = !!p.bannedSpectator; + if (existing.bannedSpectator !== nb) { existing.bannedSpectator = nb; changed = true; } + return; + } + const px = Number(p.x); + const py = Number(p.y); + const x = Number.isFinite(px) ? px : 1; + const y = Number.isFinite(py) ? py : 1; + const joP = Number(p.spawnJoinOrder); + others.set(p.id, { + x, y, tx: x, ty: y, + direction: p.direction || 'down', + nickname: p.nickname, + characterId: p.characterId ?? null, + spawnJoinOrder: Number.isFinite(joP) ? Math.max(0, Math.floor(joP)) : 0, + playTint: playTintFromJoinPeer(p) || playTintForPreviewBotId(p.id) || resolvePlayTintForPeer(p.id, p), + gauntletJumpTicks: 0, gauntletJumpVis: 0, gauntletScore: 0, + jumpSurviveEliminated: false, spaceShooterScore: 0, + balloonBossScore: 0, balloonBossBossDmg: 0, + balloonBossBalloons: balloonBossBalloonsStartPlay(), + balloonBossEliminated: false, quizCarryHeld: null, + bannedSpectator: !!p.bannedSpectator, + }); + if (p.characterId) { + /* pre-warm เลเยอร์ + สีย้อม (tinted source) ของ peer ที่เพิ่งรู้จัก → avatar ในช่อง SCORE + ขึ้นสีตรงกันทุกเครื่อง (กันฝั่ง client เห็น avatar เป็นสีเทา/ไม่มีสี) */ + const tintP = playTintFromJoinPeer(p) || playTintForPreviewBotId(p.id) || resolvePlayTintForPeer(p.id, p); + preloadPlayTintForAvatar(p.characterId, tintP); + } + changed = true; + }); + return changed; + } + socket.on('quiz-carry-lobby-sync', (d) => { if (!d || typeof d.readyMap !== 'object') return; quizCarryLobbyReadyMap = { ...d.readyMap }; - /* 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 (Number.isFinite(d.expectedActive)) missionExpectedActiveCount = d.expectedActive; + upsertOthersFromPeersSnapPlay(d.peers); if (quizCarryPregameActive && isQuizCarry()) { updateQuizCarryPregameHud(); } @@ -18895,6 +19056,13 @@ socket.on('gauntlet-crown-lobby-sync', (d) => { if (!d || typeof d.readyMap !== 'object') return; gauntletCrownLobbyReadyMap = { ...d.readyMap }; + if (Number.isFinite(d.expectedActive)) missionExpectedActiveCount = d.expectedActive; + /* เติม others ที่ขาด → host/client เห็นจำนวนผู้เล่นเท่ากัน + ยาน spawn ตรง slot เดียวกัน + re-layout เฉพาะตอนยังไม่เริ่มเล่น (pre-live) ไม่งั้นยานคนที่กำลังเล่นจะถูกดีดกลับจุดเกิด */ + if (upsertOthersFromPeersSnapPlay(d.peers)) { + if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') applySpaceShooterSpawnLayoutPlay(); + if (mapData && mapData.gameType === 'balloon_boss' && !(balloonBossLiveAnchorServerMs > 0)) applyBalloonBossSpawnLayoutPlay(); + } if (gauntletCrownPregamePhase === 'howto') { gauntletCrownSyncGuestReadyIfNeeded(); updateGauntletCrownHowtoHud(); @@ -18939,13 +19107,46 @@ if (!data || !Array.isArray(data.bots)) return; /* เกมที่ render อ่านตำแหน่งจาก x/y หรือ Cx/Cy ตรงๆ (ไม่ผ่าน lerp ของ main tick) → สแนปตำแหน่งเลย */ const snapPos = isSpaceShooter() || isBalloonBoss() || isJumpSurvive(); + /* ชุดบอทต้องตรงกับ host เป๊ะ — กัน client คำนวณ wantBots ต่างจาก host (เช่นนับ human เกิน) + แล้วได้บอทคนละจำนวน → position/การตายไม่ตรงกัน + (host ส่งบอททั้งหมดทุก 80ms → ตัวที่ไม่อยู่ในลิสต์ = host ไม่มี → ลบทิ้ง; ตัวที่ขาด = สร้างเพิ่ม) */ + const hostBotIds = new Set(); + data.bots.forEach((rb) => { if (rb && isPreviewBotId(rb.id)) hostBotIds.add(String(rb.id)); }); + [...others.keys()].filter(isPreviewBotId).forEach((bid) => { + if (!hostBotIds.has(String(bid))) others.delete(bid); + }); data.bots.forEach((rb) => { if (!rb || !isPreviewBotId(rb.id)) return; - const o = others.get(rb.id); - if (!o) return; + let o = others.get(rb.id); + if (!o) { + /* บอทที่ host มีแต่ client ยังไม่มี → สร้าง entry ใหม่ให้ render ได้ (ตำแหน่ง/สีตามที่ host ส่ง) */ + const nx = Number.isFinite(rb.x) ? rb.x : 1; + const ny = Number.isFinite(rb.y) ? rb.y : 1; + o = { + x: nx, y: ny, tx: nx, ty: ny, + direction: rb.direction || 'down', + nickname: 'บอท', + characterId: rb.cid != null ? rb.cid : null, + playTint: (rb.tint && rb.tint.body) ? { head: rb.tint.head, hair: rb.tint.hair, body: rb.tint.body } : (playTintForPreviewBotId(rb.id) || resolvePlayTintForPeer(rb.id)), + spaceShooterScore: 0, balloonBossScore: 0, balloonBossBossDmg: 0, + balloonBossBalloons: balloonBossBalloonsStartPlay(), balloonBossEliminated: false, + spaceShooterEliminated: false, jumpSurviveEliminated: false, quizCarryHeld: null, + }; + others.set(rb.id, o); + if (o.characterId) { try { preloadPlayTintForAvatar(o.characterId, o.playTint); } catch (_e) { /* ignore */ } } + } + if (rb.cid != null && o.characterId == null) o.characterId = rb.cid; if (Number.isFinite(rb.x)) { o.tx = rb.x; if (snapPos || !Number.isFinite(o.x)) o.x = rb.x; } if (Number.isFinite(rb.y)) { o.ty = rb.y; if (snapPos || !Number.isFinite(o.y)) o.y = rb.y; } if (rb.direction) o.direction = rb.direction; + /* สีบอทจาก host → ใช้ตามให้ตรงกันทุกเครื่อง (อัปเดตเฉพาะเมื่อเปลี่ยน กัน churn ทุกเฟรม) */ + if (rb.tint && rb.tint.body) { + const tk = String(rb.tint.head) + '|' + String(rb.tint.hair) + '|' + String(rb.tint.body); + if (o._fbTintKey !== tk) { + o._fbTintKey = tk; + o.playTint = { head: rb.tint.head, hair: rb.tint.hair, body: rb.tint.body }; + } + } o.botIsWalking = !!rb.walking; if (rb.gScore != null) o.gauntletScore = rb.gScore; if (rb.gElim != null) o.gauntletEliminated = !!rb.gElim; @@ -18966,6 +19167,53 @@ }); }); + /* MG7 balloon_boss — รับกระสุนของผู้เล่น/บอทคนอื่น แล้วเพิ่มเข้า array ให้เห็นตรงกันทุกเครื่อง + fast-forward ตำแหน่งตามเวลาที่ผ่านไปจาก t0 (กระสุนผู้เล่นไม่มีแรงโน้มถ่วง → ตำแหน่ง = spawn + v*elapsed เป๊ะทุกเครื่อง) */ + socket.on('balloon-boss-shot', (d) => { + if (!d || !mapData || mapData.gameType !== 'balloon_boss') return; + if (d.ownerId != null && myId != null && String(d.ownerId) === String(myId)) return; /* ของตัวเองเพิ่มไปแล้ว */ + const sx = Number(d.sx), sy = Number(d.sy), vx = Number(d.vx), vy = Number(d.vy); + if (![sx, sy, vx, vy].every(Number.isFinite)) return; + const t0 = Number(d.t0); + const fwd = Number.isFinite(t0) ? Math.max(0, Math.min(2.5, (serverNow() - t0) / 1000)) : 0; + balloonBossPlayerBullets.push({ x: sx + vx * fwd, y: sy + vy * fwd, vx, vy, ownerId: d.ownerId }); + }); + + /* MG7 — รับสัญญาณจบเกมจาก host → จบพร้อมกันทุกเครื่อง + ใช้คะแนนสุดท้ายของ host (อันดับตรงกัน) */ + socket.on('balloon-boss-over', (d) => { + if (!mapData || mapData.gameType !== 'balloon_boss' || balloonBossGameEnded) return; + const reason = (d && d.reason === 'victory') ? 'victory' : 'all_dead'; + if (d && Array.isArray(d.scores)) { + d.scores.forEach((s) => { + if (!s || s.id == null) return; + const sc = Math.max(0, Number(s.score) || 0); + if (myId != null && String(s.id) === String(myId)) me.balloonBossScore = sc; + else { const o = others.get(String(s.id)) || others.get(s.id); if (o) o.balloonBossScore = sc; } + }); + } + endBalloonBossGame(reason); + }); + + /* MG6 space_shooter — รับกระสุนผู้เล่น/บอทคนอื่น (ตรงดิ่งขึ้น vy คงที่) → ตำแหน่ง = y + vy*elapsed เป๊ะทุกเครื่อง */ + socket.on('space-shooter-shot', (d) => { + if (!d || !mapData || mapData.gameType !== 'space_shooter') return; + if (d.ownerId != null && myId != null && String(d.ownerId) === String(myId)) return; + const x = Number(d.x), y = Number(d.y), vy = Number(d.vy); + if (![x, y, vy].every(Number.isFinite)) return; + const t0 = Number(d.t0); + const fwd = Number.isFinite(t0) ? Math.max(0, Math.min(2, (serverNow() - t0) / 1000)) : 0; + spaceShooterBullets.push({ x, y: y + vy * fwd, vy, ownerId: d.ownerId }); + }); + + /* MG6 — รับสัญญาณจบเกมจาก host → ใช้คะแนนสุดท้ายของ host แล้วจบพร้อมกัน (อันดับตรงกัน) */ + socket.on('space-shooter-over', (d) => { + if (!mapData || mapData.gameType !== 'space_shooter' || spaceShooterGameEnded) return; + if (!isSpaceShooterMissionUiMapPlay()) return; + const kind = (d && d.kind === 'all_dead') ? 'all_dead' : 'time_up'; + if (d) applySpaceShooterOverScoresPlay(d.scores); + endSpaceShooterMissionRound(kind); + }); + socket.on('user-move', (data) => { if (data && myId != null && data.id === myId) { /* Gauntlet: ตำแหน่งอ้างอิงจาก gauntlet-sync + เลอร์ปเท่านั้น — ไม่งั้น echo จาก move จะสแนป me.x/y โดยไม่อัปเดต tx/ty ทำให้เลอร์ปผิดและชนเซิร์ฟเวอร์ไม่ตรง */ @@ -19056,9 +19304,9 @@ } else { o.tx = data.x; o.ty = data.y; - /* quiz + quiz_carry: ผู้เล่นยืน/ขยับเป็นช่องบนแมปคงที่ → สแนปตำแหน่งทันที (ไม่พึ่ง lerp อย่างเดียว) - กัน host ค้าง/หายจากจอเมื่อ move มาห่างหรือมาไม่ต่อเนื่อง */ - if (mapData && (isQuiz() || isQuizCarry())) { + /* quiz (ยืนบนช่องคงที่ + move มาห่าง) → สแนปทันที กัน host ค้าง/หาย + quiz_carry → ปล่อยให้ lerp (move ถี่ทุก 80ms) เพื่อให้ "ตัวละครคนอื่นมี animation เดิน" (เดิมสแนป = ไม่มี delta = ยืนนิ่ง) */ + if (mapData && isQuiz() && !isQuizCarry()) { const px = Number(data.x); const py = Number(data.y); if (Number.isFinite(px)) o.x = px; @@ -19067,9 +19315,20 @@ } o.direction = data.direction || o.direction; if (data.characterId != null) o.characterId = data.characterId; - if (mapData && mapData.gameType === 'space_shooter' && data.spaceShooterScore != null) { - const s = Number(data.spaceShooterScore); - if (Number.isFinite(s)) o.spaceShooterScore = Math.max(0, s); + if (mapData && isQuizCarry() && data.quizCarryHeld !== undefined) { + o.quizCarryHeld = (data.quizCarryHeld == null ? null : data.quizCarryHeld); + } + if (mapData && mapData.gameType === 'space_shooter') { + if (data.spaceShooterScore != null) { + const s = Number(data.spaceShooterScore); + if (Number.isFinite(s)) o.spaceShooterScore = Math.max(0, s); + } + if (data.spaceShooterHits != null) { + const hh = Number(data.spaceShooterHits); + if (Number.isFinite(hh)) o.spaceShooterHits = Math.max(0, hh); + } + /* ตาย/รอด มาจากเจ้าของยานเท่านั้น → ทุกเครื่องเห็นตรงกัน */ + if (data.spaceShooterEliminated != null) o.spaceShooterEliminated = !!data.spaceShooterEliminated; } if (mapData && mapData.gameType === 'balloon_boss') { if (data.balloonBossScore != null) { @@ -22057,7 +22316,18 @@ const existImgs = ul.querySelectorAll('img.play-cyber-score-av'); if (existImgs.length === hudRows.length) { for (let i = 0; i < existImgs.length; i++) { - if (!cyberHudScoreAvatarImgLooksReady(existImgs[i])) setCyberHudScoreAvatarImg(existImgs[i], hudRows[i]); + /* ย้ำ retry ถ้ายังไม่ใช่หน้าย้อมสี (data-url) ใน mock hud — กันค้าง raw frame ไม่มีสี (bot/remote) + throttle 600ms กันสร้าง retry-chain ซ้ำซ้อนทุกเฟรม */ + const im = existImgs[i]; + const needTinted = isMissionMockHudPlay() && !(im && typeof im.src === 'string' && im.src.indexOf('data:image/') === 0); + if (!cyberHudScoreAvatarImgLooksReady(im) || needTinted) { + const nowR = Date.now(); + const lastR = Number(im && im.dataset ? im.dataset.hudAvRetryAt : 0) || 0; + if (nowR - lastR > 600) { + if (im && im.dataset) im.dataset.hudAvRetryAt = String(nowR); + applyCyberHudScoreAvatarImgWithRetry(im, hudRows[i], 0); + } + } } return; } @@ -22077,7 +22347,9 @@ + (mg2MockHud ? ' play-cyber-score-av--mg2' : '') + (stripScoreHud && !mg2MockHud ? ' play-cyber-score-av--crown-strip' : ''); av.alt = ''; - setCyberHudScoreAvatarImg(av, row); + /* ใช้เวอร์ชัน retry (preload tint+layers จน compose หน้าแบบย้อมสีสำเร็จ) + กัน bot/remote โชว์ raw frame ไม่ย้อมสี เมื่อ character layers ยังโหลดไม่เสร็จ (พบบน host เพราะบอทไม่ได้ preload จาก plist) */ + applyCyberHudScoreAvatarImgWithRetry(av, row, 0); const sc = document.createElement('span'); sc.className = 'play-cyber-score-val' + (cyberQmMockHud ? ' play-cyber-qm-score' : '') + (mg2MockHud ? ' play-cyber-mg2-score' : '') @@ -23312,6 +23584,12 @@ document.addEventListener('keyup', (e) => { keys[e.code] = false; keys[e.key] = false; }); let lastSend = 0; + /* payload move มาตรฐาน — quiz_carry แนบ quizCarryHeld เพื่อให้คนอื่นเห็นว่าเราถืออะไร */ + function buildPlayMovePayload() { + const mv = { x: me.x, y: me.y, direction: me.direction }; + if (isQuizCarry()) mv.quizCarryHeld = (me.quizCarryHeld == null ? null : me.quizCarryHeld); + return mv; + } function tick() { if (!mapData) { requestAnimationFrame(tick); return; } /* คำถามพิเศษเปิดอยู่ — หยุดเกมทุกอย่าง (input/sim/timer) เห็นแต่ภาพค้างไว้ @@ -23482,7 +23760,7 @@ clampPlayEntityFootprintToMap(me, mapData); enforceQuizBattleLaneOnMePlay(); const t = Date.now(); - if (t - lastSend > 80) { lastSend = t; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } + if (t - lastSend > 80) { lastSend = t; socket.emit('move', buildPlayMovePayload()); } maybeAutoOpenQuizBattle(); draw(); requestAnimationFrame(tick); @@ -23550,7 +23828,7 @@ const movedThisTick = Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5; me.isWalking = !!(accX !== 0 || accY !== 0) || playPath.length > 0 || movedThisTick; const now = Date.now(); - if (now - lastSend > 80) { lastSend = now; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } + if (now - lastSend > 80) { lastSend = now; socket.emit('move', buildPlayMovePayload()); } maybeAutoOpenQuizBattle(); draw(); requestAnimationFrame(tick); @@ -23737,7 +24015,8 @@ if (jumpSurviveMissionPhase !== 'howto') return; const humans = quizCarryPregameHumanIds(); const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); - if (totPlayers === 1) { + /* เริ่มเองได้เฉพาะ offline/พรีวิว — ห้องจริงต้องผ่าน server (กัน host เริ่มคนเดียวก่อน client พร้อม) */ + if (totPlayers === 1 && (previewMode || !socket || !socket.connected)) { if (!(humans.length === 1 || isMePlayHost())) return; beginJumpSurviveMissionCountdownThenRun(); return; @@ -23752,7 +24031,7 @@ return; } if (myId == null) return; - const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); + const humansReady = gauntletCrownAllReadyPlay(); if (humansReady) { socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } }); return; @@ -23768,7 +24047,8 @@ if (spaceShooterMissionPhase !== 'howto') return; const humansSs = quizCarryPregameHumanIds(); const totPlayersSs = Math.max(1, quizCarryPregameTotalPlayers()); - if (totPlayersSs === 1) { + /* เริ่มเองได้เฉพาะ offline/พรีวิว — ห้องจริงต้องผ่าน server (กัน host เริ่มคนเดียวก่อน client พร้อม) */ + if (totPlayersSs === 1 && (previewMode || !socket || !socket.connected)) { if (!(humansSs.length === 1 || isMePlayHost())) return; beginSpaceShooterMissionCountdownThenRun(); return; @@ -23783,7 +24063,7 @@ return; } if (myId == null) return; - const humansReadySs = humansSs.length > 0 && humansSs.every((id) => !!gauntletCrownLobbyReadyMap[id]); + const humansReadySs = gauntletCrownAllReadyPlay(); if (humansReadySs) { socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } }); return; @@ -23799,7 +24079,8 @@ if (quizQuestionMissionPhase !== 'howto') return; const humans = quizCarryPregameHumanIds(); const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); - if (totPlayers === 1) { + /* เริ่มเองได้เฉพาะ offline/พรีวิว — ห้องจริงต้องผ่าน server (กัน host เริ่มคนเดียวก่อน client พร้อม) */ + if (totPlayers === 1 && (previewMode || !socket || !socket.connected)) { beginQuizQuestionMissionCountdownThenRun(); return; } @@ -23813,7 +24094,7 @@ return; } if (myId == null) return; - const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); + const humansReady = gauntletCrownAllReadyPlay(); if (humansReady) { socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } }); return; @@ -23829,7 +24110,8 @@ if (stackTowerMissionPhase !== 'howto') return; const humansSt = quizCarryPregameHumanIds(); const totPlayersSt = Math.max(1, quizCarryPregameTotalPlayers()); - if (totPlayersSt === 1) { + /* เริ่มเองได้เฉพาะ offline/พรีวิว — ห้องจริงต้องผ่าน server (กัน host เริ่มคนเดียวก่อน client พร้อม) */ + if (totPlayersSt === 1 && (previewMode || !socket || !socket.connected)) { if (!(humansSt.length === 1 || isMePlayHost())) return; beginStackTowerMissionCountdownThenRun(); return; @@ -23844,7 +24126,7 @@ return; } if (myId == null) return; - const humansReadySt = humansSt.length > 0 && humansSt.every((id) => !!gauntletCrownLobbyReadyMap[id]); + const humansReadySt = gauntletCrownAllReadyPlay(); if (humansReadySt) { socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } }); return; @@ -23858,11 +24140,22 @@ } if (!usesCrownLobbyShellPlay()) return; if (gauntletCrownPregamePhase !== 'howto') return; - if (myId == null || !isMePlayHost()) return; + if (myId == null) return; + /* guest (client) ต้องกด Ready เองได้ด้วย — เดิม block นี้ return ทันทีถ้าไม่ใช่ host + ทำให้ MG2/MG7 (balloon_boss) client กด Ready ไม่ได้ → host start ไม่ได้ */ + if (!isMePlayHost()) { + const sidG = String(myId); + const nextG = !gauntletCrownLobbyReadyMap[sidG]; + gauntletCrownLobbyReadyMap[sidG] = nextG; + updateGauntletCrownHowtoHud(); + if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextG }); + return; + } const humans = quizCarryPregameHumanIds(); - const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); + const humansReady = gauntletCrownAllReadyPlay(); if (humansReady) { - if (humans.length === 1 && isMePlayHost()) { + /* เริ่มเองได้เฉพาะ offline/พรีวิว — ห้องจริงต้องผ่าน server (กัน host เริ่มคนเดียวก่อน client พร้อม) */ + if (humans.length === 1 && isMePlayHost() && (previewMode || !socket || !socket.connected)) { beginGauntletCrownCountdownThenRun(); return; } @@ -23889,7 +24182,7 @@ primary.addEventListener('click', () => { if (!quizCarryPregameActive || myId == null) return; const humans = quizCarryPregameHumanIds(); - const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]); + const humansReady = quizCarryAllReadyPlay(); if (humansReady) { /* ทุกคนพร้อมแล้ว → เฉพาะ host กด START */ if (!isMePlayHost()) return; diff --git a/www/html/Game/public/js/room-lobby.js b/www/html/Game/public/js/room-lobby.js index 6bfb203..d212e6e 100644 --- a/www/html/Game/public/js/room-lobby.js +++ b/www/html/Game/public/js/room-lobby.js @@ -174,6 +174,10 @@ let suspectSelectedIndex = 0; /** ตรงกับเซิร์ฟ — เฟสเลือกผู้ต้องสงสัยยังไม่จบ (ปิด overlay แล้วยังเปิดได้จากปุ่มโถง) */ let serverSuspectPhaseActive = false; + /** จำนวนผู้เล่นที่กลับเข้า LobbyB แล้ว/ทั้งหมด — host เริ่มมินิเกมรอบถัดไปได้เมื่อครบ (allHere) */ + let lobbyBPresentCount = 0; + let lobbyBTotalCount = 0; + let lobbyBAllHere = true; /** มินิเกม 3 แบบที่สุ่มแล้ว — ล็อกกับการ์ด 0–2 จนกว่าสร้างห้องใหม่ */ let suspectCardMinigames = []; /** หลักฐาน (ติกถูก) ที่เก็บได้ต่อผู้ต้องสงสัย 0–2 (เล่นมินิเกมจบ +1 ต่อช่อง สูงสุด 3) */ @@ -6274,7 +6278,9 @@ const accuseBtn = document.getElementById('suspect-btn-accuse'); const startBtn = document.getElementById('suspect-btn-start'); const hint = document.getElementById('suspect-pick-hint'); - const canStartInvestigation = isHost && canInvestigateSuspect(suspectSelectedIndex); + /* ต้องรอผู้เล่นทุกคนกลับเข้า LobbyB ครบก่อน host ถึงเริ่มได้ (กันคนยังโหลดไม่เสร็จหลุดรอบ) */ + const waitingForPlayers = !lobbyBAllHere; + const canStartInvestigation = isHost && canInvestigateSuspect(suspectSelectedIndex) && !waitingForPlayers; if (startBtn) { startBtn.disabled = !canStartInvestigation; startBtn.classList.toggle('suspect-btn-start--disabled', !canStartInvestigation); @@ -6283,6 +6289,8 @@ if (hint) { if (!isHost) { hint.textContent = 'รอ Host เลือกการ์ดและกดเริ่มสืบสวน'; + } else if (waitingForPlayers) { + hint.textContent = 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + lobbyBPresentCount + '/' + lobbyBTotalCount + ') แล้วจึงเริ่มได้'; } else if (!canInvestigateSuspect(suspectSelectedIndex)) { hint.textContent = 'สืบผู้ต้องสงสัยคนนี้ครบ 3 หลักฐานแล้ว — เลือกคนอื่นเพื่อสืบต่อ'; } else if (allSuspectsInvestigated()) { @@ -6409,6 +6417,15 @@ applySuspectSelectionVisual(idx); }); + /* เซิร์ฟแจ้งจำนวนผู้เล่นที่กลับเข้า LobbyB แล้ว — ใช้ล็อกปุ่มเริ่มจนกว่าจะครบทุกคน */ + socket.on('detective-lobbyb-presence', (data) => { + if (!data) return; + lobbyBPresentCount = Number(data.present) || 0; + lobbyBTotalCount = Number(data.total) || 0; + lobbyBAllHere = (data.allHere !== false) && (lobbyBTotalCount <= 0 || lobbyBPresentCount >= lobbyBTotalCount); + if (suspectPickOverlayOpen) updateSuspectHostUi(); + }); + // ===== ห้องสรุปหลักฐาน (ไต่สวน) ===== socket.on('testimony-open', (data) => { openTestimony(data || {}); }); socket.on('testimony-status', (data) => { testimonyStatusUpdate(data || {}); }); diff --git a/www/html/Game/public/play.html b/www/html/Game/public/play.html index 53bbfaf..c715e63 100644 --- a/www/html/Game/public/play.html +++ b/www/html/Game/public/play.html @@ -5205,7 +5205,7 @@ - +
v —