fixed game sync
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 เสถียรข้ามการเปลี่ยนหน้า (lobbyA→lobbyB→minigame): จำลำดับด้วย 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
|
||||
(เปลี่ยนหน้า lobby→play→lobbyB ทำให้ 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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user