fixed game sync

This commit is contained in:
2026-06-20 18:13:43 +00:00
parent 4fc71fd08a
commit fc2ff30e50
7 changed files with 499 additions and 153 deletions
+41 -11
View File
@@ -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
}
]
+74 -25
View File
@@ -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() {
+165 -79
View File
@@ -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;
});
+125 -17
View File
@@ -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;
+1 -1
View File
@@ -5205,7 +5205,7 @@
<script src="/app-base.js?v=2"></script>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0306"></script>
<script src="js/play.js?v=0.006131841"></script>
<script src="js/play.js?v=0.006131844"></script>
<div class="version-tag">v —</div>
<style id="qc-howto-mg2-fix">
/* HOW TO PLAY ของ quiz_carry (Minigame-4) -> ให้เหมือน Minigame-2 (gch-mg2-mock)
+2 -2
View File
@@ -1615,9 +1615,9 @@
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/display-name.js?v=2"></script>
<script src="js/version.js?v=0.0122"></script>
<script src="js/customize-popup.js?v=38" data-customize-triggers="" data-customize-asset-base="img/03-5-Customize"></script>
<script src="js/customize-popup.js?v=40" data-customize-triggers="" data-customize-asset-base="img/03-5-Customize"></script>
<script src="js/achievements.js?v=0.002" data-asset-base="img/03-6-Profile"></script>
<script src="js/room-lobby.js?v=0.0294"></script>
<script src="js/room-lobby.js?v=0.0298"></script>
<div class="version-tag">v —</div>
</body>
</html>
+91 -18
View File
@@ -39,6 +39,10 @@ function normalizeForcedMinigameKeys(v) {
return [one(arr[0]), one(arr[1]), one(arr[2])];
}
const LOBBY_SLOT_TOTAL = 6;
/* 3-2-1 (ms) server liveBeginsAt = now +
ให client บไปหาเวลาเดยวก GO/เร/จบ ตรงกนทกเครอง (เผ buffer ใหกคนร event ) */
const LIVE_COUNTDOWN_MS = 3500;
const LOBBY_THEME_COUNT = 8;
const LOBBY_SKIN_COUNT = 3;
@@ -3195,6 +3199,7 @@ function buildPeersSnapForSpace(space, extraFields) {
nickname: p.nickname,
ready: !!p.ready,
characterId: p.characterId ?? null,
spawnJoinOrder: typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : null,
lobbyColorThemeIndex: p.lobbyColorThemeIndex || null,
lobbySkinToneIndex: p.lobbySkinToneIndex || null,
};
@@ -3458,9 +3463,8 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
maybeSpawnSpecialQuizForRun(space);
/* ไอคอนยังไม่โผล่ตอนนี้ — รอ client ส่ง 'special-quiz-arm' เมื่อเกม "live จริง" (พ้น howto/นับถอยหลัง) แล้วค่อยหน่วง 2 วิโผล่ */
let si = 0;
space.peers.forEach((p) => {
const sp = pickSpawnForJoin(md, si++);
const sp = pickSpawnForJoin(md, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0);
p.x = sp.x;
p.y = sp.y;
p.direction = 'down';
@@ -3590,9 +3594,8 @@ function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) {
space.mapId = POST_CASE_LOBBY_SPACE_ID;
space.mapData = lb;
space.suspectPhaseActive = true;
let gi = 0;
space.peers.forEach((p) => {
const sp = pickSpawnForJoin(lb, gi++);
const sp = pickSpawnForJoin(lb, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0);
p.x = sp.x;
p.y = sp.y;
p.direction = 'down';
@@ -6419,7 +6422,8 @@ function initGauntletPeersAll(space, md) {
const pos = gauntletSpawnPositions(md, list.length);
const crown = isGauntletCrownHeistMapBySpace(space);
list.forEach((p, i) => {
const pt = pos[i] || pos[pos.length - 1];
const ord = typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : i;
const pt = pos[ord] || pos[i] || pos[pos.length - 1];
p.x = pt.x;
p.y = pt.y;
p.gauntletJumpTicks = 0;
@@ -6874,6 +6878,26 @@ function pickSpawnForJoin(md, joinOrderIndex) {
return pickRandomSpawnFromMap(md);
}
/* spawnJoinOrder (lobbyAlobbyBminigame): playerKey ( browser)
ไมใช peers.size เปลยนตามลำด reconnect ตำแหนงเก / / slot ของแตละคนสลบกนม */
function resolveSpawnJoinOrder(space, playerKey) {
if (!space.spawnOrderByKey || typeof space.spawnOrderByKey !== 'object') space.spawnOrderByKey = {};
const key = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : '';
const used = new Set();
space.peers.forEach((p) => {
if (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) used.add(p.spawnJoinOrder);
});
/* host/ผู้เล่นเดิมกลับเข้ามา → คืนลำดับเดิมถ้ายังว่าง (ไม่มี peer อื่นถืออยู่) */
if (key && Object.prototype.hasOwnProperty.call(space.spawnOrderByKey, key)) {
const prev = space.spawnOrderByKey[key];
if (typeof prev === 'number' && prev >= 0 && !used.has(prev)) return prev;
}
let ord = 0;
while (used.has(ord)) ord++;
if (key) space.spawnOrderByKey[key] = ord;
return ord;
}
/** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */
function pickRandomSpawnFromMap(md) {
const fallback = md.spawn || { x: 1, y: 1 };
@@ -7401,7 +7425,7 @@ io.on('connection', (socket) => {
if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space);
ensurePeerLobbyThemes(space);
const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData;
const spawnJoinOrder = space.peers.size;
const spawnJoinOrder = resolveSpawnJoinOrder(space, playerKey);
const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder);
const bbStartBalloons = balloonBossBalloonsForMap(mdJoin);
/* ใช้สีที่ client เลือกไว้ใน Main-Lobby — คนจริงสำคัญกว่าบอท */
@@ -7460,7 +7484,7 @@ io.on('connection', (socket) => {
io.to(spaceId).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } });
}
if (mdJoin.gameType === 'gauntlet') {
const ord = [...space.peers.keys()].indexOf(socket.id);
const ord = typeof peer.spawnJoinOrder === 'number' ? peer.spawnJoinOrder : [...space.peers.keys()].indexOf(socket.id);
const pos = gauntletSpawnPositions(mdJoin, space.peers.size);
const pt = pos[ord] != null ? pos[ord] : pos[pos.length - 1];
peer.x = pt.x;
@@ -7500,6 +7524,7 @@ io.on('connection', (socket) => {
botSlotCount: effectiveBotSlotCount(space),
lobbyBotThemes: syncLobbyBotThemeSlots(space),
joinLocked: !!space.joinLocked,
missionLiveBeginsAt: space.missionLiveBeginsAt || null,
};
if (serverMapIsPostCaseLobbyB(space)) {
joinCb.detectiveLobbyLevel = space.detectiveLobbyLevel != null ? space.detectiveLobbyLevel : null;
@@ -7707,6 +7732,37 @@ io.on('connection', (socket) => {
});
});
/* lobby client: host relay
( desync จากการทแตละเครองสมเดนบอทแยกก) */
socket.on('lobby-bot-move', (data) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || space.hostId !== socket.id) return; /* เฉพาะ host เท่านั้น */
if (!data || !Array.isArray(data.bots) || data.bots.length > 16) return;
const bots = [];
for (const b of data.bots) {
if (!b || typeof b.id !== 'string' || b.id.length > 40) continue;
const x = Number(b.x), y = Number(b.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
bots.push({
id: b.id, x, y,
direction: typeof b.direction === 'string' ? b.direction.slice(0, 5) : 'down',
isWalking: !!b.isWalking,
});
}
if (bots.length) socket.to(sid).emit('lobby-bot-move', { bots });
});
/* fill-bot 7 : host state relay
( desync จากการทแตละเครองจำลองบอทแยกก) */
socket.on('fill-bot-state', (data) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || space.hostId !== socket.id) return; /* เฉพาะ host เท่านั้น */
if (!data || !Array.isArray(data.bots) || data.bots.length > 16) return;
socket.to(sid).emit('fill-bot-state', { bots: data.bots });
});
/** เปลี่ยนชื่อที่แสดงใน lobby / เกม (ซิงก์ทุก client ในห้อง) */
socket.on('update-display-name', ({ nickname }, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
@@ -8206,9 +8262,8 @@ io.on('connection', (socket) => {
space.troublesomeDebTimer = null;
space.troublesomeMaxTimer = null;
initTroublesomeState(space);
let li = 0;
space.peers.forEach((p) => {
const sp = pickSpawnForJoin(space.mapData, li++);
const sp = pickSpawnForJoin(space.mapData, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0);
p.x = sp.x;
p.y = sp.y;
p.direction = 'down';
@@ -8355,8 +8410,11 @@ io.on('connection', (socket) => {
if (wasHost) {
const firstPeer = space.peers.values().next().value;
if (firstPeer) {
space.hostId = firstPeer.id;
space.hostPlayerKey = firstPeer.playerKey || space.hostPlayerKey; /* host ออกจริง (ไม่ใช่เปลี่ยนหน้า) → ย้าย host ให้คนถัดไปแบบถาวร */
space.hostId = firstPeer.id; /* ย้าย pointer host ชั่วคราวให้คนที่เหลือ (liveness) */
/* hostPlayerKey host reclaim rejoin play.html
(เปลยนหน lobbyplaylobbyB ทำให socket disconnect วคราว ไมใชออกจากหองจร
าเขยนท key host/client สลบกนถาวรหลงเข minigame) */
if (!space.hostPlayerKey && firstPeer.playerKey) space.hostPlayerKey = firstPeer.playerKey;
io.to(sid).emit('host-changed', { hostId: firstPeer.id });
}
}
@@ -8475,10 +8533,17 @@ io.on('connection', (socket) => {
const humanIds = [...space.peers.keys()];
const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]);
if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' });
io.to(sid).emit('quiz-carry-lobby-started', {});
const liveBeginsAt = Date.now() + LIVE_COUNTDOWN_MS;
space.missionLiveBeginsAt = liveBeginsAt;
io.to(sid).emit('quiz-carry-lobby-started', { liveBeginsAt });
reply({ ok: true });
});
/* ตอบเวลา server ปัจจุบัน → client ใช้คำนวณ clock offset (ชดเชย latency) ให้เวลาในเกมตรงกันทุกเครื่อง */
socket.on('time-sync', (cb) => {
if (typeof cb === 'function') cb({ t: Date.now() });
});
socket.on('gauntlet-crown-lobby-sync-request', () => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
@@ -8520,14 +8585,22 @@ io.on('connection', (socket) => {
const humanIds = [...space.peers.keys()];
const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]);
if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' });
/* เวลาเริ่มเล่นจริงร่วมกัน (server epoch) → ทุก client นับ 3-2-1 ไปหาจุดนี้ → GO พร้อมกัน */
const liveBeginsAt = Date.now() + LIVE_COUNTDOWN_MS;
space.missionLiveBeginsAt = liveBeginsAt;
if (isQuizQuestionMissionShellSpace(space)) {
const mdQuiz = maps.get(space.mapId) || space.mapData;
if (mdQuiz && mdQuiz.gameType === 'quiz') {
clearSpaceQuizTimers(space);
startQuizGame(sid, space, mdQuiz);
}
/* quiz: เลื่อน server timer ไปเริ่มที่ liveBeginsAt (ไม่ใช่ทันที) ให้ตรงกับตอน client จบ 3-2-1 */
setTimeout(() => {
const sp = spaces.get(sid);
if (!sp) return;
const md2 = maps.get(sp.mapId) || sp.mapData;
if (md2 && md2.gameType === 'quiz') {
clearSpaceQuizTimers(sp);
startQuizGame(sid, sp, md2);
}
}, LIVE_COUNTDOWN_MS);
}
io.to(sid).emit('gauntlet-crown-lobby-started', {});
io.to(sid).emit('gauntlet-crown-lobby-started', { liveBeginsAt });
reply({ ok: true });
});