From fc2ff30e5083542790c41b6da14043f72ce0f7c6 Mon Sep 17 00:00:00 2001 From: giteaadmin Date: Sat, 20 Jun 2026 18:13:43 +0000 Subject: [PATCH] fixed game sync --- www/html/Admin/private/store.json | 52 ++++- www/html/Game/public/js/customize-popup.js | 99 ++++++--- www/html/Game/public/js/play.js | 244 ++++++++++++++------- www/html/Game/public/js/room-lobby.js | 142 ++++++++++-- www/html/Game/public/play.html | 2 +- www/html/Game/public/room-lobby.html | 4 +- www/html/Game/server.js | 109 +++++++-- 7 files changed, 499 insertions(+), 153 deletions(-) diff --git a/www/html/Admin/private/store.json b/www/html/Admin/private/store.json index 818922f..d7e46d4 100644 --- a/www/html/Admin/private/store.json +++ b/www/html/Admin/private/store.json @@ -26,14 +26,22 @@ "providerUserId": "p_1775013892442_jveup54h5u", "notes": "auto: player-coins", "blocked": false, - "coins": 0, + "coins": 8, "createdAt": "2026-04-01T03:24:52+00:00", - "updatedAt": "2026-06-19T10:08:36+00:00", - "lobbyColorThemeIndex": 1, + "updatedAt": "2026-06-20T17:54:37+00:00", + "lobbyColorThemeIndex": 8, "lobbySkinToneIndex": 2, "agentDisplayId": "596772", "linkCode": "HGBQ964A", - "linkCodeLastUsedAt": "2026-06-19T09:20:42+00:00" + "linkCodeLastUsedAt": "2026-06-19T09:20:42+00:00", + "score": 8, + "scoreByCase": { + "8": 8 + }, + "lbName": "QS", + "achievements": { + "d1_minigame_solver": 1 + } }, { "id": "483a104becd7a5f92c0e5cad", @@ -103,18 +111,23 @@ "providerUserId": "p_1777015659667_xqfjh247bgp", "notes": "auto: player-coins", "blocked": false, - "coins": 320, + "coins": 330, "createdAt": "2026-04-24T07:28:02+00:00", - "updatedAt": "2026-06-14T10:44:27+00:00", - "score": 320, + "updatedAt": "2026-06-20T17:54:37+00:00", + "score": 330, "scoreByCase": { - "8": 210, + "8": 220, "5": 30, "7": 60, "2": 10, "9": 10 }, - "lbName": "ผู้เล่น" + "lbName": "MONE", + "achievements": { + "d1_minigame_solver": 2 + }, + "lobbyColorThemeIndex": 6, + "lobbySkinToneIndex": 1 }, { "id": "d666c2fbf6005b69ee192882", @@ -529,8 +542,25 @@ "blocked": false, "coins": 0, "createdAt": "2026-06-19T09:43:23+00:00", - "updatedAt": "2026-06-19T10:10:56+00:00", - "lobbyColorThemeIndex": 1, + "updatedAt": "2026-06-20T17:27:35+00:00", + "lobbyColorThemeIndex": 6, + "lobbySkinToneIndex": 1, + "achievements": { + "d1_minigame_solver": 1 + } + }, + { + "id": "fbb262e6c2aa47bd97aef554", + "email": "", + "displayName": "Guest", + "loginType": "guest", + "providerUserId": "p_1781938677448_8j2wnr5ha1b", + "notes": "auto: player-coins", + "blocked": false, + "coins": 0, + "createdAt": "2026-06-20T06:57:57+00:00", + "updatedAt": "2026-06-20T06:58:53+00:00", + "lobbyColorThemeIndex": 7, "lobbySkinToneIndex": 1 } ] diff --git a/www/html/Game/public/js/customize-popup.js b/www/html/Game/public/js/customize-popup.js index f206605..e9aeb5d 100644 --- a/www/html/Game/public/js/customize-popup.js +++ b/www/html/Game/public/js/customize-popup.js @@ -148,16 +148,24 @@ * หา characterId สำหรับ apply — ถ้า localStorage ว่าง ให้ดึงจาก API เหมือน lobby.js * (กันเคสเครื่องที่ไม่เคยเซฟ gameCharacterId → กดยืนยันแล้วตัวใหญ่ไม่อัปเดต) */ + var resolvedDefaultCharacterId = ''; function resolveApplyCharacterId(cb) { var id = getStoredCharacterId(); if (id) { cb(id); return; } + if (resolvedDefaultCharacterId) { cb(resolvedDefaultCharacterId); return; } try { fetch(projectPath('/Game/api/characters'), { credentials: 'same-origin', cache: 'no-store' }) .then(function (r) { return r.json(); }) .then(function (list) { if (!Array.isArray(list) || !list.length) { cb(''); return; } - var last = list[list.length - 1]; - cb(last && last.id ? String(last.id).trim() : ''); + // เลือกตัวที่มี layer files ก่อน (composite สีได้จริง) ไม่งั้นใช้ตัวล่าสุด + var pick = null; + for (var i = 0; i < list.length; i += 1) { + if (list[i] && list[i].hasLayerFiles) { pick = list[i]; break; } + } + if (!pick) pick = list[list.length - 1]; + resolvedDefaultCharacterId = pick && pick.id ? String(pick.id).trim() : ''; + cb(resolvedDefaultCharacterId); }) .catch(function () { cb(''); }); } catch (e) { cb(''); } @@ -427,18 +435,40 @@ }); } - function loadImageCached(url) { - if (!url) return Promise.resolve(null); - if (previewImagePromiseCache[url]) return previewImagePromiseCache[url]; - previewImagePromiseCache[url] = new Promise(function (resolve) { + function loadImageOnce(url, crossOrigin) { + return new Promise(function (resolve) { var img = new Image(); img.decoding = 'async'; + if (crossOrigin) { try { img.crossOrigin = 'anonymous'; } catch (e) { /* ignore */ } } img.onload = function () { resolve(img.naturalWidth > 0 ? img : null); }; img.onerror = function () { resolve(null); }; + // cache-bust เฉพาะตอน retry กันเบราว์เซอร์คืน error response ที่ cache ไว้ img.src = url; if (img.complete && img.naturalWidth > 0) resolve(img); }); - return previewImagePromiseCache[url]; + } + + function loadImageCached(url) { + if (!url) return Promise.resolve(null); + if (previewImagePromiseCache[url]) return previewImagePromiseCache[url]; + // same-origin: อย่าตั้ง crossOrigin (บาง nginx ทำรูปโหลดล้ม) — ตั้งเฉพาะข้ามโดเมนเพื่อกัน canvas taint + var crossOrigin = false; + try { + var u = new URL(url, window.location.href); + crossOrigin = (u.origin !== window.location.origin); + } catch (e) { /* ignore */ } + var p = loadImageOnce(url, crossOrigin).then(function (img) { + if (img) return img; + // retry 1 ครั้ง (กันรูป layer หลุดชั่วคราวตอน burst โหลดหน้าแรก) แนบ cache-bust กัน error response ที่ cache + var bust = url + (url.indexOf('?') === -1 ? '?' : '&') + 'r=' + Date.now(); + return loadImageOnce(bust, crossOrigin); + }).then(function (img) { + // อย่า cache ผลที่ล้ม → รอบหน้า (เปลี่ยนสี/เปิดใหม่) จะลองโหลดอีกครั้ง ไม่ค้าง null ทั้ง session + if (!img && previewImagePromiseCache[url] === p) delete previewImagePromiseCache[url]; + return img; + }); + previewImagePromiseCache[url] = p; + return p; } function resolveFirstImage(urls, idx) { @@ -1026,6 +1056,20 @@ syncScrollUi(); } + /** โหลดรูปตัวละครจริง (idle ทิศ down) แบบไม่ย้อม — ใช้เป็น fallback ที่สีถูกต้อง แทน heuristic ที่ย้อมเหลืองทั้งรูป */ + function loadRawCharacterImageSrc(characterId) { + if (!characterId) return Promise.resolve(''); + var enc = encodeURIComponent(characterId); + var rawCandidates = [ + characterAssetsBasePath() + enc + '_down_idle.png', + characterAssetsBasePath() + enc + '_down.png', + characterAssetsBasePath() + enc + '_down_0.png' + ]; + return resolveFirstImage(rawCandidates).then(function (img) { + return (img && img.src) ? img.src : ''; + }).catch(function () { return ''; }); + } + function renderPreview() { if (!refs.previewFace) return; var token = ++previewRenderToken; @@ -1033,26 +1077,31 @@ var fallbackSrc = domAvatar.src || asset('face-' + state.activeItem + '.png'); refs.previewFace.src = fallbackSrc; refs.previewFace.alt = domAvatar.alt; - var characterId = getStoredCharacterId(); - composeTintedCharacterPreview(characterId, getSelectedTint()) - .then(function (outSrc) { - if (token !== previewRenderToken || !refs.previewFace) return; - if (outSrc) { - refs.previewFace.src = outSrc; - refs.previewFace.alt = 'ตัวอย่างตัวละครที่ปรับแต่งสีแล้ว'; - return; - } - return composeHeuristicTintFromSource(fallbackSrc, getSelectedTint()).then(function (heuristicSrc) { + var tint = getSelectedTint(); + // resolve characterId: localStorage → default char จาก API (กันเครื่องที่ยังไม่เคยเลือกตัวละคร → preview ว่าง/เหลือง) + resolveApplyCharacterId(function (characterId) { + if (token !== previewRenderToken || !refs.previewFace) return; + composeTintedCharacterPreview(characterId, tint) + .then(function (outSrc) { if (token !== previewRenderToken || !refs.previewFace) return; - refs.previewFace.src = heuristicSrc || fallbackSrc; - refs.previewFace.alt = heuristicSrc ? 'ตัวอย่างตัวละครที่ปรับแต่งสีแล้ว' : domAvatar.alt; + if (outSrc) { + refs.previewFace.src = outSrc; + refs.previewFace.alt = 'ตัวอย่างตัวละครที่ปรับแต่งสีแล้ว'; + return; + } + // composite ล้ม → โชว์รูปตัวละครจริงแบบไม่ย้อม (สีถูกต้อง) แทน heuristic ที่ย้อมเหลืองทั้งรูป + return loadRawCharacterImageSrc(characterId).then(function (rawSrc) { + if (token !== previewRenderToken || !refs.previewFace) return; + refs.previewFace.src = rawSrc || fallbackSrc; + refs.previewFace.alt = domAvatar.alt; + }); + }) + .catch(function () { + if (token !== previewRenderToken || !refs.previewFace) return; + refs.previewFace.src = fallbackSrc; + refs.previewFace.alt = domAvatar.alt; }); - }) - .catch(function () { - if (token !== previewRenderToken || !refs.previewFace) return; - refs.previewFace.src = fallbackSrc; - refs.previewFace.alt = domAvatar.alt; - }); + }); } function syncCoins() { diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index f52af60..6a5dec5 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -4607,7 +4607,7 @@ el.textContent = ''; return; } - const s = Math.max(0, Math.ceil((playQuizPhaseEndsAt - Date.now()) / 1000)); + const s = Math.max(0, Math.ceil((playQuizPhaseEndsAt - serverNow()) / 1000)); el.textContent = s + ' วินาที'; } @@ -5965,7 +5965,7 @@ /** Preview bots only — full tail-off loss needs server support for real humans */ const GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX = 40; function maybeEliminateGauntletPreviewBotsTailOffCameraPlay(camX, camY, zDrawVal) { - if (!playBotsEnabled() || !isGauntletCrownHeistMapPlay() || !mapData) return; + if (!playBotsEnabled() || !isMePlayHost() || !isGauntletCrownHeistMapPlay() || !mapData) return; if (gauntletCrownPregamePhase !== 'live') return; const halfW = canvas.width / (2 * zDrawVal); const worldMinX = camX - halfW; @@ -6877,19 +6877,11 @@ } cd.classList.remove('is-hidden'); syncGauntletCrownCountdownMg2ModePlay(cd); - let n = 3; - setCountdown321QuestionAssetGraphic(numEl, n); - const step = function () { - n--; - if (n > 0) { - setCountdown321QuestionAssetGraphic(numEl, n); - stackTowerMissionCountdownTimer = setTimeout(step, 1000); - } else { - stackTowerMissionCountdownTimer = null; - runFinish(); - } - }; - stackTowerMissionCountdownTimer = setTimeout(step, 1000); + runAlignedCountdownPlay( + function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, + runFinish, + function (id) { stackTowerMissionCountdownTimer = id; } + ); } function stackTowerMissionBuildPayload(endOpts) { @@ -7198,19 +7190,11 @@ } cd.classList.remove('is-hidden'); syncGauntletCrownCountdownMg2ModePlay(cd); - let n = 3; - setCountdown321QuestionAssetGraphic(numEl, n); - const step = function () { - n--; - if (n > 0) { - setCountdown321QuestionAssetGraphic(numEl, n); - jumperMissionCountdownTimer = setTimeout(step, 1000); - } else { - jumperMissionCountdownTimer = null; - runFinish(); - } - }; - jumperMissionCountdownTimer = setTimeout(step, 1000); + runAlignedCountdownPlay( + function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, + runFinish, + function (id) { jumperMissionCountdownTimer = id; } + ); } /** Jumper ภารกิจ mnptfts2: เกรดจากจำนวนผู้รอด — 6=A … 3=D; น้อยกว่า 2 = F; พอดี 2 คน = E */ @@ -7596,19 +7580,11 @@ } cd.classList.remove('is-hidden'); syncGauntletCrownCountdownMg2ModePlay(cd); - let n = 3; - setCountdown321QuestionAssetGraphic(numEl, n); - const step = function () { - n--; - if (n > 0) { - setCountdown321QuestionAssetGraphic(numEl, n); - spaceShooterMissionCountdownTimer = setTimeout(step, 1000); - } else { - spaceShooterMissionCountdownTimer = null; - runFinish(); - } - }; - spaceShooterMissionCountdownTimer = setTimeout(step, 1000); + runAlignedCountdownPlay( + function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, + runFinish, + function (id) { spaceShooterMissionCountdownTimer = id; } + ); } function spaceShooterBuildMissionPayload() { @@ -8140,19 +8116,11 @@ } cd.classList.remove('is-hidden'); syncGauntletCrownCountdownMg2ModePlay(cd); - let n = 3; - setCountdown321QuestionAssetGraphic(numEl, n); - const step = function () { - n--; - if (n > 0) { - setCountdown321QuestionAssetGraphic(numEl, n); - quizQuestionMissionCountdownTimer = setTimeout(step, 1000); - } else { - quizQuestionMissionCountdownTimer = null; - runFinish(); - } - }; - quizQuestionMissionCountdownTimer = setTimeout(step, 1000); + runAlignedCountdownPlay( + function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, + runFinish, + function (id) { quizQuestionMissionCountdownTimer = id; } + ); } function quizQuestionMissionBuildPayload() { @@ -9033,6 +9001,84 @@ return String(playHostId) === String(myId); } + /* บอทเติมช่อง (fill-bot) ใน 7 มินิเกมต้องเดิน/เล่น/คะแนนตรงกันทุก client: + host เป็นผู้จำลอง bot ทั้งหมด (AI/ฟิสิกส์/คะแนน) แล้ว broadcast state — client อื่นปิด local sim แล้ว render ตาม + (เดิม: ทุกเครื่องสุ่ม Math.random() แยกกัน → บอทตัวเดียวกันอยู่คนละที่/คนละคะแนนบนแต่ละจอ) */ + function fbNum(v) { return (typeof v === 'number' && Number.isFinite(v)) ? Math.round(v * 100) / 100 : undefined; } + function broadcastFillBotState() { + if (!playBotsEnabled() || !isMePlayHost() || !mapData) return; + const arr = []; + others.forEach((o, id) => { + if (!isPreviewBotId(id) || !o) return; + const row = { id: id, x: fbNum(o.x), y: fbNum(o.y), direction: o.direction || 'down', walking: !!o.botIsWalking }; + if (o.gauntletScore != null) row.gScore = o.gauntletScore; + if (o.gauntletEliminated != null) row.gElim = !!o.gauntletEliminated; + if (o.gauntletJumpTicks != null) row.gJump = o.gauntletJumpTicks; + if (o.gauntletJumpVis != null) row.gJumpVis = fbNum(o.gauntletJumpVis); + if (o.jumpSurviveEliminated != null) row.jsElim = !!o.jumpSurviveEliminated; + if (o.spaceShooterCx != null) row.ssCx = fbNum(o.spaceShooterCx); + if (o.spaceShooterCy != null) row.ssCy = fbNum(o.spaceShooterCy); + if (o.spaceShooterScore != null) row.ssScore = o.spaceShooterScore; + if (o.spaceShooterEliminated != null) row.ssElim = !!o.spaceShooterEliminated; + if (o.balloonBossCx != null) row.bbCx = fbNum(o.balloonBossCx); + if (o.balloonBossCy != null) row.bbCy = fbNum(o.balloonBossCy); + if (o.balloonBossScore != null) row.bbScore = o.balloonBossScore; + if (o.balloonBossBossDmg != null) row.bbDmg = o.balloonBossBossDmg; + if (o.balloonBossBalloons != null) row.bbBalloons = o.balloonBossBalloons; + if (o.balloonBossEliminated != null) row.bbElim = !!o.balloonBossEliminated; + if (o.quizCarryHeld !== undefined) row.qcHeld = o.quizCarryHeld; + arr.push(row); + }); + if (arr.length) socket.emit('fill-bot-state', { bots: arr }); + } + setInterval(broadcastFillBotState, 80); /* ~12.5Hz — self-gate ถ้าไม่ใช่ host หรือไม่มีบอท */ + + /* ===== Server-time sync + เวลาเริ่มเล่นจริงร่วมกัน (liveBeginsAt) ===== + แก้ปัญหา "เวลาในเกมไม่เท่ากัน เริ่ม/จบไม่พร้อมกัน": เดิมแต่ละเครื่องนับ 3-2-1 เองด้วย setTimeout + และจับเวลารอบด้วย performance.now() ของตัวเอง → GO และจุดจบไม่ตรงกัน + แก้: sync นาฬิกากับ server (serverNow) แล้วทุกคนนับไปหา "liveBeginsAt" (server epoch) จุดเดียวกัน + เมื่อ GO พร้อมกัน → นาฬิการอบ (performance.now ตั้งแต่ GO) ก็ sync กันเองอัตโนมัติ */ + let playClockOffset = 0; /* serverTime - clientTime (ms) */ + let playClockBestRtt = null; + function serverNow() { return Date.now() + playClockOffset; } + function syncPlayClock(round) { + round = round || 0; + if (!socket || !socket.connected) return; + const t0 = Date.now(); + socket.emit('time-sync', (res) => { + const t1 = Date.now(); + if (res && Number.isFinite(res.t)) { + const rtt = t1 - t0; + if (playClockBestRtt == null || rtt <= playClockBestRtt) { + playClockBestRtt = rtt; + playClockOffset = res.t + rtt / 2 - t1; /* ชดเชยครึ่งหนึ่งของ round-trip latency */ + } + } + if (round < 5) setTimeout(function () { syncPlayClock(round + 1); }, 300); + }); + } + + let playMissionLiveBeginsAt = 0; /* server epoch ms ที่เกมเริ่มจริงพร้อมกันทุกคน (0 = ยังไม่รู้) */ + function setPlayMissionLiveBeginsAt(v) { + const n = Number(v); + playMissionLiveBeginsAt = (Number.isFinite(n) && n > 0) ? n : 0; + } + + /* นับถอยหลัง 3-2-1 ให้ "GO" ตรงกันทุกเครื่อง โดยนับไปหา liveBeginsAt (เวลา server) แทน setTimeout(1000) ในเครื่องตัวเอง + drawNum(n): วาดเลขนับถอยหลัง · onGo(): เริ่มเล่นจริง · assignTimer(id): เก็บ timer id เดิมไว้ clear ได้ */ + function runAlignedCountdownPlay(drawNum, onGo, assignTimer) { + const begin = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : (serverNow() + 3000); + let lastN = -1; + const tick = function () { + const remMs = begin - serverNow(); + if (remMs <= 60) { assignTimer(null); onGo(); return; } + const n = Math.min(3, Math.max(1, Math.ceil(remMs / 1000))); + if (n !== lastN) { lastN = n; try { drawNum(n); } catch (_e) { /* ignore */ } } + assignTimer(setTimeout(tick, 80)); + }; + tick(); + } + function hideQuizCarryPregameOverlay() { const ov = document.getElementById('quiz-carry-pregame-overlay'); if (ov) { @@ -12235,7 +12281,7 @@ } function applyGauntletPreviewBotsAfterSync() { - if (!playBotsEnabled() || !mapData || mapData.gameType !== 'gauntlet') return; + if (!playBotsEnabled() || !isMePlayHost() || !mapData || mapData.gameType !== 'gauntlet') return; if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return; if (isGauntletCrownHeistMapPlay() && gauntletCrownRunwayStripFrozenPlay()) return; const w = mapData.width || 20; @@ -12536,21 +12582,14 @@ const textEl = document.getElementById('gauntlet-crown-countdown-text'); const mg2Cd = isGauntletCrownHeistMapPlay(); cd.classList.remove('is-hidden'); - let n = 3; - if (mg2Cd) setGauntletCrownMg2CountdownGraphic(numEl, textEl, n); - else setCountdown321QuestionAssetGraphic(numEl, n); - const step = () => { - n--; - if (n > 0) { + runAlignedCountdownPlay( + function (n) { if (mg2Cd) setGauntletCrownMg2CountdownGraphic(numEl, textEl, n); else setCountdown321QuestionAssetGraphic(numEl, n); - gauntletCrownCountdownTimer = setTimeout(step, 1000); - } else { - gauntletCrownCountdownTimer = null; - runFinish(); - } - }; - gauntletCrownCountdownTimer = setTimeout(step, 1000); + }, + runFinish, + function (id) { gauntletCrownCountdownTimer = id; } + ); try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ } try { syncPlayCyberHud(); } catch (_sh) { /* ignore */ } } @@ -15548,7 +15587,7 @@ } function stepSpaceShooterPreviewBots(dt) { - if (spaceShooterGameEnded || !playBotsEnabled() || !mapData || mapData.gameType !== 'space_shooter') return; + if (spaceShooterGameEnded || !playBotsEnabled() || !isMePlayHost() || !mapData || mapData.gameType !== 'space_shooter') return; if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return; const ts = tileSize; const mw = (mapData.width || 20) * ts; @@ -16271,7 +16310,7 @@ } function stepBalloonBossPreviewBots(dt, mw, mh, bossCx, bossCy, ts) { - if (!playBotsEnabled() || !mapData || mapData.gameType !== 'balloon_boss') return; + if (!playBotsEnabled() || !isMePlayHost() || !mapData || mapData.gameType !== 'balloon_boss') return; [...others.keys()].filter(isPreviewBotId).forEach((bid) => { const o = others.get(bid); if (!o || o.balloonBossEliminated || o.balloonBossCx == null) return; @@ -17659,7 +17698,7 @@ } function stepJumpSurvivePreviewBots(dt, halfViewH) { - if (!playBotsEnabled() || !mapData || !isJumpSurvive()) return; + if (!playBotsEnabled() || !isMePlayHost() || !mapData || !isJumpSurvive()) return; const md = mapData; const { cw, ch } = getCharacterFootprintWH(md); const ts = tileSize; @@ -17776,6 +17815,15 @@ const dt = Math.min(0.05, Math.max(0, (now - jumpSurviveLastTickMs) / 1000)); jumpSurviveLastTickMs = now; + /* ผู้เล่นจริงระยะไกล (ไม่ใช่บอท): เลอร์ปตำแหน่งตาม move ที่รับมา (o.tx/o.ty) + เดิม jump_survive ไม่ได้เลอร์ป others เลย → host/เพื่อนค้างที่จุดเกิดขณะกล้องเลื่อนขึ้น = ตัวละครหายจากจอ + (เกมอื่นทำที่ tick กลาง 23235 แต่ jump_survive return ก่อนถึงตรงนั้น จึงต้องทำในนี้เอง) */ + others.forEach((o, id) => { + if (isPreviewBotId(id) || !o) 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; + }); + let zJumpSurviveWorld = zoom; if (previewMode && editorEmbedReturn && mapData) zJumpSurviveWorld *= playEmbedUserZoomMul; const halfViewH = canvas.height / (2 * zJumpSurviveWorld); @@ -18513,6 +18561,8 @@ return; } myId = socket.id; + try { syncPlayClock(); } catch (_sc) { /* ignore */ } + if (res && res.missionLiveBeginsAt != null) setPlayMissionLiveBeginsAt(res.missionLiveBeginsAt); if (playBannedSpectator) setupBannedSpectatorUi(); if (specialQuizForceDebug) { try { socket.emit('special-quiz-force', { on: true }); } catch (eF) { /* ignore */ } @@ -18669,7 +18719,8 @@ updateQuizCarryPregameHud(); } }); - socket.on('quiz-carry-lobby-started', () => { + socket.on('quiz-carry-lobby-started', (payload) => { + setPlayMissionLiveBeginsAt(payload && payload.liveBeginsAt); if (!quizCarryEmbedMissionFlowActive()) return; endQuizCarryEmbedPregameAndStart(); }); @@ -18693,7 +18744,8 @@ updateSpaceShooterMissionHowtoHud(); } }); - socket.on('gauntlet-crown-lobby-started', () => { + socket.on('gauntlet-crown-lobby-started', (payload) => { + setPlayMissionLiveBeginsAt(payload && payload.liveBeginsAt); if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') { beginQuizQuestionMissionCountdownThenRun(); return; @@ -18713,6 +18765,39 @@ if (!usesCrownLobbyShellPlay()) return; beginGauntletCrownCountdownThenRun(); }); + /* รับ state ของ fill-bot จาก host (เฉพาะ client ที่ไม่ใช่ host) → render ตาม ไม่จำลองเอง */ + socket.on('fill-bot-state', (data) => { + if (isMePlayHost()) return; + if (!data || !Array.isArray(data.bots)) return; + /* เกมที่ render อ่านตำแหน่งจาก x/y หรือ Cx/Cy ตรงๆ (ไม่ผ่าน lerp ของ main tick) → สแนปตำแหน่งเลย */ + const snapPos = isSpaceShooter() || isBalloonBoss() || isJumpSurvive(); + data.bots.forEach((rb) => { + if (!rb || !isPreviewBotId(rb.id)) return; + const o = others.get(rb.id); + if (!o) return; + 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; + o.botIsWalking = !!rb.walking; + if (rb.gScore != null) o.gauntletScore = rb.gScore; + if (rb.gElim != null) o.gauntletEliminated = !!rb.gElim; + if (rb.gJump != null) o.gauntletJumpTicks = rb.gJump; + if (rb.gJumpVis != null) o.gauntletJumpVis = rb.gJumpVis; + if (rb.jsElim != null) o.jumpSurviveEliminated = !!rb.jsElim; + if (rb.ssCx != null) o.spaceShooterCx = rb.ssCx; + if (rb.ssCy != null) o.spaceShooterCy = rb.ssCy; + if (rb.ssScore != null) o.spaceShooterScore = rb.ssScore; + if (rb.ssElim != null) o.spaceShooterEliminated = !!rb.ssElim; + if (rb.bbCx != null) o.balloonBossCx = rb.bbCx; + if (rb.bbCy != null) o.balloonBossCy = rb.bbCy; + if (rb.bbScore != null) o.balloonBossScore = rb.bbScore; + if (rb.bbDmg != null) o.balloonBossBossDmg = rb.bbDmg; + if (rb.bbBalloons != null) o.balloonBossBalloons = rb.bbBalloons; + if (rb.bbElim != null) o.balloonBossEliminated = !!rb.bbElim; + if (rb.qcHeld !== undefined) o.quizCarryHeld = rb.qcHeld; + }); + }); + socket.on('user-move', (data) => { if (data && myId != null && data.id === myId) { /* Gauntlet: ตำแหน่งอ้างอิงจาก gauntlet-sync + เลอร์ปเท่านั้น — ไม่งั้น echo จาก move จะสแนป me.x/y โดยไม่อัปเดต tx/ty ทำให้เลอร์ปผิดและชนเซิร์ฟเวอร์ไม่ตรง */ @@ -19729,7 +19814,7 @@ /** แยกบอท preview ที่ทับกันบนจุดเดียว (มักชนกันที่ขอบแมป) */ function separateClumpedPreviewBots() { - if (!playBotsEnabled() || !mapData) return; + if (!playBotsEnabled() || !isMePlayHost() || !mapData) return; const ids = [...others.keys()].filter(isPreviewBotId); const minD = 0.52; const pushAmt = MOVE_SPEED * 0.5; @@ -20129,7 +20214,7 @@ } function stepPreviewBots() { - if (!playBotsEnabled() || !mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss()) return; + if (!playBotsEnabled() || !isMePlayHost() || !mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss()) return; if (isQuizCarry()) { stepQuizCarryPreviewBots(); return; @@ -21456,7 +21541,7 @@ } else if (!playQuizPhaseEndsAt) { timeVal.textContent = '—'; } else { - timeVal.textContent = String(Math.max(0, Math.ceil((playQuizPhaseEndsAt - Date.now()) / 1000))); + timeVal.textContent = String(Math.max(0, Math.ceil((playQuizPhaseEndsAt - serverNow()) / 1000))); } } else if (isStack() && isStackTowerMissionUiMapPlay()) { if (stackTowerMissionPhase === 'live') { @@ -21491,7 +21576,7 @@ if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) { timeVal.textContent = '···'; } else if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) { - const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000)); + const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - serverNow()) / 1000)); const mm = Math.floor(rem / 60); const ss = rem % 60; timeVal.textContent = `${mm}:${String(ss).padStart(2, '0')}`; @@ -22797,7 +22882,7 @@ ctx.fillStyle = '#e0af68'; if (!(isGauntletCrownHeistMapPlay() && gauntletCrownRunwayBgDrawActivePlay())) { if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) { - const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000)); + const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - serverNow()) / 1000)); const mm = Math.floor(rem / 60); const ss = rem % 60; const clock = `${mm}:${String(ss).padStart(2, '0')}`; @@ -23157,7 +23242,8 @@ } /* บอท preview ขยับที่ o.x/o.y โดยตรง — ห้าม LERP กับ tx/ty เพราะ stepPreviewBots เคยตั้ง tx=ก่อนเดิน ทำให้โดนดึงถอย ~20%/เฟรม กระตุกและแทบไม่ไป */ others.forEach((o, id) => { - if (playBotsEnabled() && isPreviewBotId(id)) return; + /* 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; }); diff --git a/www/html/Game/public/js/room-lobby.js b/www/html/Game/public/js/room-lobby.js index 55f2cf3..6bfb203 100644 --- a/www/html/Game/public/js/room-lobby.js +++ b/www/html/Game/public/js/room-lobby.js @@ -338,10 +338,17 @@ var rlLayerMissing = {}; function rlLoadImg(src, cb) { - if (rlLayerMissing[src]) { cb(null); return; } // เคย 404 แล้ว ไม่ขอซ้ำ (กัน console spam) + if (rlLayerMissing[src]) { cb(null); return; } // เคย 404 (ล้ม 2 ครั้ง) แล้ว ไม่ขอซ้ำ (กัน console spam) var img = new Image(); img.onload = function () { cb(img); }; - img.onerror = function () { rlLayerMissing[src] = true; cb(null); }; + img.onerror = function () { + // retry 1 ครั้งพร้อม cache-bust ก่อนมาร์ค missing — กัน layer หลุดชั่วคราวตอน burst โหลดหน้าแรก + // (ถ้ามาร์ค missing ทันทีจากการล้มครั้งเดียว → color layer หาย → ตัวละครขาวทั้ง session) + var img2 = new Image(); + img2.onload = function () { cb(img2); }; + img2.onerror = function () { rlLayerMissing[src] = true; cb(null); }; + img2.src = src + (src.indexOf('?') === -1 ? '?' : '&') + 'r=' + Date.now(); + }; img.src = src; } @@ -616,8 +623,8 @@ if (!ti) return false; var myIdx = getMyLobbyThemeIndex(); if (forSelfChange && myIdx === ti) return true; - /* บอทไม่บล็อก: ถ้าสีถูกบอทยึดอยู่ ยังเลือกได้ (server จะดันบอทไปสีอื่นให้) — บล็อกเฉพาะสีที่ "คนจริง" ใช้ */ - return getLobbyTakenThemeIndices(forSelfChange ? socket.id : null, true).indexOf(ti) < 0; + /* บล็อกสีที่ "ผู้ใช้คนอื่นหรือบอท" ใช้อยู่ทั้งหมด — ห้ามเลือกซ้ำกับใครเลย (มี 8 สี ผู้เล่นสูงสุด 6 จึงเหลือว่างเสมอ) */ + return getLobbyTakenThemeIndices(forSelfChange ? socket.id : null, false).indexOf(ti) < 0; } function applyThemeIndexToOccupant(target, themeIndex, skinIndex, done) { @@ -645,6 +652,28 @@ applyThemeIndexToOccupant(peer, themeIndex, skinIndex, done); } + /** อัปเดตสีให้ "บอทที่มีอยู่แล้ว" ตาม lobbyBotThemesFromServer — กันบอทค้างสีเดิมแล้วซ้ำกับผู้เล่น + * (ensureLobbyBots ลงสีเฉพาะตอนสร้างบอทใหม่ ไม่แตะบอทเดิมเวลา server ดันบอทเปลี่ยนสี) */ + function reapplyLobbyBotThemesFromServer() { + if (!Array.isArray(lobbyBotThemesFromServer) || !lobbyBots.size) return; + lobbyBots.forEach(function (bot, id) { + if (!bot) return; + var key = String(id); + if (key.indexOf(LOBBY_BOT_PREFIX) !== 0) return; + var slot = parseInt(key.slice(LOBBY_BOT_PREFIX.length), 10); + if (!(slot >= 0)) return; + var srv = lobbyBotThemesFromServer[slot]; + var ti = srv && parseLobbyThemeIndex(srv.themeIndex); + if (!ti) return; + if (parseLobbyThemeIndex(bot.colorThemeIndex) === ti) return; // สีไม่เปลี่ยน → ข้าม + var si = parseLobbySkinIndex(srv.skinToneIndex) || parseLobbySkinIndex(bot.colorSkinIndex) || ((slot % 3) + 1); + applyThemeIndexToOccupant(bot, ti, si, function () { + if (bot.characterId) preloadLobbyTintedCharacter(bot.characterId, bot.colorTheme, bot.colorSkin, function () {}); + if (mapData && canvas) drawLobbyMap(); + }); + }); + } + function applyAllPeerThemesFromServer(done) { var pending = 1; function tick() { @@ -713,6 +742,7 @@ } } syncLobbyCaseBots(); + reapplyLobbyBotThemesFromServer(); if (mapData && canvas) drawLobbyMap(); if (typeof window.refreshCustomizeColorRow === 'function') window.refreshCustomizeColorRow(); }); @@ -857,8 +887,6 @@ return c; } - var rlCharManifest = null; - var rlManifestId = null; /** ตัวละคร default — ใช้เมื่อผู้เล่น/บอทไม่มี characterId (กันตัวละครหายเป็นวงกลม blob) */ var rlDefaultCharId = ''; (function rlLoadDefaultChar() { @@ -877,25 +905,46 @@ } catch (e) { /* ignore */ } })(); - function rlEnsureManifest(cb) { - var id = getStoredCharacterId(); + /** เก็บ layerManifest แบบ per-id (เดิมเก็บ slot เดียวผูกกับ getStoredCharacterId เท่านั้น + * → ถ้า localStorage ว่าง/ตัวที่วาดเป็น default char คนละ id แมนิเฟสต์ไม่โหลด tint เลยไม่ทำงาน = ตัวขาว) */ + var rlManifestById = Object.create(null); + var rlManifestFetching = false; + var rlManifestWaiters = []; + + function rlEnsureManifestFor(id, cb) { if (!id) { if (cb) cb(null); return; } - if (rlManifestId === id && rlCharManifest) { if (cb) cb(rlCharManifest); return; } + if (rlManifestById[id]) { if (cb) cb(rlManifestById[id]); return; } + if (cb) rlManifestWaiters.push({ id: id, cb: cb }); + if (rlManifestFetching) return; + rlManifestFetching = true; fetch(SERVER + '/api/characters', { cache: 'no-store' }) .then(function (r) { return r.json(); }) .then(function (list) { - var c = Array.isArray(list) ? list.filter(function (x) { return x && x.id === id; })[0] : null; - if (c && c.layerManifest) { rlCharManifest = c.layerManifest; rlManifestId = id; if (cb) cb(rlCharManifest); } - else if (cb) cb(null); + if (Array.isArray(list)) { + list.forEach(function (c) { + if (c && c.id && c.layerManifest) rlManifestById[c.id] = c.layerManifest; + }); + } }) - .catch(function () { if (cb) cb(null); }); + .catch(function () { /* ignore */ }) + .then(function () { + rlManifestFetching = false; + var waiters = rlManifestWaiters.splice(0); + waiters.forEach(function (w) { try { w.cb(rlManifestById[w.id] || null); } catch (e) { /* ignore */ } }); + }); + } + + /** wrapper เดิม — โหลด manifest ของตัวละครที่ผู้เล่นเลือกไว้ (ใช้ตอน init) */ + function rlEnsureManifest(cb) { + rlEnsureManifestFor(getStoredCharacterId() || rlDefaultCharId, cb); } function rlFrameLayerMap(id, dir, frameSuffix) { - if (!rlCharManifest || rlManifestId !== id) return null; + var manifest = rlManifestById[id]; + if (!manifest) return null; var entry = (frameSuffix === 'idle') - ? (rlCharManifest.byDirIdle && rlCharManifest.byDirIdle[dir]) - : (rlCharManifest.byDir && rlCharManifest.byDir[dir]); + ? (manifest.byDirIdle && manifest.byDirIdle[dir]) + : (manifest.byDir && manifest.byDir[dir]); if (!entry || !entry.frames || !entry.frames.length) return null; if (frameSuffix === 'idle') return entry.frames[0] || null; var fi = parseInt(frameSuffix, 10); @@ -903,6 +952,14 @@ } function rlBuildTintedFrame(id, dir, frameSuffix, theme, skin, cb) { + // โหลด manifest ของ id นี้ให้พร้อมก่อน (คืนทันทีถ้า cache แล้ว) — กันตัวขาวเมื่อ id ที่วาด + // ไม่ตรงกับตัวที่ผู้เล่นเลือก (เช่น default char / peer ที่ใช้ตัวละครคนละตัว) + rlEnsureManifestFor(id, function () { + rlBuildTintedFrameInner(id, dir, frameSuffix, theme, skin, cb); + }); + } + + function rlBuildTintedFrameInner(id, dir, frameSuffix, theme, skin, cb) { var map = rlFrameLayerMap(id, dir, frameSuffix); if (!map) { cb(null); return; } var names = RL_LAYER_NAMES.filter(function (n) { return map[n]; }); @@ -2170,6 +2227,35 @@ }); } + /* บอท lobby ต้องเดิน "ตรงกันทุก client": host เป็นเจ้าของตำแหน่ง (เดินจริงด้วย stepLobbyCaseBots) + แล้ว broadcast ให้คนอื่น — client ที่ไม่ใช่ host จะไม่สุ่มเดินเอง แต่ lerp ตามตำแหน่งที่ได้รับ + (เดิม: ทุกเครื่องสุ่มจุดเป้าเอง → บอทตัวเดียวกันอยู่คนละที่บนจอแต่ละคน) */ + let lobbyBotBroadcastAt = 0; + function broadcastLobbyBotPositions() { + if (lobbyBots.size === 0) return; + const now = Date.now(); + if (now - lobbyBotBroadcastAt < 100) return; /* ~10Hz พอให้ลื่นและไม่กินแบนด์วิดท์ */ + lobbyBotBroadcastAt = now; + const arr = []; + lobbyBots.forEach((b, id) => { + arr.push({ + id: id, + x: Math.round(b.x * 1000) / 1000, + y: Math.round(b.y * 1000) / 1000, + direction: b.direction || 'down', + isWalking: !!b.isWalking, + }); + }); + if (arr.length) socket.emit('lobby-bot-move', { bots: arr }); + } + + function lerpLobbyBots() { + lobbyBots.forEach((b) => { + if (typeof b.botSyncTX === 'number') b.x += (b.botSyncTX - b.x) * LERP; + if (typeof b.botSyncTY === 'number') b.y += (b.botSyncTY - b.y) * LERP; + }); + } + function lobbyTick() { const me = peers.get(socket.id); if (!mapData || !me) { requestAnimationFrame(lobbyTick); return; } @@ -2179,7 +2265,14 @@ if (p.ty != null) p.y += (p.ty - p.y) * LERP; } }); - if (!suspectPickOverlayOpen && lobbyBotSlotCount > 0) stepLobbyCaseBots(); + if (lobbyBotSlotCount > 0) { + if (hostId === socket.id) { + if (!suspectPickOverlayOpen) stepLobbyCaseBots(); + broadcastLobbyBotPositions(); /* host เดินจริง → ส่งตำแหน่งให้ทุกคน */ + } else { + lerpLobbyBots(); /* client อื่น: ตามตำแหน่งจาก host (ไม่สุ่มเดินเอง) */ + } + } const w = mapData.width || 20, h = mapData.height || 15; const preWalkX = me.x, preWalkY = me.y; let accX = 0, accY = 0; @@ -3788,6 +3881,21 @@ } catch (err) { console.warn('WebRTC signal error', err); } }); + /* รับตำแหน่งบอทจาก host (เฉพาะ client ที่ไม่ใช่ host) → เก็บเป็นเป้า lerp ใน lobbyTick */ + socket.on('lobby-bot-move', (data) => { + if (hostId === socket.id) return; /* host เป็นเจ้าของเอง ไม่ต้องรับ */ + if (!data || !Array.isArray(data.bots)) return; + data.bots.forEach((rb) => { + if (!rb || typeof rb.id !== 'string') return; + const b = lobbyBots.get(rb.id); + if (!b) return; + if (Number.isFinite(rb.x)) b.botSyncTX = rb.x; + if (Number.isFinite(rb.y)) b.botSyncTY = rb.y; + if (rb.direction) b.direction = rb.direction; + b.isWalking = !!rb.isWalking; + }); + }); + socket.on('host-changed', (data) => { hostId = data.hostId || null; const isHost = hostId === socket.id; diff --git a/www/html/Game/public/play.html b/www/html/Game/public/play.html index e5d0aab..2e8ace6 100644 --- a/www/html/Game/public/play.html +++ b/www/html/Game/public/play.html @@ -5205,7 +5205,7 @@ - +
v —