From 35e42be4f4d23afcaf9cdce717c6f57c785894e0 Mon Sep 17 00:00:00 2001 From: giteaadmin Date: Mon, 22 Jun 2026 17:09:05 +0000 Subject: [PATCH] update sync game 1-7 --- www/html/Admin/private/store.json | 138 ++++++++- www/html/Game/data/game-timing.json | 4 +- www/html/Game/public/js/play.js | 431 ++++++++++++++++++++++++---- www/html/Game/server.js | 119 +++++++- www/html/Main-Lobby/index.html | 2 +- www/html/Main-Lobby/lobby.js | 1 + 6 files changed, 626 insertions(+), 69 deletions(-) diff --git a/www/html/Admin/private/store.json b/www/html/Admin/private/store.json index 70991f9..92bc6fb 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": 897, + "coins": 945, "createdAt": "2026-04-02T05:52:21+00:00", - "updatedAt": "2026-06-19T09:42:28+00:00", + "updatedAt": "2026-06-22T16:52:22+00:00", "daily": { "anchorMs": 1781197200000, "claimedDays": [ @@ -65,9 +65,9 @@ false, false ], - "lockUntilMs": 1781888400000 + "lockUntilMs": 0 }, - "score": 792, + "score": 840, "scoreByCase": { "1": 16, "10": 320, @@ -76,7 +76,7 @@ "13": 40, "14": 20, "9": 40, - "12": 60, + "12": 108, "15": 20, "4": 10, "5": 20, @@ -145,14 +145,24 @@ { "id": "0f14afd3ee469abd185d026c", "email": "", - "displayName": "Guest", + "displayName": "MONE", "loginType": "guest", "providerUserId": "p_1778099867825_vd4pphrr5a", "notes": "auto: player-coins", "blocked": false, - "coins": 0, + "coins": 72, "createdAt": "2026-05-06T20:37:46+00:00", - "updatedAt": "2026-05-06T20:37:46+00:00" + "updatedAt": "2026-06-22T17:04:52+00:00", + "lobbyColorThemeIndex": 5, + "lobbySkinToneIndex": 1, + "score": 72, + "scoreByCase": { + "12": 72 + }, + "lbName": "MONE", + "achievements": { + "d1_minigame_solver": 9 + } }, { "id": "7c655e167f8dd9f927cd94b2", @@ -364,7 +374,7 @@ "blocked": false, "coins": 10, "createdAt": "2026-06-19T06:23:24+00:00", - "updatedAt": "2026-06-19T06:24:28+00:00", + "updatedAt": "2026-06-22T13:54:04+00:00", "daily": { "anchorMs": 1781802000000, "claimedDays": [ @@ -376,7 +386,7 @@ false, false ], - "lockUntilMs": 1781888400000 + "lockUntilMs": 0 } }, { @@ -615,6 +625,114 @@ "achievements": { "d1_minigame_solver": 2 } + }, + { + "id": "9940a0de4693908fab29c349", + "email": "", + "displayName": "MONE", + "loginType": "guest", + "providerUserId": "p_1782122326943_7p2fnvivscm", + "notes": "auto: player-coins", + "blocked": false, + "coins": 20, + "createdAt": "2026-06-22T09:58:46+00:00", + "updatedAt": "2026-06-22T10:04:04+00:00", + "lobbyColorThemeIndex": 3, + "lobbySkinToneIndex": 1, + "score": 20, + "scoreByCase": { + "10": 20 + }, + "lbName": "MONE", + "achievements": { + "d1_minigame_solver": 1 + } + }, + { + "id": "053be00ea728989cff210a11", + "email": "", + "displayName": "ผู้เล่น", + "loginType": "guest", + "providerUserId": "p_1782122328583_4mqrfx5txyo", + "notes": "auto: player-coins", + "blocked": false, + "coins": 26, + "createdAt": "2026-06-22T09:58:48+00:00", + "updatedAt": "2026-06-22T12:17:11+00:00", + "lobbyColorThemeIndex": 1, + "lobbySkinToneIndex": 1, + "score": 26, + "scoreByCase": { + "10": 8, + "8": 18 + }, + "lbName": "MONE", + "achievements": { + "d1_minigame_solver": 1 + } + }, + { + "id": "3d78f962ad8309e86cf2b4c4", + "email": "", + "displayName": "ผู้เล่น", + "loginType": "guest", + "providerUserId": "p_1782130171358_cwsd1t9lt2c", + "notes": "auto: player-coins", + "blocked": false, + "coins": 18, + "createdAt": "2026-06-22T12:09:30+00:00", + "updatedAt": "2026-06-22T12:17:11+00:00", + "score": 18, + "scoreByCase": { + "8": 18 + }, + "lbName": "ผู้เล่น", + "achievements": { + "d1_minigame_solver": 1 + } + }, + { + "id": "2a1635c13b08c05b62dbb53c", + "email": "", + "displayName": "ผู้เล่น", + "loginType": "guest", + "providerUserId": "p_1778097234255_htbbepc8x3m", + "notes": "auto: player-coins", + "blocked": false, + "coins": 50, + "createdAt": "2026-06-22T13:52:23+00:00", + "updatedAt": "2026-06-22T14:43:06+00:00", + "score": 50, + "scoreByCase": { + "12": 20, + "13": 30 + }, + "lbName": "ผู้เล่น", + "achievements": { + "d1_minigame_solver": 5 + } + }, + { + "id": "07361aa244ad0d88dc486d5f", + "email": "", + "displayName": "MONE", + "loginType": "guest", + "providerUserId": "p_1782136893353_1wlj7nbaoli", + "notes": "auto: player-coins", + "blocked": false, + "coins": 16, + "createdAt": "2026-06-22T14:01:32+00:00", + "updatedAt": "2026-06-22T14:28:00+00:00", + "lobbyColorThemeIndex": 8, + "lobbySkinToneIndex": 1, + "score": 16, + "scoreByCase": { + "13": 16 + }, + "lbName": "MONE", + "achievements": { + "d1_minigame_solver": 2 + } } ] } \ No newline at end of file diff --git a/www/html/Game/data/game-timing.json b/www/html/Game/data/game-timing.json index befa624..9ab5ccd 100644 --- a/www/html/Game/data/game-timing.json +++ b/www/html/Game/data/game-timing.json @@ -89,9 +89,9 @@ "balloonBossPlayerBalloonFallbackUrl": "/Game/img/MegaVirus/Artboard%209.png", "balloonBossBalloonsPerPlayer": 3, "forcedMinigameKeys": [ + "quiz", "quiz_carry", - "space_shooter", - "balloon_boss" + "jump_survive" ], "testSpecialCardByMap": {}, "troublesomeForceOffer": false, diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index 966d080..58eba2c 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -383,8 +383,15 @@ 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', {}, function (res) { + 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; }); @@ -425,6 +432,25 @@ 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) { @@ -589,42 +615,119 @@ let stackPreviewTurnIndex = 0; let stackPreviewBotThinkUntil = 0; + /** ลำดับตา MG3 = ผู้เล่นจริงทุกคน + บอท เรียงตาม id แบบคงที่ → ทุกเครื่องได้ลำดับ/ที่นั่งเดียวกัน (shared tower) + (เดิม [human=ตัวเอง seat1 + bots] ต่อเครื่อง → แต่ละเครื่องมองตัวเองเป็นคนเดียว เลยแยกตึกกัน) */ function rebuildStackPreviewTurnOrder() { if (!playBotsEnabled() || !mapData || mapData.gameType !== 'stack') { stackPreviewTurnOrder = null; return; } - const bots = [...others.keys()].filter(isPreviewBotId).sort(); - stackPreviewTurnOrder = [{ kind: 'human', seat: 1 }]; - for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) { - stackPreviewTurnOrder.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null }); + 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; } - stackPreviewTurnIndex = 0; + /* เล่นจริง: non-host ไม่สร้าง order เอง — ใช้ของ host จาก stack-state (กัน order ไม่ตรง/client ตกหล่นช่วงโหลด) */ + if (!isMePlayHost()) return; + /* host: ผู้เล่นจริงทุกคน (ตัด banned spectator กัน stall) + บอท เรียง id → ที่นั่งคงที่; preserve คนที่กำลังถึงตา */ + const prevActorId = (stackCurrentTurnEntryPlay() && stackCurrentTurnEntryPlay().id) || null; + const parts = []; + if (myId != null && !playBannedSpectator) parts.push({ kind: 'human', id: String(myId) }); + others.forEach((o, id) => { + if (isPreviewBotId(id)) { parts.push({ kind: 'bot', id: String(id), botId: String(id) }); return; } + if (o && o.bannedSpectator) return; /* คนถูกแบน = ดูเฉยๆ ไม่เข้าคิววาง */ + parts.push({ kind: 'human', id: String(id) }); + }); + parts.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const capped = parts.slice(0, STACK_PREVIEW_TURN_COUNT); + stackPreviewTurnOrder = capped.map((p, i) => ({ + kind: p.kind, seat: i + 1, id: p.id, botId: p.kind === 'bot' ? p.botId : null, + })); + let ni = -1; + if (prevActorId != null) ni = stackPreviewTurnOrder.findIndex((e) => String(e.id) === String(prevActorId)); + if (ni >= 0) stackPreviewTurnIndex = ni; + else if (stackPreviewTurnIndex >= stackPreviewTurnOrder.length) stackPreviewTurnIndex = 0; stackPreviewBotThinkUntil = 0; markStackNextDropVisualDirty(); + broadcastStackStatePlay(); + } + + /** host broadcast ลำดับตา + index ปัจจุบัน → non-host ใช้ตรงกัน (single source of truth) */ + function broadcastStackStatePlay() { + if (!detectiveCaseFillBots || !isMePlayHost() || !socket || myId == null || !stackTurnOrderReadyPlay()) return; + try { + socket.emit('stack-state', { + idx: stackPreviewTurnIndex, + order: stackPreviewTurnOrder.map((e) => ({ kind: e.kind, seat: e.seat, id: e.id, botId: e.botId || null })), + }); + } catch (e) { /* ignore */ } + } + + /** order พร้อมใช้ (มีอย่างน้อย 1 ที่นั่ง) */ + function stackTurnOrderReadyPlay() { + return !!(stackPreviewTurnOrder && stackPreviewTurnOrder.length >= 1); + } + function stackCurrentTurnEntryPlay() { + if (!stackTurnOrderReadyPlay()) return null; + return stackPreviewTurnOrder[stackPreviewTurnIndex % stackPreviewTurnOrder.length] || null; + } + function isMyStackTurn() { + if (!playBotsEnabled()) return true; /* เล่นเดี่ยว → กดได้ */ + if (detectiveCaseFillBots && !stackTurnOrderReadyPlay()) return false; /* เล่นจริง: รอ order จาก host ก่อน */ + if (!stackTurnOrderReadyPlay()) return true; + const cur = stackCurrentTurnEntryPlay(); + if (!cur || cur.kind !== 'human') return false; + if (!detectiveCaseFillBots) return true; /* editor preview: human = previewer คนเดียว */ + return String(cur.id) === String(myId); + } + function isStackCurrentBotTurnPlay() { + const cur = stackCurrentTurnEntryPlay(); + return !!(cur && cur.kind === 'bot'); + } + /** เจ้าของตาที่วาง broadcast ผลการวาง → ทุกเครื่อง apply เหมือนกัน + เลื่อนตาพร้อมกัน */ + function broadcastStackDropPlay(hit, seat, heavy, actorId, isBot) { + if (!detectiveCaseFillBots || !socket || myId == null || !hit) return; /* broadcast เฉพาะเล่นจริง */ + try { + socket.emit('stack-drop', { + placedCx: hit.placedCx, placedW: hit.placedW, miss: !!hit.miss, perfect: !!hit.perfect, + pts: hit.pts || 0, progressDelta: hit.progressDelta || 0, landOffsetTiles: hit.landOffsetTiles || 0, + overlap: hit.overlap, supportRatio: hit.supportRatio, delta: hit.delta || 0, + seat: seat || 1, heavy: !!heavy, actorId: actorId != null ? String(actorId) : '', isBot: !!isBot, + }); + } catch (e) { /* ignore */ } } function advanceStackPreviewTurn() { - if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return; - stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % STACK_PREVIEW_TURN_COUNT; + 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() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return true; - const cur = stackPreviewTurnOrder[stackPreviewTurnIndex]; + if (!playBotsEnabled() || !stackTurnOrderReadyPlay()) return true; + const cur = stackCurrentTurnEntryPlay(); return !!(cur && cur.kind === 'human'); } function getStackPreviewCurrentSeat() { - if (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return 1; - const cur = stackPreviewTurnOrder[stackPreviewTurnIndex]; + if (!stackTurnOrderReadyPlay()) return 1; + const cur = stackCurrentTurnEntryPlay(); return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1; } function getStackDropActorSeat() { - if (playBotsEnabled() && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) { + if (playBotsEnabled() && stackTurnOrderReadyPlay()) { return getStackPreviewCurrentSeat(); } return 1; @@ -735,6 +838,9 @@ 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; @@ -2882,7 +2988,9 @@ } function getAvatarImg(characterId, direction, now, isWalking) { - const img = characterId ? getCharacterFrame(characterId, direction, now, isWalking) : null; + /* '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; } @@ -2935,6 +3043,8 @@ /** วาดตัวละคร — ทิศลง + 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) { @@ -3260,7 +3370,14 @@ }); if (anyAlive) return; mg1AllDeadEndScheduled = true; - setTimeout(function () { try { tryFinishDetectiveMinigameAndReturnLobby(); } catch (e) { /* ignore */ } }, 1600); + /* ทุกคนตอบผิดหมด → 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'; @@ -3585,6 +3702,10 @@ /* 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(); @@ -5994,7 +6115,12 @@ 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; @@ -7068,13 +7194,24 @@ const missesOut = stackMini && stackMini.teamMissesLeft != null && stackMini.teamMissesLeft <= 0; const timeOut = elapsed >= timeLim; if (pct < 100 && !timeOut && !missesOut) return; - stackTowerMissionEndedOnce = true; - stackTowerMissionPhase = 'ended'; - applyStackTowerMissionPanelImages(); let outcome = 'time_up'; if (pct >= 100) outcome = 'decrypt_complete'; else if (missesOut) outcome = 'misses_exhausted'; - beginStackTowerResultFlashThenGcm(stackTowerMissionBuildPayload({ outcome: outcome })); + /* เล่นจริง: 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() { @@ -9097,6 +9234,14 @@ if (o.balloonBossBalloons != null) row.bbBalloons = o.balloonBossBalloons; if (o.balloonBossEliminated != null) row.bbElim = !!o.balloonBossEliminated; if (o.quizCarryHeld !== undefined) row.qcHeld = o.quizCarryHeld; + /* MG1 quiz: สถานะล็อก (ตอบผิด=ผี) + คะแนนบอท → host เป็นเจ้าของ ส่งให้ทุกเครื่องตรงกัน + (เดิมไม่ส่งเลย → non-host เห็นบอทล็อก/respawn/คะแนนคนละแบบ) */ + if (isQuiz()) { + if (o.quizCannotTrue != null) row.qct = !!o.quizCannotTrue; + if (o.quizCannotFalse != null) row.qcf = !!o.quizCannotFalse; + const qsc = playLiveQuizScores[id]; + if (qsc != null) row.qsc = Math.max(0, qsc | 0); + } /* สีบอท (host เป็นเจ้าของ) — แนบไปด้วยเพื่อให้ทุก client แสดงสีตรงกัน (เดิมแต่ละเครื่องคำนวณสีเองจาก theme/sessionStorage → สีบอทไม่ตรงกันเมื่อ sync ธีมมาช้า) */ if (o.playTint && o.playTint.body) { @@ -9562,7 +9707,7 @@ } function playQuizCarryAvatarUrlForMission(characterId) { - if (!characterId) return ''; + if (!characterId || characterId === LEGACY_PLACEHOLDER_CHARACTER_ID) return ''; return BASE + '/img/characters/' + encodeURIComponent(String(characterId)) + '_down.png'; } @@ -11174,6 +11319,7 @@ ? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles)) : autoW; stackFall = null; + stackRemoteDropQueue = []; const towerMission = isStackTowerMissionUiMapPlay(); const towerMul = towerMission ? STACK_TOWER_BLOCK_WORLD_SCALE : 1; const towerLayerStripH = Math.max(14, tileSize * 0.31) * towerMul; @@ -11534,11 +11680,10 @@ function stackHumanDropGateOkPlay() { if (!stackMini || !isStack() || stackFall || stackMini.settling) return false; if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return false; - if (playBotsEnabled() && mapData && mapData.gameType === 'stack' && - (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) { + if (playBotsEnabled() && mapData && mapData.gameType === 'stack' && !stackTurnOrderReadyPlay()) { rebuildStackPreviewTurnOrder(); } - if (playBotsEnabled() && !isStackPreviewHumanTurn()) return false; + if (playBotsEnabled() && !isMyStackTurn()) return false; /* กดได้เฉพาะตาตัวเอง (shared tower) */ if (Date.now() - stackMini.lastDropAt < 480) return false; return true; } @@ -11549,6 +11694,8 @@ ensureStackNextDropVisual(); const hit = computeStackDropResult(stackMini); if (!startStackFallAnimation(hit, playBotsEnabled() ? { human: true } : null)) return false; + /* broadcast ผลการวางของเรา → ทุกเครื่องวางบล็อกเดียวกัน + เลื่อนตาพร้อมกัน */ + if (playBotsEnabled()) broadcastStackDropPlay(hit, getStackDropActorSeat(), !!stackMini.pendingDropHeavy, myId, false); if (playBotsEnabled()) { lastStackPreviewActorId = '__human__'; lastStackPreviewActorUntil = Date.now() + 900; @@ -11565,6 +11712,8 @@ previewBotAlignStackSwingPhasePlay(stackMini, o); const hit = computeStackDropResult(stackMini); if (!startStackFallAnimation(hit, playBotsEnabled() ? { botId } : null)) return; + /* host เป็นเจ้าของบอท → broadcast ผลการวางของบอทให้ทุกเครื่องวางตรงกัน */ + broadcastStackDropPlay(hit, getStackDropActorSeat(), !!stackMini.pendingDropHeavy, botId, true); o.stackBotGlowUntil = Date.now() + Math.max(900, 400 + (stackFall && stackFall.dur ? stackFall.dur : 800)); lastStackPreviewActorId = botId; lastStackPreviewActorUntil = Date.now() + Math.max(1000, 500 + (stackFall && stackFall.dur ? stackFall.dur : 800)); @@ -11575,14 +11724,15 @@ /** Stack preview: สลับตา — เฉพาะบอทที่ถึงตาถึงจะจำลองปล่อยหนึ่งครั้งแล้วเลื่อนตา */ function stepPreviewStackTurnBased(nowMs) { if (!playBotsEnabled() || !isStack() || !stackMini || stackFall || stackMini.settling) return; + /* เล่นจริง: host เท่านั้นจำลองบอท (แล้ว broadcast) — non-host รอรับ stack-drop; editor preview (previewFillBots) จำลองเองได้ */ + if (detectiveCaseFillBots && !isMePlayHost()) return; if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return; - if (mapData && mapData.gameType === 'stack' && - (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) { + if (mapData && mapData.gameType === 'stack' && !stackTurnOrderReadyPlay()) { rebuildStackPreviewTurnOrder(); } const order = stackPreviewTurnOrder; - if (!order || order.length !== STACK_PREVIEW_TURN_COUNT) return; - const cur = order[stackPreviewTurnIndex]; + if (!order || !order.length) return; + const cur = order[stackPreviewTurnIndex % order.length]; if (!cur || cur.kind !== 'bot') return; if (!cur.botId) { advanceStackPreviewTurn(); @@ -11605,11 +11755,29 @@ simulatePreviewBotStackDrop(cur.botId, o); } + /** apply ดรอปถัดไปในคิว (จาก host/คนอื่น) — เริ่มแอนิเมชันร่วงเหมือนเดิม → settle apply ชั้น + เลื่อนตา */ + function applyNextRemoteStackDropPlay() { + if (!stackMini || stackFall || stackMini.settling) return false; + const item = stackRemoteDropQueue.shift(); + if (!item || !item.hit) return false; + ensureStackNextDropVisual(); + stackMini.pendingDropSeat = item.seat || 1; + stackMini.pendingDropHeavy = !!item.heavy; + stackMini.widthTiles = stackBlockWidthTilesForPlay(stackMini.initialWidthTiles, !!item.heavy); + return startStackFallAnimation(item.hit, item.actor || { human: true }); + } + function stackTickFrame() { stackTowerMissionMaybeEndPlay(); const now = performance.now(); const dt = Math.min(0.06, (now - lastStackTickMs) / 1000); lastStackTickMs = now; + /* เล่นจริง: host ส่งลำดับตา+index เป็นระยะ → non-host adopt (ครอบ late-join: client โหลดช้า host จะรวมเข้า order ให้) */ + if (detectiveCaseFillBots && isMePlayHost()) { + if (!stackTurnOrderReadyPlay()) rebuildStackPreviewTurnOrder(); + const ts = Date.now(); + if (ts - stackLastStateEmit > 300) { stackLastStateEmit = ts; broadcastStackStatePlay(); } + } if (stackMini && stackMini.settling) { const s = stackMini.settling; const u = (now - s.t0) / s.dur; @@ -11675,8 +11843,25 @@ if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn(); } } + } else if (stackMini && stackRemoteDropQueue.length && applyNextRemoteStackDropPlay()) { + /* ว่าง (ไม่มีบล็อกร่วง/settle) + มีดรอปในคิวจาก host/คนอื่น → apply ทีละอัน (ไม่แกว่งเฟรมนี้) */ } else if (stackMini) { - stackMini.phase += stackMini.phaseSpeed * dt * Math.PI * 2; + /* เล่นจริง: แกว่งเฉพาะตาคนที่คุม (เรา หรือ host คุมบอท) แล้ว broadcast เฟสให้คนอื่น mirror; + editor preview / เล่นเดี่ยว: แกว่งเองตลอดเหมือนเดิม */ + let advancePhase = true; + if (detectiveCaseFillBots) { + advancePhase = isMyStackTurn() || (isMePlayHost() && isStackCurrentBotTurnPlay()); + } + if (advancePhase) { + stackMini.phase += stackMini.phaseSpeed * dt * Math.PI * 2; + if (detectiveCaseFillBots && socket && myId != null) { + const tnow = Date.now(); + if (tnow - stackLastSwingEmit > 50) { + stackLastSwingEmit = tnow; + try { socket.emit('stack-swing', { phase: stackMini.phase }); } catch (e) { /* ignore */ } + } + } + } } stepPreviewStackTurnBased(Date.now()); } @@ -12666,6 +12851,8 @@ balloonBossBossBullets = []; balloonBossBossFireAcc = 0; balloonBossScorePopups = []; + balloonBossHostBossDmg = null; + balloonBossLastSyncEmit = 0; me.balloonBossScore = 0; me.balloonBossBossDmg = 0; me.balloonBossBalloons = balloonBossBalloonsStartPlay(); @@ -16389,8 +16576,12 @@ return s; } - /** รวมดาเมจที่ทำกับบอส (ไม่เท่ากับคะแนน — คะแนน +10 ต่อครั้ง, HP บอส -2 ต่อครั้ง) */ + /** รวมดาเมจที่ทำกับบอส (ไม่เท่ากับคะแนน — คะแนน +10 ต่อครั้ง, HP บอส -2 ต่อครั้ง) + non-host: ใช้ค่าที่ host เป็นเจ้าของ (single source of truth) ถ้าได้รับแล้ว — HP บอสตรงกันทุกเครื่อง */ function balloonBossTeamBossDamagePlay() { + if (!isMePlayHost() && balloonBossHostBossDmg != null) { + return Math.max(0, balloonBossHostBossDmg | 0); + } let s = Math.max(0, me.balloonBossBossDmg | 0); others.forEach((o) => { s += Math.max(0, o.balloonBossBossDmg | 0); }); return s; @@ -16733,17 +16924,30 @@ continue; } let hit = false; - aliveRefs.forEach((entry) => { - if (hit) return; - const ent = entry.ref; - if (!ent || ent.balloonBossCx == null) return; - const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy); + /* การชนกระสุนบอส: เจ้าของตัดสินยานตัวเองเท่านั้น (+ บอทพรีวิว = host) — เหมือน MG6 + คนอื่นรอสถานะลูกโป่ง/ตายจาก move ของเจ้าตัว + balloon-boss-sync ของ host + (เดิมทุกเครื่องคิดชนให้ทุกยานด้วยตำแหน่ง interpolate ที่ต่างกัน → ลูกโป่งเพี้ยน/ตายไม่ตรงกัน) */ + if (me && !me.balloonBossEliminated && me.balloonBossCx != null) { + const cc = balloonBossBalloonClusterWorldPlay(me, bossC.cx, bossC.cy); const dx = b.x - cc.cx, dy = b.y - cc.cy; if (dx * dx + dy * dy <= (36 * 36)) { hit = true; - applyBalloonBossHitToEntity(ent); + applyBalloonBossHitToEntity(me); } - }); + } + if (!hit && isMePlayHost()) { + aliveRefs.forEach((entry) => { + if (hit || !isPreviewBotId(entry.id)) return; + const ent = entry.ref; + if (!ent || ent.balloonBossCx == null) return; + const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy); + const dx = b.x - cc.cx, dy = b.y - cc.cy; + if (dx * dx + dy * dy <= (36 * 36)) { + hit = true; + applyBalloonBossHitToEntity(ent); + } + }); + } if (hit) balloonBossBossBullets.splice(bi, 1); } @@ -16917,6 +17121,21 @@ balloonBossEliminated: !!me.balloonBossEliminated, }); } + /* MG7 host-authoritative boss HP: host broadcast ดาเมจรวม + สถานะผู้เล่นคนอื่น ~7Hz + → ทุกเครื่องแสดง HP บอส/ลูกโป่ง/การตาย ตรงกับ host (single source of truth) */ + if (isMePlayHost() && socket && myId != null && tEmit - balloonBossLastSyncEmit > 140) { + balloonBossLastSyncEmit = tEmit; + const syncRefs = buildBalloonBossParticipantRefsPlay(); + socket.emit('balloon-boss-sync', { + bossDmg: Math.max(0, balloonBossTeamBossDamagePlay() | 0), + players: syncRefs.map((e) => ({ + id: e.id, + d: Math.max(0, (e.ref && (e.ref.balloonBossBossDmg | 0)) || 0), + b: Math.max(0, (e.ref && (e.ref.balloonBossBalloons | 0)) || 0), + e: !!(e.ref && e.ref.balloonBossEliminated), + })), + }); + } } function drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs) { @@ -18135,6 +18354,8 @@ jumpSurviveVy = 0; jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY); ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'KeyA', 'KeyW', 'KeyS', 'KeyD'].forEach((c) => { keys[c] = false; }); + /* broadcast การตายทันที — เดิมพอตายแล้วบล็อก emit move (เงื่อนไข !eliminated) → คนอื่นไม่เคยรู้ว่าเราตาย = "ไม่ยอมตาย" */ + try { if (socket && myId != null) socket.emit('move', { x: me.x, y: me.y, direction: me.direction, jumpSurviveEliminated: true }); } catch (eJ) { /* ignore */ } } else { jumpSurviveVy = r.vy; jumpSurviveOnGround = r.onGround; @@ -18966,6 +19187,8 @@ bannedSpectator: !!data.bannedSpectator, }); if (playBotsEnabled()) rebalancePreviewBots(); + /* MG3: มีคนเข้าใหม่ → host รวมเข้าลำดับตา (preserve คนที่กำลังถึงตา) แล้ว broadcast ใหม่ — กัน late-join ตกหล่น */ + if (isStack() && detectiveCaseFillBots && isMePlayHost()) rebuildStackPreviewTurnOrder(); if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud(); if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud(); if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud(); @@ -19115,6 +19338,7 @@ [...others.keys()].filter(isPreviewBotId).forEach((bid) => { if (!hostBotIds.has(String(bid))) others.delete(bid); }); + let quizBotScoreChanged = false; data.bots.forEach((rb) => { if (!rb || !isPreviewBotId(rb.id)) return; let o = others.get(rb.id); @@ -19136,8 +19360,9 @@ 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; } + /* delta ใหญ่ = teleport/respawn (เช่น บอทควิซตอบผิดถูกดีดกลับจุดเกิด) → สแนปเลย ไม่งั้น lerp ไถลทะลุกำแพง */ + if (Number.isFinite(rb.x)) { const bigX = Number.isFinite(o.x) && Math.abs(rb.x - o.x) > 3; o.tx = rb.x; if (snapPos || bigX || !Number.isFinite(o.x)) o.x = rb.x; } + if (Number.isFinite(rb.y)) { const bigY = Number.isFinite(o.y) && Math.abs(rb.y - o.y) > 3; o.ty = rb.y; if (snapPos || bigY || !Number.isFinite(o.y)) o.y = rb.y; } if (rb.direction) o.direction = rb.direction; /* สีบอทจาก host → ใช้ตามให้ตรงกันทุกเครื่อง (อัปเดตเฉพาะเมื่อเปลี่ยน กัน churn ทุกเฟรม) */ if (rb.tint && rb.tint.body) { @@ -19164,7 +19389,68 @@ if (rb.bbBalloons != null) o.balloonBossBalloons = rb.bbBalloons; if (rb.bbElim != null) o.balloonBossEliminated = !!rb.bbElim; if (rb.qcHeld !== undefined) o.quizCarryHeld = rb.qcHeld; + /* MG1 quiz: รับสถานะล็อก + คะแนนบอทจาก host (single source of truth) */ + if (rb.qct != null) o.quizCannotTrue = !!rb.qct; + if (rb.qcf != null) o.quizCannotFalse = !!rb.qcf; + /* ghost overlay (วาดผี) อ่านจาก playQuizEverWrong — non-host ไม่ได้รันบล็อกตัดสินบอท (host-only) + จึงต้อง bridge จากสถานะล็อกที่ host ส่งมา ไม่งั้น client ไม่เห็นบอทเป็นผีตอนตอบผิด */ + if (o.quizCannotTrue && o.quizCannotFalse) playQuizEverWrong[String(rb.id)] = true; + if (rb.qsc != null) { + const k = String(rb.id); + const nv = Math.max(0, rb.qsc | 0); + if (playLiveQuizScores[k] !== nv) { playLiveQuizScores[k] = nv; quizBotScoreChanged = true; } + } }); + if (quizBotScoreChanged && isQuiz()) renderPlayQuizScoreboard(playLiveQuizScores); + }); + + /* MG3 stack (shared tower): รับผลการวางจากเจ้าของตา → เข้าคิว แล้วทยอย apply ทีละอัน (กันทิ้งดรอปตอนบล็อกกำลังร่วง = ตึก/% ต่างกัน) */ + socket.on('stack-drop', (d) => { + if (!d || !mapData || !isStack() || !stackMini || !detectiveCaseFillBots) return; + const hit = { + miss: !!d.miss, perfect: !!d.perfect, pts: Number(d.pts) || 0, progressDelta: Number(d.progressDelta) || 0, + landOffsetTiles: Number(d.landOffsetTiles) || 0, + placedW: Number(d.placedW), placedCx: Number(d.placedCx), + overlap: Number(d.overlap), + supportRatio: (d.supportRatio == null ? null : Number(d.supportRatio)), + delta: Number(d.delta) || 0, + }; + stackRemoteDropQueue.push({ + hit: hit, seat: Number(d.seat) || 1, heavy: !!d.heavy, + actor: d.isBot ? { botId: String(d.actorId || '') } : { human: true }, + }); + }); + + /* MG3 stack (shared tower): รับเฟสแกว่งจากเจ้าของตา → mirror บล็อกที่กำลังแกว่ง (เห็นเหมือนกันจนกว่าจะถึงตาตัวเอง) */ + socket.on('stack-swing', (d) => { + if (!d || !mapData || !isStack() || !stackMini || !detectiveCaseFillBots) return; + if (isMyStackTurn()) return; /* ตาเรา → คุมเฟสเอง */ + if (isMePlayHost() && isStackCurrentBotTurnPlay()) return; /* host คุมบอทเอง */ + const ph = Number(d.phase); + if (Number.isFinite(ph)) stackMini.phase = ph; + }); + + /* MG3 stack — non-host รับลำดับตา+index จาก host (single source of truth) → เลิกแยกตึก/ตาเหลื่อม */ + socket.on('stack-state', (d) => { + if (!d || !mapData || !isStack() || !detectiveCaseFillBots || isMePlayHost()) return; + if (Array.isArray(d.order)) { + stackPreviewTurnOrder = d.order.map((e) => ({ + kind: e.kind, seat: e.seat, + id: e.id != null ? String(e.id) : null, + botId: e.botId != null ? String(e.botId) : null, + })); + } + if (stackPreviewTurnOrder && stackPreviewTurnOrder.length && Number.isFinite(Number(d.idx))) { + const n = stackPreviewTurnOrder.length; + stackPreviewTurnIndex = ((Math.floor(Number(d.idx)) % n) + n) % n; + } + }); + + /* MG3 stack — host ตัดสินจบรอบ → non-host จบพร้อมกันด้วย outcome เดียวกัน (กัน "จบไม่พร้อมกัน") */ + socket.on('stack-over', (d) => { + if (!mapData || !isStack() || !detectiveCaseFillBots) return; + const outcome = (d && typeof d.outcome === 'string') ? d.outcome : 'time_up'; + finishStackTowerMissionLocalPlay(outcome); }); /* MG7 balloon_boss — รับกระสุนของผู้เล่น/บอทคนอื่น แล้วเพิ่มเข้า array ให้เห็นตรงกันทุกเครื่อง @@ -19194,6 +19480,26 @@ endBalloonBossGame(reason); }); + /* MG7 — host เป็นเจ้าของ HP บอส: non-host รับดาเมจรวม + สถานะผู้เล่นคนอื่น (ลูกโป่ง/ตาย) มาตรง ๆ + host เพิกเฉย (ตัวเองคำนวณเอง) · ตัวเราเองไม่ทับ (เป็นเจ้าของ balloon/dmg ของตัวเอง broadcast ผ่าน move) */ + socket.on('balloon-boss-sync', (d) => { + if (!d || !mapData || mapData.gameType !== 'balloon_boss' || balloonBossGameEnded) return; + if (isMePlayHost()) return; + const bd = Number(d.bossDmg); + if (Number.isFinite(bd)) balloonBossHostBossDmg = Math.max(0, bd); + if (Array.isArray(d.players)) { + d.players.forEach((p) => { + if (!p || p.id == null) return; + if (myId != null && String(p.id) === String(myId)) return; /* ตัวเองเป็นเจ้าของ ไม่ทับ */ + const o = others.get(String(p.id)) || others.get(p.id); + if (!o) return; + const dd = Number(p.d); if (Number.isFinite(dd)) o.balloonBossBossDmg = Math.max(0, dd); + const bb = Number(p.b); if (Number.isFinite(bb)) o.balloonBossBalloons = Math.max(0, bb); + if (typeof p.e === 'boolean') o.balloonBossEliminated = p.e; + }); + } + }); + /* MG6 space_shooter — รับกระสุนผู้เล่น/บอทคนอื่น (ตรงดิ่งขึ้น vy คงที่) → ตำแหน่ง = y + vy*elapsed เป๊ะทุกเครื่อง */ socket.on('space-shooter-shot', (d) => { if (!d || !mapData || mapData.gameType !== 'space_shooter') return; @@ -19304,13 +19610,16 @@ } else { o.tx = data.x; o.ty = data.y; - /* quiz (ยืนบนช่องคงที่ + move มาห่าง) → สแนปทันที กัน host ค้าง/หาย - quiz_carry → ปล่อยให้ lerp (move ถี่ทุก 80ms) เพื่อให้ "ตัวละครคนอื่นมี animation เดิน" (เดิมสแนป = ไม่มี delta = ยืนนิ่ง) */ + /* quiz: ปล่อยให้ lerp (24025) เลื่อน o.x→o.tx เอง → มี delta = "ตัวละครคนอื่นเดินมี animation" + (เดิมสแนปทันที = o.x==o.tx = ไม่มี delta = ยืนนิ่งไม่เดิน) ; สแนปเฉพาะ delta ใหญ่ (เทเลพอร์ต/คลิกไกล) กันลากช้า/ค้าง */ if (mapData && isQuiz() && !isQuizCarry()) { const px = Number(data.x); const py = Number(data.y); - if (Number.isFinite(px)) o.x = px; - if (Number.isFinite(py)) o.y = py; + const bigJump = (Number.isFinite(px) && Math.abs(px - o.x) > 2.5) || (Number.isFinite(py) && Math.abs(py - o.y) > 2.5); + if (bigJump) { + if (Number.isFinite(px)) o.x = px; + if (Number.isFinite(py)) o.y = py; + } } } o.direction = data.direction || o.direction; @@ -19330,6 +19639,10 @@ /* ตาย/รอด มาจากเจ้าของยานเท่านั้น → ทุกเครื่องเห็นตรงกัน */ if (data.spaceShooterEliminated != null) o.spaceShooterEliminated = !!data.spaceShooterEliminated; } + /* MG5: การตายของผู้เล่นมนุษย์มาจากเจ้าตัว (broadcast ผ่าน move) → ทุกเครื่องเห็นตรงกัน (เดิมไม่ส่ง = เพื่อน "ไม่ยอมตาย") */ + if (mapData && mapData.gameType === 'jump_survive' && data.jumpSurviveEliminated != null) { + o.jumpSurviveEliminated = !!data.jumpSurviveEliminated; + } if (mapData && mapData.gameType === 'balloon_boss') { if (data.balloonBossScore != null) { const s = Number(data.balloonBossScore); @@ -19349,8 +19662,15 @@ }); socket.on('user-left', (data) => { if (!data || isPreviewBotId(data.id)) return; + const leftWasCurrentStack = !!(isStack() && detectiveCaseFillBots && isMePlayHost() + && stackCurrentTurnEntryPlay() && String(stackCurrentTurnEntryPlay().id) === String(data.id)); others.delete(data.id); if (playBotsEnabled()) rebalancePreviewBots(); + /* MG3: คนออก → host รื้อลำดับตาใหม่; ถ้าคนที่ออกกำลังถึงตา → เลื่อนตา (กันค้างเพราะไม่มีใครวางแทน) */ + if (isStack() && detectiveCaseFillBots && isMePlayHost()) { + rebuildStackPreviewTurnOrder(); + if (leftWasCurrentStack && !(stackFall || (stackMini && stackMini.settling))) advanceStackPreviewTurn(); + } if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud(); if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud(); if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud(); @@ -19395,8 +19715,9 @@ } }); } - /** เกมจริง: bot ไม่อยู่ในผล server → เช็คโซนยืนเอง ผิด = เป็นผี (เหมือน path preview) */ - if (isQuiz() && Array.isArray(r.results) && playBotsEnabled()) { + /** เกมจริง: bot ไม่อยู่ในผล server → HOST ตัดสินบอทคนเดียว (เช็คโซน/respawn/คะแนน) แล้ว broadcast ผ่าน fill-bot-state + (เดิมทุก client ตัดสินเอง + respawn ด้วย Math.random แยกกัน → ตำแหน่ง/ล็อก/คะแนนบอทคนละแบบทุกจอ) */ + if (isQuiz() && Array.isArray(r.results) && playBotsEnabled() && isMePlayHost()) { const correctTrueBot = !!r.correctTrue; others.forEach(function (o, id) { if (!isPreviewBotId(id) || !o) return; @@ -19951,6 +20272,8 @@ balloonBossPlayerFireCd = 0; balloonBossLastMoveEmit = 0; balloonBossBossFireAcc = 0; + balloonBossHostBossDmg = null; + balloonBossLastSyncEmit = 0; me.balloonBossScore = 0; me.balloonBossBossDmg = 0; me.balloonBossBalloons = balloonBossBalloonsStartPlay(); @@ -21533,7 +21856,8 @@ /** แถบ Cyber SCORE + สรุปผล — หน้า idle down + tint ต่อผู้เล่น */ function setCyberHudScoreAvatarImg(avImg, row, skipReuse) { - const cid = row.characterId ? String(row.characterId) : ''; + /* 'Chatest' = placeholder ไม่มี sprite จริง → ปฏิบัติเป็นว่าง ใช้ default (กัน 404 + retry วน ×16) */ + const cid = (row.characterId && row.characterId !== LEGACY_PLACEHOLDER_CHARACTER_ID) ? String(row.characterId) : ''; if (!cid) { avImg.src = defaultAvatarImg.src; return; @@ -23714,8 +24038,11 @@ others.forEach((o, id) => { /* host เดินบอทเองตรงๆ (o.x/o.y) → ข้าม lerp; แต่ client อื่นต้อง lerp ตามตำแหน่งที่ host ส่งมา (o.tx/o.ty) */ if (playBotsEnabled() && isPreviewBotId(id) && isMePlayHost()) return; - if (o.tx != null) o.x += (o.tx - o.x) * LERP; - if (o.ty != null) o.y += (o.ty - o.y) * LERP; + /* MG4 (quiz_carry) บอทเดินต่อเนื่องเร็ว → non-host ตามด้วย lerp 0.2 จะตามหลัง host ~1-2 ช่องตลอด ("เดินไม่ตรงกัน") + เร่ง lerp เฉพาะบอท quiz_carry ให้ตามตำแหน่ง host ทันขึ้น (ไม่แตะเกมอื่น/ผู้เล่นจริง) */ + const lerpF = (isQuizCarry() && isPreviewBotId(id)) ? 0.5 : LERP; + if (o.tx != null) o.x += (o.tx - o.x) * lerpF; + if (o.ty != null) o.y += (o.ty - o.y) * lerpF; }); tickQuizCarryEmbedCountdown(); tickQuizCarryRoundTimers(); @@ -24033,7 +24360,7 @@ if (myId == null) return; const humansReady = gauntletCrownAllReadyPlay(); if (humansReady) { - socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } }); + socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (r && r.ok === false) showCrownStartBlockedNoticePlay(r); }); return; } const sidH = String(myId); @@ -24065,7 +24392,7 @@ if (myId == null) return; const humansReadySs = gauntletCrownAllReadyPlay(); if (humansReadySs) { - socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } }); + socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (r && r.ok === false) showCrownStartBlockedNoticePlay(r); }); return; } const sidHostSs = String(myId); @@ -24096,7 +24423,7 @@ if (myId == null) return; const humansReady = gauntletCrownAllReadyPlay(); if (humansReady) { - socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } }); + socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (r && r.ok === false) showCrownStartBlockedNoticePlay(r); }); return; } const sidH = String(myId); @@ -24128,7 +24455,7 @@ if (myId == null) return; const humansReadySt = gauntletCrownAllReadyPlay(); if (humansReadySt) { - socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } }); + socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (r && r.ok === false) showCrownStartBlockedNoticePlay(r); }); return; } const sidHostSt = String(myId); @@ -24160,7 +24487,7 @@ return; } socket.emit('gauntlet-crown-lobby-start', {}, (r) => { - if (!r || !r.ok) { /* ignore */ } + if (r && r.ok === false) showCrownStartBlockedNoticePlay(r); }); return; } diff --git a/www/html/Game/server.js b/www/html/Game/server.js index f222b7c..c4e7533 100644 --- a/www/html/Game/server.js +++ b/www/html/Game/server.js @@ -1800,6 +1800,10 @@ function gameAwardSecret() { /* เหรียญตามอันดับผู้รอดชีวิต: อันดับ 1→10, 2→8, 3→6, 4→4, 5→2, 6+→0 */ const MINIGAME_RANK_COINS = [10, 8, 6, 4, 2, 0]; +/* เพดานคะแนนที่ client รายงานเองได้ (stack/quiz_carry) — sanity cap กัน overflow/ค่าขยะ/spoof สูงเกินจริง + ค่าจริงต่อรอบอยู่หลักร้อย-พัน เพดานนี้กว้างพอไม่กระทบเล่นจริง (ไม่ใช่ anti-cheat สมบูรณ์ — ดูคอมเมนต์ที่ handler) */ +const REPORTED_MINI_SCORE_MAX = 50000; + /* คะแนน + สถานะรอด/ตาย ของผู้เล่นต่อ gameType ตอนจบมินิเกม */ function minigamePlayerScoreSurvived(space, p, gameType) { /* Card 3 Ban — ผู้ถูกแบนเข้ามาดูเฉยๆ ไม่ได้เล่น → ไม่นับเป็นผู้รอด/ไม่ได้คะแนน */ @@ -1810,8 +1814,11 @@ function minigamePlayerScoreSurvived(space, p, gameType) { return { score: st ? Math.max(0, Number(st.score) | 0) : 0, survived: st ? !st.eliminated : true }; } case 'gauntlet': - case 'jump_survive': return { score: Math.max(0, p.gauntletScore | 0), survived: !p.gauntletEliminated }; + case 'jump_survive': + /* jump_survive ไม่มี gauntlet ticker → gauntletScore/Eliminated ฝั่ง server เป็น 0/false เสมอ + ใช้ค่าที่ client รายงานตอนจบแทน (reportedMiniSurvived = ไม่ตาย) ; เดิมเข้า case gauntlet → ทุกคน survived เสมอ = coin มั่ว */ + return { score: Math.max(0, p.reportedMiniScore | 0), survived: p.reportedMiniSurvived !== false }; case 'space_shooter': return { score: Math.max(0, p.spaceShooterScore | 0), survived: true }; case 'balloon_boss': @@ -4143,8 +4150,12 @@ function endQuizGame(sid, space, message) { clearSpaceQuizTimers(space); if (space.quizSession) space.quizSession.active = false; if (space.detectiveMinigameActive) { - returnDetectiveSpaceToLobbyB(sid, space, message, { allPlayers: true }); - space.quizSession = null; + /* MG1: ไม่ auto-return/auto-grant — ส่ง quiz-ended ให้ client โชว์ผล (รอกดปุ่มรับการ์ดเอง เหมือนเกมอื่น) + เดิม returnDetectiveSpaceToLobbyB ที่นี่ = server แจกการ์ด+เด้งกลับเองทันที = "auto รับผล/การ์ด" + การแจกการ์ด/กลับ LobbyB จะเกิดเมื่อ client กดปุ่ม → detective-minigame-finished (เส้นทางเดียวกับ MG2-7) */ + io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', returnToLobbyB: true }); + /* คง quizSession (active=false แล้ว) ไว้ → ตอน client กด finish → returnDetectiveSpaceToLobbyB อ่านคะแนน quiz มาคิดเหรียญได้ + (ถ้า null ที่นี่ minigamePlayerScoreSurvived('quiz') = 0 หมด → เหรียญเพี้ยน) ; จะถูกแทนเมื่อเริ่มมินิเกมรอบใหม่ */ return; } io.to(sid).emit('quiz-ended', { message: message || 'จบเกม' }); @@ -7886,6 +7897,87 @@ io.on('connection', (socket) => { socket.to(sid).emit('balloon-boss-over', { reason, scores }); }); + /* MG7 balloon_boss — host เป็นเจ้าของ HP บอส: relay ดาเมจรวม + สถานะผู้เล่น (ลูกโป่ง/ตาย) เป็นระยะ + → ทุกเครื่องแสดง HP บอสตรงกับ host เป๊ะ (เดิมแต่ละเครื่องรวม balloonBossBossDmg จาก move เองทยอยมา = bar เพี้ยน) */ + socket.on('balloon-boss-sync', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */ + const bossDmg = Math.max(0, Number(data.bossDmg) || 0); + const players = Array.isArray(data.players) + ? data.players.filter((p) => p && p.id != null).slice(0, 12).map((p) => ({ + id: p.id, + d: Math.max(0, Number(p.d) || 0), + b: Math.max(0, Number(p.b) || 0), + e: !!p.e, + })) + : []; + socket.to(sid).emit('balloon-boss-sync', { bossDmg, players }); + }); + + /* MG3 stack (shared tower) — relay ผลการวาง (เจ้าของตา/บอทของ host) ให้ทุกคนวางบล็อกเดียวกัน + เลื่อนตาพร้อมกัน */ + socket.on('stack-drop', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + const num = (v) => (typeof v === 'number' && Number.isFinite(v)) ? v : 0; + socket.to(sid).emit('stack-drop', { + placedCx: num(data.placedCx), placedW: num(data.placedW), + miss: !!data.miss, perfect: !!data.perfect, + pts: num(data.pts), progressDelta: num(data.progressDelta), landOffsetTiles: num(data.landOffsetTiles), + overlap: num(data.overlap), + supportRatio: (data.supportRatio == null ? null : num(data.supportRatio)), + delta: num(data.delta), + seat: Math.max(1, Math.min(8, Math.floor(num(data.seat)) || 1)), heavy: !!data.heavy, + actorId: data.actorId != null ? String(data.actorId).slice(0, 64) : '', isBot: !!data.isBot, + }); + }); + /* MG1 quiz — host ขอจบเกมก่อนครบข้อ (ทุกคนตอบผิดหมด=ผีหมด) → endQuizGame โชว์ผล/รอกดรับการ์ด (ไม่ auto) */ + socket.on('quiz-end-request', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) return; /* host เท่านั้น */ + if (!space.detectiveMinigameActive || !space.quizSession || !space.quizSession.active) return; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'quiz') return; + endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดหมด'); + }); + + /* MG3 stack — relay เฟสแกว่ง (เจ้าของตา → คนอื่น mirror บล็อกที่กำลังแกว่ง) */ + socket.on('stack-swing', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + const ph = Number(data.phase); + if (!Number.isFinite(ph)) return; + socket.to(sid).emit('stack-swing', { phase: ph }); + }); + /* MG3 stack — host เป็นเจ้าของลำดับตา+index relay ให้ non-host (กัน host/client มองลำดับตาไม่ตรง) */ + socket.on('stack-state', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */ + const order = Array.isArray(data.order) + ? data.order.filter((e) => e && (e.kind === 'human' || e.kind === 'bot')).slice(0, 8).map((e) => ({ + kind: e.kind === 'bot' ? 'bot' : 'human', + seat: Math.max(1, Math.min(8, Math.floor(Number(e.seat)) || 1)), + id: e.id != null ? String(e.id).slice(0, 64) : null, + botId: e.botId != null ? String(e.botId).slice(0, 64) : null, + })) + : []; + const idx = Math.max(0, Math.floor(Number(data.idx)) || 0); + socket.to(sid).emit('stack-state', { order, idx }); + }); + + /* MG3 stack — host ตัดสินจบรอบ relay ให้ทุกคนจบพร้อมกัน */ + socket.on('stack-over', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return; + const outcome = (data && typeof data.outcome === 'string') ? data.outcome.slice(0, 32) : 'time_up'; + socket.to(sid).emit('stack-over', { outcome }); + }); + /* MG6 space_shooter — host ตัดสินจบเกม + แนบคะแนนสุดท้าย → ทุกคนจบ/อันดับตรงกัน */ socket.on('space-shooter-over', (data) => { const sid = socket.data.spaceId; @@ -8062,7 +8154,16 @@ io.on('connection', (socket) => { /* client รายงานคะแนน/รอด ของตัวเอง (ใช้กับเกมที่ server ไม่ได้นับคะแนน เช่น stack/quiz_carry) */ const finPeer = space.peers.get(socket.id); if (finPeer && data && typeof data === 'object') { - if (Number.isFinite(Number(data.score))) finPeer.reportedMiniScore = Math.max(0, Math.floor(Number(data.score))); + /* คะแนน client รายงานเอง (stack/quiz_carry) — clamp ด้วย sanity cap กัน overflow/ค่าขยะ/spoof สูงเกินจริง + หมายเหตุ: ยังเป็น client-trusted ภายในเพดาน (anti-cheat สมบูรณ์ต้องจำลองเกมฝั่ง server — เกินสโคปตอนนี้) */ + if (Number.isFinite(Number(data.score))) { + let rs = Math.max(0, Math.floor(Number(data.score))); + if (rs > REPORTED_MINI_SCORE_MAX) { + console.warn('[mini-score] clamp reportedMiniScore', socket.id, rs, '->', REPORTED_MINI_SCORE_MAX); + rs = REPORTED_MINI_SCORE_MAX; + } + finPeer.reportedMiniScore = rs; + } if (data.survived === false) finPeer.reportedMiniSurvived = false; } if (!space.detectiveMinigameActive) { @@ -8870,6 +8971,13 @@ io.on('connection', (socket) => { moveForceEmit = true; } } + if (p && md && md.gameType === 'jump_survive' && data) { + /* MG5: การตายมาจากเจ้าตัว → relay แม้ตำแหน่งไม่เปลี่ยน (ตอนตายไม่ขยับ) ไม่งั้นคนอื่นไม่รู้ = "ไม่ยอมตาย" */ + if (data.jumpSurviveEliminated && !p.jumpSurviveEliminated) { + p.jumpSurviveEliminated = true; + moveForceEmit = true; + } + } if (p && md && md.gameType === 'balloon_boss' && data) { const bbDefaultBalloons = balloonBossBalloonsForMap(md); if (data.balloonBossScore != null) { @@ -8934,6 +9042,9 @@ io.on('connection', (socket) => { if (md && md.gameType === 'quiz_carry') { out.quizCarryHeld = (p.quizCarryHeld === undefined ? null : p.quizCarryHeld); } + if (md && md.gameType === 'jump_survive') { + out.jumpSurviveEliminated = !!p.jumpSurviveEliminated; + } if (nx !== data.x || ny !== data.y) socket.emit('user-move', out); socket.to(sid).emit('user-move', out); } diff --git a/www/html/Main-Lobby/index.html b/www/html/Main-Lobby/index.html index 094ba76..ad1cd60 100644 --- a/www/html/Main-Lobby/index.html +++ b/www/html/Main-Lobby/index.html @@ -231,6 +231,6 @@ - + diff --git a/www/html/Main-Lobby/lobby.js b/www/html/Main-Lobby/lobby.js index 16875ac..6898028 100644 --- a/www/html/Main-Lobby/lobby.js +++ b/www/html/Main-Lobby/lobby.js @@ -84,6 +84,7 @@ function normalizeCharacterAssetId(id) { var s = String(id == null ? '' : id).trim(); if (!s) return ''; + if (s === 'Chatest') return ''; // legacy placeholder — ไม่มี sprite จริง ถือเป็นว่าง (fallback ไปตัวจริงจาก API) กัน 404 รัว if (s.length > 96) s = s.slice(0, 96); if (!/^[a-zA-Z0-9._-]+$/.test(s)) return ''; return s;