diff --git a/www/html/Admin/private/store.json b/www/html/Admin/private/store.json
index 30055c3..d55ba6a 100644
--- a/www/html/Admin/private/store.json
+++ b/www/html/Admin/private/store.json
@@ -51,9 +51,9 @@
"providerUserId": "p_1775109142385_wq7wfy1p32j",
"notes": "auto: player-coins",
"blocked": false,
- "coins": 2072,
+ "coins": 2140,
"createdAt": "2026-04-02T05:52:21+00:00",
- "updatedAt": "2026-06-26T07:58:33+00:00",
+ "updatedAt": "2026-06-26T10:25:41+00:00",
"daily": {
"anchorMs": 1781197200000,
"claimedDays": [
@@ -67,13 +67,13 @@
],
"lockUntilMs": 0
},
- "score": 18,
+ "score": 86,
"scoreByCase": {
"1": 16,
- "10": 514,
+ "10": 562,
"8": 296,
"11": 20,
- "13": 224,
+ "13": 234,
"14": 30,
"9": 40,
"12": 408,
@@ -83,7 +83,7 @@
"7": 6,
"3": 142,
"6": 44,
- "2": 80
+ "2": 90
},
"lbName": "Q",
"achievements": {
@@ -651,19 +651,20 @@
"providerUserId": "p_1782016990238_0u37bv7pxlxs",
"notes": "auto: player-coins",
"blocked": false,
- "coins": 288,
+ "coins": 368,
"createdAt": "2026-06-21T04:43:11+00:00",
- "updatedAt": "2026-06-26T08:07:51+00:00",
+ "updatedAt": "2026-06-26T09:44:40+00:00",
"lobbyColorThemeIndex": 8,
"lobbySkinToneIndex": 1,
- "score": 258,
+ "score": 338,
"scoreByCase": {
"13": 82,
- "10": 120,
+ "10": 172,
"6": 8,
"8": 20,
- "2": 18,
- "3": 10
+ "2": 36,
+ "3": 10,
+ "7": 10
},
"lbName": "MONE",
"achievements": {
diff --git a/www/html/Game/data/game-timing.json b/www/html/Game/data/game-timing.json
index 3cec9d9..9f6aceb 100644
--- a/www/html/Game/data/game-timing.json
+++ b/www/html/Game/data/game-timing.json
@@ -74,7 +74,7 @@
"/Game/img/ViolentCrime/Meteo-3.png"
],
"spaceShooterAsteroidExplodeFrameMs": 60,
- "spaceShooterAsteroidIntervalMs": 300,
+ "spaceShooterAsteroidIntervalMs": 200,
"spaceShooterShipDamageOverlayUrls": [
"/Game/img/ViolentCrime/Rocket-broke-1.png",
"/Game/img/ViolentCrime/Rocket-broke-2.png",
diff --git a/www/html/Game/data/quiz-settings.json b/www/html/Game/data/quiz-settings.json
index 71df438..5550e8c 100644
--- a/www/html/Game/data/quiz-settings.json
+++ b/www/html/Game/data/quiz-settings.json
@@ -4,7 +4,7 @@
"betweenMs": 3000,
"carryReadMs": 3000,
"carryAnswerMs": 15000,
- "carrySessionLength": 2,
+ "carrySessionLength": 10,
"carryMapPanelTheme": {
"panelBg": "rgba(255, 0, 0, 0)",
"panelBorder": "rgba(255, 255, 255, 0)",
diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js
index 777eaaa..7b2801d 100644
--- a/www/html/Game/public/js/play.js
+++ b/www/html/Game/public/js/play.js
@@ -106,7 +106,20 @@
ov.innerHTML = '
กำลังโหลดเกม…
';
document.body.insertBefore(ov, document.body.firstChild);
}
- setTimeout(hidePlayLoadingOverlay, 8000); /* safety: กันค้างถ้าโหลดนาน/ติด */
+ /* safety กันค้าง — แต่ "อย่าซ่อนถ้าเกมยังไม่ render" (เน็ตช้า join นาน → ถ้าซ่อน 8 วิ = จอดำเปล่า ไม่รู้สถานะ)
+ เช็คทุก 1.5 วิ: เกมเริ่มวาดแล้ว (playLoadingHideScheduled) → ปล่อย path ปกติซ่อน · ยังไม่วาด+ช้า → โชว์ "เน็ตช้า (N วิ)" คง spinner · เกิน 30 วิ ค่อยซ่อนกันค้างถาวร */
+ const _ploT0 = Date.now();
+ const _ploCheck = function () {
+ if (playLoadingHidden || playLoadingHideScheduled) return; /* ซ่อนแล้ว/เกมเริ่มวาดแล้ว */
+ const waited = Date.now() - _ploT0;
+ if (waited > 30000) { hidePlayLoadingOverlay(); return; } /* hard limit กันค้างถาวร */
+ if (waited > 5000) {
+ const txt = document.querySelector('#play-loading-overlay .plo-text');
+ if (txt) txt.textContent = 'เน็ตช้า… กำลังโหลด (' + Math.floor(waited / 1000) + ' วิ)';
+ }
+ setTimeout(_ploCheck, 1500);
+ };
+ setTimeout(_ploCheck, 1500);
} catch (_e) { playLoadingHidden = true; }
})();
const playMapIdFromQuery = (params.get('map') || '').trim();
@@ -730,7 +743,9 @@
socket.emit('stack-state', {
idx: stackPreviewTurnIndex,
/* theme ต่อที่นั่ง (host เป็นเจ้าของ) → ทุกจอลงสี block ตรงกัน (เดิม non-host คำนวณสีเองจาก playTint → ไม่ตรง 2 จอ) */
- order: stackPreviewTurnOrder.map((e) => ({ kind: e.kind, seat: e.seat, id: e.id, botId: e.botId || null, theme: stackSeatColorThemePlay(e.seat) || null })),
+ order: stackPreviewTurnOrder.map((e) => ({ kind: e.kind, seat: e.seat, id: e.id, botId: e.botId || null, theme: stackSeatThemeForBroadcastPlay(e.seat) })),
+ /* สีของ block ที่ "วางแล้ว" ทั้งหอ (host authoritative) → client sync เขียนทับ layer ที่เพี้ยนค้าง (จากจังหวะ dual-host ต้นเกม) */
+ lthemes: (stackMini && Array.isArray(stackMini.layers)) ? stackMini.layers.map(function (l) { return (l && l.theme >= 1 && l.theme <= 8) ? l.theme : 0; }) : [],
});
logStackSeatColors('host-bcast');
} catch (e) { /* ignore */ }
@@ -740,6 +755,29 @@
function stackTurnOrderReadyPlay() {
return !!(stackPreviewTurnOrder && stackPreviewTurnOrder.length >= 1);
}
+ /** (host) คิวตาตรงกับผู้เล่น/บอทปัจจุบันไหม — ใช้ auto-heal late-join (client เข้าทีหลังต้องได้ seat ไม่งั้น "เล่นไม่ได้" + ไม่มีสี)
+ เทียบรายชื่อคน (me + others ที่ไม่ใช่บอท/ไม่ถูกแบน) + บอท กับ entry ในคิว; ต่างเมื่อไหร่ = ต้อง rebuild */
+ let _stackHumansSig = '';
+ function stackOrderNeedsRebuildPlay() {
+ if (!stackTurnOrderReadyPlay() || !Array.isArray(stackPreviewTurnOrder)) return true;
+ /* เช็คเฉพาะ "คน" (humans ≤ maxPlayers จึงลงคิวครบเสมอ ไม่โดน cap ตัด) — บอทจัดการใน rebuild เอง */
+ const cur = [];
+ if (myId != null && !playBannedSpectator) cur.push(String(myId));
+ others.forEach((o, id) => {
+ if (isPreviewBotId(id)) return;
+ if (o && o.bannedSpectator) return;
+ cur.push(String(id));
+ });
+ cur.sort();
+ const sig = cur.join('|');
+ if (sig === _stackHumansSig) return false; /* คนไม่เปลี่ยน → ไม่ต้องเช็ค (กัน rebuild ถี่) */
+ const ord = [];
+ stackPreviewTurnOrder.forEach((e) => { if (e && e.kind !== 'bot' && !e.botId && e.id != null) ord.push(String(e.id)); });
+ ord.sort();
+ const need = (sig !== ord.join('|'));
+ if (!need) _stackHumansSig = sig; /* ตรงแล้ว → cache กันเช็คซ้ำทุกเฟรม */
+ return need;
+ }
function stackCurrentTurnEntryPlay() {
if (!stackTurnOrderReadyPlay()) return null;
return stackPreviewTurnOrder[stackPreviewTurnIndex % stackPreviewTurnOrder.length] || null;
@@ -766,8 +804,8 @@
pts: hit.pts || 0, progressDelta: hit.progressDelta || 0, landOffsetTiles: hit.landOffsetTiles || 0,
overlap: hit.overlap, supportRatio: hit.supportRatio, delta: hit.delta || 0,
seat: seat || 1, heavy: !!heavy, actorId: actorId != null ? String(actorId) : '', isBot: !!isBot,
- /* สี (theme) ที่ host/เจ้าของตา resolve ตอนวาง → ส่งไปด้วย ให้บล็อกที่วางบนทุกจอใช้สีเดียวกัน (กัน host/client คนละสี) */
- theme: stackSeatColorThemePlay(seat) || null,
+ /* สี (theme) ที่ host/เจ้าของตา resolve ตอนวาง → ส่งไปด้วย ให้บล็อกที่วางบนทุกจอใช้สีเดียวกัน (กัน host/client คนละสี) — never-null */
+ theme: stackSeatThemeForBroadcastPlay(seat),
});
} catch (e) { /* ignore */ }
}
@@ -843,6 +881,15 @@
return ent ? playLobbyThemeIndexForEntityPlay(ent) : null;
}
+ /** สีที่ host จะ broadcast — "ไม่มีทาง null": resolve ปกติ ไม่ได้ค่อย default ตาม seat
+ → client (broadcast-first) ยึดค่า host ได้เสมอ ไม่ตกไปคำนวณเอง = สีตรง host ทุกจอแน่นอน */
+ function stackSeatThemeForBroadcastPlay(seat) {
+ const t = stackSeatColorThemePlay(seat);
+ if (t >= 1 && t <= 8) return t;
+ const s = Math.floor(Number(seat)) || 1;
+ return ((s - 1) % 8) + 1;
+ }
+
/** ส่ง log ไป /gamelog (ดูรวมที่หน้าเว็บ) */
function gamelogPost(cat, ev, detail, who) {
try {
@@ -855,6 +902,7 @@
/** วินิจฉัยสี block MG3 ต่อ seat — เทียบ "สี avatar (playTint)" vs "สี block (resolved)"; ถ้าไม่ตรง → ส่งเข้า /gamelog
เรียกเอง: window.__mg3ColorLog() หรือ auto throttle */
let _mg3ColorLogAt = 0;
+ let _mg3SnapAt = 0;
let _mg3LastMismatchKey = '';
function logStackSeatColors(tag, force) {
try {
@@ -872,13 +920,34 @@
return { seat: e.seat, kind: e.kind, nick: (ent && ent.nickname) || String(e.id || e.botId || '').slice(0, 6), avatarIdx: avatarIdx, blockIdx: blockIdx, srv: (ent && ent.lobbyThemeIndexSrv) || null, bc: e.theme || null, ok: (avatarIdx && blockIdx) ? (avatarIdx === blockIdx) : null };
});
console.log('[mg3-color/' + (tag || '') + '] host=' + isMePlayHost(), JSON.stringify(rows));
- /* ส่งเฉพาะกรณีพบ mismatch (avatar≠block) → เห็นใน /gamelog (MG3) ทันที ไม่ต้องเปิด console */
+ const role = isMePlayHost() ? 'host' : 'client';
+ /* (A) snapshot ทุก ~5 วิ จาก "ทั้ง host และ client" → เทียบใน /gamelog ได้: block(=สีที่วาด) av(=สี avatar) srv bc */
+ if (now - _mg3SnapAt > 5000) {
+ _mg3SnapAt = now;
+ /* รายชื่อ "คน" ใน others (เทียบกับคิว) — หา root "client หายจากคิว" */
+ const othH = [];
+ if (myId != null) othH.push((me && me.nickname ? me.nickname : 'me') + '(self' + (playBannedSpectator ? ',BAN' : '') + ')');
+ others.forEach(function (o, id) {
+ if (isPreviewBotId(id)) return;
+ othH.push((o && o.nickname ? o.nickname : String(id).slice(0, 5)) + (o && o.bannedSpectator ? '(BAN)' : ''));
+ });
+ const ordSeats = rows.length;
+ /* block ที่ "วางแล้ว" (frozen layer.theme) — ตัวที่ user เห็นเพี้ยน; เทียบ host vs client ทีละ layer (seat:theme) */
+ let lyr = '';
+ if (stackMini && Array.isArray(stackMini.layers)) {
+ const L = stackMini.layers;
+ lyr = ' || layers(' + L.length + ')=[' + L.slice(-12).map(function (l) { return (l && l.seat != null ? l.seat : '?') + ':' + (l && l.theme != null ? l.theme : 'null'); }).join(',') + ']';
+ }
+ const summary = 'order=' + ordSeats + 'seat othHumans=[' + othH.join(',') + '] :: '
+ + rows.map(function (r) { return 's' + r.seat + ':' + (r.nick || '?').slice(0, 5) + ' blk=' + r.blockIdx + ' av=' + r.avatarIdx; }).join(' | ') + lyr;
+ gamelogPost('MG3', 'color-snap', summary, role);
+ }
+ /* (B) mismatch (avatar≠block บนจอเดียวกัน) → ส่งทันที (เปลี่ยนค่อยส่งซ้ำ) */
const bad = rows.filter(function (r) { return r.ok === false; });
if (bad.length) {
- const key = (isMePlayHost() ? 'H' : 'C') + '|' + bad.map(function (r) { return r.seat + ':' + r.avatarIdx + '!=' + r.blockIdx; }).join(',');
+ const key = role + '|' + bad.map(function (r) { return r.seat + ':' + r.avatarIdx + '!=' + r.blockIdx; }).join(',');
if (key !== _mg3LastMismatchKey) {
_mg3LastMismatchKey = key;
- const role = isMePlayHost() ? 'host' : 'client';
const detail = bad.map(function (r) { return r.nick + ' seat' + r.seat + ' avatar#' + r.avatarIdx + ' แต่ block#' + r.blockIdx + ' (srv' + r.srv + '/bc' + r.bc + ')'; }).join(' | ');
gamelogPost('MG3', 'color-mismatch', detail, role);
}
@@ -12325,7 +12394,8 @@
lastStackTickMs = now;
/* เล่นจริง: host ส่งลำดับตา+index เป็นระยะ → non-host adopt (ครอบ late-join: client โหลดช้า host จะรวมเข้า order ให้) */
if (detectiveCaseFillBots && isMePlayHost()) {
- if (!stackTurnOrderReadyPlay()) rebuildStackPreviewTurnOrder();
+ /* rebuild เมื่อ (ก) ยังไม่มีคิว หรือ (ข) คน/บอท เปลี่ยน (เช่น client เข้าทีหลัง = late-join) → ให้ทุกคนได้ seat + สี */
+ if (!stackTurnOrderReadyPlay() || stackOrderNeedsRebuildPlay()) rebuildStackPreviewTurnOrder();
const ts = Date.now();
if (ts - stackLastStateEmit > 300) { stackLastStateEmit = ts; broadcastStackStatePlay(); }
}
@@ -12602,11 +12672,13 @@
? stackFall.actorSeat
: (m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat());
const heavyDraw = stackFall ? !!stackFall.actorHeavy : !!m.pendingDropHeavy;
+ /* remote drop → ใช้สีจาก host (actorTheme/pendingDropTheme) เหมือน block ที่วาง → block ห้อย/ตก สีตรง host */
+ const themeOverrideDraw = stackFall ? (stackFall.actorTheme || null) : (m.pendingDropTheme || null);
const hueHint = (m.layers.length * 47) % 360;
ctx.save();
ctx.translate(bcx, bcy);
ctx.rotate(fallTiltRad + swingTiltRad);
- drawStackBlockSpriteOrHue(ctx, -twWorld * zoom / 2, 0, twWorld * zoom, drawStripPx, seatDraw, heavyDraw, hueHint, { missTint: !!hitMissTint, missOverlay: !!hitMissTint });
+ drawStackBlockSpriteOrHue(ctx, -twWorld * zoom / 2, 0, twWorld * zoom, drawStripPx, seatDraw, heavyDraw, hueHint, { missTint: !!hitMissTint, missOverlay: !!hitMissTint, themeOverride: themeOverrideDraw });
ctx.lineWidth = 1;
ctx.restore();
}
@@ -20399,7 +20471,13 @@
/* MG3 stack — non-host รับลำดับตา+index จาก host (single source of truth) → เลิกแยกตึก/ตาเหลื่อม */
socket.on('stack-state', (d) => {
- if (!d || !mapData || !isStack() || !detectiveCaseFillBots || isMePlayHost()) return;
+ if (!d || !mapData || !isStack() || !detectiveCaseFillBots) return;
+ /* ได้รับ stack-state = server relay จาก "host จริง" (socket.to ไม่ส่งกลับผู้ส่ง) → เรา "ไม่ใช่ host"
+ แก้ playHostId ที่ผิด (dual-host: เข้าใจผิดว่าเป็น host แล้ว sim เอง = ลำดับ/สี block หลุดกับ host) */
+ if (d.hostId != null && myId != null && String(d.hostId) !== String(myId)) {
+ if (String(playHostId) !== String(d.hostId)) playHostId = d.hostId;
+ }
+ if (isMePlayHost()) return;
if (Array.isArray(d.order)) {
stackPreviewTurnOrder = d.order.map((e) => ({
kind: e.kind, seat: e.seat,
@@ -20410,7 +20488,24 @@
}
if (stackPreviewTurnOrder && stackPreviewTurnOrder.length && Number.isFinite(Number(d.idx))) {
const n = stackPreviewTurnOrder.length;
- stackPreviewTurnIndex = ((Math.floor(Number(d.idx)) % n) + n) % n;
+ const newIdx = ((Math.floor(Number(d.idx)) % n) + n) % n;
+ if (newIdx !== stackPreviewTurnIndex) {
+ stackPreviewTurnIndex = newIdx;
+ /* ตาเปลี่ยน → refresh "block ที่ห้อย" (pendingDropSeat/สี) ให้ตรงตาปัจจุบัน
+ เดิมไม่ mark dirty → block ห้อยค้างโชว์สีของตาที่แล้ว = ก่อนปล่อยสีไม่ตรง */
+ markStackNextDropVisualDirty();
+ }
+ }
+ /* sync สี block ที่ "วางแล้ว" จาก host → เขียนทับ layer ที่เพี้ยนค้าง (จากจังหวะ dual-host ต้นเกม)
+ sync เฉพาะเมื่อจำนวนชั้นตรงกัน (aligned) — กันเขียนผิดตำแหน่งช่วง drop คาบเกี่ยว */
+ if (Array.isArray(d.lthemes) && stackMini && Array.isArray(stackMini.layers)
+ && d.lthemes.length === stackMini.layers.length) {
+ for (let i = 0; i < stackMini.layers.length; i++) {
+ const ht = Math.floor(Number(d.lthemes[i]));
+ if (ht >= 1 && ht <= 8 && stackMini.layers[i] && stackMini.layers[i].theme !== ht) {
+ stackMini.layers[i].theme = ht;
+ }
+ }
}
logStackSeatColors('client-recv');
});
diff --git a/www/html/Game/public/js/room-lobby.js b/www/html/Game/public/js/room-lobby.js
index b36d2b7..f324115 100644
--- a/www/html/Game/public/js/room-lobby.js
+++ b/www/html/Game/public/js/room-lobby.js
@@ -10,6 +10,21 @@
const nick = (params.get('nick') || '').trim() || 'ผู้เล่น';
const DISPLAY_NAME_STORAGE_KEY = 'roomLobbyDisplayName';
let profileDisplayName = nick;
+ /* ===== log LobbyA/B → /gamelog (จับ timeline init: connect/join/preload/loading-hide หาเหตุ "กดไม่ได้ต้องรอ") ===== */
+ const _lobbyT0 = Date.now();
+ function lobbyLogCat() {
+ try { if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID || serverSuspectPhaseActive) return 'LobbyB'; } catch (e) { /* ignore */ }
+ return 'LobbyA';
+ }
+ function lobbyLog(ev, detail) {
+ try {
+ fetch('/gamelog/log.php', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true,
+ body: JSON.stringify({ cat: lobbyLogCat(), room: (typeof spaceId === 'string' ? spaceId : ''), who: (function () { try { return getProfileDisplayName(); } catch (e) { return nick; } })(), ev: ev || '', detail: (detail || '') + ' [+' + (Date.now() - _lobbyT0) + 'ms]', src: 'client' }),
+ }).catch(function () {});
+ } catch (e) { /* ignore */ }
+ }
+ lobbyLog('lobby-open', 'space=' + spaceId);
const DESIGN_MENU = typeof designMainMenuHref === 'function' ? designMainMenuHref() : '/Main-Menu/';
if (!spaceId) { location.href = DESIGN_MENU; return; }
const roomIdValueEl = document.getElementById('room-id-value');
@@ -510,6 +525,7 @@
var elapsed = Date.now() - roomLoadingShownAt;
if (elapsed < ROOM_LOADING_MIN_MS) { setTimeout(hideRoomLoading, ROOM_LOADING_MIN_MS - elapsed); return; }
roomLoadingHidden = true;
+ try { lobbyLog('loading-hide', 'เริ่มเล่นได้'); } catch (e) { /* ignore */ }
var ov = document.getElementById('room-loading-overlay');
if (ov) { ov.classList.add('is-hidden'); setTimeout(function () { if (ov.parentNode) ov.parentNode.removeChild(ov); }, 600); }
}
@@ -3215,6 +3231,7 @@
socket.on('peer-display-name', applyPeerDisplayNameUpdate);
socket.on('connect', () => {
+ lobbyLog('socket-connect', '');
/* ส่งค่าสีที่เลือกใน Main-Lobby ติดไปด้วย — server จะใช้ค่านี้ถ้ายังว่าง (กันโชว์สีแดง default ตอนเข้าใหม่) */
var savedThemeIdx = null;
var savedSkinIdx = null;
@@ -3245,6 +3262,8 @@
}
mapData = res.mapData;
roomJoinReady = true;
+ clientLobbyMapId = res.mapId != null ? res.mapId : null;
+ lobbyLog('join-ok', 'peers=' + (res.peers || []).length + ' host=' + (res.hostId === socket.id ? 'me' : 'other') + ' map=' + res.mapId + ' bots=' + (res.botSlotCount != null ? res.botSlotCount : '?'));
/* ไอคอนกุญแจ 🔒 โชว์เฉพาะห้อง private (จาก res.isPrivate) — public ไม่โชว์ */
try {
const lockEl = document.getElementById('room-id-lock');
@@ -7585,14 +7604,22 @@
setTimeout(resizeAndDraw, 100);
updateLobbyProfileAvatar();
setupRoomCustomize();
+ lobbyLog('preload-start', '');
rlEnsureManifest(function () {
+ lobbyLog('manifest-done', '');
rlResolveMyColors(function () {
+ lobbyLog('colors-resolved', '');
updateRoomProfileAvatarTinted();
preloadMyTintedCharacter(function () {
+ lobbyLog('mychar-tinted', 'roomJoinReady=' + roomJoinReady);
+ /* พอ tint "ตัวเรา" เสร็จ → เปิดให้เล่นได้เลย (ไม่รอ tint คนอื่นครบ = เดิมกิน ~12 วิ คอขวด)
+ tint คนอื่นโหลด background → avatar คนอื่นโผล่สีตามทีหลังนิดหน่อย (ยอมรับได้) */
+ roomPreloadReady = true;
+ setRoomLoadingPct(100);
+ lobbyLog('preload-ready(mychar)', '');
+ maybeHideRoomLoading();
preloadAllLobbyTintedOccupants(function () {
- roomPreloadReady = true;
- setRoomLoadingPct(100);
- maybeHideRoomLoading();
+ lobbyLog('preload-occupants-done(bg)', '');
}, function (d, t) {
setRoomLoadingPct(t ? (d / t) * 100 : 100);
});
diff --git a/www/html/Game/public/play.html b/www/html/Game/public/play.html
index db0a15c..7f880f1 100644
--- a/www/html/Game/public/play.html
+++ b/www/html/Game/public/play.html
@@ -5412,7 +5412,7 @@
-
+
v —